android: improve media and file attachment bubble rendering
Some checks are pending
CI / test (push) Has started running
Some checks are pending
CI / test (push) Has started running
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user