* 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:
6 files changed
+580
-1
No files matched your search
@@ -35,6 +35,13 @@ data class Attachment(
|
|||||||
uri.contains(".gif", ignoreCase = true) ||
|
uri.contains(".gif", ignoreCase = true) ||
|
||||||
uri.startsWith("data:image/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
|
val isGateway: Boolean
|
||||||
get() = source == AttachmentSource.GATEWAY
|
get() = source == AttachmentSource.GATEWAY
|
||||||
|
|
||||||
|
|||||||
@@ -2471,7 +2471,19 @@ private fun InlineAttachment(
|
|||||||
onImageClick: (ImageViewerModel) -> Unit = {},
|
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val clickable = Modifier.clickable { onOpen(attachment) }
|
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.
|
// Image / GIF attachment — show thumbnail with GIF badge & tap-to-play animation.
|
||||||
com.m57.hermescontrol.ui.chat.components.GifImageThumbnail(
|
com.m57.hermescontrol.ui.chat.components.GifImageThumbnail(
|
||||||
model = attachment.uri,
|
model = attachment.uri,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
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 -> {
|
is MdBlock.Image -> {
|
||||||
val model: Any = remember(block.uri) { resolveImageUrl(block.uri) }
|
val model: Any = remember(block.uri) { resolveImageUrl(block.uri) }
|
||||||
val isGif =
|
val isGif =
|
||||||
@@ -393,6 +416,17 @@ private fun tryParseImage(line: String): MdBlock.Image? {
|
|||||||
return MdBlock.Image(uri = uri, alt = m.groupValues[1].trim())
|
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
|
@Composable
|
||||||
private fun MarkdownTable(
|
private fun MarkdownTable(
|
||||||
block: MdBlock.Table,
|
block: MdBlock.Table,
|
||||||
@@ -565,6 +599,11 @@ internal fun parseBlocks(src: String): List<MdBlock> {
|
|||||||
// Standalone Markdown image:  on its own line.
|
// Standalone Markdown image:  on its own line.
|
||||||
// Inline images inside a paragraph are left as-is (rendered as text)
|
// Inline images inside a paragraph are left as-is (rendered as text)
|
||||||
// to keep scope tight; standalone is the common agent-media case.
|
// 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 -> {
|
line.isNotBlank() && tryParseImage(line) != null -> {
|
||||||
blocks.add(tryParseImage(line)!!)
|
blocks.add(tryParseImage(line)!!)
|
||||||
i++
|
i++
|
||||||
@@ -964,6 +1003,11 @@ internal sealed interface MdBlock {
|
|||||||
val alt: String = "",
|
val alt: String = "",
|
||||||
) : MdBlock
|
) : MdBlock
|
||||||
|
|
||||||
|
data class Video(
|
||||||
|
val uri: String,
|
||||||
|
val alt: String = "",
|
||||||
|
) : MdBlock
|
||||||
|
|
||||||
data class Quote(
|
data class Quote(
|
||||||
val text: String,
|
val text: String,
|
||||||
) : MdBlock
|
) : 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -244,4 +244,27 @@ class MarkdownTextFeatureTest {
|
|||||||
assertTrue(nonGifAttachment.isImage)
|
assertTrue(nonGifAttachment.isImage)
|
||||||
assertFalse(nonGifAttachment.isGif)
|
assertFalse(nonGifAttachment.isGif)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 14. Standalone video parsing (issue #722)
|
||||||
|
@Test
|
||||||
|
fun testStandaloneVideo_parsesToVideoBlock() {
|
||||||
|
val md = ""
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user