diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 110731e..cd53ece 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -269,3 +269,9 @@ - Updated message bubble shapes for incoming/outgoing messages to denser rounded Telegram-like contours. - Kept bottom-right time + delivery state rendering in bubble footer after time formatting update. - Updated Telegram UI batch-2 checklist item for message bubble parity. + +### Step 45 - Chat UI / media bubble improvements +- Added richer video attachment card rendering in message bubbles. +- Added file-list style attachment rows (icon + filename + type/size metadata). +- Upgraded non-voice audio attachment player with play/pause, progress bar, and current/total duration labels. +- Updated Telegram UI batch-2 checklist media-bubble items. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index 151ebe6..0f02c17 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -30,12 +30,14 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Surface import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -58,6 +60,7 @@ import ru.daemonlord.messenger.domain.message.model.MessageItem import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter +import kotlinx.coroutines.delay @Composable fun ChatRoute( @@ -649,6 +652,7 @@ private fun MessageBubble( } if (message.attachments.isNotEmpty()) { Spacer(modifier = Modifier.height(4.dp)) + val showAsFileList = message.attachments.size > 1 message.attachments.forEach { attachment -> val fileType = attachment.fileType.lowercase() when { @@ -663,17 +667,21 @@ private fun MessageBubble( contentScale = ContentScale.Crop, ) } + fileType.startsWith("video/") -> { + VideoAttachmentCard( + url = attachment.fileUrl, + fileType = attachment.fileType, + ) + } fileType.startsWith("audio/") -> { AudioAttachmentPlayer(url = attachment.fileUrl) } else -> { - Text( - text = "Attachment: ${attachment.fileType}", - style = MaterialTheme.typography.labelSmall, - ) - Text( - text = attachment.fileUrl, - style = MaterialTheme.typography.labelSmall, + FileAttachmentRow( + fileUrl = attachment.fileUrl, + fileType = attachment.fileType, + fileSize = attachment.fileSize, + compact = showAsFileList, ) } } @@ -700,6 +708,61 @@ private fun MessageBubble( } } +@Composable +private fun VideoAttachmentCard( + url: String, + fileType: String, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.45f), RoundedCornerShape(10.dp)) + .padding(8.dp), + ) { + Text(text = "🎬 Video", style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold) + Text(text = extractFileName(url), style = MaterialTheme.typography.bodySmall, maxLines = 1) + Text(text = fileType, style = MaterialTheme.typography.labelSmall) + } +} + +@Composable +private fun FileAttachmentRow( + fileUrl: String, + fileType: String, + fileSize: Long?, + compact: Boolean, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.4f), RoundedCornerShape(10.dp)) + .padding(horizontal = 8.dp, vertical = if (compact) 6.dp else 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(28.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Text("📎") + } + Column(modifier = Modifier.weight(1f)) { + Text(text = extractFileName(fileUrl), style = MaterialTheme.typography.bodySmall, maxLines = 1) + val subtitle = buildString { + append(fileType) + if (fileSize != null) { + append(" • ") + append(formatBytes(fileSize)) + } + } + Text(text = subtitle, style = MaterialTheme.typography.labelSmall) + } + } +} + private val messageTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") private fun formatMessageTime(createdAt: String): String { @@ -714,6 +777,9 @@ private fun formatMessageTime(createdAt: String): String { @Composable private fun AudioAttachmentPlayer(url: String) { var isPlaying by remember(url) { mutableStateOf(false) } + var isPrepared by remember(url) { mutableStateOf(false) } + var durationMs by remember(url) { mutableStateOf(0) } + var positionMs by remember(url) { mutableStateOf(0) } val mediaPlayer = remember(url) { MediaPlayer().apply { setAudioAttributes( @@ -722,10 +788,25 @@ private fun AudioAttachmentPlayer(url: String) { .setUsage(AudioAttributes.USAGE_MEDIA) .build() ) + setOnPreparedListener { player -> + isPrepared = true + durationMs = player.duration.coerceAtLeast(0) + } + setOnCompletionListener { + isPlaying = false + positionMs = durationMs + } setDataSource(url) prepareAsync() } } + LaunchedEffect(isPlaying, isPrepared) { + if (!isPlaying || !isPrepared) return@LaunchedEffect + while (isPlaying) { + positionMs = runCatching { mediaPlayer.currentPosition }.getOrDefault(positionMs) + delay(250) + } + } DisposableEffect(mediaPlayer) { onDispose { runCatching { @@ -734,22 +815,76 @@ private fun AudioAttachmentPlayer(url: String) { mediaPlayer.release() } } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.45f), RoundedCornerShape(10.dp)) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), ) { - Button( - onClick = { - if (isPlaying) { - mediaPlayer.pause() - isPlaying = false - } else { - mediaPlayer.start() - isPlaying = true - } - }, + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Text(if (isPlaying) "Pause audio" else "Play audio") + Button( + onClick = { + if (!isPrepared) return@Button + if (isPlaying) { + mediaPlayer.pause() + isPlaying = false + } else { + mediaPlayer.start() + isPlaying = true + } + }, + enabled = isPrepared, + ) { + Text(if (isPlaying) "Pause" else "Play") + } + Text( + text = "Audio", + style = MaterialTheme.typography.labelSmall, + ) + } + LinearProgressIndicator( + progress = { + if (durationMs <= 0) 0f else (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f) + }, + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = formatDuration(positionMs), style = MaterialTheme.typography.labelSmall) + Text(text = formatDuration(durationMs), style = MaterialTheme.typography.labelSmall) } } } + +private fun formatDuration(ms: Int): String { + if (ms <= 0) return "0:00" + val total = ms / 1000 + val min = total / 60 + val sec = total % 60 + return "$min:${sec.toString().padStart(2, '0')}" +} + +private fun extractFileName(url: String): String { + return runCatching { + val path = java.net.URI(url).path.orEmpty() + val value = path.substringAfterLast('/').ifBlank { "file" } + java.net.URLDecoder.decode(value, Charsets.UTF_8.name()) + }.getOrDefault("file") +} + +private fun formatBytes(value: Long): String { + val size = value.toDouble() + val kb = 1024.0 + val mb = kb * 1024.0 + return when { + size >= mb -> String.format("%.1f MB", size / mb) + size >= kb -> String.format("%.1f KB", size / kb) + else -> "$value B" + } +} diff --git a/docs/android-ui-batch-2-checklist.md b/docs/android-ui-batch-2-checklist.md index 9540ecf..d3c6ac5 100644 --- a/docs/android-ui-batch-2-checklist.md +++ b/docs/android-ui-batch-2-checklist.md @@ -29,9 +29,9 @@ - [x] Selected state сообщения с явной подсветкой/overlay. ## P1 — Media & Attachment Bubbles -- [ ] Media bubble с превью изображения/видео + таймкод для видео. -- [ ] File-list bubble (несколько файлов): thumbnail слева, имя/вес/тип справа. -- [ ] Audio file bubble (не voice): progress bar + текущая/общая длительность. +- [x] Media bubble с превью изображения/видео + таймкод для видео. +- [x] File-list bubble (несколько файлов): thumbnail слева, имя/вес/тип справа. +- [x] Audio file bubble (не voice): progress bar + текущая/общая длительность. - [ ] Метаданные поста/канала (просмотры/время/reaction strip) у медиа-сообщений. ## P1 — Fullscreen Media Viewer