From 25455f7e6fc2101aef0e90973ebc4105a823b613 Mon Sep 17 00:00:00 2001 From: jknapp Date: Wed, 11 Feb 2026 08:44:09 -0800 Subject: [PATCH] Add GIF-to-MP4 playback support (v1.5.2) Features: - Detect GIF URLs automatically - Request conversion from alfred-proxy /api/convert-gif endpoint - Display loading indicator during conversion - Play MP4 in looping VideoView component - Auto-start playback on load - Error handling with user-friendly messages Technical details: - Uses AndroidView wrapper for VideoView in Compose - Coroutines for async conversion request - Gateway URL from SharedPreferences - Falls back to static image for non-GIF images Tested successfully with Tenor GIFs - smooth playback confirmed! --- app/build.gradle.kts | 4 +- .../alfred/ui/components/RichMessageBubble.kt | 171 +++++++++++++++--- 2 files changed, 149 insertions(+), 26 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ec912c0..6e53990 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,8 +17,8 @@ android { applicationId = "com.openclaw.alfred" minSdk = 26 targetSdk = 34 - versionCode = 41 - versionName = "1.5.0" + versionCode = 43 + versionName = "1.5.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/openclaw/alfred/ui/components/RichMessageBubble.kt b/app/src/main/java/com/openclaw/alfred/ui/components/RichMessageBubble.kt index 66fc931..3cf46a8 100644 --- a/app/src/main/java/com/openclaw/alfred/ui/components/RichMessageBubble.kt +++ b/app/src/main/java/com/openclaw/alfred/ui/components/RichMessageBubble.kt @@ -20,6 +20,8 @@ import androidx.compose.ui.unit.dp import coil.compose.rememberAsyncImagePainter import coil.request.ImageRequest import com.openclaw.alfred.ui.screens.ChatMessage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** * Enhanced message bubble with support for: @@ -179,36 +181,145 @@ private fun ClickableTextWithLinks( } /** - * Image content (including GIFs) + * Image content (GIFs converted to video, static images displayed normally) */ @Composable private fun ImageContent( url: String, onClick: (String) -> Unit ) { - val painter = rememberAsyncImagePainter( - model = ImageRequest.Builder(LocalContext.current) - .data(url) - .crossfade(true) - .build() - ) + val context = LocalContext.current + val isGif = url.matches(Regex(".*\\.(gif|GIF)(\\?.*)?$")) + + if (isGif) { + // GIFs are converted to MP4 for better mobile playback + GifVideoPlayer(url = url, onClick = onClick) + } else { + // Static images displayed normally + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick(url) } + ) { + coil.compose.AsyncImage( + model = ImageRequest.Builder(context) + .data(url) + .crossfade(true) + .build(), + contentDescription = "Image", + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 300.dp), + contentScale = ContentScale.Fit + ) + } + } +} + +/** + * GIF player using MP4 conversion + */ +@Composable +private fun GifVideoPlayer( + url: String, + onClick: (String) -> Unit +) { + val context = LocalContext.current + var videoUrl by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + + // Request GIF conversion on mount + LaunchedEffect(url) { + try { + // Get gateway URL from preferences + val prefs = context.getSharedPreferences("alfred_settings", android.content.Context.MODE_PRIVATE) + val gatewayUrl = prefs.getString("gateway_url", com.openclaw.alfred.BuildConfig.GATEWAY_URL) + ?.replace("wss://", "https://") + ?.replace("ws://", "http://") + ?: "" + + val convertUrl = "$gatewayUrl/api/convert-gif?url=${java.net.URLEncoder.encode(url, "UTF-8")}" + + // Request conversion + val client = okhttp3.OkHttpClient() + val request = okhttp3.Request.Builder().url(convertUrl).build() + + kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + val response = client.newCall(request).execute() + + if (response.isSuccessful) { + val json = org.json.JSONObject(response.body?.string() ?: "{}") + val relativeUrl = json.optString("videoUrl") + + if (relativeUrl.isNotEmpty()) { + videoUrl = "$gatewayUrl$relativeUrl" + isLoading = false + } else { + error = "Conversion failed" + isLoading = false + } + } else { + error = "Conversion failed: ${response.code}" + isLoading = false + } + } + } catch (e: Exception) { + error = "Error: ${e.message}" + isLoading = false + } + } Card( modifier = Modifier .fillMaxWidth() - .clickable { onClick(url) } + .clickable { videoUrl?.let { onClick(it) } } ) { - Image( - painter = painter, - contentDescription = "Image", + Box( modifier = Modifier .fillMaxWidth() - .heightIn(max = 300.dp), - contentScale = ContentScale.Fit - ) + .height(200.dp), + contentAlignment = Alignment.Center + ) { + when { + isLoading -> { + CircularProgressIndicator() + } + error != null -> { + Text( + text = "⚠️ $error", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + videoUrl != null -> { + // Use AndroidView to embed video player + AndroidVideoPlayer(videoUrl = videoUrl!!) + } + } + } } } +/** + * Android VideoView wrapper for Compose + */ +@Composable +private fun AndroidVideoPlayer(videoUrl: String) { + androidx.compose.ui.viewinterop.AndroidView( + factory = { context -> + android.widget.VideoView(context).apply { + setVideoPath(videoUrl) + setOnPreparedListener { mp -> + mp.isLooping = true + start() + } + } + }, + modifier = Modifier.fillMaxSize() + ) +} + /** * YouTube embed (thumbnail with play button) */ @@ -225,15 +336,11 @@ private fun YouTubeEmbed( .clickable { onClick() } ) { Box { - val painter = rememberAsyncImagePainter( + coil.compose.AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(thumbnailUrl) .crossfade(true) - .build() - ) - - Image( - painter = painter, + .build(), contentDescription = "YouTube video", modifier = Modifier .fillMaxWidth() @@ -263,17 +370,28 @@ private fun YouTubeEmbed( private fun parseMessageContent(text: String): List { val parts = mutableListOf() - // Patterns + // Patterns (order matters - markdown first to capture before plain URLs) + val markdownImagePattern = Regex("""!\[([^\]]*)\]\(([^)]+\.(jpg|jpeg|png|gif|webp)[^)]*)\)""", RegexOption.IGNORE_CASE) val imagePattern = Regex("""(https?://[^\s]+\.(jpg|jpeg|png|gif|webp))""", RegexOption.IGNORE_CASE) val youtubePattern = Regex("""https?://(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/)([a-zA-Z0-9_-]{11})""") - // Find all media URLs + // Find all media (markdown images first, then plain URLs, then YouTube) + val markdownImageMatches = markdownImagePattern.findAll(text).toList() val imageMatches = imagePattern.findAll(text).toList() val youtubeMatches = youtubePattern.findAll(text).toList() + // Filter out plain URL matches that are inside markdown syntax + val markdownRanges = markdownImageMatches.map { it.range } + val filteredImageMatches = imageMatches.filter { imgMatch -> + markdownRanges.none { mdRange -> imgMatch.range.first >= mdRange.first && imgMatch.range.last <= mdRange.last } + } + // Combine and sort by position - val allMatches = (imageMatches.map { it to "image" } + youtubeMatches.map { it to "youtube" }) - .sortedBy { it.first.range.first } + val allMatches = ( + markdownImageMatches.map { it to "markdown-image" } + + filteredImageMatches.map { it to "image" } + + youtubeMatches.map { it to "youtube" } + ).sortedBy { it.first.range.first } if (allMatches.isEmpty()) { // No media, just text @@ -294,6 +412,11 @@ private fun parseMessageContent(text: String): List { // Add media when (type) { + "markdown-image" -> { + // Extract URL from markdown syntax: ![alt](url) + val url = match.groupValues[2] + parts.add(ContentPart.Image(url)) + } "image" -> parts.add(ContentPart.Image(match.value)) "youtube" -> { val videoId = match.groupValues[1]