android: improve media and file attachment bubble rendering
Some checks are pending
CI / test (push) Has started running

This commit is contained in:
Codex
2026-03-09 14:37:00 +03:00
parent 542af1d4c1
commit c947d96748
3 changed files with 165 additions and 24 deletions

View File

@@ -269,3 +269,9 @@
- Updated message bubble shapes for incoming/outgoing messages to denser rounded Telegram-like contours. - 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. - 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. - 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.

View File

@@ -30,12 +30,14 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -58,6 +60,7 @@ import ru.daemonlord.messenger.domain.message.model.MessageItem
import java.time.Instant import java.time.Instant
import java.time.ZoneId import java.time.ZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlinx.coroutines.delay
@Composable @Composable
fun ChatRoute( fun ChatRoute(
@@ -649,6 +652,7 @@ private fun MessageBubble(
} }
if (message.attachments.isNotEmpty()) { if (message.attachments.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
val showAsFileList = message.attachments.size > 1
message.attachments.forEach { attachment -> message.attachments.forEach { attachment ->
val fileType = attachment.fileType.lowercase() val fileType = attachment.fileType.lowercase()
when { when {
@@ -663,17 +667,21 @@ private fun MessageBubble(
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
} }
fileType.startsWith("video/") -> {
VideoAttachmentCard(
url = attachment.fileUrl,
fileType = attachment.fileType,
)
}
fileType.startsWith("audio/") -> { fileType.startsWith("audio/") -> {
AudioAttachmentPlayer(url = attachment.fileUrl) AudioAttachmentPlayer(url = attachment.fileUrl)
} }
else -> { else -> {
Text( FileAttachmentRow(
text = "Attachment: ${attachment.fileType}", fileUrl = attachment.fileUrl,
style = MaterialTheme.typography.labelSmall, fileType = attachment.fileType,
) fileSize = attachment.fileSize,
Text( compact = showAsFileList,
text = attachment.fileUrl,
style = MaterialTheme.typography.labelSmall,
) )
} }
} }
@@ -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 val messageTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
private fun formatMessageTime(createdAt: String): String { private fun formatMessageTime(createdAt: String): String {
@@ -714,6 +777,9 @@ private fun formatMessageTime(createdAt: String): String {
@Composable @Composable
private fun AudioAttachmentPlayer(url: String) { private fun AudioAttachmentPlayer(url: String) {
var isPlaying by remember(url) { mutableStateOf(false) } 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) { val mediaPlayer = remember(url) {
MediaPlayer().apply { MediaPlayer().apply {
setAudioAttributes( setAudioAttributes(
@@ -722,10 +788,25 @@ private fun AudioAttachmentPlayer(url: String) {
.setUsage(AudioAttributes.USAGE_MEDIA) .setUsage(AudioAttributes.USAGE_MEDIA)
.build() .build()
) )
setOnPreparedListener { player ->
isPrepared = true
durationMs = player.duration.coerceAtLeast(0)
}
setOnCompletionListener {
isPlaying = false
positionMs = durationMs
}
setDataSource(url) setDataSource(url)
prepareAsync() prepareAsync()
} }
} }
LaunchedEffect(isPlaying, isPrepared) {
if (!isPlaying || !isPrepared) return@LaunchedEffect
while (isPlaying) {
positionMs = runCatching { mediaPlayer.currentPosition }.getOrDefault(positionMs)
delay(250)
}
}
DisposableEffect(mediaPlayer) { DisposableEffect(mediaPlayer) {
onDispose { onDispose {
runCatching { runCatching {
@@ -734,22 +815,76 @@ private fun AudioAttachmentPlayer(url: String) {
mediaPlayer.release() mediaPlayer.release()
} }
} }
Row( Column(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier
horizontalArrangement = Arrangement.spacedBy(8.dp), .fillMaxWidth()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.45f), RoundedCornerShape(10.dp))
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
Button( Row(
onClick = { verticalAlignment = Alignment.CenterVertically,
if (isPlaying) { horizontalArrangement = Arrangement.spacedBy(8.dp),
mediaPlayer.pause()
isPlaying = false
} else {
mediaPlayer.start()
isPlaying = true
}
},
) { ) {
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"
}
}

View File

@@ -29,9 +29,9 @@
- [x] Selected state сообщения с явной подсветкой/overlay. - [x] Selected state сообщения с явной подсветкой/overlay.
## P1 — Media & Attachment Bubbles ## P1 — Media & Attachment Bubbles
- [ ] Media bubble с превью изображения/видео + таймкод для видео. - [x] Media bubble с превью изображения/видео + таймкод для видео.
- [ ] File-list bubble (несколько файлов): thumbnail слева, имя/вес/тип справа. - [x] File-list bubble (несколько файлов): thumbnail слева, имя/вес/тип справа.
- [ ] Audio file bubble (не voice): progress bar + текущая/общая длительность. - [x] Audio file bubble (не voice): progress bar + текущая/общая длительность.
- [ ] Метаданные поста/канала (просмотры/время/reaction strip) у медиа-сообщений. - [ ] Метаданные поста/канала (просмотры/время/reaction strip) у медиа-сообщений.
## P1 — Fullscreen Media Viewer ## P1 — Fullscreen Media Viewer