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:
@@ -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 {
|
||||
|
||||
@@ -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,27 +181,31 @@ 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) }
|
||||
) {
|
||||
Image(
|
||||
painter = painter,
|
||||
coil.compose.AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(url)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = "Image",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -207,6 +213,111 @@ private fun ImageContent(
|
||||
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 { 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()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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: 
|
||||
val url = match.groupValues[2]
|
||||
parts.add(ContentPart.Image(url))
|
||||
}
|
||||
"image" -> parts.add(ContentPart.Image(match.value))
|
||||
"youtube" -> {
|
||||
val videoId = match.groupValues[1]
|
||||
|
||||
Reference in New Issue
Block a user