From e170b65753db24dbd6ae96071428c71ba9f8df1c Mon Sep 17 00:00:00 2001 From: greenart7c3 Date: Wed, 22 Apr 2026 15:08:32 -0300 Subject: [PATCH] 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`. --- .../amethyst/ui/components/GifVideoView.kt | 185 ++++++++++++++++++ .../amethyst/ui/components/MyAsyncImage.kt | 145 ++++++++------ .../ui/components/ZoomableContentView.kt | 71 +++++-- 3 files changed, 322 insertions(+), 79 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt new file mode 100644 index 000000000..fadb4fee6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt @@ -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), + ) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt index 13d94ae4c..85cdb4403 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/MyAsyncImage.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 9839bb52a..0f67b1f25 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -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) }