diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin index 936ef50..e28a413 100644 Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin differ diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock index 397a875..5954663 100644 Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock differ diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin index dd98604..0b6f5b8 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin differ diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock index ad3f864..4f8ef11 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock differ diff --git a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin index 58d92ea..edb116b 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin and b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin differ diff --git a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock index 05f79fa..ec5f104 100644 Binary files a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin index db8dd4b..c5aa896 100644 Binary files a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin and b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/client-mobile/chats/data/remote/dto/ChatDtos.kt b/client-mobile/chats/data/remote/dto/ChatDtos.kt index 2a38635..5904e9e 100644 --- a/client-mobile/chats/data/remote/dto/ChatDtos.kt +++ b/client-mobile/chats/data/remote/dto/ChatDtos.kt @@ -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 = emptyList(), - @SerializedName("reactions") val reactions: List? = emptyList() + @SerializedName("reactions") val reactions: List? = 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 = emptyList(), @SerializedName("members") val members: List = emptyList() ) data class ChatMemberDto( @SerializedName("userId") val userId: String, - @SerializedName("user") val user: UserBasicDto? + @SerializedName("user") val user: UserBasicDto? = null ) diff --git a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt index 8c6c95e..e2ded3c 100644 --- a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt +++ b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt @@ -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" } } - diff --git a/client-mobile/chats/domain/model/Message.kt b/client-mobile/chats/domain/model/Message.kt index f3cbec6..12ce75a 100644 --- a/client-mobile/chats/domain/model/Message.kt +++ b/client-mobile/chats/domain/model/Message.kt @@ -9,14 +9,22 @@ data class Message( val content: String?, val sequenceId: Int, val createdAt: String, - val media: List = emptyList(), - val mediaUrl: String? = null, + val media: List = emptyList(), val mediaType: MediaType = MediaType.TEXT, val reactions: Map = 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 } diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt index 4e77737..26ffbf4 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt @@ -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(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) } ) } diff --git a/client-mobile/chats/presentation/components/MessageBubble.kt b/client-mobile/chats/presentation/components/MessageBubble.kt index 2c5c1a5..92d9667 100644 --- a/client-mobile/chats/presentation/components/MessageBubble.kt +++ b/client-mobile/chats/presentation/components/MessageBubble.kt @@ -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?>(null) } + var lightboxMediaIndex by remember { mutableStateOf(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, onMediaClick: (String) -> Unit, modifier: Modifier = Modifier) { - val items = urls.take(4) +fun PhotoGrid(media: List, 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, onMediaClick: (String) -> Unit, modifier: Modi } } } - diff --git a/client-mobile/core/presentation/components/AppAudioPlayer.kt b/client-mobile/core/presentation/components/AppAudioPlayer.kt index 6c2f619..3c653b6 100644 --- a/client-mobile/core/presentation/components/AppAudioPlayer.kt +++ b/client-mobile/core/presentation/components/AppAudioPlayer.kt @@ -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) } diff --git a/client-mobile/core/presentation/components/AppMediaLightbox.kt b/client-mobile/core/presentation/components/AppMediaLightbox.kt index d794fa1..d52da33 100644 --- a/client-mobile/core/presentation/components/AppMediaLightbox.kt +++ b/client-mobile/core/presentation/components/AppMediaLightbox.kt @@ -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, + 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)) diff --git a/client-mobile/core/presentation/components/AppVideoPlayer.kt b/client-mobile/core/presentation/components/AppVideoPlayer.kt index 1948a69..cf252d6 100644 --- a/client-mobile/core/presentation/components/AppVideoPlayer.kt +++ b/client-mobile/core/presentation/components/AppVideoPlayer.kt @@ -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) } diff --git a/client-mobile/core/utils/PlaybackManager.kt b/client-mobile/core/utils/PlaybackManager.kt new file mode 100644 index 0000000..562c458 --- /dev/null +++ b/client-mobile/core/utils/PlaybackManager.kt @@ -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() + + // ID сообщения, которое воспроизводится сейчас (чтобы останавливать другие) + var currentPlayingUrl = mutableStateOf(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 + } +}