Ответы, аудио, голосовые

This commit is contained in:
Халимов Рустам
2026-04-14 12:29:32 +03:00
parent 58bbdae26c
commit d7e75797ef
16 changed files with 553 additions and 282 deletions
Binary file not shown.
Binary file not shown.
+20 -16
View File
@@ -4,22 +4,23 @@ import com.google.gson.annotations.SerializedName
data class UserBasicDto( data class UserBasicDto(
@SerializedName("id") val id: String, @SerializedName("id") val id: String,
@SerializedName("userName") val userName: String, @SerializedName("username") val username: String? = null,
@SerializedName("displayName") val displayName: String, @SerializedName("displayName") val displayName: String? = null,
@SerializedName("avatarUrl") val avatarUrl: String? @SerializedName("avatarUrl") val avatarUrl: String? = null
) )
data class MessageDto( data class MessageDto(
@SerializedName("id") val id: String, @SerializedName("id") val id: String,
@SerializedName("chatId") val chatId: String, @SerializedName("chatId") val chatId: String? = null,
@SerializedName("senderId") val senderId: String, @SerializedName("senderId") val senderId: String? = null,
@SerializedName("content") val content: String?, @SerializedName("content") val content: String? = null,
@SerializedName("type") val type: String, @SerializedName("type") val type: String? = null,
@SerializedName("sequenceId") val sequenceId: Int, @SerializedName("sequenceId") val sequenceId: Int? = null,
@SerializedName("createdAt") val createdAt: String, @SerializedName("createdAt") val createdAt: String? = null,
@SerializedName("sender") val sender: UserBasicDto, @SerializedName("sender") val sender: UserBasicDto? = null,
@SerializedName("media") val media: List<MediaItemDto> = emptyList(), @SerializedName("media") val media: List<MediaItemDto> = emptyList(),
@SerializedName("reactions") val reactions: List<ReactionDto>? = emptyList() @SerializedName("reactions") val reactions: List<ReactionDto>? = emptyList(),
@SerializedName("replyTo") val replyTo: MessageDto? = null
) )
data class ReactionDto( data class ReactionDto(
@@ -31,20 +32,23 @@ data class ReactionDto(
data class MediaItemDto( data class MediaItemDto(
@SerializedName("id") val id: String, @SerializedName("id") val id: String,
@SerializedName("type") val type: String, @SerializedName("type") val type: String,
@SerializedName("url") val url: String @SerializedName("url") val url: String,
@SerializedName("filename") val filename: String? = null,
@SerializedName("size") val size: Long? = null,
@SerializedName("duration") val duration: Double? = null
) )
data class ChatDto( data class ChatDto(
@SerializedName("id") val id: String, @SerializedName("id") val id: String,
@SerializedName("type") val type: String, @SerializedName("type") val type: String,
@SerializedName("name") val name: String?, @SerializedName("name") val name: String? = null,
@SerializedName("avatar") val avatar: String?, @SerializedName("avatar") val avatar: String? = null,
@SerializedName("unreadCount") val unreadCount: Int, @SerializedName("unreadCount") val unreadCount: Int = 0,
@SerializedName("messages") val messages: List<MessageDto> = emptyList(), @SerializedName("messages") val messages: List<MessageDto> = emptyList(),
@SerializedName("members") val members: List<ChatMemberDto> = emptyList() @SerializedName("members") val members: List<ChatMemberDto> = emptyList()
) )
data class ChatMemberDto( data class ChatMemberDto(
@SerializedName("userId") val userId: String, @SerializedName("userId") val userId: String,
@SerializedName("user") val user: UserBasicDto? @SerializedName("user") val user: UserBasicDto? = null
) )
@@ -57,12 +57,8 @@ class ChatRepositoryImpl @Inject constructor(
} }
} }
// Mappers // Mappers
fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat { fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat {
// Если это личный чат и имя пустое, ищем имя собеседника в списке участников
val chatName = name ?: if (type == "personal") { val chatName = name ?: if (type == "personal") {
members.firstOrNull { it.userId != currentUserId }?.user?.displayName ?: "Unknown Chat" members.firstOrNull { it.userId != currentUserId }?.user?.displayName ?: "Unknown Chat"
} else "Group Chat" } else "Group Chat"
@@ -98,17 +94,26 @@ fun MessageDto.toDomain(baseUrl: String): Message {
return Message( return Message(
id = id, id = id,
chatId = chatId, chatId = chatId ?: "",
senderId = senderId, senderId = senderId ?: "",
senderName = sender.displayName, senderName = sender?.displayName ?: "Unknown",
senderAvatar = sender.avatarUrl?.ensureAbsoluteUrl(baseUrl), senderAvatar = sender?.avatarUrl?.ensureAbsoluteUrl(baseUrl),
content = content, content = content,
sequenceId = sequenceId, sequenceId = sequenceId ?: 0,
createdAt = createdAt, createdAt = createdAt ?: "",
media = media.map { it.url.ensureAbsoluteUrl(baseUrl) }, media = media.map {
mediaUrl = media.firstOrNull()?.url?.ensureAbsoluteUrl(baseUrl), chats.domain.model.Media(
id = it.id,
type = it.type,
url = it.url.ensureAbsoluteUrl(baseUrl),
filename = it.filename,
size = it.size,
duration = it.duration
)
},
mediaType = domainMediaType, mediaType = domainMediaType,
reactions = reactions?.associate { it.emoji to it.count } ?: emptyMap() reactions = reactions?.associate { it.emoji to it.count } ?: emptyMap(),
replyTo = replyTo?.toDomain(baseUrl)
) )
} }
@@ -121,4 +126,3 @@ fun String.ensureAbsoluteUrl(baseUrl: String): String {
"$base$path" "$base$path"
} }
} }
+10 -2
View File
@@ -9,14 +9,22 @@ data class Message(
val content: String?, val content: String?,
val sequenceId: Int, val sequenceId: Int,
val createdAt: String, val createdAt: String,
val media: List<String> = emptyList(), val media: List<Media> = emptyList(),
val mediaUrl: String? = null,
val mediaType: MediaType = MediaType.TEXT, val mediaType: MediaType = MediaType.TEXT,
val reactions: Map<String, Int> = emptyMap(), val reactions: Map<String, Int> = emptyMap(),
val isRead: Boolean = false, val isRead: Boolean = false,
val replyTo: Message? = null val replyTo: Message? = null
) )
data class Media(
val id: String,
val type: String,
val url: String,
val filename: String? = null,
val size: Long? = null,
val duration: Double? = null
)
enum class MediaType { enum class MediaType {
TEXT, IMAGE, VIDEO, AUDIO, FILE, STORY_REPLY TEXT, IMAGE, VIDEO, AUDIO, FILE, STORY_REPLY
} }
@@ -25,6 +25,7 @@ import core.utils.VoiceRecorder
import core.utils.copyUriToFile import core.utils.copyUriToFile
import java.io.File import java.io.File
import ru.knot.messager.R import ru.knot.messager.R
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -37,11 +38,38 @@ fun ChatDetailScreen(
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val listState = rememberLazyListState() val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val voiceRecorder = remember { VoiceRecorder(context) } val voiceRecorder = remember { VoiceRecorder(context) }
var textInput by remember { mutableStateOf("") } var textInput by remember { mutableStateOf("") }
var isEmojiPickerVisible by remember { mutableStateOf(false) } var isEmojiPickerVisible by remember { mutableStateOf(false) }
var isRecording by remember { mutableStateOf(false) } var isRecording by remember { mutableStateOf(false) }
var autoPlayingMessageId by remember { mutableStateOf<String?>(null) }
var currentPlaybackSpeed by remember { mutableFloatStateOf(1.0f) }
val playNextVoiceMessage = { currentId: String, speed: Float ->
val currentIndex = state.messages.indexOfFirst { it.id == currentId }
if (currentIndex != -1 && currentIndex < state.messages.size - 1) {
val nextVoiceIndexInSublist = state.messages.subList(currentIndex + 1, state.messages.size)
.indexOfFirst { it.mediaType == chats.domain.model.MediaType.AUDIO && it.content.isNullOrEmpty() }
if (nextVoiceIndexInSublist != -1) {
val actualNextIndex = currentIndex + 1 + nextVoiceIndexInSublist
autoPlayingMessageId = state.messages[actualNextIndex].id
currentPlaybackSpeed = speed
// Прокручиваем к следующему сообщению, иначе оно не распарсится LazyColumn
scope.launch {
listState.animateScrollToItem(actualNextIndex)
}
} else {
autoPlayingMessageId = null
}
} else {
autoPlayingMessageId = null
}
}
// Пикер галереи // Пикер галереи
val galleryLauncher = rememberLauncherForActivityResult( val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent() contract = ActivityResultContracts.GetContent()
@@ -126,7 +154,9 @@ fun ChatDetailScreen(
MessageBubble( MessageBubble(
message = message, message = message,
isCurrentUser = message.senderId == viewModel.getCurrentUserId(), isCurrentUser = message.senderId == viewModel.getCurrentUserId(),
senderAvatar = if (message.senderId == viewModel.getCurrentUserId()) null else message.senderAvatar, autoPlay = message.id == autoPlayingMessageId,
initialPlaybackSpeed = if (message.id == autoPlayingMessageId) currentPlaybackSpeed else 1.0f,
onVoiceFinished = { speed -> playNextVoiceMessage(message.id, speed) },
onReactionClick = { emoji -> viewModel.addReaction(message.id, emoji) } onReactionClick = { emoji -> viewModel.addReaction(message.id, emoji) }
) )
} }
@@ -19,15 +19,13 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup import androidx.compose.ui.window.Popup
import chats.domain.model.Message import chats.domain.model.Message
import chats.domain.model.MediaType import chats.domain.model.MediaType
import chats.domain.model.Media
import coil.compose.AsyncImage import coil.compose.AsyncImage
import core.presentation.components.AppVideoPlayer import core.presentation.components.AppVideoPlayer
import core.presentation.components.AppAudioPlayer import core.presentation.components.AppAudioPlayer
import core.presentation.components.AppAvatar import core.presentation.components.AppAvatar
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.DoneAll
import chats.presentation.components.LinkPreview import chats.presentation.components.LinkPreview
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
@@ -35,31 +33,33 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.VolumeOff
import core.presentation.components.AppMediaLightbox import core.presentation.components.AppMediaLightbox
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable @Composable
fun MessageBubble( fun MessageBubble(
message: Message, message: Message,
isCurrentUser: Boolean, isCurrentUser: Boolean,
senderAvatar: String? = null, onReactionClick: (String) -> Unit = {},
onReactionClick: (String) -> Unit = {} autoPlay: Boolean = false,
initialPlaybackSpeed: Float = 1.0f,
onVoiceFinished: (Float) -> Unit = {}
) { ) {
val context = LocalContext.current val context = LocalContext.current
var showReactionPicker by remember { mutableStateOf(false) } var showReactionPicker by remember { mutableStateOf(false) }
var lightboxMedia by remember { mutableStateOf<Pair<String, String>?>(null) } var lightboxMediaIndex by remember { mutableStateOf<Int?>(null) }
val backgroundColor = if (isCurrentUser) { val backgroundColor = if (isCurrentUser) {
Color(0xFF3096E5) // Updated Blue Color(0xFF3096E5)
} else { } else {
Color(0xFF212121) // Updated Dark Grey Color(0xFF212121)
} }
val contentColor = Color.White val contentColor = Color.White
val alignment = if (isCurrentUser) Alignment.CenterEnd else Alignment.CenterStart
val shape = if (isCurrentUser) { val shape = if (isCurrentUser) {
RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp) RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp)
} else { } else {
@@ -76,14 +76,7 @@ fun MessageBubble(
horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start, horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom verticalAlignment = Alignment.Bottom
) { ) {
if (!isCurrentUser) { // No avatars here as per user request (web parity)
AppAvatar(
url = senderAvatar,
name = message.senderName,
size = 32.dp,
modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)
)
}
Column( Column(
modifier = Modifier modifier = Modifier
@@ -118,6 +111,7 @@ fun MessageBubble(
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(4.dp))
.background(contentColor.copy(alpha = 0.1f)) .background(contentColor.copy(alpha = 0.1f))
.height(IntrinsicSize.Min) .height(IntrinsicSize.Min)
.clickable { /* Scroll to reply */ }
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -135,10 +129,11 @@ fun MessageBubble(
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
) )
Text( Text(
text = reply.content ?: "[Media]", text = reply.content ?: if (reply.media.isNotEmpty()) "\uD83D\uDCCE Media" else "",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = contentColor.copy(alpha = 0.8f), color = contentColor.copy(alpha = 0.8f),
maxLines = 1 maxLines = 1,
overflow = TextOverflow.Ellipsis
) )
} }
} }
@@ -148,78 +143,69 @@ fun MessageBubble(
if (message.media.isNotEmpty()) { if (message.media.isNotEmpty()) {
val mediaCount = message.media.size val mediaCount = message.media.size
if (mediaCount == 1) { if (mediaCount == 1) {
val mediaUrl = message.media[0] val mediaItem = message.media[0]
when (message.mediaType) { when {
MediaType.IMAGE -> { mediaItem.type.startsWith("image") || message.mediaType == MediaType.IMAGE -> {
AsyncImage( AsyncImage(
model = mediaUrl, model = mediaItem.url,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.clickable { .clickable { lightboxMediaIndex = 0 },
lightboxMedia = mediaUrl to "image"
},
contentScale = ContentScale.FillWidth contentScale = ContentScale.FillWidth
) )
} }
MediaType.VIDEO -> { mediaItem.type.startsWith("video") || message.mediaType == MediaType.VIDEO -> {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(200.dp) .height(200.dp)
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background(Color.Black) .background(Color.Black)
.clickable { .clickable { lightboxMediaIndex = 0 },
lightboxMedia = mediaUrl to "video"
},
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
AppVideoPlayer( AppVideoPlayer(
url = mediaUrl, url = mediaItem.url,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
useController = false, useController = false,
autoPlay = true, autoPlay = true,
isMuted = true isMuted = true
) )
// Volume Off icon in top right
Icon( Icon(
imageVector = Icons.Default.VolumeOff, imageVector = Icons.Default.VolumeOff,
contentDescription = null, contentDescription = null,
tint = Color.White.copy(alpha = 0.7f), tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).size(20.dp)
.align(Alignment.TopEnd)
.padding(8.dp)
.size(20.dp)
) )
} }
} }
MediaType.AUDIO -> { mediaItem.type.startsWith("audio") || mediaItem.filename?.endsWith(".mp3", true) == true || message.mediaType == MediaType.AUDIO -> {
AppAudioPlayer( AppAudioPlayer(
url = mediaUrl, url = mediaItem.url,
name = mediaItem.filename,
size = mediaItem.size,
isVoiceMessage = isVoiceMessage, isVoiceMessage = isVoiceMessage,
initialPlaybackSpeed = initialPlaybackSpeed,
autoPlay = autoPlay,
onFinished = onVoiceFinished,
contentColor = contentColor contentColor = contentColor
) )
} }
MediaType.FILE -> { else -> {
FileItem(mediaUrl = mediaUrl, isCurrentUser = isCurrentUser, contentColor = contentColor) FileItem(media = mediaItem, isCurrentUser = isCurrentUser, contentColor = contentColor)
} }
else -> {}
} }
} else if (mediaCount > 1) { } else {
// Photo Grid for multiple images // Multi-media grid
PhotoGrid( PhotoGrid(
urls = message.media, message.media,
onMediaClick = { lightboxMedia = it to "image" }, onMediaClick = { index -> lightboxMediaIndex = index },
modifier = Modifier modifier = Modifier.fillMaxWidth().height(300.dp).clip(RoundedCornerShape(12.dp))
.fillMaxWidth()
.height(300.dp)
.clip(RoundedCornerShape(12.dp))
) )
} }
if (mediaCount > 0) { Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(4.dp))
}
} }
// Text Content // Text Content
@@ -245,11 +231,11 @@ fun MessageBubble(
} }
val formattedTime = remember(message.createdAt) { val formattedTime = remember(message.createdAt) {
try { try {
// Handle ISO 8601 strings like 2024-05-14T10:49:02.12Z val instant = Instant.parse(message.createdAt)
val timePart = message.createdAt.substringAfter('T').take(5) val zonedDateTime = instant.atZone(ZoneId.systemDefault())
if (timePart.contains(':')) timePart else "00:00" zonedDateTime.format(DateTimeFormatter.ofPattern("HH:mm"))
} catch (e: Exception) { } catch (e: Exception) {
"00:00" message.createdAt.substringAfter('T').take(5)
} }
} }
Text( Text(
@@ -271,11 +257,11 @@ fun MessageBubble(
} }
} }
lightboxMedia?.let { (url, type) -> lightboxMediaIndex?.let { index ->
AppMediaLightbox( AppMediaLightbox(
url = url, mediaList = message.media,
type = type, initialIndex = index,
onClose = { lightboxMedia = null } onClose = { lightboxMediaIndex = null }
) )
} }
} }
@@ -302,11 +288,7 @@ fun MessageReactions(
Text(text = emoji, fontSize = 12.sp) Text(text = emoji, fontSize = 12.sp)
if (count > 1) { if (count > 1) {
Spacer(modifier = Modifier.width(2.dp)) Spacer(modifier = Modifier.width(2.dp))
Text( Text(text = count.toString(), fontSize = 10.sp, color = Color.White)
text = count.toString(),
fontSize = 10.sp,
color = Color.White
)
} }
} }
} }
@@ -315,7 +297,7 @@ fun MessageReactions(
} }
@Composable @Composable
fun FileItem(mediaUrl: String, isCurrentUser: Boolean, contentColor: Color) { fun FileItem(media: Media, isCurrentUser: Boolean, contentColor: Color) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -331,25 +313,12 @@ fun FileItem(mediaUrl: String, isCurrentUser: Boolean, contentColor: Color) {
.background(if (isCurrentUser) Color.White.copy(alpha = 0.2f) else Color(0xFF3390EC).copy(alpha = 0.2f)), .background(if (isCurrentUser) Color.White.copy(alpha = 0.2f) else Color(0xFF3390EC).copy(alpha = 0.2f)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(Icons.Default.Description, contentDescription = null, tint = contentColor)
Icons.Default.Description,
contentDescription = null,
tint = contentColor
)
} }
Column( Column(
modifier = Modifier modifier = Modifier.weight(1f).padding(horizontal = 8.dp)
.weight(1f)
.padding(horizontal = 8.dp)
) { ) {
val fileName = remember(mediaUrl) { val fileName = media.filename ?: "File"
val decoded = Uri.decode(mediaUrl.substringAfterLast("/"))
if (decoded.length > 30) {
decoded.take(15) + "..." + decoded.takeLast(10)
} else {
decoded
}
}
Text( Text(
text = fileName, text = fileName,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -357,48 +326,38 @@ fun FileItem(mediaUrl: String, isCurrentUser: Boolean, contentColor: Color) {
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
val sizeStr = media.size?.let {
if (it > 1024 * 1024) "${it / (1024 * 1024)} MB" else "${it / 1024} KB"
} ?: "File"
Text( Text(
text = if (mediaUrl.endsWith(".mp3", ignoreCase = true) || mediaUrl.contains("audio")) "Audio" else "File", text = sizeStr,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.6f) color = contentColor.copy(alpha = 0.6f)
) )
} }
Icon( Icon(Icons.Default.Download, contentDescription = null, tint = contentColor, modifier = Modifier.size(20.dp))
Icons.Default.Download,
contentDescription = null,
tint = contentColor,
modifier = Modifier.size(20.dp)
)
} }
} }
@Composable @Composable
fun PhotoGrid(urls: List<String>, onMediaClick: (String) -> Unit, modifier: Modifier = Modifier) { fun PhotoGrid(media: List<Media>, onMediaClick: (Int) -> Unit, modifier: Modifier = Modifier) {
val items = urls.take(4) val items = media.take(4)
Column(modifier = modifier) { Column(modifier = modifier) {
val rows = (items.size + 1) / 2 val rows = (items.size + 1) / 2
for (i in 0 until rows) { for (i in 0 until rows) {
Row(modifier = Modifier.weight(1f)) { Row(modifier = Modifier.weight(1f)) {
val firstIndex = i * 2 val firstIndex = i * 2
AsyncImage( AsyncImage(
model = items[firstIndex], model = items[firstIndex].url,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier.weight(1f).fillMaxHeight().padding(1.dp).clickable { onMediaClick(firstIndex) },
.weight(1f)
.fillMaxHeight()
.padding(1.dp)
.clickable { onMediaClick(items[firstIndex]) },
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
if (firstIndex + 1 < items.size) { if (firstIndex + 1 < items.size) {
AsyncImage( AsyncImage(
model = items[firstIndex + 1], model = items[firstIndex + 1].url,
contentDescription = null, contentDescription = null,
modifier = Modifier modifier = Modifier.weight(1f).fillMaxHeight().padding(1.dp).clickable { onMediaClick(firstIndex + 1) },
.weight(1f)
.fillMaxHeight()
.padding(1.dp)
.clickable { onMediaClick(items[firstIndex + 1]) },
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
} else if (rows > 1) { } else if (rows > 1) {
@@ -408,4 +367,3 @@ fun PhotoGrid(urls: List<String>, onMediaClick: (String) -> Unit, modifier: Modi
} }
} }
} }
@@ -8,6 +8,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow 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.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -17,66 +19,110 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.MediaItem
import androidx.media3.common.PlaybackParameters import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.GraphicEq
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
@Composable @Composable
fun AppAudioPlayer( fun AppAudioPlayer(
url: String, url: String,
name: String? = null,
size: Long? = null,
isVoiceMessage: Boolean = false, isVoiceMessage: Boolean = false,
initialPlaybackSpeed: Float = core.utils.PlaybackManager.globalPlaybackSpeed.value,
autoPlay: Boolean = false,
onFinished: (Float) -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentColor: Color = Color.White contentColor: Color = Color.White
) { ) {
val context = LocalContext.current val context = LocalContext.current
var playbackSpeed by remember { mutableFloatStateOf(initialPlaybackSpeed) }
// Update global speed when changed locally
LaunchedEffect(playbackSpeed) {
core.utils.PlaybackManager.globalPlaybackSpeed.value = playbackSpeed
}
val exoPlayer = remember { val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply { ExoPlayer.Builder(context).build().apply {
val mediaItem = MediaItem.fromUri(url) val mediaItem = MediaItem.fromUri(url)
setMediaItem(mediaItem) setMediaItem(mediaItem)
playbackParameters = PlaybackParameters(playbackSpeed)
val savedPos = core.utils.PlaybackManager.getPosition(url)
seekTo(savedPos)
prepare() prepare()
} }
} }
// Обработка сигнала авто-запуска
LaunchedEffect(autoPlay) {
if (autoPlay) {
exoPlayer.play()
core.utils.PlaybackManager.currentPlayingUrl.value = url
}
}
var isPlaying by remember { mutableStateOf(false) } var isPlaying by remember { mutableStateOf(false) }
var currentPosition by remember { mutableLongStateOf(0L) } var currentPosition by remember { mutableLongStateOf(core.utils.PlaybackManager.getPosition(url)) }
var duration by remember { mutableLongStateOf(0L) } var duration by remember { mutableLongStateOf(0L) }
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
var layoutSize by remember { mutableStateOf(IntSize.Zero) } var layoutSize by remember { mutableStateOf(IntSize.Zero) }
val fileName = remember(url) { url.substringAfterLast("/") } // Глобальная синхронизация: если играет кто-то другой, ставим паузу
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) { DisposableEffect(Unit) {
val listener = object : Player.Listener { val listener = object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) { override fun onIsPlayingChanged(playing: Boolean) {
isPlaying = playing isPlaying = playing
if (playing) {
core.utils.PlaybackManager.currentPlayingUrl.value = url
}
} }
override fun onPlaybackStateChanged(state: Int) { override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_READY) { if (state == Player.STATE_READY) {
duration = exoPlayer.duration 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) exoPlayer.addListener(listener)
onDispose { onDispose {
core.utils.PlaybackManager.savePosition(url, exoPlayer.currentPosition)
exoPlayer.removeListener(listener) exoPlayer.removeListener(listener)
exoPlayer.release() exoPlayer.release()
} }
} }
LaunchedEffect(isPlaying) { LaunchedEffect(isPlaying) {
while (isPlaying) { if (isPlaying) {
currentPosition = exoPlayer.currentPosition while (isPlaying) {
delay(100) currentPosition = exoPlayer.currentPosition
core.utils.PlaybackManager.savePosition(url, currentPosition)
delay(200)
}
} }
} }
@@ -84,38 +130,39 @@ fun AppAudioPlayer(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.background(Color.Black.copy(alpha = 0.2f)) .background(Color.White.copy(alpha = 0.12f))
.padding(8.dp) .padding(8.dp)
) { ) {
if (!isVoiceMessage) { // Header: Icon and Name
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 4.dp) modifier = Modifier.padding(bottom = 8.dp)
) { ) {
Icon( Icon(
Icons.Default.GraphicEq, imageVector = Icons.Default.VolumeUp,
contentDescription = null, contentDescription = null,
tint = contentColor.copy(alpha = 0.7f), tint = contentColor.copy(alpha = 0.6f),
modifier = Modifier.size(16.dp) modifier = Modifier.size(14.dp)
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(6.dp))
Text( Text(
text = fileName, text = if (isVoiceMessage) "Голосовое сообщение" else fileName,
fontSize = 12.sp, fontSize = 13.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = contentColor, color = contentColor,
maxLines = 1, maxLines = 1,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
}
} }
Row( Row(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) { ) {
// Play/Pause Button
Box( Box(
modifier = Modifier modifier = Modifier
.size(40.dp) .size(44.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color.White) .background(Color.White)
.clickable { if (isPlaying) exoPlayer.pause() else exoPlayer.play() }, .clickable { if (isPlaying) exoPlayer.pause() else exoPlayer.play() },
@@ -124,21 +171,19 @@ fun AppAudioPlayer(
Icon( Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = null, contentDescription = null,
tint = Color(0xFF3096E5), tint = Color(0xFF3390EC),
modifier = Modifier.size(28.dp) modifier = Modifier.size(28.dp)
) )
} }
Column( Spacer(modifier = Modifier.width(12.dp))
modifier = Modifier
.weight(1f) Column(modifier = Modifier.weight(1f)) {
.padding(horizontal = 12.dp) // Waveform
) {
// Waveform / Progress
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(32.dp) .height(30.dp)
.onSizeChanged { layoutSize = it } .onSizeChanged { layoutSize = it }
.pointerInput(duration) { .pointerInput(duration) {
detectTapGestures { offset -> detectTapGestures { offset ->
@@ -153,28 +198,27 @@ fun AppAudioPlayer(
contentAlignment = Alignment.CenterStart contentAlignment = Alignment.CenterStart
) { ) {
androidx.compose.foundation.Canvas(modifier = Modifier.fillMaxSize()) { androidx.compose.foundation.Canvas(modifier = Modifier.fillMaxSize()) {
val barCount = 35 val barCount = 40
val barSpacing = 2.dp.toPx() val barSpacing = 2.dp.toPx()
val barWidth = (size.width - (barCount - 1) * barSpacing) / barCount val barWidth = (this.size.width - (barCount - 1) * barSpacing) / barCount
val progress = if (duration > 0) currentPosition.toFloat() / duration.toFloat() else 0f val progress = if (duration > 0) currentPosition.toFloat() / duration.toFloat() else 0f
// Fake consistent waveform items
val barHeights = listOf( val barHeights = listOf(
0.4f, 0.6f, 0.3f, 0.8f, 0.5f, 0.9f, 0.4f, 0.7f, 0.5f, 0.8f, 0.3f, 0.5f, 0.2f, 0.7f, 0.4f, 0.8f, 0.3f, 0.6f, 0.4f, 0.7f,
0.3f, 0.7f, 0.6f, 0.9f, 0.4f, 0.8f, 0.5f, 0.7f, 0.3f, 0.9f, 0.2f, 0.6f, 0.5f, 0.8f, 0.3f, 0.7f, 0.4f, 0.6f, 0.2f, 0.8f,
0.5f, 0.6f, 0.4f, 0.8f, 0.6f, 0.7f, 0.5f, 0.4f, 0.5f, 0.7f, 0.4f, 0.5f, 0.3f, 0.7f, 0.5f, 0.6f, 0.4f, 0.3f, 0.4f, 0.6f,
0.4f, 0.3f, 0.5f, 0.6f, 0.4f 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) { for (i in 0 until barCount) {
val x = i * (barWidth + barSpacing) val x = i * (barWidth + barSpacing)
val heightPct = barHeights[i % barHeights.size] val heightPct = barHeights[i % barHeights.size]
val barHeight = size.height * heightPct val barHeight = this.size.height * heightPct
val isActive = (i.toFloat() / barCount) <= progress val isActive = (i.toFloat() / barCount) <= progress
drawRoundRect( drawRoundRect(
color = if (isActive) Color.White else Color.White.copy(alpha = 0.25f), color = if (isActive) Color.White else Color.White.copy(alpha = 0.25f),
topLeft = androidx.compose.ui.geometry.Offset(x, (size.height - barHeight) / 2), topLeft = androidx.compose.ui.geometry.Offset(x, (this.size.height - barHeight) / 2),
size = androidx.compose.ui.geometry.Size(barWidth, barHeight), size = androidx.compose.ui.geometry.Size(barWidth, barHeight),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(2.dp.toPx()) cornerRadius = androidx.compose.ui.geometry.CornerRadius(2.dp.toPx())
) )
@@ -182,56 +226,79 @@ fun AppAudioPlayer(
} }
} }
Spacer(modifier = Modifier.height(2.dp))
// Footer: Time, Size, Download
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Text( Row(verticalAlignment = Alignment.CenterVertically) {
text = "${formatDuration(currentPosition)} / ${formatDuration(duration)}", Text(
fontSize = 11.sp, text = formatDuration(if (isPlaying || currentPosition > 0) currentPosition else duration),
color = contentColor.copy(alpha = 0.7f) 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) { if (!isVoiceMessage) {
Icon( Icon(
Icons.Default.Download, imageVector = Icons.Default.Download,
contentDescription = null, contentDescription = null,
tint = contentColor.copy(alpha = 0.7f), tint = contentColor.copy(alpha = 0.6f),
modifier = Modifier.size(14.dp) modifier = Modifier.size(14.dp).clickable { /* Handle download */ }
) )
} }
} }
} }
Surface( // Speed toggle (only for voice messages or if user wants)
onClick = { if (isVoiceMessage || playbackSpeed > 1.0f) {
playbackSpeed = when (playbackSpeed) { Spacer(modifier = Modifier.width(8.dp))
1.0f -> 1.5f Surface(
1.5f -> 2.0f onClick = {
else -> 1.0f 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
)
} }
exoPlayer.playbackParameters = PlaybackParameters(playbackSpeed)
},
color = Color.White.copy(alpha = 0.15f),
shape = CircleShape,
modifier = Modifier.size(36.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = "${if (playbackSpeed % 1.0f == 0.0f) playbackSpeed.toInt() else playbackSpeed}x",
fontSize = 11.sp,
fontWeight = FontWeight.Black,
color = Color.White
)
} }
} }
} }
} }
} }
private fun formatDuration(durationMs: Long): String { private fun formatDuration(durationMs: Long): String {
if (durationMs <= 0) return "0:00"
val totalSeconds = durationMs / 1000 val totalSeconds = durationMs / 1000
val minutes = totalSeconds / 60 val minutes = totalSeconds / 60
val seconds = totalSeconds % 60 val seconds = totalSeconds % 60
return String.format("%02d:%02d", minutes, seconds) return String.format("%d:%02d", minutes, seconds)
} }
@@ -22,13 +22,19 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage import coil.compose.AsyncImage
import chats.domain.model.Media
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable @Composable
fun AppMediaLightbox( fun AppMediaLightbox(
url: String, mediaList: List<Media>,
type: String = "image", initialIndex: Int = 0,
onClose: () -> Unit onClose: () -> Unit
) { ) {
val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { mediaList.size })
Dialog( Dialog(
onDismissRequest = onClose, onDismissRequest = onClose,
properties = DialogProperties( properties = DialogProperties(
@@ -36,41 +42,61 @@ fun AppMediaLightbox(
decorFitsSystemWindows = false decorFitsSystemWindows = false
) )
) { ) {
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black) .background(Color.Black)
.pointerInput(Unit) { ) {
detectTransformGestures { _, pan, zoom, _ -> HorizontalPager(
scale = (scale * zoom).coerceIn(1f, 5f) state = pagerState,
offset += pan modifier = Modifier.fillMaxSize(),
pageSpacing = 16.dp,
beyondBoundsPageCount = 1
) { page ->
val media = mediaList[page]
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
if (media.type.startsWith("video")) {
AppVideoPlayer(
url = media.url,
modifier = Modifier.fillMaxSize(),
useController = true,
autoPlay = true
)
} else {
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
AsyncImage(
model = media.url,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTransformGestures(
onGesture = { _, pan, zoom, _ ->
scale = (scale * zoom).coerceIn(1f, 5f)
if (scale > 1f) {
offset += pan
} else {
offset = androidx.compose.ui.geometry.Offset.Zero
}
}
)
}
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offset.x,
translationY = offset.y
),
contentScale = ContentScale.Fit
)
} }
} }
) {
if (type == "video") {
AppVideoPlayer(
url = url,
modifier = Modifier.fillMaxSize(),
useController = true,
autoPlay = true
)
} else {
AsyncImage(
model = url,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offset.x,
translationY = offset.y
),
contentScale = ContentScale.Fit
)
} }
// Top Bar // Top Bar
@@ -91,8 +117,16 @@ fun AppMediaLightbox(
Icon(Icons.Default.Close, contentDescription = null, tint = Color.White) Icon(Icons.Default.Close, contentDescription = null, tint = Color.White)
} }
if (mediaList.isNotEmpty()) {
Text(
text = "${pagerState.currentPage + 1} / ${mediaList.size}",
color = Color.White,
style = MaterialTheme.typography.titleMedium
)
}
IconButton( IconButton(
onClick = { /* Handle download */ }, onClick = { /* Handle download of mediaList[pagerState.currentPage] */ },
modifier = Modifier modifier = Modifier
.clip(CircleShape) .clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f)) .background(Color.Black.copy(alpha = 0.5f))
@@ -1,17 +1,32 @@
package core.presentation.components package core.presentation.components
import androidx.annotation.OptIn import androidx.compose.animation.*
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
import kotlinx.coroutines.delay
import androidx.annotation.OptIn
import androidx.media3.common.util.UnstableApi
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.draw.clip
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@Composable @Composable
@@ -24,7 +39,6 @@ fun AppVideoPlayer(
onVideoFinished: () -> Unit = {} onVideoFinished: () -> Unit = {}
) { ) {
val context = LocalContext.current val context = LocalContext.current
val exoPlayer = remember { val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply { ExoPlayer.Builder(context).build().apply {
val mediaItem = MediaItem.fromUri(url) val mediaItem = MediaItem.fromUri(url)
@@ -32,36 +46,158 @@ fun AppVideoPlayer(
volume = if (isMuted) 0f else 1f volume = if (isMuted) 0f else 1f
prepare() prepare()
playWhenReady = autoPlay playWhenReady = autoPlay
addListener(object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_ENDED) {
onVideoFinished()
}
}
})
} }
} }
LaunchedEffect(isMuted) { var isPlaying by remember { mutableStateOf(autoPlay) }
exoPlayer.volume = if (isMuted) 0f else 1f var currentPosition by remember { mutableLongStateOf(0L) }
} var duration by remember { mutableLongStateOf(0L) }
var isControlsVisible by remember { mutableStateOf(true) }
var isDragging by remember { mutableStateOf(false) }
// Очистка ресурсов при выходе
DisposableEffect(Unit) { DisposableEffect(Unit) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(playing: Boolean) {
isPlaying = playing
}
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_READY) {
duration = exoPlayer.duration
} else if (state == Player.STATE_ENDED) {
onVideoFinished()
}
}
}
exoPlayer.addListener(listener)
onDispose { onDispose {
exoPlayer.removeListener(listener)
exoPlayer.release() exoPlayer.release()
} }
} }
AndroidView( LaunchedEffect(isPlaying, isDragging) {
factory = { if (isPlaying && !isDragging) {
PlayerView(it).apply { while (isPlaying) {
player = exoPlayer currentPosition = exoPlayer.currentPosition
this.useController = useController delay(500)
// Для сторис используем заполнение экрана
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM
} }
}, }
modifier = modifier }
)
Box(modifier = modifier.background(Color.Black)) {
AndroidView(
factory = {
PlayerView(it).apply {
player = exoPlayer
this.useController = false // Use our custom UI
this.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
setBackgroundColor(android.graphics.Color.BLACK)
}
},
modifier = Modifier.fillMaxSize().clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
isControlsVisible = !isControlsVisible
}
)
AnimatedVisibility(
visible = isControlsVisible,
enter = fadeIn(),
exit = fadeOut()
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.3f))
) {
// Center Controls
Row(
modifier = Modifier.align(Alignment.Center),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
IconButton(
onClick = { exoPlayer.seekTo((exoPlayer.currentPosition - 10000).coerceAtLeast(0)) },
modifier = Modifier.size(48.dp)
) {
Icon(Icons.Rounded.Replay10, contentDescription = null, tint = Color.White, modifier = Modifier.size(36.dp))
}
Box(
modifier = Modifier
.size(64.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.2f))
.clickable { if (isPlaying) exoPlayer.pause() else exoPlayer.play() },
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (isPlaying) Icons.Rounded.Pause else Icons.Rounded.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(44.dp)
)
}
IconButton(
onClick = { exoPlayer.seekTo((exoPlayer.currentPosition + 10000).coerceAtMost(duration)) },
modifier = Modifier.size(48.dp)
) {
Icon(Icons.Rounded.Forward10, contentDescription = null, tint = Color.White, modifier = Modifier.size(36.dp))
}
}
// Bottom Controls
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 16.dp, start = 16.dp, end = 16.dp)
) {
Slider(
value = currentPosition.toFloat(),
onValueChange = {
isDragging = true
currentPosition = it.toLong()
},
onValueChangeFinished = {
exoPlayer.seekTo(currentPosition)
isDragging = false
},
valueRange = 0f..(duration.toFloat().coerceAtLeast(1f)),
colors = SliderDefaults.colors(
thumbColor = Color.White,
activeTrackColor = Color.White,
inactiveTrackColor = Color.White.copy(alpha = 0.3f)
),
modifier = Modifier.height(24.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "${formatTime(currentPosition)} · ${formatTime(duration)}",
color = Color.White,
fontSize = 12.sp
)
IconButton(onClick = { /* Settings */ }) {
Icon(Icons.Rounded.Settings, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
}
}
}
}
}
}
}
private fun formatTime(ms: Long): String {
val totalSeconds = ms / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return String.format("%02d:%02d", minutes, seconds)
} }
@@ -0,0 +1,30 @@
package core.utils
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.*
object PlaybackManager {
// Храним позиции воспроизведения: URL -> Position (Long)
private val savedPositions = mutableMapOf<String, Long>()
// ID сообщения, которое воспроизводится сейчас (чтобы останавливать другие)
var currentPlayingUrl = mutableStateOf<String?>(null)
// Глобальная скорость проигрывания (сохраняем между треками)
var globalPlaybackSpeed = mutableStateOf(1.0f)
fun savePosition(url: String, position: Long) {
if (url.isNotBlank()) {
savedPositions[url] = position
}
}
fun getPosition(url: String): Long {
return savedPositions[url] ?: 0L
}
fun resetPosition(url: String) {
savedPositions[url] = 0L
}
}