feat(chat): add inline video player component and modal viewer (#722) (#732)

* feat(chat): add inline video player component and modal viewer

Closes #722

* feat(chat): modernize video player with compact lock pill, save, and share

* refactor(chat): simplify inline video thumbnail to launch fullscreen player on tap

* fix(chat): pass session cookie and auth token headers to VideoView for protected media playback

* fix(chat): format unquoted cookie header for VideoView auth

* fix(chat): resolve video URLs via resolveImageUrl for gateway-hosted media
This commit is contained in:
M57 authored and GitHub committed 2026-08-01 00:47:26 +03:00
1 parent c3e026964c
commit 63b966b133
6 files changed
+580 -1

No files matched your search

@@ -2471,7 +2471,19 @@ private fun InlineAttachment(
onImageClick: (ImageViewerModel) -> Unit = {},
) {
val clickable = Modifier.clickable { onOpen(attachment) }
if (attachment.isImage) {
if (attachment.isVideo) {
var showVideoDialog by remember { mutableStateOf(false) }
com.m57.hermescontrol.ui.chat.components.InlineVideoPlayer(
videoUri = attachment.uri,
onFullScreenClick = { showVideoDialog = true },
)
if (showVideoDialog) {
com.m57.hermescontrol.ui.chat.components.VideoViewerDialog(
videoUri = attachment.uri,
onDismissRequest = { showVideoDialog = false },
)
}
} else if (attachment.isImage) {
// Image / GIF attachment — show thumbnail with GIF badge & tap-to-play animation.
com.m57.hermescontrol.ui.chat.components.GifImageThumbnail(
model = attachment.uri,
@@ -24,6 +24,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -267,6 +268,28 @@ fun MarkdownText(
}
}
is MdBlock.Video -> {
val resolvedUri = remember(block.uri) { resolveImageUrl(block.uri) }
var showVideoDialog by remember { mutableStateOf(false) }
androidx.compose.foundation.layout.Box(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
) {
com.m57.hermescontrol.ui.chat.components.InlineVideoPlayer(
videoUri = resolvedUri,
onFullScreenClick = { showVideoDialog = true },
)
if (showVideoDialog) {
com.m57.hermescontrol.ui.chat.components.VideoViewerDialog(
videoUri = resolvedUri,
onDismissRequest = { showVideoDialog = false },
)
}
}
}
is MdBlock.Image -> {
val model: Any = remember(block.uri) { resolveImageUrl(block.uri) }
val isGif =
@@ -393,6 +416,17 @@ private fun tryParseImage(line: String): MdBlock.Image? {
return MdBlock.Image(uri = uri, alt = m.groupValues[1].trim())
}
private fun tryParseVideo(line: String): MdBlock.Video? {
val m = IMAGE_RE.matchAt(line, 0) ?: return null
val uri = m.groupValues[2].trim()
if (uri.isEmpty()) return null
val ext = uri.substringAfterLast('.', "").lowercase()
if (ext in listOf("mp4", "webm", "mkv", "mov", "avi", "3gp") || uri.contains("video/", ignoreCase = true)) {
return MdBlock.Video(uri = uri, alt = m.groupValues[1].trim())
}
return null
}
@Composable
private fun MarkdownTable(
block: MdBlock.Table,
@@ -565,6 +599,11 @@ internal fun parseBlocks(src: String): List<MdBlock> {
// Standalone Markdown image: ![alt](uri) on its own line.
// Inline images inside a paragraph are left as-is (rendered as text)
// to keep scope tight; standalone is the common agent-media case.
line.isNotBlank() && tryParseVideo(line) != null -> {
blocks.add(tryParseVideo(line)!!)
i++
}
line.isNotBlank() && tryParseImage(line) != null -> {
blocks.add(tryParseImage(line)!!)
i++
@@ -964,6 +1003,11 @@ internal sealed interface MdBlock {
val alt: String = "",
) : MdBlock
data class Video(
val uri: String,
val alt: String = "",
) : MdBlock
data class Quote(
val text: String,
) : MdBlock
@@ -0,0 +1,111 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* Minimal inline Video Thumbnail component for chat bubbles and markdown blocks (issue #722).
* Shows a video preview card with a play button badge that launches the full-screen video viewer.
*/
@Composable
fun InlineVideoPlayer(
videoUri: String,
modifier: Modifier = Modifier,
onFullScreenClick: () -> Unit = {},
) {
Box(
modifier =
modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(14.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
.clickable { onFullScreenClick() }
.testTag("inline_video_player"),
) {
// Play icon badge in center
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.65f),
modifier =
Modifier
.align(Alignment.Center)
.size(56.dp)
.testTag("video_play_overlay"),
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play Video",
tint = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier.size(36.dp),
)
}
}
// VIDEO label badge in bottom-right corner
Box(
modifier =
Modifier
.align(Alignment.BottomEnd)
.padding(8.dp)
.background(
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f),
shape = RoundedCornerShape(16.dp),
).padding(horizontal = 8.dp, vertical = 4.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Video",
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.inverseOnSurface,
)
Text(
text = " VIDEO",
color = MaterialTheme.colorScheme.inverseOnSurface,
fontSize = 10.sp,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
/** Formats milliseconds into `M:SS` or `H:MM:SS` */
fun formatMediaDuration(durationMs: Long): String {
if (durationMs <= 0) return "0:00"
val totalSeconds = durationMs / 1000
val seconds = totalSeconds % 60
val minutes = (totalSeconds / 60) % 60
val hours = totalSeconds / 3600
return if (hours > 0) {
"%d:%02d:%02d".format(hours, minutes, seconds)
} else {
"%d:%02d".format(minutes, seconds)
}
}
@@ -0,0 +1,382 @@
package com.m57.hermescontrol.ui.chat.components
import android.net.Uri
import android.widget.Toast
import android.widget.VideoView
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
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.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.chat.ImageBytesResolver
import com.m57.hermescontrol.ui.chat.MediaImageStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Fullscreen Video Viewer modal dialog with Save & Share (issue #722).
*/
@Composable
fun VideoViewerDialog(
videoUri: String,
onDismissRequest: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var isPlaying by remember { mutableStateOf(true) }
var currentPositionMs by remember { mutableLongStateOf(0L) }
var durationMs by remember { mutableLongStateOf(0L) }
var videoViewRef by remember { mutableStateOf<VideoView?>(null) }
var showControls by remember { mutableStateOf(true) }
var isBusy by remember { mutableStateOf(false) }
val savedMsg = stringResource(R.string.image_viewer_saved)
val saveFailedMsg = stringResource(R.string.image_viewer_save_failed)
val loadFailedFmt = stringResource(R.string.image_viewer_load_failed)
val shareTitle = stringResource(R.string.image_viewer_share_title)
val onSave: () -> Unit = {
if (!isBusy) {
isBusy = true
scope.launch(Dispatchers.IO) {
val resolved = ImageBytesResolver.resolve(context, videoUri, "video/mp4")
val result =
when (resolved) {
is ImageBytesResolver.Result.Bytes -> {
val uri =
MediaImageStore.saveToDownloads(
context,
resolved.bytes,
"hermes-video.${resolved.extension}",
resolved.mimeType,
)
if (uri != null) savedMsg else saveFailedMsg
}
is ImageBytesResolver.Result.Error -> {
String.format(loadFailedFmt, resolved.message)
}
}
withContext(Dispatchers.Main) {
isBusy = false
Toast.makeText(context, result, Toast.LENGTH_SHORT).show()
}
}
}
}
val onShare: () -> Unit = {
if (!isBusy) {
isBusy = true
scope.launch(Dispatchers.IO) {
val resolved = ImageBytesResolver.resolve(context, videoUri, "video/mp4")
val intent =
when (resolved) {
is ImageBytesResolver.Result.Bytes -> {
MediaImageStore.buildShareIntent(
context,
resolved.bytes,
"hermes-video.${resolved.extension}",
resolved.mimeType,
)
}
is ImageBytesResolver.Result.Error -> {
null
}
}
withContext(Dispatchers.Main) {
isBusy = false
if (intent != null) {
context.startActivity(android.content.Intent.createChooser(intent, shareTitle))
} else {
Toast.makeText(context, saveFailedMsg, Toast.LENGTH_SHORT).show()
}
}
}
}
}
LaunchedEffect(isPlaying) {
while (isPlaying) {
videoViewRef?.let { view ->
if (view.isPlaying) {
currentPositionMs = view.currentPosition.toLong()
if (durationMs <= 0 && view.duration > 0) {
durationMs = view.duration.toLong()
}
}
}
delay(250)
}
}
DisposableEffect(videoUri) {
onDispose {
videoViewRef?.stopPlayback()
}
}
Dialog(
onDismissRequest = onDismissRequest,
properties =
DialogProperties(
usePlatformDefaultWidth = false,
dismissOnBackPress = true,
dismissOnClickOutside = true,
),
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim)
.clickable { showControls = !showControls }
.testTag("video_viewer_dialog"),
) {
AndroidView(
factory = { ctx ->
VideoView(ctx).apply {
val uri = Uri.parse(videoUri)
val headers = mutableMapOf<String, String>()
val cookieVal =
com.m57.hermescontrol.data.remote.CookieManager
.getSessionCookie()
if (!cookieVal.isNullOrBlank()) {
headers["Cookie"] = "hermes_session_at=$cookieVal"
}
val token =
com.m57.hermescontrol.data.local.AuthManager
.getToken()
if (!token.isNullOrBlank()) {
headers["Authorization"] = "Bearer $token"
}
setVideoURI(uri, headers)
setOnPreparedListener { mp ->
durationMs = mp.duration.toLong()
start()
isPlaying = true
}
setOnCompletionListener {
isPlaying = false
currentPositionMs = durationMs
}
}
},
update = { view ->
videoViewRef = view
},
modifier = Modifier.fillMaxSize(),
)
// Center overlay play button when paused
if (!isPlaying) {
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.65f),
modifier =
Modifier
.align(Alignment.Center)
.size(64.dp)
.clickable {
videoViewRef?.let { view ->
view.start()
isPlaying = true
}
},
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play Video",
tint = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier.size(40.dp),
)
}
}
}
// Top Bar with Back/Close, Save, Share
AnimatedVisibility(
visible = showControls,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.TopCenter),
) {
Surface(
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.70f),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = onDismissRequest,
modifier = Modifier.testTag("close_video_dialog_button"),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Close",
tint = MaterialTheme.colorScheme.inverseOnSurface,
)
}
Spacer(modifier = Modifier.weight(1f))
if (isBusy) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp).padding(horizontal = 8.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.inverseOnSurface,
)
} else {
IconButton(onClick = onSave, enabled = !isBusy) {
Icon(
imageVector = Icons.Filled.Download,
contentDescription = "Save",
tint = MaterialTheme.colorScheme.inverseOnSurface,
)
}
IconButton(onClick = onShare, enabled = !isBusy) {
Icon(
imageVector = Icons.Filled.Share,
contentDescription = "Share",
tint = MaterialTheme.colorScheme.inverseOnSurface,
)
}
}
}
}
}
// Floating Bottom Controls Bar
AnimatedVisibility(
visible = showControls || !isPlaying,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.BottomCenter).padding(12.dp),
) {
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.80f),
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = {
videoViewRef?.let { view ->
if (isPlaying) {
view.pause()
isPlaying = false
} else {
view.start()
isPlaying = true
}
}
},
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
tint = MaterialTheme.colorScheme.inverseOnSurface,
)
}
Spacer(modifier = Modifier.width(4.dp))
Text(
text = formatMediaDuration(currentPositionMs),
color = MaterialTheme.colorScheme.inverseOnSurface,
fontSize = 12.sp,
)
Slider(
value = if (durationMs > 0) currentPositionMs.toFloat() / durationMs.toFloat() else 0f,
onValueChange = { fraction ->
val targetMs = (fraction * durationMs).toLong()
currentPositionMs = targetMs
videoViewRef?.seekTo(targetMs.toInt())
},
modifier =
Modifier
.weight(1f)
.padding(horizontal = 8.dp),
colors =
SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.inverseOnSurface,
activeTrackColor = MaterialTheme.colorScheme.primary,
inactiveTrackColor = MaterialTheme.colorScheme.inverseOnSurface.copy(alpha = 0.3f),
),
)
Text(
text = formatMediaDuration(durationMs),
color = MaterialTheme.colorScheme.inverseOnSurface,
fontSize = 12.sp,
)
}
}
}
}
}
}