From e170b65753db24dbd6ae96071428c71ba9f8df1c Mon Sep 17 00:00:00 2001 From: greenart7c3 Date: Wed, 22 Apr 2026 15:08:32 -0300 Subject: [PATCH 1/3] 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) } From bd222e3e9b6646bfba8d497fae87d20bbc93eb78 Mon Sep 17 00:00:00 2001 From: greenart7c3 Date: Fri, 24 Apr 2026 08:26:00 -0300 Subject: [PATCH 2/3] refactor(media): simplify GIF rendering and wire autoplay setting Replaces the video-style play/pause controls on GIFs with normal image rendering. When Auto-play Videos is off, GIFs pause on the first frame (feed GIFs additionally show a small "gif" label in the bottom-left); tapping still opens the fullscreen dialog. Profile-picture GIFs now respect the same setting. The key fix is bypassing ProfilePictureFetcher (which serves a static JPEG thumbnail and prevents animation); GIF avatars load via the raw URL so Coil's animated decoder runs. Autoplay is read reactively from a new autoPlayVideosFlow StateFlow so toggling the setting immediately starts/stops animation in the drawer, top bar, and every other avatar. --- .../amethyst/ui/components/GifVideoView.kt | 112 ++++++------------ .../ui/components/RobohashAsyncImage.kt | 78 +++++++++++- .../drawer/AccountSwitchBottomSheet.kt | 4 + .../ui/navigation/drawer/DrawerContent.kt | 4 + .../topbars/UserDrawerSearchTopBar.kt | 5 + .../amethyst/ui/note/MultiSetCompose.kt | 5 + .../amethyst/ui/note/NoteCompose.kt | 5 + .../amethyst/ui/note/UserProfilePicture.kt | 4 + .../amethyst/ui/note/types/CommunityHeader.kt | 8 ++ .../amethyst/ui/screen/UiSettingsState.kt | 10 ++ .../feed/types/RenderCreateChannelNote.kt | 5 + .../header/ShortEphemeralChatChannelHeader.kt | 5 + .../header/LongPublicChatChannelHeader.kt | 5 + .../header/ShortPublicChatChannelHeader.kt | 5 + .../send/ChannelFileUploadDialog.kt | 5 + .../chats/rooms/ChatroomHeaderCompose.kt | 14 +++ .../profile/header/badges/DisplayBadges.kt | 4 + .../ui/screen/loggedIn/qrcode/ShowQRScreen.kt | 4 + .../ui/screen/loggedIn/search/SearchScreen.kt | 12 ++ amethyst/src/main/res/values/strings.xml | 1 + 20 files changed, 219 insertions(+), 76 deletions(-) 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 index fadb4fee6..1b33389f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/GifVideoView.kt @@ -21,44 +21,40 @@ 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.foundation.layout.padding import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect 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.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp import coil3.asDrawable import coil3.compose.AsyncImagePainter import coil3.compose.SubcomposeAsyncImage import coil3.compose.SubcomposeAsyncImageContent +import com.vitorpamplona.amethyst.R 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.Font10SP +import com.vitorpamplona.amethyst.ui.theme.SmallBorder import com.vitorpamplona.amethyst.ui.theme.imageModifier import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag -import kotlinx.coroutines.delay @Composable fun GifVideoView( @@ -73,24 +69,17 @@ fun GifVideoView( 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 autoPlay = accountViewModel.settings.autoPlayVideos() val borderModifier = if (roundedCorner) MaterialTheme.colorScheme.imageModifier else Modifier val context = LocalContext.current + val containerModifier = + (if (ratio != null) borderModifier.aspectRatio(ratio) else borderModifier) + .fillMaxWidth() + .let { if (onDialog != null) it.clickable { onDialog() } else it } + Box( - modifier = - (if (ratio != null) borderModifier.aspectRatio(ratio) else borderModifier) - .fillMaxWidth() - .clickable { controllerVisible.value = !controllerVisible.value }, + modifier = containerModifier, contentAlignment = Alignment.Center, ) { SubcomposeAsyncImage( @@ -104,13 +93,9 @@ fun GifVideoView( val successState = state as? AsyncImagePainter.State.Success val drawable = successState?.result?.image?.asDrawable(context.resources) - LaunchedEffect(automaticallyStartPlayback.value, drawable) { + LaunchedEffect(autoPlay, drawable) { if (drawable is Animatable) { - if (automaticallyStartPlayback.value) { - drawable.start() - } else { - drawable.stop() - } + if (autoPlay) drawable.start() else drawable.stop() } } @@ -127,6 +112,11 @@ fun GifVideoView( is AsyncImagePainter.State.Success -> { SubcomposeAsyncImageContent(Modifier.fillMaxSize()) + + SideEffect { + val image = (state as AsyncImagePainter.State.Success).result.image + MediaAspectRatioCache.add(videoUri, image.width, image.height) + } } is AsyncImagePainter.State.Error -> { @@ -137,49 +127,21 @@ fun GifVideoView( } } - // 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( + if (!autoPlay) { + Text( + text = stringResource(R.string.gif), + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = Font10SP, + lineHeight = Font10SP, 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), - ) - } - } + .align(Alignment.BottomStart) + .padding(6.dp) + .clip(SmallBorder) + .background(Color.Black.copy(alpha = 0.6f)) + .padding(horizontal = 4.dp, vertical = 1.dp), + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt index 677b2966d..ac4a0badb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RobohashAsyncImage.kt @@ -20,9 +20,15 @@ */ package com.vitorpamplona.amethyst.ui.components +import android.graphics.drawable.Animatable import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize 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.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter @@ -31,7 +37,12 @@ import androidx.compose.ui.graphics.FilterQuality import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import coil3.asDrawable import coil3.compose.AsyncImage +import coil3.compose.AsyncImagePainter +import coil3.compose.SubcomposeAsyncImage +import coil3.compose.SubcomposeAsyncImageContent import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash @@ -75,8 +86,18 @@ fun RobohashFallbackAsyncImage( filterQuality: FilterQuality = DrawScope.DefaultFilterQuality, loadProfilePicture: Boolean, loadRobohash: Boolean, + autoPlayGif: Boolean = true, ) { - if (model != null && loadProfilePicture) { + if (model != null && loadProfilePicture && isGifUrl(model)) { + GifProfilePicture( + userHex = robot, + userPicture = model, + contentDescription = contentDescription, + modifier = modifier, + loadRobohash = loadRobohash, + autoPlay = autoPlayGif, + ) + } else if (model != null && loadProfilePicture) { val painter = if (loadRobohash) { rememberVectorPainter( @@ -124,3 +145,58 @@ fun RobohashFallbackAsyncImage( } } } + +@Composable +fun GifProfilePicture( + userHex: String, + userPicture: String, + contentDescription: String?, + modifier: Modifier, + loadRobohash: Boolean, + autoPlay: Boolean, +) { + val resources = LocalContext.current.resources + val fallbackPainter = + if (loadRobohash) { + rememberVectorPainter(image = CachedRobohash.get(userHex, MaterialTheme.colorScheme.isLight)) + } else { + forwardingPainter( + painter = rememberMaterialSymbolPainter(MaterialSymbols.Face), + colorFilter = MaterialTheme.colorScheme.onBackgroundColorFilter, + ) + } + + Box(modifier = modifier) { + SubcomposeAsyncImage( + model = userPicture, + contentDescription = contentDescription, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) { + val state by painter.state.collectAsState() + val successState = state as? AsyncImagePainter.State.Success + val drawable = successState?.result?.image?.asDrawable(resources) + + LaunchedEffect(drawable, autoPlay) { + if (drawable is Animatable) { + if (autoPlay) drawable.start() else drawable.stop() + } + } + + when (state) { + is AsyncImagePainter.State.Success -> { + SubcomposeAsyncImageContent() + } + + else -> { + Image( + painter = fallbackPainter, + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt index fbd7bd992..f736ede50 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt @@ -225,6 +225,10 @@ private fun AccountPicture( modifier = AccountPictureModifier, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 2a522939b..fe07aa6be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -259,6 +259,10 @@ fun ProfileContentTemplate( .clickable(onClick = onClick), loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, ) if (bestDisplayName != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt index a9e9b4b00..759e46175 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/UserDrawerSearchTopBar.kt @@ -31,6 +31,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage @@ -87,6 +88,10 @@ private fun LoggedInUserPictureDrawer( contentScale = ContentScale.Crop, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 44475cfc7..f703eaef9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -61,6 +61,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.emojicoder.EmojiCoder import com.vitorpamplona.amethyst.commons.model.EmptyTagList @@ -623,6 +624,10 @@ fun WatchUserMetadataAndFollowsAndRenderUserProfilePicture( contentScale = ContentScale.Crop, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 8fabae8e9..bd089ebaa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -56,6 +56,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.compose.produceCachedStateAsync import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -1818,6 +1819,10 @@ private fun ChannelNotePicture( modifier = MaterialTheme.colorScheme.channelNotePictureModifier, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index 2fe382482..c8f6e26df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -637,6 +637,10 @@ fun InnerUserPicture( contentScale = ContentScale.Crop, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt index d99dc4185..907e24d23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/CommunityHeader.kt @@ -328,6 +328,10 @@ fun ShortCommunityHeader( modifier = HeaderPictureModifier, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, ) } @@ -378,6 +382,10 @@ fun ShortCommunityHeaderNoActions( modifier = HeaderPictureModifier, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt index d6177d0c6..ef3a608d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UiSettingsState.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @Stable @@ -140,5 +141,14 @@ class UiSettingsState( fun autoPlayVideos() = uiSettingsFlow.automaticallyPlayVideos.value == BooleanType.ALWAYS + val autoPlayVideosFlow: StateFlow = + uiSettingsFlow.automaticallyPlayVideos + .map { it == BooleanType.ALWAYS } + .stateIn( + scope, + SharingStarted.Eagerly, + uiSettingsFlow.automaticallyPlayVideos.value == BooleanType.ALWAYS, + ) + fun showImages() = showImages.value } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt index 17f41c99f..3c801a11b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/types/RenderCreateChannelNote.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.defaults.Constants import com.vitorpamplona.amethyst.commons.model.EmptyTagList @@ -162,6 +163,10 @@ fun RenderChannelData( modifier = MaterialTheme.colorScheme.largeProfilePictureModifier, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt index 3813e90bf..8c76d8f50 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/ephemChat/header/ShortEphemeralChatChannelHeader.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo @@ -103,6 +104,10 @@ private fun DrawRelayIcon( modifier = HeaderPictureModifier, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt index 5027a95e8..08441b657 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/LongPublicChatChannelHeader.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel @@ -89,6 +90,10 @@ fun LongPublicChatChannelHeader( modifier = MaterialTheme.colorScheme.largeProfilePictureModifier, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt index a87eaa23d..046050a25 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/nip28PublicChat/header/ShortPublicChatChannelHeader.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observeChannel @@ -72,6 +73,10 @@ fun ShortPublicChatChannelHeader( modifier = HeaderPictureModifier, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt index 8c8b38343..e521b2901 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel @@ -73,6 +74,10 @@ fun ChannelFileUploadDialog( modifier = HeaderPictureModifier, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt index cf2228a25..edc6bf1d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/ChatroomHeaderCompose.kt @@ -209,6 +209,10 @@ private fun ChannelRoomCompose( hasNewMessages = (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, onClick = { nav.nav(routeFor(channel)) }, ) } @@ -241,6 +245,10 @@ private fun ChannelRoomCompose( hasNewMessages = (noteEvent?.createdAt ?: Long.MIN_VALUE) > lastReadTime, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, onClick = { nav.nav(routeFor(channel)) }, ) } @@ -276,6 +284,10 @@ private fun MarmotGroupRoomCompose( hasNewMessages = unread > 0, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, onClick = { nav.nav(Route.MarmotGroupChat(chatroom.nostrGroupId)) }, ) } @@ -399,6 +411,7 @@ fun ChannelName( hasNewMessages: Boolean, loadProfilePicture: Boolean, loadRobohash: Boolean, + autoPlayGif: Boolean, onClick: () -> Unit, ) { ChannelName( @@ -410,6 +423,7 @@ fun ChannelName( modifier = AccountPictureModifier, loadProfilePicture = loadProfilePicture, loadRobohash = loadRobohash, + autoPlayGif = autoPlayGif, ) }, channelTitle, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt index 32f6b70be..a4d9cab71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/badges/DisplayBadges.kt @@ -416,6 +416,10 @@ private fun RenderBadgeImage( modifier = modifier, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRScreen.kt index f8281aa32..8ac06786a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/qrcode/ShowQRScreen.kt @@ -232,6 +232,10 @@ fun RenderName( modifier = MaterialTheme.colorScheme.largeProfilePictureModifier, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, ) } Row( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index f6b16b7d7..33c5a524e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -568,6 +568,10 @@ private fun DisplaySearchResults( hasNewMessages = false, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, onClick = { nav.nav(routeFor(item)) }, ) @@ -597,6 +601,10 @@ private fun DisplaySearchResults( hasNewMessages = false, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, onClick = { nav.nav(routeFor(item)) }, ) @@ -624,6 +632,10 @@ private fun DisplaySearchResults( hasNewMessages = false, loadProfilePicture = accountViewModel.settings.showProfilePictures(), loadRobohash = accountViewModel.settings.isNotPerformanceMode(), + autoPlayGif = + accountViewModel.settings.autoPlayVideosFlow + .collectAsStateWithLifecycle() + .value, onClick = { nav.nav(routeFor(item)) }, ) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 4bbb8f3e0..f83fe25d4 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2506,4 +2506,5 @@ Private emoji Public emojis are visible to everyone and appear in your reaction menu and \":\" autocomplete picker when this pack is in your emoji list. Private emojis are stored encrypted on relays and visible only to you. They appear in your reaction menu and \":\" autocomplete just like public ones. + Gif From a9081923f81d9b2ddd6cd7a7ceb7e282e6c246f8 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Fri, 24 Apr 2026 18:15:56 +0000 Subject: [PATCH 3/3] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-pl-rPL/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 68ae40353..abfb482e5 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -771,6 +771,13 @@ %1$s zaakceptował Twoją ofertę %1$s wykonał ruch — teraz Twoja kolej Aktualizacje szachów + Odpowiedzi + Powiadamia Cię, gdy ktoś zareaguje na twój post + %1$s odpowiedział + na: %1$s + Nowe odpowiedzi + Wzmianki + Powiadamia Cię, gdy ktoś wspomni o Tobie lub zacytuje Cię w poście Połączenia przychodzące Powiadomienia o przychodzących połączeniach głosowych i wideo