diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index ef6826680fd..cd30a77a579 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -760,7 +760,7 @@ public final class io/getstream/chat/android/compose/ui/attachments/factory/Unsu public final class io/getstream/chat/android/compose/ui/attachments/preview/ComposableSingletons$MediaGalleryPreviewScreenKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/attachments/preview/ComposableSingletons$MediaGalleryPreviewScreenKt; - public static field lambda-1 Lkotlin/jvm/functions/Function6; + public static field lambda-1 Lkotlin/jvm/functions/Function7; public static field lambda-10 Lkotlin/jvm/functions/Function2; public static field lambda-11 Lkotlin/jvm/functions/Function2; public static field lambda-12 Lkotlin/jvm/functions/Function2; @@ -771,7 +771,7 @@ public final class io/getstream/chat/android/compose/ui/attachments/preview/Comp public static field lambda-17 Lkotlin/jvm/functions/Function2; public static field lambda-18 Lkotlin/jvm/functions/Function2; public static field lambda-19 Lkotlin/jvm/functions/Function2; - public static field lambda-2 Lkotlin/jvm/functions/Function6; + public static field lambda-2 Lkotlin/jvm/functions/Function7; public static field lambda-20 Lkotlin/jvm/functions/Function2; public static field lambda-21 Lkotlin/jvm/functions/Function2; public static field lambda-22 Lkotlin/jvm/functions/Function2; @@ -788,7 +788,7 @@ public final class io/getstream/chat/android/compose/ui/attachments/preview/Comp public static field lambda-8 Lkotlin/jvm/functions/Function2; public static field lambda-9 Lkotlin/jvm/functions/Function2; public fun ()V - public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function6; + public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function7; public final fun getLambda-10$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-11$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-12$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; @@ -799,7 +799,7 @@ public final class io/getstream/chat/android/compose/ui/attachments/preview/Comp public final fun getLambda-17$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-18$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-19$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda-2$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function6; + public final fun getLambda-2$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function7; public final fun getLambda-20$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-21$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-22$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; @@ -861,8 +861,8 @@ public final class io/getstream/chat/android/compose/ui/attachments/preview/Medi } public final class io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreenKt { - public static final fun MediaGalleryPreviewScreen (Lio/getstream/chat/android/compose/viewmodel/mediapreview/MediaGalleryPreviewViewModel;ILkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/compose/ui/theme/MediaGalleryConfig;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function6;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;III)V - public static final fun MediaGalleryPreviewScreen (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/ConnectionState;Lio/getstream/chat/android/models/User;ILio/getstream/chat/android/models/Attachment;ZZZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/compose/ui/theme/MediaGalleryConfig;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function6;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;IIII)V + public static final fun MediaGalleryPreviewScreen (Lio/getstream/chat/android/compose/viewmodel/mediapreview/MediaGalleryPreviewViewModel;ILkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/compose/ui/theme/MediaGalleryConfig;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function7;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;III)V + public static final fun MediaGalleryPreviewScreen (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/ConnectionState;Lio/getstream/chat/android/models/User;ILio/getstream/chat/android/models/Attachment;ZZZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/compose/ui/theme/MediaGalleryConfig;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function7;Lkotlin/jvm/functions/Function5;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;IIII)V } public final class io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity : androidx/appcompat/app/AppCompatActivity { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/AudioRecordAttachmentContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/AudioRecordAttachmentContent.kt index d578c8445be..1b37e8fdc15 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/AudioRecordAttachmentContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/AudioRecordAttachmentContent.kt @@ -17,7 +17,6 @@ package io.getstream.chat.android.compose.ui.attachments.content import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -38,7 +37,6 @@ import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue 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.res.painterResource import androidx.compose.ui.tooling.preview.Preview @@ -58,6 +56,7 @@ import io.getstream.chat.android.compose.state.messages.attachments.AttachmentSt import io.getstream.chat.android.compose.ui.components.LoadingIndicator import io.getstream.chat.android.compose.ui.components.audio.PlaybackTimerText import io.getstream.chat.android.compose.ui.components.audio.StaticWaveformSlider +import io.getstream.chat.android.compose.ui.components.button.SpeedButton import io.getstream.chat.android.compose.ui.components.button.StreamButton import io.getstream.chat.android.compose.ui.components.button.StreamButtonStyleDefaults import io.getstream.chat.android.compose.ui.theme.ChatPreviewTheme @@ -65,11 +64,9 @@ import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.theme.MessageStyling import io.getstream.chat.android.compose.ui.theme.StreamTokens import io.getstream.chat.android.compose.ui.util.applyIf -import io.getstream.chat.android.compose.ui.util.clickable import io.getstream.chat.android.compose.ui.util.shouldBeDisplayedAsFullSizeAttachment import io.getstream.chat.android.compose.viewmodel.messages.AudioPlayerViewModel import io.getstream.chat.android.compose.viewmodel.messages.AudioPlayerViewModelFactory -import io.getstream.chat.android.extensions.isInt import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.Attachment.UploadState import io.getstream.chat.android.ui.common.state.messages.list.AudioPlayerState @@ -307,36 +304,6 @@ internal fun PlaybackToggleButton( } } -private val speedButtonShape = RoundedCornerShape(StreamTokens.radiusLg) - -/** - * Represents the speed button. - */ -@Composable -private fun SpeedButton( - speed: Float, - outlineColor: Color, - enabled: Boolean = true, - onClick: () -> Unit, -) { - val colors = ChatTheme.colors - val textColor = if (enabled) colors.controlPlaybackToggleText else colors.textDisabled - val borderColor = if (enabled) outlineColor else colors.borderUtilityDisabled - Text( - text = when (speed.isInt()) { - true -> "x${speed.toInt()}" - else -> "x$speed" - }, - style = ChatTheme.typography.metadataEmphasis, - color = textColor, - modifier = Modifier - .border(1.dp, borderColor, speedButtonShape) - .clip(speedButtonShape) - .applyIf(enabled) { clickable(onClick = onClick) } - .padding(horizontal = StreamTokens.spacingXs, vertical = StreamTokens.spacing2xs), - ) -} - @Composable private fun UploadProgressIndicator( uploadState: UploadState.InProgress, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewOptionsMenu.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewOptionsMenu.kt index fad71eeb547..756da36d715 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewOptionsMenu.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewOptionsMenu.kt @@ -16,22 +16,19 @@ package io.getstream.chat.android.compose.ui.attachments.preview -import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.Surface +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.ripple import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -40,7 +37,6 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.state.mediagallerypreview.Delete import io.getstream.chat.android.compose.state.mediagallerypreview.MediaGalleryPreviewOption @@ -50,18 +46,17 @@ import io.getstream.chat.android.compose.state.mediagallerypreview.ShowInChat import io.getstream.chat.android.compose.ui.components.StreamHorizontalDivider import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.theme.MediaGalleryOptionsConfig +import io.getstream.chat.android.compose.ui.theme.StreamTokens import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.ConnectionState import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.User /** - * Composable rendering the options menu overlay for media gallery preview. + * Composable rendering the options menu as a bottom sheet for media gallery preview. * - * Displays a dropdown menu in the top-right corner with available actions for the - * currently displayed attachment. The menu appears as a floating surface with a - * semi-transparent overlay covering the entire screen behind it. Clicking anywhere - * outside the menu dismisses it. + * Displays a [ModalBottomSheet] with available actions for the currently displayed attachment. + * The sheet can be dismissed by swiping down, tapping outside, or tapping the scrim. * * Each option is rendered as a [MediaGalleryOptionItem] with dividers between items. * @@ -69,8 +64,9 @@ import io.getstream.chat.android.models.User * @param options List of available options to display in the menu. * @param onOptionClick Callback invoked when an option is clicked, providing both the attachment and option. * @param onDismiss Callback invoked when the menu should be dismissed. - * @param modifier Optional modifier applied to the Surface containing the options. + * @param modifier Optional modifier applied to the [ModalBottomSheet]. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable internal fun MediaGalleryOptionsMenu( attachment: Attachment, @@ -79,39 +75,25 @@ internal fun MediaGalleryOptionsMenu( onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(ChatTheme.colors.backgroundCoreScrim) - .clickable( - indication = null, - interactionSource = null, - onClick = onDismiss, - ), + ModalBottomSheet( + modifier = modifier, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + containerColor = ChatTheme.colors.backgroundElevationElevation1, + scrimColor = ChatTheme.colors.backgroundCoreScrim, + onDismissRequest = onDismiss, ) { - Surface( - modifier = modifier - .padding(16.dp) - .width(150.dp) - .wrapContentHeight() - .align(Alignment.TopEnd), - shape = RoundedCornerShape(16.dp), - shadowElevation = 4.dp, - color = ChatTheme.colors.backgroundElevationElevation1, - ) { - Column(modifier = Modifier.fillMaxWidth()) { - options.forEachIndexed { index, option -> - MediaGalleryOptionItem( - option = option, - onClick = { - onDismiss() - onOptionClick(attachment, option) - }, - ) + Column(modifier = Modifier.fillMaxWidth()) { + options.forEachIndexed { index, option -> + MediaGalleryOptionItem( + option = option, + onClick = { + onOptionClick(attachment, option) + onDismiss() + }, + ) - if (index != options.lastIndex) { - StreamHorizontalDivider() - } + if (index != options.lastIndex) { + StreamHorizontalDivider() } } } @@ -138,28 +120,27 @@ internal fun MediaGalleryOptionItem( Row( modifier = Modifier .fillMaxWidth() - .background(ChatTheme.colors.backgroundElevationElevation1) + .padding(horizontal = StreamTokens.spacing2xs) .clickable( interactionSource = null, indication = ripple(), enabled = option.isEnabled, onClick = onClick, ) - .padding(horizontal = 16.dp, vertical = 8.dp), + .padding(StreamTokens.spacingSm), verticalAlignment = Alignment.CenterVertically, ) { Icon( - modifier = Modifier.size(18.dp), + modifier = Modifier.size(20.dp), painter = option.iconPainter, tint = option.iconColor, contentDescription = option.title, ) - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(StreamTokens.spacingSm)) Text( text = option.title, color = option.titleColor, - style = ChatTheme.typography.bodyEmphasis, - fontSize = 12.sp, + style = ChatTheme.typography.bodyDefault, maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt index 2fc2c9baf29..c668a1dbf6a 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewScreen.kt @@ -21,7 +21,6 @@ package io.getstream.chat.android.compose.ui.attachments.preview import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -46,7 +45,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -57,28 +55,24 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.media3.common.MediaItem import androidx.media3.common.Player import io.getstream.chat.android.client.utils.attachment.isImage import io.getstream.chat.android.client.utils.attachment.isVideo import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.state.mediagallerypreview.MediaGalleryPreviewOption +import io.getstream.chat.android.compose.ui.attachments.preview.internal.GalleryMediaEffect import io.getstream.chat.android.compose.ui.attachments.preview.internal.MediaGalleryImagePage import io.getstream.chat.android.compose.ui.attachments.preview.internal.MediaGalleryPhotosMenu import io.getstream.chat.android.compose.ui.attachments.preview.internal.MediaGalleryVideoPage -import io.getstream.chat.android.compose.ui.attachments.preview.internal.createPlayer +import io.getstream.chat.android.compose.ui.attachments.preview.internal.VideoPlaybackControls +import io.getstream.chat.android.compose.ui.attachments.preview.internal.rememberMediaGalleryPlayerState import io.getstream.chat.android.compose.ui.components.NetworkLoadingIndicator import io.getstream.chat.android.compose.ui.components.SimpleDialog import io.getstream.chat.android.compose.ui.components.Timestamp @@ -152,9 +146,7 @@ public fun MediaGalleryPreviewScreen( onDismissGallery: () -> Unit = { viewModel.toggleGallery(false) }, header: @Composable (attachments: List, currentPage: Int) -> Unit = { _, _ -> MediaGalleryPreviewHeader( - modifier = Modifier - .fillMaxWidth() - .height(56.dp), + modifier = Modifier.fillMaxWidth(), message = viewModel.message, connectionState = viewModel.connectionState, onLeadingContentClick = onHeaderLeadingContentClick, @@ -165,29 +157,37 @@ public fun MediaGalleryPreviewScreen( padding: PaddingValues, pagerState: PagerState, attachments: List, - onPlaybackError: () -> Unit, - ) -> Unit = { padding, pagerState, attachments, onPlaybackError -> + player: Player?, + onMediaClick: () -> Unit, + ) -> Unit = { padding, pagerState, attachments, player, onMediaClick -> MediaGalleryPager( modifier = Modifier .fillMaxSize() - .background(ChatTheme.colors.backgroundCoreApp) .padding(padding), + player = player, pagerState = pagerState, attachments = attachments, - onPlaybackError = { onPlaybackError() }, - ) - }, - footer: @Composable (attachments: List, currentPage: Int) -> Unit = { attachments, currentPage -> - MediaGalleryPreviewFooter( - attachments = attachments, - currentPage = currentPage, - totalPages = attachments.size, - connectionState = viewModel.connectionState, - isSharingInProgress = viewModel.isSharingInProgress, - onLeadingContentClick = onFooterLeadingContentClick, - onTrailingContentClick = onFooterTrailingContentClick, + onMediaClick = onMediaClick, ) }, + footer: @Composable (attachments: List, currentPage: Int, player: Player?) -> Unit = + { attachments, currentPage, player -> + MediaGalleryPreviewFooter( + attachments = attachments, + currentPage = currentPage, + totalPages = attachments.size, + connectionState = viewModel.connectionState, + isSharingInProgress = viewModel.isSharingInProgress, + onLeadingContentClick = onFooterLeadingContentClick, + onTrailingContentClick = onFooterTrailingContentClick, + topContent = { + val currentAttachment = attachments.getOrNull(currentPage) + if (player != null && currentAttachment?.isVideo() == true) { + VideoPlaybackControls(player = player) + } + }, + ) + }, optionsMenu: @Composable ( attachment: Attachment, options: List, @@ -306,29 +306,37 @@ public fun MediaGalleryPreviewScreen( padding: PaddingValues, pagerState: PagerState, attachments: List, - onPlaybackError: () -> Unit, - ) -> Unit = { padding, pagerState, attachments, onPlaybackError -> + player: Player?, + onMediaClick: () -> Unit, + ) -> Unit = { padding, pagerState, attachments, player, onMediaClick -> MediaGalleryPager( modifier = Modifier .fillMaxSize() - .background(ChatTheme.colors.backgroundCoreApp) .padding(padding), + player = player, pagerState = pagerState, attachments = attachments, - onPlaybackError = { onPlaybackError() }, - ) - }, - footer: @Composable (attachments: List, currentPage: Int) -> Unit = { attachments, currentPage -> - MediaGalleryPreviewFooter( - attachments = attachments, - currentPage = currentPage, - totalPages = attachments.size, - connectionState = connectionState, - isSharingInProgress = isSharingInProgress, - onLeadingContentClick = onFooterLeadingContentClick, - onTrailingContentClick = onFooterTrailingContentClick, + onMediaClick = onMediaClick, ) }, + footer: @Composable (attachments: List, currentPage: Int, player: Player?) -> Unit = + { attachments, currentPage, player -> + MediaGalleryPreviewFooter( + attachments = attachments, + currentPage = currentPage, + totalPages = attachments.size, + connectionState = connectionState, + isSharingInProgress = isSharingInProgress, + onLeadingContentClick = onFooterLeadingContentClick, + onTrailingContentClick = onFooterTrailingContentClick, + topContent = { + val currentAttachment = attachments.getOrNull(currentPage) + if (player != null && currentAttachment?.isVideo() == true) { + VideoPlaybackControls(player = player) + } + }, + ) + }, optionsMenu: @Composable ( attachment: Attachment, options: List, @@ -341,11 +349,13 @@ public fun MediaGalleryPreviewScreen( ) }, ) { - // Filters out any link attachments. Pass this value along to all children + // Filters out non-media and link attachments. Pass this value along to all children // Composable-s that read message attachments to prevent inconsistent state. val filteredAttachments by remember(message) { derivedStateOf { - message.attachments.filter { attachment -> !attachment.hasLink() } + message.attachments.filter { attachment -> + !attachment.hasLink() && (attachment.isImage() || attachment.isVideo()) + } } } val startingPosition = if (initialPage !in filteredAttachments.indices) 0 else initialPage @@ -360,41 +370,65 @@ public fun MediaGalleryPreviewScreen( } } + val playbackErrorText = stringResource(R.string.stream_ui_message_list_video_display_error) val coroutineScope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } + var isImmersive by remember { mutableStateOf(false) } + + // Hoisted player state shared between the pager content and the bottom bar. + val playerState = rememberMediaGalleryPlayerState( + onPlaybackError = { + coroutineScope.launch { + snackbarHostState.showSnackbar( + message = playbackErrorText, + duration = SnackbarDuration.Short, + ) + } + }, + ) + GalleryMediaEffect(playerState, pagerState.currentPage, filteredAttachments) // Full-size container holding the main scaffold and the overlay menus Box(modifier = modifier) { + // Scaffold padding is intentionally ignored to prevent content from shifting when the top/bottom bars + // animate in/out during immersive mode. + @Suppress("UnusedMaterial3ScaffoldPaddingParameter") Scaffold( modifier = Modifier.fillMaxSize(), + containerColor = ChatTheme.colors.backgroundCoreApp, topBar = { - header(filteredAttachments, pagerState.currentPage) + AnimatedVisibility( + visible = !isImmersive, + enter = fadeIn(), + exit = fadeOut(), + ) { + header(filteredAttachments, pagerState.currentPage) + } }, bottomBar = { - if (message.id.isNotEmpty()) { - footer(filteredAttachments, pagerState.currentPage) + AnimatedVisibility( + visible = !isImmersive && message.id.isNotEmpty(), + enter = fadeIn(), + exit = fadeOut(), + ) { + footer(filteredAttachments, pagerState.currentPage, playerState.player) } }, - ) { padding -> + ) { _ -> if (message.id.isNotEmpty()) { Box(modifier = Modifier.fillMaxSize()) { // Main content - val playbackErrorText = stringResource(R.string.stream_ui_message_list_video_display_error) - content(padding, pagerState, filteredAttachments) { - // Show snackbar when playback error occurs - coroutineScope.launch { - snackbarHostState.showSnackbar( - message = playbackErrorText, - duration = SnackbarDuration.Short, - ) - } - } + content( + PaddingValues(), + pagerState, + filteredAttachments, + playerState.player, + { isImmersive = !isImmersive }, + ) // Error snackbar StreamSnackbarHost( hostState = snackbarHostState, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = padding.calculateBottomPadding()), + modifier = Modifier.align(Alignment.BottomCenter), ) } // Prompt the user to share a large file (if needed) @@ -412,16 +446,10 @@ public fun MediaGalleryPreviewScreen( } // Attachment options - AnimatedVisibility( - visible = isShowingOptions, - enter = fadeIn(), - exit = fadeOut(), - ) { - if (pagerState.currentPage in filteredAttachments.indices) { - val attachment = filteredAttachments[pagerState.currentPage] - val options = defaultMediaOptions(currentUser, message, connectionState, config.optionsConfig) - optionsMenu(attachment, options) - } + if (isShowingOptions && pagerState.currentPage in filteredAttachments.indices) { + val attachment = filteredAttachments[pagerState.currentPage] + val options = defaultMediaOptions(currentUser, message, connectionState, config.optionsConfig) + optionsMenu(attachment, options) } // Gallery @@ -536,79 +564,18 @@ internal fun MediaGalleryPreviewHeader( * @param pagerState The [PagerState] for managing the pager's state. (passed from outside, so it can be also used by * the adjacent components. For example, to show the current position of the pager in the footer) * @param attachments The list of [Attachment]s to be displayed in the pager. - * @param onPlaybackError Callback to be invoked when an error during the playing of a video occurs. * @param modifier The [Modifier] to be applied to the pager. + * @param player The [Player] instance used for video playback. + * @param onMediaClick Callback to be invoked when the media is clicked (e.g., to toggle immersive mode). */ -@Suppress("LongMethod") @Composable internal fun MediaGalleryPager( pagerState: PagerState, attachments: List, - onPlaybackError: (error: Throwable) -> Unit, + player: Player?, modifier: Modifier = Modifier, + onMediaClick: () -> Unit = {}, ) { - val context = LocalContext.current - val previewMode = LocalInspectionMode.current - var showBuffering by remember { mutableStateOf(true) } - // Create a single instance of the player for the pager, - // so it can be reused across pages, - // improving performance and preventing issues when switching between pages. - var player by remember { mutableStateOf(null) } - // Saved playback state (pageIndex to position) for restoration after app resume. - var savedPlaybackState by remember { mutableStateOf?>(null) } - val currentPage = pagerState.currentPage - LaunchedEffect(currentPage, player) { - player?.let { activePlayer -> - activePlayer.pause() // Pause the player when the page changes - val attachment = attachments[currentPage] - // Prepare the player with the media item if it's a video. - if (attachment.isVideo()) { - attachment.assetUrl?.let { assetUrl -> - // Restore playback position if returning to the same page after app resume. - val startPosition = savedPlaybackState - ?.takeIf { (savedPage, _) -> savedPage == currentPage } - ?.second - ?: 0L - savedPlaybackState = null - activePlayer.setMediaItem(MediaItem.fromUri(assetUrl), startPosition) - activePlayer.prepare() - } - } - } - } - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner, previewMode) { - val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_START -> { - // Player should not be created in preview mode to prevent exceptions. - if (!previewMode && player == null) { - player = createPlayer( - context = context, - onBuffering = { isBuffering -> showBuffering = isBuffering }, - onPlaybackError = onPlaybackError, - ) - } - } - Lifecycle.Event.ON_PAUSE -> { - player?.pause() - } - Lifecycle.Event.ON_STOP -> { - // Save playback position before releasing the player. - savedPlaybackState = pagerState.currentPage to (player?.currentPosition ?: 0L) - player?.release() - player = null - } - else -> Unit - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - player?.release() - player = null - } - } HorizontalPager( modifier = modifier, state = pagerState, @@ -620,6 +587,7 @@ internal fun MediaGalleryPager( attachment = attachment, pagerState = pagerState, page = page, + onTap = onMediaClick, ) } @@ -629,8 +597,7 @@ internal fun MediaGalleryPager( modifier = Modifier.fillMaxSize(), player = it, thumbnailUrl = attachment.thumbUrl, - showBuffering = showBuffering, - onPlaybackError = onPlaybackError, + onClick = onMediaClick, ) } } @@ -680,6 +647,7 @@ internal fun MediaGalleryPreviewFooter( backgroundColor: Color = ChatTheme.colors.backgroundElevationElevation1, contentColor: Color = ChatTheme.colors.textPrimary, config: MediaGalleryConfig = ChatTheme.config.mediaGallery, + topContent: @Composable (() -> Unit)? = null, leadingContent: @Composable (Modifier) -> Unit = { if (config.isShareVisible) { MediaGalleryPreviewShareIcon( @@ -719,16 +687,19 @@ internal fun MediaGalleryPreviewFooter( color = backgroundColor, contentColor = contentColor, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - leadingContent(Modifier) - centerContent(Modifier.weight(1f)) - trailingContent(Modifier) + Column { + topContent?.invoke() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + leadingContent(Modifier) + centerContent(Modifier.weight(1f)) + trailingContent(Modifier) + } } } } @@ -749,7 +720,7 @@ internal fun MediaGalleryPreviewCloseIcon( onClick = onClick, ) { Icon( - painter = painterResource(id = R.drawable.stream_compose_ic_close), + painter = painterResource(id = R.drawable.stream_compose_ic_arrow_back), contentDescription = stringResource(id = R.string.stream_compose_cancel), ) } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt index 8e2bafe659c..2b5cc8bd49b 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt @@ -17,12 +17,12 @@ package io.getstream.chat.android.compose.ui.attachments.preview.internal import android.annotation.SuppressLint -import android.util.Log import androidx.annotation.OptIn import androidx.compose.animation.Crossfade import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.calculatePan @@ -35,6 +35,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.pager.PagerState import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf @@ -44,7 +46,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput @@ -97,6 +98,7 @@ internal fun MediaGalleryImagePage( attachment: Attachment, pagerState: PagerState, page: Int, + onTap: () -> Unit = {}, ) { @SuppressLint("UnusedBoxWithConstraintsScope") BoxWithConstraints( @@ -121,6 +123,7 @@ internal fun MediaGalleryImagePage( var currentScale by remember { mutableFloatStateOf(DefaultZoomScale) } var translation by remember { mutableStateOf(Offset(0f, 0f)) } + var wasDragged by remember { mutableStateOf(false) } val scale by animateFloatAsState(targetValue = currentScale, label = "") @@ -157,6 +160,7 @@ internal fun MediaGalleryImagePage( coroutineScope { awaitEachGesture { awaitFirstDown(requireUnconsumed = true) + wasDragged = false do { val event = awaitPointerEvent(pass = PointerEventPass.Initial) @@ -170,6 +174,9 @@ internal fun MediaGalleryImagePage( ) val offset = event.calculatePan() + if (offset != Offset.Zero || zoom != DefaultZoomScale) { + wasDragged = true + } val newTranslationX = translation.x + offset.x * currentScale val newTranslationY = translation.y + offset.y * currentScale @@ -198,8 +205,10 @@ internal fun MediaGalleryImagePage( coroutineScope { awaitEachGesture { awaitFirstDown() - withTimeoutOrNull(DoubleTapTimeoutMs) { + val secondDown = withTimeoutOrNull(DoubleTapTimeoutMs) { awaitFirstDown() + } + if (secondDown != null) { currentScale = when { currentScale == MaxZoomScale -> DefaultZoomScale currentScale >= MidZoomScale -> MaxZoomScale @@ -209,6 +218,8 @@ internal fun MediaGalleryImagePage( if (currentScale == DefaultZoomScale) { translation = Offset(0f, 0f) } + } else if (!wasDragged) { + onTap() } } } @@ -235,11 +246,11 @@ internal fun MediaGalleryImagePage( } } - Log.d("isCurrentPage", "${page != pagerState.currentPage}") - - if (pagerState.currentPage != page) { - currentScale = DefaultZoomScale - translation = Offset(0f, 0f) + LaunchedEffect(pagerState.settledPage) { + if (pagerState.settledPage != page) { + currentScale = DefaultZoomScale + translation = Offset(0f, 0f) + } } } } @@ -285,8 +296,6 @@ private fun ErrorIcon(modifier: Modifier) { * * @param player The [Player] instance used for video playback. * @param thumbnailUrl The url of the thumbnail to display before the video is played. - * @param showBuffering Whether to show a buffering indicator while the video is loading. - * @param onPlaybackError Callback invoked when video playback encounters an error. * @param modifier The [Modifier] to be applied to the video player. */ @OptIn(UnstableApi::class) @@ -294,20 +303,37 @@ private fun ErrorIcon(modifier: Modifier) { internal fun MediaGalleryVideoPage( player: Player, thumbnailUrl: String?, - showBuffering: Boolean, - onPlaybackError: (error: Throwable) -> Unit, modifier: Modifier = Modifier, + onClick: () -> Unit = {}, ) { var showThumbnail by remember { mutableStateOf(true) } + var showBuffering by remember { mutableStateOf(player.playbackState == Player.STATE_BUFFERING) } + DisposableEffect(player) { + val listener = object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (isPlaying) showThumbnail = false + } + + override fun onPlaybackStateChanged(playbackState: Int) { + showBuffering = playbackState == Player.STATE_BUFFERING + } + } + player.addListener(listener) + onDispose { player.removeListener(listener) } + } Box( - modifier = modifier, + modifier = modifier.clickable( + interactionSource = null, + indication = null, + onClick = onClick, + ), contentAlignment = Alignment.Center, ) { // Video player AndroidView( modifier = Modifier .matchParentSize() - .background(Color.Black), + .background(ChatTheme.colors.backgroundCoreApp), factory = { context -> createPlayerView(context, player) }, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPlayerState.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPlayerState.kt new file mode 100644 index 00000000000..592baa36db3 --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPlayerState.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.attachments.preview.internal + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import io.getstream.chat.android.client.utils.attachment.isVideo +import io.getstream.chat.android.models.Attachment + +@Stable +internal class MediaGalleryPlayerState internal constructor() { + var player: Player? by mutableStateOf(null) + internal set + + /** Playback position captured before the last player release. */ + var savedPosition: Long = 0L + internal set +} + +/** + * Creates and remembers a lifecycle-managed [Player]. + * + * @param onPlaybackError Callback invoked when a playback error occurs. + */ +@Composable +internal fun rememberMediaGalleryPlayerState( + onPlaybackError: (Throwable) -> Unit, +): MediaGalleryPlayerState { + val context = LocalContext.current + val previewMode = LocalInspectionMode.current + val lifecycleOwner = LocalLifecycleOwner.current + val state = remember(::MediaGalleryPlayerState) + + DisposableEffect(lifecycleOwner, previewMode) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_START -> { + if (!previewMode && state.player == null) { + state.player = createPlayer( + context = context, + onPlaybackError = onPlaybackError, + onBuffering = {}, + ) + } + } + + Lifecycle.Event.ON_PAUSE -> state.player?.pause() + + Lifecycle.Event.ON_STOP -> { + state.savedPosition = state.player?.currentPosition ?: 0L + state.player?.release() + state.player = null + } + + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + state.player?.release() + state.player = null + } + } + + return state +} + +/** + * A side effect that prepares the correct media item whenever the gallery page changes + * or the player becomes available. + * + * When the player is recreated on the same page (e.g. after ON_STOP → ON_START), + * playback resumes from [MediaGalleryPlayerState.savedPosition]. + * + * @param playerState The lifecycle-managed player state. + * @param currentPage The current pager page index. + * @param attachments The list of attachments displayed in the pager. + */ +@Composable +internal fun GalleryMediaEffect( + playerState: MediaGalleryPlayerState, + currentPage: Int, + attachments: List, +) { + var lastPreparedPage by remember { mutableIntStateOf(-1) } + + LaunchedEffect(currentPage, playerState.player) { + playerState.player?.let { player -> + player.pause() + val attachment = attachments.getOrNull(currentPage) ?: return@LaunchedEffect + if (attachment.isVideo()) { + attachment.assetUrl?.let { assetUrl -> + val startPosition = if (currentPage == lastPreparedPage) { + playerState.savedPosition + } else { + 0L + } + player.setMediaItem(MediaItem.fromUri(assetUrl), startPosition) + player.prepare() + } + } + lastPreparedPage = currentPage + } + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamMediaPlayerContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamMediaPlayerContent.kt index cdf58302cca..aa7d75b8dca 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamMediaPlayerContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamMediaPlayerContent.kt @@ -44,6 +44,7 @@ import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.ui.components.LoadingIndicator import io.getstream.chat.android.compose.ui.components.common.PlayButton import io.getstream.chat.android.compose.ui.components.common.PlayButtonSize +import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.util.StreamAsyncImage import io.getstream.chat.android.compose.ui.util.clickable import io.getstream.chat.android.core.internal.StreamHandsOff @@ -154,7 +155,7 @@ internal fun MediaThumbnail( modifier: Modifier = Modifier, ) { Box( - modifier = modifier.background(Color.Black), + modifier = modifier.background(ChatTheme.colors.backgroundCoreApp), contentAlignment = Alignment.Center, ) { if (thumbnailUrl != null) { @@ -224,13 +225,7 @@ internal fun createPlayerView(context: Context, player: Player): PlayerView { .inflate(R.layout.stream_compose_player_view, null) as PlayerView return playerView.apply { this.player = player - controllerShowTimeoutMs = ControllerShowTimeout - controllerAutoShow = false - controllerHideOnTouch = true - setShowPreviousButton(false) - setShowNextButton(false) + useController = false setShowBuffering(PlayerView.SHOW_BUFFERING_NEVER) } } - -private const val ControllerShowTimeout = 2000 diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/VideoPlaybackControls.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/VideoPlaybackControls.kt new file mode 100644 index 00000000000..c544370546c --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/VideoPlaybackControls.kt @@ -0,0 +1,365 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.attachments.preview.internal + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.progressSemantics +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.coerceIn +import androidx.compose.ui.unit.dp +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.ui.components.button.SpeedButton +import io.getstream.chat.android.compose.ui.components.button.StreamButton +import io.getstream.chat.android.compose.ui.components.button.StreamButtonSize +import io.getstream.chat.android.compose.ui.components.button.StreamButtonStyleDefaults +import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.theme.StreamTokens +import io.getstream.chat.android.compose.ui.util.dragPointerInput +import kotlinx.coroutines.delay + +/** + * A composable that displays video playback controls for the media gallery. + * + * Contains play/pause button, current time, seek bar, and speed toggle. + * + * @param player The [Player] instance to control. + * @param modifier The [Modifier] to be applied. + */ +@Composable +internal fun VideoPlaybackControls( + player: Player, + modifier: Modifier = Modifier, +) { + val state = rememberVideoPlaybackControlsState(player) + + Row( + modifier = modifier.padding(start = StreamTokens.spacingSm, end = StreamTokens.spacingMd), + verticalAlignment = Alignment.CenterVertically, + ) { + // Play/Pause button + StreamButton( + onClick = state::togglePlayPause, + style = StreamButtonStyleDefaults.secondaryGhost, + size = StreamButtonSize.Small, + modifier = Modifier.minimumInteractiveComponentSize(), + ) { + val icon = if (state.isPlaying) { + R.drawable.stream_compose_ic_pause + } else { + R.drawable.stream_compose_ic_play + } + val contentDescription = if (state.isPlaying) { + R.string.stream_compose_audio_playback_pause + } else { + R.string.stream_compose_cd_play_button + } + Icon( + painter = painterResource(icon), + contentDescription = stringResource(contentDescription), + modifier = Modifier.size(20.dp), + ) + } + + Text( + text = ChatTheme.durationFormatter.format(state.currentPosition.toInt()), + style = ChatTheme.typography.captionDefault, + color = if (state.isPlaying) ChatTheme.colors.accentPrimary else ChatTheme.colors.textPrimary, + ) + + PlaybackSlider( + progress = state.progress, + isPlaying = state.isPlaying, + modifier = Modifier + .weight(1f) + .height(20.dp) + .padding(horizontal = StreamTokens.spacingMd), + onDragStart = { state.onDragStart() }, + onDrag = state::onDrag, + onDragStop = state::onDragStop, + ) + + SpeedButton( + speed = state.speed, + onClick = state::cycleSpeed, + ) + } +} + +/** + * A progress bar matching the Figma "Mobile / Playback Progress Bar" component. + * + * Displays a rounded track (4dp) with a 12dp white circular thumb with border and shadow. + * + * @param progress The current progress (0f..1f). + * @param isPlaying Whether playback is active (changes thumb and track colors). + * @param modifier The [Modifier] to be applied. + * @param onDragStart Callback when the user starts dragging. + * @param onDrag Callback during drag with the current progress. + * @param onDragStop Callback when the user stops dragging with the final progress. + */ +@Composable +private fun PlaybackSlider( + progress: Float, + isPlaying: Boolean, + modifier: Modifier = Modifier, + onDragStart: (Float) -> Unit = {}, + onDrag: (Float) -> Unit = {}, + onDragStop: (Float) -> Unit = {}, +) { + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + val currentProgress by rememberUpdatedState(progress) + var widthPx by remember { mutableFloatStateOf(0f) } + val animatedProgress by animateFloatAsState( + targetValue = progress, + animationSpec = if (isPlaying) { + tween(durationMillis = PositionPollingIntervalMs.toInt(), easing = LinearEasing) + } else { + snap() + }, + label = "playback-progress", + ) + Box( + modifier = modifier + .progressSemantics(value = progress) + .onSizeChanged { size -> widthPx = size.width.toFloat() } + .dragPointerInput( + enabled = true, + onDragStart = { onDragStart(it.toHorizontalProgress(widthPx, isRtl)) }, + onDrag = { onDrag(it.toHorizontalProgress(widthPx, isRtl)) }, + onDragStop = { onDragStop(it?.toHorizontalProgress(widthPx, isRtl) ?: currentProgress) }, + ), + contentAlignment = Alignment.CenterStart, + ) { + // Track background + Box( + modifier = Modifier + .fillMaxWidth() + .height(TrackHeight) + .clip(CircleShape) + .background(ChatTheme.colors.chatWaveformBar), + ) + // Active track + Box( + modifier = Modifier + .fillMaxWidth(fraction = animatedProgress) + .height(TrackHeight) + .clip(CircleShape) + .background(ChatTheme.colors.chatWaveformBarPlaying), + ) + // Thumb + PlaybackThumb(progress = animatedProgress, isPlaying = isPlaying, parentWidthPx = widthPx) + } +} + +@Composable +private fun BoxScope.PlaybackThumb( + progress: Float, + isPlaying: Boolean, + parentWidthPx: Float, +) { + val thumbOffset = if (parentWidthPx > 0) { + with(LocalDensity.current) { + val parentWidth = parentWidthPx.toDp() + val center = parentWidth * progress + val left = center - (ThumbSize / 2) + left.coerceIn(0.dp, parentWidth - ThumbSize) + } + } else { + 0.dp + } + val colors = ChatTheme.colors + val bgColor = if (isPlaying) { + colors.controlPlaybackThumbBgActive + } else { + colors.controlPlaybackThumbBgDefault + } + val borderColor = if (isPlaying) { + colors.controlPlaybackThumbBorderActive + } else { + colors.controlPlaybackThumbBorderDefault + } + Box( + modifier = Modifier + .align(Alignment.CenterStart) + .offset(x = thumbOffset) + .size(ThumbSize) + .shadow(2.dp, CircleShape) + .background(bgColor, CircleShape) + .border(1.dp, borderColor, CircleShape), + ) +} + +private fun Offset.toHorizontalProgress(widthPx: Float, isRtl: Boolean): Float { + val raw = (x / widthPx).coerceIn(0f, 1f) + return if (isRtl) 1f - raw else raw +} + +@Suppress("MagicNumber") +private val PlaybackSpeeds = floatArrayOf(1f, 1.5f, 2f) +private const val PositionPollingIntervalMs = 100L +private val TrackHeight = 4.dp +private val ThumbSize = 12.dp + +/** + * Observable state holder for [VideoPlaybackControls]. + * + * Observes a [Player] via listener for discrete events (play/pause, speed, duration) + * and polls for continuous position updates while playing. + * + * @param player The [Player] instance to observe and control. + */ +@Stable +internal class VideoPlaybackControlsState(private val player: Player) { + var isPlaying: Boolean by mutableStateOf(player.isPlaying) + private set + + var currentPosition: Long by mutableLongStateOf(player.currentPosition) + private set + + var duration: Long by mutableLongStateOf(player.duration.coerceAtLeast(0L)) + private set + + var speed: Float by mutableFloatStateOf(player.playbackParameters.speed) + private set + + var isSeeking: Boolean by mutableStateOf(false) + private set + + val progress: Float + get() = if (duration > 0) currentPosition.toFloat() / duration.toFloat() else 0f + + val listener: Player.Listener = object : Player.Listener { + override fun onIsPlayingChanged(playing: Boolean) { + isPlaying = playing + } + + override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) { + speed = playbackParameters.speed + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + duration = player.duration.coerceAtLeast(0L) + currentPosition = player.currentPosition.coerceAtLeast(0L) + } + + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_READY) { + duration = player.duration.coerceAtLeast(0L) + } + if (playbackState == Player.STATE_ENDED) { + currentPosition = duration + } + } + } + + fun togglePlayPause() { + if (player.isPlaying) { + player.pause() + } else { + if (player.playbackState == Player.STATE_ENDED) { + player.seekTo(0L) + } + player.play() + } + } + + fun cycleSpeed() { + val currentIndex = PlaybackSpeeds.indexOfFirst { it == speed } + val nextIndex = (currentIndex + 1) % PlaybackSpeeds.size + player.playbackParameters = player.playbackParameters.withSpeed(PlaybackSpeeds[nextIndex]) + } + + fun onDragStart() { + isSeeking = true + } + + fun onDrag(dragProgress: Float) { + currentPosition = (dragProgress * duration).toLong() + } + + fun onDragStop(dragProgress: Float) { + currentPosition = (dragProgress * duration).toLong() + player.seekTo(currentPosition) + isSeeking = false + } + + suspend fun pollPosition() { + while (isPlaying) { + if (!isSeeking) { + currentPosition = player.currentPosition.coerceAtLeast(0L) + } + delay(PositionPollingIntervalMs) + } + } +} + +@Composable +private fun rememberVideoPlaybackControlsState(player: Player): VideoPlaybackControlsState { + val state = remember(player) { VideoPlaybackControlsState(player) } + + DisposableEffect(player) { + player.addListener(state.listener) + onDispose { player.removeListener(state.listener) } + } + + LaunchedEffect(player, state.isPlaying) { + state.pollPosition() + } + + return state +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/attachments/ChannelMediaAttachmentsPreviewScreen.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/attachments/ChannelMediaAttachmentsPreviewScreen.kt index 202ad352462..c5c25917058 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/attachments/ChannelMediaAttachmentsPreviewScreen.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channel/attachments/ChannelMediaAttachmentsPreviewScreen.kt @@ -40,6 +40,8 @@ import io.getstream.chat.android.compose.ui.attachments.preview.ConfirmShareLarg import io.getstream.chat.android.compose.ui.attachments.preview.MediaGalleryPager import io.getstream.chat.android.compose.ui.attachments.preview.MediaGalleryPreviewShareIcon import io.getstream.chat.android.compose.ui.attachments.preview.MediaGalleryPreviewSharingInProgressIndicator +import io.getstream.chat.android.compose.ui.attachments.preview.internal.GalleryMediaEffect +import io.getstream.chat.android.compose.ui.attachments.preview.internal.rememberMediaGalleryPlayerState import io.getstream.chat.android.compose.ui.theme.ChannelMediaAttachmentsPreviewBottomBarParams import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.util.StreamSnackbarHost @@ -141,6 +143,11 @@ private fun ChannelMediaAttachmentsPreviewContent( pageCount = items::size, ) val snackbarHostState = remember { SnackbarHostState() } + val attachments = remember(items) { + items.map(ChannelAttachmentsViewState.Content.Item::attachment) + } + val playerState = rememberMediaGalleryPlayerState(onPlaybackError = onVideoPlaybackError) + GalleryMediaEffect(playerState, pagerState.currentPage, attachments) Scaffold( modifier = Modifier.fillMaxSize(), topBar = { @@ -166,10 +173,8 @@ private fun ChannelMediaAttachmentsPreviewContent( .padding(padding) .fillMaxSize(), pagerState = pagerState, - attachments = remember(items) { - items.map(ChannelAttachmentsViewState.Content.Item::attachment) - }, - onPlaybackError = onVideoPlaybackError, + attachments = attachments, + player = playerState.player, ) LoadMoreHandler( pagerState = pagerState, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/button/SpeedButton.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/button/SpeedButton.kt new file mode 100644 index 00000000000..f648eb8d3f6 --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/button/SpeedButton.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.components.button + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.theme.StreamTokens +import io.getstream.chat.android.compose.ui.util.applyIf +import io.getstream.chat.android.compose.ui.util.clickable +import io.getstream.chat.android.extensions.isInt + +private val speedButtonShape = RoundedCornerShape(StreamTokens.radiusLg) + +/** + * A button that displays the current playback speed and cycles through speeds on click. + * + * @param speed The current playback speed. + * @param outlineColor The border color of the button. + * @param enabled Whether the button is enabled. + * @param onClick Callback invoked when the button is clicked. + */ +@Composable +internal fun SpeedButton( + speed: Float, + outlineColor: Color = ChatTheme.colors.controlPlaybackToggleBorder, + enabled: Boolean = true, + onClick: () -> Unit, +) { + val colors = ChatTheme.colors + val textColor = if (enabled) colors.controlPlaybackToggleText else colors.textDisabled + val borderColor = if (enabled) outlineColor else colors.borderUtilityDisabled + Text( + text = when (speed.isInt()) { + true -> "x${speed.toInt()}" + else -> "x$speed" + }, + style = ChatTheme.typography.metadataEmphasis, + color = textColor, + modifier = Modifier + .border(1.dp, borderColor, speedButtonShape) + .clip(speedButtonShape) + .applyIf(enabled) { clickable(onClick = onClick) } + .padding(horizontal = StreamTokens.spacingXs, vertical = StreamTokens.spacing2xs), + ) +} diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewActivityTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewActivityTest.kt index c71033abf21..d16bfc11724 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewActivityTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/attachments/preview/MediaGalleryPreviewActivityTest.kt @@ -18,10 +18,14 @@ package io.getstream.chat.android.compose.ui.attachments.preview import android.app.Activity import android.content.Intent +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performSemanticsAction import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -78,7 +82,10 @@ internal class MediaGalleryPreviewActivityTest : MockedChatClientTest { composeTestRule.waitForIdle() composeTestRule.onNodeWithContentDescription("Image options").performClick() - composeTestRule.onNodeWithText("Reply").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNode(hasText("Reply") and hasClickAction()) + .performSemanticsAction(SemanticsActions.OnClick) + composeTestRule.waitForIdle() scenario.assertResult( expected = MediaGalleryPreviewResult( @@ -99,7 +106,10 @@ internal class MediaGalleryPreviewActivityTest : MockedChatClientTest { composeTestRule.waitForIdle() composeTestRule.onNodeWithContentDescription("Image options").performClick() - composeTestRule.onNodeWithText("Show in chat").performClick() + composeTestRule.waitForIdle() + composeTestRule.onNode(hasText("Show in chat") and hasClickAction()) + .performSemanticsAction(SemanticsActions.OnClick) + composeTestRule.waitForIdle() scenario.assertResult( expected = MediaGalleryPreviewResult( @@ -122,8 +132,10 @@ internal class MediaGalleryPreviewActivityTest : MockedChatClientTest { composeTestRule.waitForIdle() composeTestRule.onNodeWithContentDescription("Image options").performClick() + composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Delete").performClick() + composeTestRule.onNode(hasText("Delete") and hasClickAction()) + .performSemanticsAction(SemanticsActions.OnClick) } } @@ -136,8 +148,10 @@ internal class MediaGalleryPreviewActivityTest : MockedChatClientTest { composeTestRule.waitForIdle() composeTestRule.onNodeWithContentDescription("Image options").performClick() + composeTestRule.waitForIdle() - composeTestRule.onNodeWithText("Save media").performClick() + composeTestRule.onNode(hasText("Save media") and hasClickAction()) + .performSemanticsAction(SemanticsActions.OnClick) } } diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewOptionsMenuTest_media_gallery_options_menu_for_other_user.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewOptionsMenuTest_media_gallery_options_menu_for_other_user.png index bd47597ff1a..b2431264808 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewOptionsMenuTest_media_gallery_options_menu_for_other_user.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewOptionsMenuTest_media_gallery_options_menu_for_other_user.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewOptionsMenuTest_media_gallery_options_menu_for_own_user.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewOptionsMenuTest_media_gallery_options_menu_for_own_user.png index 4b0283edca0..b2431264808 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewOptionsMenuTest_media_gallery_options_menu_for_own_user.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewOptionsMenuTest_media_gallery_options_menu_for_own_user.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_connecting.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_connecting.png index cc519819c7d..00267bbfccd 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_connecting.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_connecting.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_message_without_id.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_message_without_id.png index 62321337745..309c83863af 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_message_without_id.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_message_without_id.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_offline.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_offline.png index f80a1c9c604..3700ae87b37 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_offline.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_offline.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_online.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_online.png index 1e9629772d6..7a6e36fc85a 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_online.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_header_online.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_connected.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_connected.png index ba58fe123ad..4b2ae23af1d 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_connected.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_connected.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_offline.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_offline.png index c3cd24c7cb6..8e09ab55703 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_offline.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_offline.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_with_gallery_bottom_sheet.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_with_gallery_bottom_sheet.png index 2434d2305d8..b04cf394ac3 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_with_gallery_bottom_sheet.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_with_gallery_bottom_sheet.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_with_options_menu.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_with_options_menu.png index 3e2d4a1e5fb..4b2ae23af1d 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_with_options_menu.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_with_options_menu.png differ diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_with_share_large_file_prompt.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_with_share_large_file_prompt.png index 7d999d9b244..221ee9a8b1c 100644 Binary files a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_with_share_large_file_prompt.png and b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.attachments.preview_MediaGalleryPreviewScreenTest_media_gallery_screen_with_share_large_file_prompt.png differ