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.
- 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.

View File

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

View File

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