diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt index d12d1635..79e1f095 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/Attachment.kt @@ -35,6 +35,13 @@ data class Attachment( uri.contains(".gif", ignoreCase = true) || uri.startsWith("data:image/gif", ignoreCase = true) + val isVideo: Boolean + get() = + mimeType.startsWith("video/") || + fileExtension in listOf("mp4", "webm", "mkv", "mov", "avi", "3gp") || + uri.contains(".mp4", ignoreCase = true) || + uri.contains(".webm", ignoreCase = true) + val isGateway: Boolean get() = source == AttachmentSource.GATEWAY diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index 1ae9df0d..6b660846 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt index ad3920db..5c460d98 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt @@ -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 { // 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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineVideoPlayer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineVideoPlayer.kt new file mode 100644 index 00000000..030ab673 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineVideoPlayer.kt @@ -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) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VideoViewerDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VideoViewerDialog.kt new file mode 100644 index 00000000..544e9782 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VideoViewerDialog.kt @@ -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(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() + 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, + ) + } + } + } + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt index 816492b0..8a0226dd 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt @@ -244,4 +244,27 @@ class MarkdownTextFeatureTest { assertTrue(nonGifAttachment.isImage) assertFalse(nonGifAttachment.isGif) } + + // 14. Standalone video parsing (issue #722) + @Test + fun testStandaloneVideo_parsesToVideoBlock() { + val md = "![demo](https://example.com/demo.mp4)" + val block = parseBlocks(md).singleOrNull() as? MdBlock.Video + assertTrue("video URL should parse to Video block", block != null) + assertEquals("https://example.com/demo.mp4", block!!.uri) + assertEquals("demo", block.alt) + } + + // 15. Attachment isVideo check (issue #722) + @Test + fun testAttachment_isVideo() { + val videoAttachment = + com.m57.hermescontrol.data.model.Attachment( + uri = "content://media/1.mp4", + name = "test.mp4", + mimeType = "video/mp4", + ) + assertTrue(videoAttachment.isVideo) + assertFalse(videoAttachment.isImage) + } }