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"
|
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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -207,6 +213,111 @@ private fun ImageContent(
|
|||||||
contentScale = ContentScale.Fit
|
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() }
|
.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: 
|
||||||
|
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]
|
||||||
|
|||||||
Reference in New Issue
Block a user