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

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

View File

@@ -4,22 +4,23 @@ import com.google.gson.annotations.SerializedName
data class UserBasicDto(
@SerializedName("id") val id: String,
@SerializedName("userName") val userName: String,
@SerializedName("displayName") val displayName: String,
@SerializedName("avatarUrl") val avatarUrl: String?
@SerializedName("username") val username: String? = null,
@SerializedName("displayName") val displayName: String? = null,
@SerializedName("avatarUrl") val avatarUrl: String? = null
)
data class MessageDto(
@SerializedName("id") val id: String,
@SerializedName("chatId") val chatId: String,
@SerializedName("senderId") val senderId: String,
@SerializedName("content") val content: String?,
@SerializedName("type") val type: String,
@SerializedName("sequenceId") val sequenceId: Int,
@SerializedName("createdAt") val createdAt: String,
@SerializedName("sender") val sender: UserBasicDto,
@SerializedName("chatId") val chatId: String? = null,
@SerializedName("senderId") val senderId: String? = null,
@SerializedName("content") val content: String? = null,
@SerializedName("type") val type: String? = null,
@SerializedName("sequenceId") val sequenceId: Int? = null,
@SerializedName("createdAt") val createdAt: String? = null,
@SerializedName("sender") val sender: UserBasicDto? = null,
@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(
@@ -31,20 +32,23 @@ data class ReactionDto(
data class MediaItemDto(
@SerializedName("id") val id: 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(
@SerializedName("id") val id: String,
@SerializedName("type") val type: String,
@SerializedName("name") val name: String?,
@SerializedName("avatar") val avatar: String?,
@SerializedName("unreadCount") val unreadCount: Int,
@SerializedName("name") val name: String? = null,
@SerializedName("avatar") val avatar: String? = null,
@SerializedName("unreadCount") val unreadCount: Int = 0,
@SerializedName("messages") val messages: List<MessageDto> = emptyList(),
@SerializedName("members") val members: List<ChatMemberDto> = emptyList()
)
data class ChatMemberDto(
@SerializedName("userId") val userId: String,
@SerializedName("user") val user: UserBasicDto?
@SerializedName("user") val user: UserBasicDto? = null
)

View File

@@ -57,12 +57,8 @@ class ChatRepositoryImpl @Inject constructor(
}
}
// Mappers
fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat {
// Если это личный чат и имя пустое, ищем имя собеседника в списке участников
val chatName = name ?: if (type == "personal") {
members.firstOrNull { it.userId != currentUserId }?.user?.displayName ?: "Unknown Chat"
} else "Group Chat"
@@ -98,17 +94,26 @@ fun MessageDto.toDomain(baseUrl: String): Message {
return Message(
id = id,
chatId = chatId,
senderId = senderId,
senderName = sender.displayName,
senderAvatar = sender.avatarUrl?.ensureAbsoluteUrl(baseUrl),
chatId = chatId ?: "",
senderId = senderId ?: "",
senderName = sender?.displayName ?: "Unknown",
senderAvatar = sender?.avatarUrl?.ensureAbsoluteUrl(baseUrl),
content = content,
sequenceId = sequenceId,
createdAt = createdAt,
media = media.map { it.url.ensureAbsoluteUrl(baseUrl) },
mediaUrl = media.firstOrNull()?.url?.ensureAbsoluteUrl(baseUrl),
sequenceId = sequenceId ?: 0,
createdAt = createdAt ?: "",
media = media.map {
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,
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"
}
}

View File

@@ -9,14 +9,22 @@ data class Message(
val content: String?,
val sequenceId: Int,
val createdAt: String,
val media: List<String> = emptyList(),
val mediaUrl: String? = null,
val media: List<Media> = emptyList(),
val mediaType: MediaType = MediaType.TEXT,
val reactions: Map<String, Int> = emptyMap(),
val isRead: Boolean = false,
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 {
TEXT, IMAGE, VIDEO, AUDIO, FILE, STORY_REPLY
}

View File

@@ -25,6 +25,7 @@ import core.utils.VoiceRecorder
import core.utils.copyUriToFile
import java.io.File
import ru.knot.messager.R
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -37,10 +38,37 @@ fun ChatDetailScreen(
val state by viewModel.state.collectAsState()
val context = LocalContext.current
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val voiceRecorder = remember { VoiceRecorder(context) }
var textInput by remember { mutableStateOf("") }
var isEmojiPickerVisible 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(
@@ -126,7 +154,9 @@ fun ChatDetailScreen(
MessageBubble(
message = message,
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) }
)
}

View File

@@ -19,15 +19,13 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import chats.domain.model.Message
import chats.domain.model.MediaType
import chats.domain.model.Media
import coil.compose.AsyncImage
import core.presentation.components.AppVideoPlayer
import core.presentation.components.AppAudioPlayer
import core.presentation.components.AppAvatar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.*
import chats.presentation.components.LinkPreview
import android.content.Intent
import android.net.Uri
@@ -35,31 +33,33 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.material3.Icon
import androidx.compose.ui.text.style.TextOverflow
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 java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
@Composable
fun MessageBubble(
message: Message,
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
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) {
Color(0xFF3096E5) // Updated Blue
Color(0xFF3096E5)
} else {
Color(0xFF212121) // Updated Dark Grey
Color(0xFF212121)
}
val contentColor = Color.White
val alignment = if (isCurrentUser) Alignment.CenterEnd else Alignment.CenterStart
val shape = if (isCurrentUser) {
RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp)
} else {
@@ -76,15 +76,8 @@ fun MessageBubble(
horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom
) {
if (!isCurrentUser) {
AppAvatar(
url = senderAvatar,
name = message.senderName,
size = 32.dp,
modifier = Modifier.padding(end = 8.dp, bottom = 4.dp)
)
}
// No avatars here as per user request (web parity)
Column(
modifier = Modifier
.widthIn(max = 300.dp)
@@ -118,6 +111,7 @@ fun MessageBubble(
.clip(RoundedCornerShape(4.dp))
.background(contentColor.copy(alpha = 0.1f))
.height(IntrinsicSize.Min)
.clickable { /* Scroll to reply */ }
) {
Box(
modifier = Modifier
@@ -135,10 +129,11 @@ fun MessageBubble(
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
)
Text(
text = reply.content ?: "[Media]",
text = reply.content ?: if (reply.media.isNotEmpty()) "\uD83D\uDCCE Media" else "",
style = MaterialTheme.typography.bodySmall,
color = contentColor.copy(alpha = 0.8f),
maxLines = 1
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@@ -148,78 +143,69 @@ fun MessageBubble(
if (message.media.isNotEmpty()) {
val mediaCount = message.media.size
if (mediaCount == 1) {
val mediaUrl = message.media[0]
when (message.mediaType) {
MediaType.IMAGE -> {
val mediaItem = message.media[0]
when {
mediaItem.type.startsWith("image") || message.mediaType == MediaType.IMAGE -> {
AsyncImage(
model = mediaUrl,
model = mediaItem.url,
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable {
lightboxMedia = mediaUrl to "image"
},
.clickable { lightboxMediaIndex = 0 },
contentScale = ContentScale.FillWidth
)
}
MediaType.VIDEO -> {
mediaItem.type.startsWith("video") || message.mediaType == MediaType.VIDEO -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.clip(RoundedCornerShape(12.dp))
.background(Color.Black)
.clickable {
lightboxMedia = mediaUrl to "video"
},
.clickable { lightboxMediaIndex = 0 },
contentAlignment = Alignment.Center
) {
AppVideoPlayer(
url = mediaUrl,
url = mediaItem.url,
modifier = Modifier.fillMaxSize(),
useController = false,
autoPlay = true,
isMuted = true
)
// Volume Off icon in top right
Icon(
imageVector = Icons.Default.VolumeOff,
contentDescription = null,
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.size(20.dp)
modifier = Modifier.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(
url = mediaUrl,
url = mediaItem.url,
name = mediaItem.filename,
size = mediaItem.size,
isVoiceMessage = isVoiceMessage,
initialPlaybackSpeed = initialPlaybackSpeed,
autoPlay = autoPlay,
onFinished = onVoiceFinished,
contentColor = contentColor
)
}
MediaType.FILE -> {
FileItem(mediaUrl = mediaUrl, isCurrentUser = isCurrentUser, contentColor = contentColor)
else -> {
FileItem(media = mediaItem, isCurrentUser = isCurrentUser, contentColor = contentColor)
}
else -> {}
}
} else if (mediaCount > 1) {
// Photo Grid for multiple images
} else {
// Multi-media grid
PhotoGrid(
urls = message.media,
onMediaClick = { lightboxMedia = it to "image" },
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
.clip(RoundedCornerShape(12.dp))
message.media,
onMediaClick = { index -> lightboxMediaIndex = index },
modifier = Modifier.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
@@ -245,11 +231,11 @@ fun MessageBubble(
}
val formattedTime = remember(message.createdAt) {
try {
// Handle ISO 8601 strings like 2024-05-14T10:49:02.12Z
val timePart = message.createdAt.substringAfter('T').take(5)
if (timePart.contains(':')) timePart else "00:00"
val instant = Instant.parse(message.createdAt)
val zonedDateTime = instant.atZone(ZoneId.systemDefault())
zonedDateTime.format(DateTimeFormatter.ofPattern("HH:mm"))
} catch (e: Exception) {
"00:00"
message.createdAt.substringAfter('T').take(5)
}
}
Text(
@@ -271,11 +257,11 @@ fun MessageBubble(
}
}
lightboxMedia?.let { (url, type) ->
lightboxMediaIndex?.let { index ->
AppMediaLightbox(
url = url,
type = type,
onClose = { lightboxMedia = null }
mediaList = message.media,
initialIndex = index,
onClose = { lightboxMediaIndex = null }
)
}
}
@@ -302,11 +288,7 @@ fun MessageReactions(
Text(text = emoji, fontSize = 12.sp)
if (count > 1) {
Spacer(modifier = Modifier.width(2.dp))
Text(
text = count.toString(),
fontSize = 10.sp,
color = Color.White
)
Text(text = count.toString(), fontSize = 10.sp, color = Color.White)
}
}
}
@@ -315,7 +297,7 @@ fun MessageReactions(
}
@Composable
fun FileItem(mediaUrl: String, isCurrentUser: Boolean, contentColor: Color) {
fun FileItem(media: Media, isCurrentUser: Boolean, contentColor: Color) {
Row(
modifier = Modifier
.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)),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Description,
contentDescription = null,
tint = contentColor
)
Icon(Icons.Default.Description, contentDescription = null, tint = contentColor)
}
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 8.dp)
modifier = Modifier.weight(1f).padding(horizontal = 8.dp)
) {
val fileName = remember(mediaUrl) {
val decoded = Uri.decode(mediaUrl.substringAfterLast("/"))
if (decoded.length > 30) {
decoded.take(15) + "..." + decoded.takeLast(10)
} else {
decoded
}
}
val fileName = media.filename ?: "File"
Text(
text = fileName,
style = MaterialTheme.typography.bodySmall,
@@ -357,48 +326,38 @@ fun FileItem(mediaUrl: String, isCurrentUser: Boolean, contentColor: Color) {
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
val sizeStr = media.size?.let {
if (it > 1024 * 1024) "${it / (1024 * 1024)} MB" else "${it / 1024} KB"
} ?: "File"
Text(
text = if (mediaUrl.endsWith(".mp3", ignoreCase = true) || mediaUrl.contains("audio")) "Audio" else "File",
text = sizeStr,
style = MaterialTheme.typography.labelSmall,
color = contentColor.copy(alpha = 0.6f)
)
}
Icon(
Icons.Default.Download,
contentDescription = null,
tint = contentColor,
modifier = Modifier.size(20.dp)
)
Icon(Icons.Default.Download, contentDescription = null, tint = contentColor, modifier = Modifier.size(20.dp))
}
}
@Composable
fun PhotoGrid(urls: List<String>, onMediaClick: (String) -> Unit, modifier: Modifier = Modifier) {
val items = urls.take(4)
fun PhotoGrid(media: List<Media>, onMediaClick: (Int) -> Unit, modifier: Modifier = Modifier) {
val items = media.take(4)
Column(modifier = modifier) {
val rows = (items.size + 1) / 2
for (i in 0 until rows) {
Row(modifier = Modifier.weight(1f)) {
val firstIndex = i * 2
AsyncImage(
model = items[firstIndex],
model = items[firstIndex].url,
contentDescription = null,
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(1.dp)
.clickable { onMediaClick(items[firstIndex]) },
modifier = Modifier.weight(1f).fillMaxHeight().padding(1.dp).clickable { onMediaClick(firstIndex) },
contentScale = ContentScale.Crop
)
if (firstIndex + 1 < items.size) {
AsyncImage(
model = items[firstIndex + 1],
model = items[firstIndex + 1].url,
contentDescription = null,
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.padding(1.dp)
.clickable { onMediaClick(items[firstIndex + 1]) },
modifier = Modifier.weight(1f).fillMaxHeight().padding(1.dp).clickable { onMediaClick(firstIndex + 1) },
contentScale = ContentScale.Crop
)
} else if (rows > 1) {
@@ -408,4 +367,3 @@ fun PhotoGrid(urls: List<String>, onMediaClick: (String) -> Unit, modifier: Modi
}
}
}

View File

@@ -8,6 +8,8 @@ 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
@@ -17,66 +19,110 @@ 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 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
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
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(0L) }
var currentPosition by remember { mutableLongStateOf(core.utils.PlaybackManager.getPosition(url)) }
var duration by remember { mutableLongStateOf(0L) }
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
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) {
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) {
while (isPlaying) {
currentPosition = exoPlayer.currentPosition
delay(100)
if (isPlaying) {
while (isPlaying) {
currentPosition = exoPlayer.currentPosition
core.utils.PlaybackManager.savePosition(url, currentPosition)
delay(200)
}
}
}
@@ -84,38 +130,39 @@ fun AppAudioPlayer(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(Color.Black.copy(alpha = 0.2f))
.background(Color.White.copy(alpha = 0.12f))
.padding(8.dp)
) {
if (!isVoiceMessage) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(bottom = 4.dp)
) {
Icon(
Icons.Default.GraphicEq,
contentDescription = null,
tint = contentColor.copy(alpha = 0.7f),
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = fileName,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
color = contentColor,
maxLines = 1,
modifier = Modifier.weight(1f)
)
}
// 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
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
// Play/Pause Button
Box(
modifier = Modifier
.size(40.dp)
.size(44.dp)
.clip(CircleShape)
.background(Color.White)
.clickable { if (isPlaying) exoPlayer.pause() else exoPlayer.play() },
@@ -124,21 +171,19 @@ fun AppAudioPlayer(
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = null,
tint = Color(0xFF3096E5),
tint = Color(0xFF3390EC),
modifier = Modifier.size(28.dp)
)
}
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp)
) {
// Waveform / Progress
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
// Waveform
Box(
modifier = Modifier
.fillMaxWidth()
.height(32.dp)
.height(30.dp)
.onSizeChanged { layoutSize = it }
.pointerInput(duration) {
detectTapGestures { offset ->
@@ -153,28 +198,27 @@ fun AppAudioPlayer(
contentAlignment = Alignment.CenterStart
) {
androidx.compose.foundation.Canvas(modifier = Modifier.fillMaxSize()) {
val barCount = 35
val barCount = 40
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
// Fake consistent waveform items
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.7f, 0.6f, 0.9f, 0.4f, 0.8f, 0.5f, 0.7f, 0.3f, 0.9f,
0.5f, 0.6f, 0.4f, 0.8f, 0.6f, 0.7f, 0.5f, 0.4f, 0.5f, 0.7f,
0.4f, 0.3f, 0.5f, 0.6f, 0.4f
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 = size.height * heightPct
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, (size.height - barHeight) / 2),
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())
)
@@ -182,56 +226,79 @@ fun AppAudioPlayer(
}
}
Spacer(modifier = Modifier.height(2.dp))
// Footer: Time, Size, Download
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "${formatDuration(currentPosition)} / ${formatDuration(duration)}",
fontSize = 11.sp,
color = contentColor.copy(alpha = 0.7f)
)
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(
Icons.Default.Download,
imageVector = Icons.Default.Download,
contentDescription = null,
tint = contentColor.copy(alpha = 0.7f),
modifier = Modifier.size(14.dp)
tint = contentColor.copy(alpha = 0.6f),
modifier = Modifier.size(14.dp).clickable { /* Handle download */ }
)
}
}
}
Surface(
onClick = {
playbackSpeed = when (playbackSpeed) {
1.0f -> 1.5f
1.5f -> 2.0f
else -> 1.0f
// 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
)
}
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 {
if (durationMs <= 0) return "0:00"
val totalSeconds = durationMs / 1000
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return String.format("%02d:%02d", minutes, seconds)
return String.format("%d:%02d", minutes, seconds)
}

View File

@@ -22,13 +22,19 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
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
fun AppMediaLightbox(
url: String,
type: String = "image",
mediaList: List<Media>,
initialIndex: Int = 0,
onClose: () -> Unit
) {
val pagerState = rememberPagerState(initialPage = initialIndex, pageCount = { mediaList.size })
Dialog(
onDismissRequest = onClose,
properties = DialogProperties(
@@ -36,41 +42,61 @@ fun AppMediaLightbox(
decorFitsSystemWindows = false
)
) {
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(androidx.compose.ui.geometry.Offset.Zero) }
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
scale = (scale * zoom).coerceIn(1f, 5f)
offset += pan
) {
HorizontalPager(
state = pagerState,
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
@@ -91,8 +117,16 @@ fun AppMediaLightbox(
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(
onClick = { /* Handle download */ },
onClick = { /* Handle download of mediaList[pagerState.currentPage] */ },
modifier = Modifier
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.5f))

View File

@@ -1,17 +1,32 @@
package core.presentation.components
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.animation.*
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.ui.Alignment
import androidx.compose.ui.Modifier
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.viewinterop.AndroidView
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
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)
@Composable
@@ -24,7 +39,6 @@ fun AppVideoPlayer(
onVideoFinished: () -> Unit = {}
) {
val context = LocalContext.current
val exoPlayer = remember {
ExoPlayer.Builder(context).build().apply {
val mediaItem = MediaItem.fromUri(url)
@@ -32,36 +46,158 @@ fun AppVideoPlayer(
volume = if (isMuted) 0f else 1f
prepare()
playWhenReady = autoPlay
addListener(object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_ENDED) {
onVideoFinished()
}
}
})
}
}
LaunchedEffect(isMuted) {
exoPlayer.volume = if (isMuted) 0f else 1f
}
var isPlaying by remember { mutableStateOf(autoPlay) }
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) {
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 {
exoPlayer.removeListener(listener)
exoPlayer.release()
}
}
AndroidView(
factory = {
PlayerView(it).apply {
player = exoPlayer
this.useController = useController
// Для сторис используем заполнение экрана
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM
LaunchedEffect(isPlaying, isDragging) {
if (isPlaying && !isDragging) {
while (isPlaying) {
currentPosition = exoPlayer.currentPosition
delay(500)
}
},
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)
}

View File

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