feat(media): add playback controls and autoplay support for GIFs

Introduces `GifVideoView` to manage GIF playback with manual play/pause
controls and support for the "Auto-play Videos" setting. Updates
`MyAsyncImage` and `ZoomableContentView` to utilize this specialized
view when GIF content is detected via file extension or MIME type.

Key changes include:
*   **GifVideoView**: A new component that uses Coil's `Animatable` to start and stop GIF animations, featuring a video-like UI with play/pause overlays and a zoom button.
*   **GIF Detection**: Added `isGif()` and `isGifUrl()` helpers to identify GIF content by MIME type or URL patterns.
*   **Integration**: Redirects GIF rendering in `MyAsyncImage` and `ZoomableContentView` from static image views to the new interactive `GifVideoView`.
This commit is contained in:
greenart7c3
2026-04-22 15:08:32 -03:00
parent 82bbfa3d94
commit e170b65753
3 changed files with 322 additions and 79 deletions
@@ -0,0 +1,185 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
import android.graphics.drawable.Animatable
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.outlined.OpenInFull
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import coil3.asDrawable
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.Size75dp
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import kotlinx.coroutines.delay
@Composable
fun GifVideoView(
videoUri: String,
contentDescription: String?,
dimensions: DimensionTag?,
blurhash: String?,
roundedCorner: Boolean,
contentScale: ContentScale,
onDialog: (() -> Unit)? = null,
accountViewModel: AccountViewModel,
thumbhash: String? = null,
) {
val ratio = dimensions?.aspectRatio() ?: MediaAspectRatioCache.get(videoUri)
val automaticallyStartPlayback = remember { mutableStateOf(accountViewModel.settings.autoPlayVideos()) }
val controllerVisible = remember { mutableStateOf(false) }
LaunchedEffect(controllerVisible.value, automaticallyStartPlayback.value) {
if (controllerVisible.value && automaticallyStartPlayback.value) {
delay(3000)
controllerVisible.value = false
}
}
val borderModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier
val context = LocalContext.current
Box(
modifier =
(if (ratio != null) borderModifier.aspectRatio(ratio) else borderModifier)
.fillMaxWidth()
.clickable { controllerVisible.value = !controllerVisible.value },
contentAlignment = Alignment.Center,
) {
SubcomposeAsyncImage(
model = videoUri,
contentDescription = contentDescription,
contentScale = contentScale,
modifier = Modifier.fillMaxSize(),
) {
val state by painter.state.collectAsState()
val successState = state as? AsyncImagePainter.State.Success
val drawable = successState?.result?.image?.asDrawable(context.resources)
LaunchedEffect(automaticallyStartPlayback.value, drawable) {
if (drawable is Animatable) {
if (automaticallyStartPlayback.value) {
drawable.start()
} else {
drawable.stop()
}
}
}
when (state) {
is AsyncImagePainter.State.Loading -> {
DisplayBlurHash(
blurhash,
contentDescription,
contentScale,
Modifier.fillMaxSize(),
thumbhash = thumbhash,
)
}
is AsyncImagePainter.State.Success -> {
SubcomposeAsyncImageContent(Modifier.fillMaxSize())
}
is AsyncImagePainter.State.Error -> {
ClickableUrl(urlText = videoUri, url = videoUri)
}
else -> {}
}
}
// Zoom button
AnimatedVisibility(
visible = controllerVisible.value,
modifier = Modifier.align(Alignment.TopEnd),
enter = fadeIn(),
exit = fadeOut(),
) {
IconButton(
onClick = { onDialog?.invoke() },
) {
Icon(
imageVector = Icons.Outlined.OpenInFull,
contentDescription = null,
tint = Color.White,
)
}
}
// Play/Pause button
AnimatedVisibility(
visible = controllerVisible.value || !automaticallyStartPlayback.value,
enter = fadeIn(),
exit = fadeOut(),
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(if (!automaticallyStartPlayback.value) Color.Black.copy(alpha = 0.3f) else Color.Transparent),
contentAlignment = Alignment.Center,
) {
IconButton(
modifier = Modifier.size(Size75dp),
onClick = { automaticallyStartPlayback.value = !automaticallyStartPlayback.value },
) {
Icon(
imageVector = if (automaticallyStartPlayback.value) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(Size75dp),
)
}
}
}
}
}
@@ -56,86 +56,105 @@ fun MyAsyncImage(
onLoadingBackground: (@Composable () -> Unit)?,
onError: (@Composable () -> Unit)?,
) {
val ratio = MediaAspectRatioCache.get(imageUrl)
val showImage = remember { mutableStateOf(accountViewModel.settings.showImages()) }
if (isGifUrl(imageUrl)) {
GifVideoView(
videoUri = imageUrl,
contentDescription = contentDescription,
dimensions = null,
blurhash = null,
roundedCorner = contentScale == ContentScale.Crop,
contentScale = contentScale,
onDialog = null,
accountViewModel = accountViewModel,
thumbhash = null,
)
} else {
val ratio = MediaAspectRatioCache.get(imageUrl)
val showImage = remember { mutableStateOf(accountViewModel.settings.showImages()) }
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
if (it) {
SubcomposeAsyncImage(
model = imageUrl,
contentDescription = contentDescription,
contentScale = contentScale,
modifier = mainImageModifier,
) {
val state by painter.state.collectAsState()
when (state) {
is AsyncImagePainter.State.Loading -> {
if (ratio != null) {
Box(loadedImageModifier.aspectRatio(ratio), contentAlignment = Alignment.Center) {
LoadingAnimation(Size40dp, Size6dp)
}
} else {
WaitAndDisplay {
if (onLoadingBackground != null) {
Box(loadedImageModifier, contentAlignment = Alignment.Center) {
onLoadingBackground()
LoadingAnimation(Size40dp, Size6dp)
}
} else {
DisplayUrlWithLoadingSymbol(imageUrl, accountViewModel.toastManager::toast)
}
}
}
}
is AsyncImagePainter.State.Error -> {
if (onError != null) {
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
if (it) {
SubcomposeAsyncImage(
model = imageUrl,
contentDescription = contentDescription,
contentScale = contentScale,
modifier = mainImageModifier,
) {
val state by painter.state.collectAsState()
when (state) {
is AsyncImagePainter.State.Loading -> {
if (ratio != null) {
Box(loadedImageModifier.aspectRatio(ratio), contentAlignment = Alignment.Center) {
onError()
LoadingAnimation(Size40dp, Size6dp)
}
} else {
Box(loadedImageModifier, contentAlignment = Alignment.Center) {
onError()
WaitAndDisplay {
if (onLoadingBackground != null) {
Box(loadedImageModifier, contentAlignment = Alignment.Center) {
onLoadingBackground()
LoadingAnimation(Size40dp, Size6dp)
}
} else {
DisplayUrlWithLoadingSymbol(imageUrl, accountViewModel.toastManager::toast)
}
}
}
} else {
ClickableUrl(urlText = imageUrl, url = imageUrl)
}
}
is AsyncImagePainter.State.Success -> {
SubcomposeAsyncImageContent(loadedImageModifier)
SideEffect {
val drawable = (state as AsyncImagePainter.State.Success).result.image
MediaAspectRatioCache.add(imageUrl, drawable.width, drawable.height)
is AsyncImagePainter.State.Error -> {
if (onError != null) {
if (ratio != null) {
Box(loadedImageModifier.aspectRatio(ratio), contentAlignment = Alignment.Center) {
onError()
}
} else {
Box(loadedImageModifier, contentAlignment = Alignment.Center) {
onError()
}
}
} else {
ClickableUrl(urlText = imageUrl, url = imageUrl)
}
}
}
else -> {}
}
}
} else {
if (ratio != null) {
Box(loadedImageModifier.aspectRatio(ratio), contentAlignment = Alignment.Center) {
IconButton(
modifier = Modifier.size(Size75dp),
onClick = { showImage.value = true },
) {
DownloadForOfflineIcon(Size75dp, MaterialTheme.colorScheme.onBackground)
is AsyncImagePainter.State.Success -> {
SubcomposeAsyncImageContent(loadedImageModifier)
SideEffect {
val drawable = (state as AsyncImagePainter.State.Success).result.image
MediaAspectRatioCache.add(imageUrl, drawable.width, drawable.height)
}
}
else -> {}
}
}
} else {
Box(loadedImageModifier.aspectRatio(16 / 9.0f), contentAlignment = Alignment.Center) {
IconButton(
modifier = Modifier.size(Size75dp),
onClick = { showImage.value = true },
) {
DownloadForOfflineIcon(Size75dp, MaterialTheme.colorScheme.onBackground)
if (ratio != null) {
Box(loadedImageModifier.aspectRatio(ratio), contentAlignment = Alignment.Center) {
IconButton(
modifier = Modifier.size(Size75dp),
onClick = { showImage.value = true },
) {
DownloadForOfflineIcon(Size75dp, MaterialTheme.colorScheme.onBackground)
}
}
} else {
Box(loadedImageModifier.aspectRatio(16 / 9.0f), contentAlignment = Alignment.Center) {
IconButton(
modifier = Modifier.size(Size75dp),
onClick = { showImage.value = true },
) {
DownloadForOfflineIcon(Size75dp, MaterialTheme.colorScheme.onBackground)
}
}
}
}
}
}
}
fun isGifUrl(url: String): Boolean =
url.endsWith(".gif", ignoreCase = true) ||
url.contains(".gif?", ignoreCase = true) ||
url.contains(".gif#", ignoreCase = true)
@@ -159,14 +159,28 @@ fun ZoomableContentView(
modifier = mediaSizingModifier(ratio, contentScale),
backdrop = (content.thumbhash ?: content.blurhash)?.let { { BlurhashBackdrop(content.blurhash, content.description, content.thumbhash) } },
) {
TwoSecondController(content) { controllerVisible ->
val mainImageModifier =
Modifier
.fillMaxWidth()
.then(boundsTrackingModifier)
.clickable { dialogOpen = true }
val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth()
UrlImageView(content, contentScale, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel)
if (content.isGif()) {
GifVideoView(
videoUri = content.url,
contentDescription = content.description,
dimensions = content.dim,
blurhash = content.blurhash,
roundedCorner = roundedCorner,
contentScale = contentScale,
onDialog = { dialogOpen = true },
accountViewModel = accountViewModel,
thumbhash = content.thumbhash,
)
} else {
TwoSecondController(content) { controllerVisible ->
val mainImageModifier =
Modifier
.fillMaxWidth()
.then(boundsTrackingModifier)
.clickable { dialogOpen = true }
val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth()
UrlImageView(content, contentScale, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel)
}
}
}
}
@@ -206,15 +220,31 @@ fun ZoomableContentView(
}
is MediaLocalImage -> {
TwoSecondController(content) { controllerVisible ->
val mainImageModifier =
Modifier
.fillMaxWidth()
.then(boundsTrackingModifier)
.clickable { dialogOpen = true }
val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth()
if (content.isGif()) {
content.localFile?.let {
GifVideoView(
videoUri = it.toUri().toString(),
contentDescription = content.description,
dimensions = content.dim,
blurhash = content.blurhash,
roundedCorner = roundedCorner,
contentScale = contentScale,
onDialog = { dialogOpen = true },
accountViewModel = accountViewModel,
thumbhash = content.thumbhash,
)
}
} else {
TwoSecondController(content) { controllerVisible ->
val mainImageModifier =
Modifier
.fillMaxWidth()
.then(boundsTrackingModifier)
.clickable { dialogOpen = true }
val loadedImageModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier.fillMaxWidth()
LocalImageView(content, contentScale, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel)
LocalImageView(content, contentScale, mainImageModifier, loadedImageModifier, controllerVisible, accountViewModel = accountViewModel)
}
}
}
@@ -647,6 +677,15 @@ fun ShowHash(content: MediaUrlContent) {
verifiedHash?.let { HashVerificationSymbol(it) }
}
fun BaseMediaContent.isGif(): Boolean =
if (this is MediaUrlContent) {
mimeType == "image/gif" || url.endsWith(".gif", ignoreCase = true) || url.contains(".gif?", ignoreCase = true) || url.contains(".gif#", ignoreCase = true)
} else if (this is MediaPreloadedContent) {
mimeType == "image/gif" || localFile?.name?.endsWith(".gif", ignoreCase = true) == true
} else {
false
}
@Composable
fun WaitAndDisplay(content: @Composable (AnimatedVisibilityScope.() -> Unit)) {
val visible = remember { mutableStateOf(false) }