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"
minSdk = 26
targetSdk = 34
versionCode = 41
versionName = "1.5.0"
versionCode = 43
versionName = "1.5.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View File

@@ -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<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 { 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<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 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<ContentPart> {
// 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]