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!
This commit is contained in:
2026-02-11 08:44:09 -08:00
parent eb15e29c8b
commit 25455f7e6f
2 changed files with 149 additions and 26 deletions

View File

@@ -17,8 +17,8 @@ android {
applicationId = "com.openclaw.alfred" applicationId = "com.openclaw.alfred"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 41 versionCode = 43
versionName = "1.5.0" versionName = "1.5.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@@ -20,6 +20,8 @@ import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest import coil.request.ImageRequest
import com.openclaw.alfred.ui.screens.ChatMessage import com.openclaw.alfred.ui.screens.ChatMessage
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** /**
* Enhanced message bubble with support for: * Enhanced message bubble with support for:
@@ -179,27 +181,31 @@ private fun ClickableTextWithLinks(
} }
/** /**
* Image content (including GIFs) * Image content (GIFs converted to video, static images displayed normally)
*/ */
@Composable @Composable
private fun ImageContent( private fun ImageContent(
url: String, url: String,
onClick: (String) -> Unit onClick: (String) -> Unit
) { ) {
val painter = rememberAsyncImagePainter( val context = LocalContext.current
model = ImageRequest.Builder(LocalContext.current) val isGif = url.matches(Regex(".*\\.(gif|GIF)(\\?.*)?$"))
.data(url)
.crossfade(true)
.build()
)
if (isGif) {
// GIFs are converted to MP4 for better mobile playback
GifVideoPlayer(url = url, onClick = onClick)
} else {
// Static images displayed normally
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onClick(url) } .clickable { onClick(url) }
) { ) {
Image( coil.compose.AsyncImage(
painter = painter, model = ImageRequest.Builder(context)
.data(url)
.crossfade(true)
.build(),
contentDescription = "Image", contentDescription = "Image",
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -208,6 +214,111 @@ private fun ImageContent(
) )
} }
} }
}
/**
* GIF player using MP4 conversion
*/
@Composable
private fun GifVideoPlayer(
url: String,
onClick: (String) -> Unit
) {
val context = LocalContext.current
var videoUrl by remember { mutableStateOf<String?>(null) }
var isLoading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(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 { videoUrl?.let { onClick(it) } }
) {
Box(
modifier = Modifier
.fillMaxWidth()
.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) * YouTube embed (thumbnail with play button)
@@ -225,15 +336,11 @@ private fun YouTubeEmbed(
.clickable { onClick() } .clickable { onClick() }
) { ) {
Box { Box {
val painter = rememberAsyncImagePainter( coil.compose.AsyncImage(
model = ImageRequest.Builder(LocalContext.current) model = ImageRequest.Builder(LocalContext.current)
.data(thumbnailUrl) .data(thumbnailUrl)
.crossfade(true) .crossfade(true)
.build() .build(),
)
Image(
painter = painter,
contentDescription = "YouTube video", contentDescription = "YouTube video",
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -263,17 +370,28 @@ private fun YouTubeEmbed(
private fun parseMessageContent(text: String): List<ContentPart> { private fun parseMessageContent(text: String): List<ContentPart> {
val parts = mutableListOf<ContentPart>() val parts = mutableListOf<ContentPart>()
// 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 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})""") 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 imageMatches = imagePattern.findAll(text).toList()
val youtubeMatches = youtubePattern.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 // Combine and sort by position
val allMatches = (imageMatches.map { it to "image" } + youtubeMatches.map { it to "youtube" }) val allMatches = (
.sortedBy { it.first.range.first } markdownImageMatches.map { it to "markdown-image" } +
filteredImageMatches.map { it to "image" } +
youtubeMatches.map { it to "youtube" }
).sortedBy { it.first.range.first }
if (allMatches.isEmpty()) { if (allMatches.isEmpty()) {
// No media, just text // No media, just text
@@ -294,6 +412,11 @@ private fun parseMessageContent(text: String): List<ContentPart> {
// Add media // Add media
when (type) { 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)) "image" -> parts.add(ContentPart.Image(match.value))
"youtube" -> { "youtube" -> {
val videoId = match.groupValues[1] val videoId = match.groupValues[1]