Repository: GetStream/stream-chat-android
Pull request #5749: [AND-462] (compose): Replace VideoView with ExoPlayer + PlayerView.
Author: login: VelikovPetar
Created: 2025-04-25T10:03:24Z | Merged: 2025-05-12T07:33:21Z

### 🎯 Goal
Introduces `media3.exoplayer` to the `compose` SDK to improve playing of video (and audio) attachments. 

### 🛠 Implementation details
- Adds `androidx.media3:media3-exoplayer` and `androidx.media3:media3-ui` dependencies. (There is also a compose artefact (`media3-ui-compose`), however it is in very early development, and doesn't provide all the options like the `media3-ui` does (fast forward/backward, speed controls, progress indicator...))
- Rework the `MediaGalleryVideoPage` to use `PlayerView` + `ExoPlayer` (instead of `VideoView`)
- Rework the `MediaAttachmentActivity` to user `PlayerView` + ExoPlayer (instead of `VideoView`)

### 🎨 UI Changes

_Add relevant screenshots_

<table>
<thead>
<tr>
<th>Scenario</th>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody>
<tr>
<td>
Video (MediaGalleryVideoPage)
</td>
<td>
<video src="https://github.com/user-attachments/assets/7b5d38c2-6086-49c3-af32-8a55e8dfdc44" controls="controls" muted="muted" />
</td>
<td>
<video src="https://github.com/user-attachments/assets/8852011e-ebdf-4b7c-b33c-7c071b71c553" controls="controls" muted="muted" />
</td>
</tr>
<tr>
<td>
Audio (MediaPreviewActivity)
</td>
<td>
<video src="https://github.com/user-attachments/assets/4701eaf6-8c4f-455b-92d8-73def24a8150" controls="controls" muted="muted" />
</td>
<td>
<video src="https://github.com/user-attachments/assets/6567e4be-5ae7-4734-9699-03294e310ec2" controls="controls" muted="muted" />
</td>
</tr>
</tbody>
</table>

### 🧪 Testing
1. Open compose sample app
2. Open channel with Video attachments
3. Play the video attachment (the performance should be much better than previously)


Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4ac0df5a10a..d2a5cf8fe28 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -75,6 +75,7 @@
 - Fix audio recording attachments not paused when the app goes to the background or the screen is covered with another one. [#5685](https://github.com/GetStream/stream-chat-android/pull/5685)
 
 ### ⬆️ Improved
+- Improve the performance of playing video/audio attachments by using `ExoPlayer` instead of `VideoView`. [#5749](https://github.com/GetStream/stream-chat-android/pull/5749)
 
 ### ✅ Added
 
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e892e01a268..6181ddbf54e 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -15,6 +15,7 @@ androidxCoreTest = "2.2.0"
 androidxFragment = "1.8.5"
 androidxKtx = "1.15.0"
 androidxLifecycle = "2.8.7"
+androidxMedia3 = "1.6.1"
 androidxNavigation = "2.8.4"
 androidxPreferences = "1.2.1"
 androidxRecyclerview = "1.3.2"
@@ -127,6 +128,8 @@ androidx-navigation-fragment-ktx = { module = "androidx.navigation:navigation-fr
 androidx-navigation-runtime-ktx = { module = "androidx.navigation:navigation-runtime-ktx", version.ref = "androidxNavigation"}
 androidx-navigation-testing = { module = "androidx.navigation:navigation-testing", version.ref = "androidxNavigation"}
 androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidxNavigation"}
+androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidxMedia3"}
+androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidxMedia3" }
 androidx-preference = { module = "androidx.preference:preference", version.ref = "androidxPreferences"}
 androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstaller", version.ref = "baseProfile"}
 androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "androidxRecyclerview"}
diff --git a/stream-chat-android-compose/build.gradle.kts b/stream-chat-android-compose/build.gradle.kts
index 29d23f5b503..460d84ac84e 100644
--- a/stream-chat-android-compose/build.gradle.kts
+++ b/stream-chat-android-compose/build.gradle.kts
@@ -98,6 +98,10 @@ dependencies {
     implementation(libs.coil.gif)
     implementation(libs.coil.video)
 
+    // Media3
+    implementation(libs.androidx.media3.exoplayer)
+    implementation(libs.androidx.media3.ui)
+
     // UI
     implementation(libs.reorderable)
     implementation(libs.shimmer.compose)
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 fb3a84cee71..99503f86af6 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
@@ -551,9 +551,9 @@ internal fun MediaGalleryPager(
 
             attachments[page].isVideo() -> {
                 MediaGalleryVideoPage(
-                    attachment = attachments[page],
-                    pagerState = pagerState,
-                    page = page,
+                    modifier = Modifier.fillMaxSize(),
+                    assetUrl = attachments[page].assetUrl,
+                    thumbnailUrl = attachments[page].thumbUrl,
                     onPlaybackError = onPlaybackError,
                 )
             }
diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity.kt
index 76810c7574c..953b5a7d481 100644
--- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity.kt
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity.kt
@@ -18,16 +18,8 @@ package io.getstream.chat.android.compose.ui.attachments.preview
 
 import android.content.Context
 import android.content.Intent
-import android.net.Uri
 import android.os.Bundle
-import android.view.Gravity
-import android.view.KeyEvent
-import android.view.ViewGroup
-import android.widget.FrameLayout
-import android.widget.MediaController
-import android.widget.ProgressBar
 import android.widget.Toast
-import android.widget.VideoView
 import androidx.activity.SystemBarStyle
 import androidx.activity.compose.BackHandler
 import androidx.activity.compose.setContent
@@ -37,6 +29,7 @@ import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.WindowInsets
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.systemBars
 import androidx.compose.foundation.layout.windowInsetsPadding
 import androidx.compose.material3.ExperimentalMaterial3Api
@@ -47,18 +40,15 @@ import androidx.compose.material3.Text
 import androidx.compose.material3.TopAppBar
 import androidx.compose.material3.TopAppBarDefaults
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.remember
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.toArgb
-import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalLayoutDirection
 import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.viewinterop.AndroidView
-import androidx.core.view.isVisible
 import io.getstream.chat.android.client.ChatClient
 import io.getstream.chat.android.compose.R
+import io.getstream.chat.android.compose.ui.attachments.preview.internal.StreamMediaPlayerContent
 import io.getstream.chat.android.compose.ui.theme.ChatTheme
 import io.getstream.chat.android.compose.ui.util.mirrorRtl
 
@@ -123,7 +113,16 @@ public class MediaPreviewActivity : AppCompatActivity() {
             modifier = modifier,
             containerColor = Color.Black,
             topBar = { MediaPreviewToolbar(title, onBackPressed) },
-            content = { MediaPreviewContent(url, onBackPressed, onPlaybackError) },
+            content = { padding ->
+                StreamMediaPlayerContent(
+                    modifier = Modifier
+                        .fillMaxSize()
+                        .padding(padding),
+                    assetUrl = url,
+                    playWhenReady = true,
+                    onPlaybackError = onPlaybackError,
+                )
+            },
         )
     }
 
@@ -174,100 +173,6 @@ public class MediaPreviewActivity : AppCompatActivity() {
         )
     }
 
-    /**
-     * Represents a video player with media controls.
-     *
-     * @param url The URL of the stream for playback.
-     * @param onBackPressed Handler for back press action.
-     * @param onPlaybackError Handler for playback errors.
-     */
-    @Composable
-    private fun MediaPreviewContent(
-        url: String,
-        onBackPressed: () -> Unit = {},
-        onPlaybackError: () -> Unit,
-    ) {
-        val context = LocalContext.current
-
-        val contentView = remember {
-            val mediaController = createMediaController(context, onBackPressed)
-
-            val frameLayout = FrameLayout(context).apply {
-                layoutParams = FrameLayout.LayoutParams(
-                    ViewGroup.LayoutParams.MATCH_PARENT,
-                    ViewGroup.LayoutParams.MATCH_PARENT,
-                )
-            }
-
-            val progressBar = ProgressBar(context).apply {
-                layoutParams = FrameLayout.LayoutParams(
-                    ViewGroup.LayoutParams.WRAP_CONTENT,
-                    ViewGroup.LayoutParams.WRAP_CONTENT,
-                ).apply {
-                    gravity = Gravity.CENTER
-                }
-            }
-
-            progressBar.isVisible = true
-
-            val videoView = VideoView(context).apply {
-                setVideoURI(Uri.parse(url))
-                setMediaController(mediaController)
-                setOnErrorListener { _, _, _ ->
-                    progressBar.isVisible = false
-                    onPlaybackError()
-                    true
-                }
-                setOnPreparedListener {
-                    progressBar.isVisible = false
-                    start()
-                    mediaController.show()
-                }
-                mediaController.setAnchorView(frameLayout)
-
-                layoutParams = FrameLayout.LayoutParams(
-                    ViewGroup.LayoutParams.MATCH_PARENT,
-                    ViewGroup.LayoutParams.MATCH_PARENT,
-                ).apply {
-                    gravity = Gravity.CENTER
-                }
-            }
-
-            frameLayout.apply {
-                addView(videoView)
-                addView(progressBar)
-            }
-        }
-
-        AndroidView(
-            modifier = Modifier
-                .fillMaxSize()
-                .background(Color.Black),
-            factory = { contentView },
-        )
-    }
-
-    /**
-     * Creates a custom instance of [MediaController] which no longer intercepts
-     * back press actions to hide media controls.
-     *
-     * @param context The Context used to create the [MediaController].
-     * @param onBackPressed Handler for back press action.
-     */
-    private fun createMediaController(
-        context: Context,
-        onBackPressed: () -> Unit = {},
-    ): MediaController {
-        return object : MediaController(context) {
-            override fun dispatchKeyEvent(event: KeyEvent): Boolean {
-                if (event.keyCode == KeyEvent.KEYCODE_BACK) {
-                    onBackPressed()
-                }
-                return super.dispatchKeyEvent(event)
-            }
-        }
-    }
-
     public companion object {
         private const val KEY_URL: String = "url"
         private const val KEY_TITLE: String = "title"
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 df065566ce1..6f0ad6cadc1 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
@@ -16,14 +16,7 @@
 
 package io.getstream.chat.android.compose.ui.attachments.preview.internal
 
-import android.content.Context
-import android.net.Uri
 import android.util.Log
-import android.view.Gravity
-import android.view.ViewGroup
-import android.widget.FrameLayout
-import android.widget.MediaController
-import android.widget.VideoView
 import androidx.compose.animation.Crossfade
 import androidx.compose.animation.core.animateFloatAsState
 import androidx.compose.foundation.Image
@@ -37,9 +30,7 @@ import androidx.compose.foundation.layout.BoxWithConstraints
 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.foundation.pager.PagerState
-import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.material3.Icon
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
@@ -49,10 +40,8 @@ import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.shadow
 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
@@ -61,17 +50,12 @@ import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.viewinterop.AndroidView
 import coil3.compose.AsyncImagePainter
 import coil3.request.ImageRequest
 import io.getstream.chat.android.compose.R
-import io.getstream.chat.android.compose.ui.attachments.content.PlayButton
-import io.getstream.chat.android.compose.ui.components.LoadingIndicator
 import io.getstream.chat.android.compose.ui.components.ShimmerProgressIndicator
 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.models.Attachment
 import io.getstream.chat.android.ui.common.utils.extensions.imagePreviewUrl
 import kotlinx.coroutines.coroutineScope
@@ -277,180 +261,38 @@ private fun ErrorIcon(modifier: Modifier) {
  * Note: It is assumed that this component is used within a pager.
  *
  * Renders a video player with thumbnail preview, play button, and loading indicators.
- * The video playback begins when the user taps the play button, showing a loading
- * indicator until the video is ready to play. The video player includes standard
- * media controls for playback.
+ * The video playback begins when the user taps the play button. The video player includes
+ * standard media controls for playback. Shows a loading indicator while the video is
+ * buffering. The video is paused when the user navigates away from this page in the pager.
  *
  * This component handles various states:
  * - Initial state with thumbnail and play button
- * - Loading state while preparing the video
+ * - Loading state while the video is buffering
  * - Playback state with media controls
  * - Error state when video playback fails
  *
  * The video playback is automatically paused and the preview is restored when
  * the user navigates away from this page in the pager.
  *
- * @param attachment The attachment containing the video to display
- * @param pagerState The state of the pager containing this video page
- * @param page The page index of this video in the pager
- * @param onPlaybackError Callback invoked when video playback encounters an error
+ * @param assetUrl The url of the video to display.
+ * @param thumbnailUrl The url of the thumbnail to display before the video is played.
+ * @param onPlaybackError Callback invoked when video playback encounters an error.
+ * @param modifier The [Modifier] to be applied to the video player.
  */
-@Suppress("LongMethod")
 @Composable
 internal fun MediaGalleryVideoPage(
-    attachment: Attachment,
-    pagerState: PagerState,
-    page: Int,
+    assetUrl: String?,
+    thumbnailUrl: String?,
     onPlaybackError: () -> Unit,
+    modifier: Modifier = Modifier,
 ) {
-    val context = LocalContext.current
-
-    var hasPrepared by remember {
-        mutableStateOf(false)
-    }
-
-    var userHasClickedPlay by remember {
-        mutableStateOf(false)
-    }
-
-    var shouldShowProgressBar by remember {
-        mutableStateOf(false)
-    }
-
-    var shouldShowPreview by remember {
-        mutableStateOf(true)
-    }
-
-    var shouldShowPlayButton by remember {
-        mutableStateOf(true)
-    }
-
-    val mediaController = remember {
-        createMediaController(context)
-    }
-
-    val videoView = remember {
-        VideoView(context)
-    }
-
-    val contentView = remember {
-        val frameLayout = FrameLayout(context).apply {
-            layoutParams = FrameLayout.LayoutParams(
-                ViewGroup.LayoutParams.MATCH_PARENT,
-                ViewGroup.LayoutParams.MATCH_PARENT,
-            )
-        }
-        videoView.apply {
-            setVideoURI(Uri.parse(attachment.assetUrl))
-            this.setMediaController(mediaController)
-            setOnErrorListener { _, _, _ ->
-                shouldShowProgressBar = false
-                onPlaybackError()
-                true
-            }
-            setOnPreparedListener {
-                // Don't remove the preview unless the user has clicked play previously,
-                // otherwise the preview will be removed whenever the video has finished downloading.
-                if (!hasPrepared && userHasClickedPlay && page == pagerState.currentPage) {
-                    shouldShowProgressBar = false
-                    shouldShowPreview = false
-                    mediaController.show()
-                }
-                hasPrepared = true
-            }
-
-            mediaController.setAnchorView(frameLayout)
-
-            layoutParams = FrameLayout.LayoutParams(
-                ViewGroup.LayoutParams.MATCH_PARENT,
-                ViewGroup.LayoutParams.MATCH_PARENT,
-            ).apply {
-                gravity = Gravity.CENTER
-            }
-        }
-
-        frameLayout.apply {
-            addView(videoView)
-        }
-    }
-
-    Box(contentAlignment = Alignment.Center) {
-        AndroidView(
-            modifier = Modifier
-                .fillMaxSize()
-                .background(Color.Black),
-            factory = { contentView },
-        )
-
-        if (shouldShowPreview) {
-            val data = if (ChatTheme.videoThumbnailsEnabled) {
-                attachment.thumbUrl
-            } else {
-                null
-            }
-
-            Box(
-                contentAlignment = Alignment.Center,
-                modifier = Modifier.fillMaxSize(),
-            ) {
-                StreamAsyncImage(
-                    modifier = Modifier
-                        .clickable {
-                            shouldShowProgressBar = true
-                            shouldShowPlayButton = false
-                            userHasClickedPlay = true
-                            // Don't remove the preview unless the player
-                            // is ready to play.
-                            if (hasPrepared) {
-                                shouldShowProgressBar = false
-                                shouldShowPreview = false
-                                mediaController.show()
-                            }
-                            videoView.start()
-                        }
-                        .fillMaxSize()
-                        .background(color = Color.Black),
-                    data = data,
-                    contentDescription = null,
-                )
-
-                if (shouldShowPlayButton) {
-                    PlayButton(
-                        modifier = Modifier
-                            .shadow(6.dp, shape = CircleShape)
-                            .background(color = Color.White, shape = CircleShape)
-                            .size(
-                                width = 42.dp,
-                                height = 42.dp,
-                            ),
-                        contentDescription = stringResource(R.string.stream_compose_cd_play_button),
-                    )
-                }
-            }
-        }
-
-        if (shouldShowProgressBar) {
-            LoadingIndicator()
-        }
-    }
-
-    if (page != pagerState.currentPage) {
-        shouldShowPlayButton = true
-        shouldShowPreview = true
-        shouldShowProgressBar = false
-        mediaController.hide()
-    }
-}
-
-/**
- * Creates a custom instance of [MediaController].
- *
- * @param context The Context used to create the [MediaController].
- */
-private fun createMediaController(
-    context: Context,
-): MediaController {
-    return object : MediaController(context) {}
+    StreamMediaPlayerContent(
+        modifier = modifier,
+        assetUrl = assetUrl,
+        thumbnailUrl = if (ChatTheme.videoThumbnailsEnabled) thumbnailUrl else null,
+        playWhenReady = false,
+        onPlaybackError = onPlaybackError,
+    )
 }
 
 /**
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
new file mode 100644
index 00000000000..2452749ad88
--- /dev/null
+++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamMediaPlayerContent.kt
@@ -0,0 +1,232 @@
+/*
+ * Copyright (c) 2014-2025 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 android.content.Context
+import androidx.annotation.OptIn
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.media3.common.MediaItem
+import androidx.media3.common.PlaybackException
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.ui.PlayerView
+import io.getstream.chat.android.compose.R
+import io.getstream.chat.android.compose.ui.attachments.content.PlayButton
+import io.getstream.chat.android.compose.ui.components.LoadingIndicator
+import io.getstream.chat.android.compose.ui.util.StreamAsyncImage
+import io.getstream.chat.android.compose.ui.util.clickable
+
+/**
+ * A Composable that displays a video player with support for thumbnails, play controls, and buffering indicators.
+ *
+ * This component creates a Media3 ExoPlayer instance that handles video playback while respecting
+ * the Android lifecycle. The player is automatically created when the composable enters the composition
+ * and released when it leaves or pauses.
+ *
+ * The component offers several features:
+ * - Shows an optional thumbnail with play button before playback starts
+ * - Displays media controls that can be shown initially or hidden
+ * - Shows a buffering indicator during loading states
+ * - Automatically handles player lifecycle and resource cleanup
+ *
+ * @param assetUrl The URL of the video to play.
+ * @param playWhenReady Whether playback should start automatically after preparation.
+ * @param onPlaybackError Callback invoked when video playback encounters an error.
+ * @param modifier Modifier to be applied to the player container.
+ * @param thumbnailUrl Optional URL of the thumbnail image to display before playback starts.
+ */
+@OptIn(UnstableApi::class)
+@Composable
+internal fun StreamMediaPlayerContent(
+    assetUrl: String?,
+    playWhenReady: Boolean,
+    onPlaybackError: () -> Unit,
+    modifier: Modifier = Modifier,
+    thumbnailUrl: String? = null,
+) {
+    val context = LocalContext.current
+    var showThumbnail by remember { mutableStateOf(!playWhenReady) }
+    var showBuffering by remember { mutableStateOf(true) }
+    val onBuffering: (Boolean) -> Unit = { isBuffering -> showBuffering = isBuffering }
+    // Create player
+    var player by remember { mutableStateOf<Player?>(null) }
+    LifecycleResumeEffect(Unit) {
+        player = createPlayer(
+            context = context,
+            onBuffering = onBuffering,
+            onPlaybackError = onPlaybackError,
+        )
+        onPauseOrDispose {
+            player?.release()
+            player = null
+        }
+    }
+    // Prepare media
+    LaunchedEffect(player, assetUrl) {
+        if (player != null && assetUrl != null) {
+            player?.setMediaItem(MediaItem.fromUri(assetUrl))
+            player?.prepare()
+            player?.playWhenReady = playWhenReady
+        }
+    }
+    // Draw player
+    player?.let { preparedPlayer ->
+        Box(
+            modifier = modifier,
+            contentAlignment = Alignment.Center,
+        ) {
+            // Video/Audio player
+            AndroidView(
+                modifier = Modifier
+                    .matchParentSize()
+                    .background(Color.Black),
+                factory = {
+                    val playerView = createPlayerView(it, preparedPlayer)
+                    if (playWhenReady) playerView.showController()
+                    playerView
+                },
+            )
+            // Thumbnail
+            if (showThumbnail) {
+                MediaThumbnail(
+                    modifier = Modifier
+                        .matchParentSize()
+                        .clickable {
+                            showThumbnail = false
+                            preparedPlayer.play()
+                        },
+                    thumbnailUrl = thumbnailUrl,
+                )
+            }
+            // Buffering indicator
+            if (showBuffering) {
+                LoadingIndicator()
+            }
+        }
+    }
+}
+
+/**
+ * A composable that displays a thumbnail image for a video with an optional play button overlay.
+ *
+ * This component shows a video thumbnail image with a centered play button that can be clicked
+ * to start video playback. The thumbnail image is only displayed if video thumbnails are
+ * enabled in the ChatTheme configuration.
+ *
+ * @param thumbnailUrl The URL of the thumbnail image to display, or null if no thumbnail is available.
+ * @param modifier Modifier to be applied to the thumbnail container.
+ */
+@Composable
+private fun MediaThumbnail(
+    thumbnailUrl: String?,
+    modifier: Modifier = Modifier,
+) {
+    Box(
+        modifier = modifier.background(Color.Black),
+        contentAlignment = Alignment.Center,
+    ) {
+        if (thumbnailUrl != null) {
+            StreamAsyncImage(
+                modifier = modifier.matchParentSize(),
+                data = thumbnailUrl,
+                contentDescription = null,
+            )
+        }
+        PlayButton(
+            modifier = Modifier
+                .shadow(6.dp, shape = CircleShape)
+                .background(color = Color.White, shape = CircleShape)
+                .size(
+                    width = 42.dp,
+                    height = 42.dp,
+                ),
+            contentDescription = stringResource(R.string.stream_compose_cd_play_button),
+        )
+    }
+}
+
+/**
+ * Creates a basic [Player] instance for playing audio/video.
+ * Creates a player of type [ExoPlayer].
+ *
+ * @param context The context to use for creating the player.
+ * @param onBuffering Callback to be invoked when the player enters or exits buffering state.
+ * @param onPlaybackError Callback to be invoked when a playback error occurs.
+ */
+private fun createPlayer(
+    context: Context,
+    onBuffering: (Boolean) -> Unit,
+    onPlaybackError: () -> Unit,
+): Player {
+    // Setup player
+    val player = ExoPlayer.Builder(context).build()
+    player.addListener(object : Player.Listener {
+        override fun onPlaybackStateChanged(playbackState: Int) {
+            if (playbackState == Player.STATE_BUFFERING) {
+                onBuffering(true)
+            } else {
+                onBuffering(false)
+            }
+        }
+
+        override fun onPlayerError(error: PlaybackException) {
+            onPlaybackError()
+        }
+    })
+    return player
+}
+
+/**
+ * Creates a [PlayerView] for displaying video content.
+ *
+ * @param context The context to use for creating the view.
+ * @param player The [Player] instance to be associated with the view.
+ * @return A configured [PlayerView] instance.
+ */
+@OptIn(UnstableApi::class)
+private fun createPlayerView(context: Context, player: Player): PlayerView {
+    return PlayerView(context).apply {
+        this.player = player
+        controllerShowTimeoutMs = ControllerShowTimeout
+        controllerAutoShow = false
+        controllerHideOnTouch = true
+        setShowPreviousButton(false)
+        setShowNextButton(false)
+        setShowBuffering(PlayerView.SHOW_BUFFERING_NEVER)
+    }
+}
+
+private const val ControllerShowTimeout = 2000


Reviews:
user: login: andremion
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T08:37:44Z
user: login: VelikovPetar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T13:01:36Z
user: login: VelikovPetar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T13:01:39Z
user: login: VelikovPetar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T13:01:47Z
user: login: VelikovPetar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T13:21:40Z
user: login: VelikovPetar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T13:44:36Z
user: login: andremion
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T07:37:46Z
user: login: andremion
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T07:48:45Z
user: login: andremion
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T07:54:29Z
user: login: VelikovPetar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T07:56:45Z
user: login: VelikovPetar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T07:57:19Z
user: login: VelikovPetar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T08:28:59Z
user: login: andremion
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T09:19:08Z
user: login: VelikovPetar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T09:23:02Z
user: login: VelikovPetar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T09:30:55Z
user: login: VelikovPetar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T09:39:06Z
user: login: andremion
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T09:45:16Z
user: login: andremion
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T09:48:00Z
user: login: VelikovPetar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T09:57:06Z
user: login: andremion
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T13:59:05Z

Review comments:
diff_hunk: @@ -277,180 +274,127 @@ private fun ErrorIcon(modifier: Modifier) {
  * Note: It is assumed that this component is used within a pager.
  *
  * Renders a video player with thumbnail preview, play button, and loading indicators.
- * The video playback begins when the user taps the play button, showing a loading
- * indicator until the video is ready to play. The video player includes standard
- * media controls for playback.
+ * The video playback begins when the user taps the play button. The video player includes
+ * standard media controls for playback. Shows a loading indicator while the video is
+ * buffering. The video is paused when the user navigates away from this page in the pager.
  *
  * This component handles various states:
  * - Initial state with thumbnail and play button
- * - Loading state while preparing the video
+ * - Loading state while the video is buffering
  * - Playback state with media controls
  * - Error state when video playback fails
  *
  * The video playback is automatically paused and the preview is restored when
  * the user navigates away from this page in the pager.
  *
- * @param attachment The attachment containing the video to display
- * @param pagerState The state of the pager containing this video page
- * @param page The page index of this video in the pager
- * @param onPlaybackError Callback invoked when video playback encounters an error
+ * @param assetUrl The url of the video to display.
+ * @param thumbnailUrl The url of the thumbnail to display before the video is played.
+ * @param onPlaybackError Callback invoked when video playback encounters an error.
+ * @param modifier The [Modifier] to be applied to the video player.
  */
-@Suppress("LongMethod")
 @Composable
 internal fun MediaGalleryVideoPage(
-    attachment: Attachment,
-    pagerState: PagerState,
-    page: Int,
+    assetUrl: String?,
+    thumbnailUrl: String?,
     onPlaybackError: () -> Unit,
+    modifier: Modifier = Modifier,
 ) {
     val context = LocalContext.current
-
-    var hasPrepared by remember {
-        mutableStateOf(false)
-    }
-
-    var userHasClickedPlay by remember {
-        mutableStateOf(false)
-    }
-
-    var shouldShowProgressBar by remember {
-        mutableStateOf(false)
-    }
-
-    var shouldShowPreview by remember {
-        mutableStateOf(true)
-    }
-
-    var shouldShowPlayButton by remember {
-        mutableStateOf(true)
-    }
-
-    val mediaController = remember {
-        createMediaController(context)
+    var showThumbnail by remember { mutableStateOf(true) }
+    var showPlayButton by remember { mutableStateOf(true) }
+    var showBuffering by remember { mutableStateOf(false) }
+    val onBuffering: (Boolean) -> Unit = { showBuffering = it }
+    // Create player
+    var player by remember { mutableStateOf<Player?>(null) }
+    LifecycleResumeEffect(Unit) {
+        player = createPlayer(
+            context = context,
+            onBuffering = onBuffering,
+            onPlaybackError = onPlaybackError,
+        )
+        onPauseOrDispose {
+            player?.release()
+            player = null
+        }
     }
-
-    val videoView = remember {
-        VideoView(context)
+    // Prepare media
+    LaunchedEffect(player, assetUrl) {
+        if (player != null && assetUrl != null) {
+            player?.setMediaItem(MediaItem.fromUri(assetUrl))
+            player?.prepare()
+        }
     }
-
-    val contentView = remember {
-        val frameLayout = FrameLayout(context).apply {
-            layoutParams = FrameLayout.LayoutParams(
-                ViewGroup.LayoutParams.MATCH_PARENT,
-                ViewGroup.LayoutParams.MATCH_PARENT,
+    // Draw player
+    player?.let { preparedPlayer ->
path: stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt
user: login: andremion
body: Can we also show `LoadingIndicator` while player is null?
We could use the `matchParentSize` modifier inside that box so that the loading indicator respects the constraints of the `MediaGalleryVideoPage`'s modifier.
WDYT?
created_at: 2025-05-05T08:08:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -277,180 +274,127 @@ private fun ErrorIcon(modifier: Modifier) {
  * Note: It is assumed that this component is used within a pager.
  *
  * Renders a video player with thumbnail preview, play button, and loading indicators.
- * The video playback begins when the user taps the play button, showing a loading
- * indicator until the video is ready to play. The video player includes standard
- * media controls for playback.
+ * The video playback begins when the user taps the play button. The video player includes
+ * standard media controls for playback. Shows a loading indicator while the video is
+ * buffering. The video is paused when the user navigates away from this page in the pager.
  *
  * This component handles various states:
  * - Initial state with thumbnail and play button
- * - Loading state while preparing the video
+ * - Loading state while the video is buffering
  * - Playback state with media controls
  * - Error state when video playback fails
  *
  * The video playback is automatically paused and the preview is restored when
  * the user navigates away from this page in the pager.
  *
- * @param attachment The attachment containing the video to display
- * @param pagerState The state of the pager containing this video page
- * @param page The page index of this video in the pager
- * @param onPlaybackError Callback invoked when video playback encounters an error
+ * @param assetUrl The url of the video to display.
+ * @param thumbnailUrl The url of the thumbnail to display before the video is played.
+ * @param onPlaybackError Callback invoked when video playback encounters an error.
+ * @param modifier The [Modifier] to be applied to the video player.
  */
-@Suppress("LongMethod")
 @Composable
 internal fun MediaGalleryVideoPage(
-    attachment: Attachment,
-    pagerState: PagerState,
-    page: Int,
+    assetUrl: String?,
+    thumbnailUrl: String?,
     onPlaybackError: () -> Unit,
+    modifier: Modifier = Modifier,
 ) {
     val context = LocalContext.current
-
-    var hasPrepared by remember {
-        mutableStateOf(false)
-    }
-
-    var userHasClickedPlay by remember {
-        mutableStateOf(false)
-    }
-
-    var shouldShowProgressBar by remember {
-        mutableStateOf(false)
-    }
-
-    var shouldShowPreview by remember {
-        mutableStateOf(true)
-    }
-
-    var shouldShowPlayButton by remember {
-        mutableStateOf(true)
-    }
-
-    val mediaController = remember {
-        createMediaController(context)
+    var showThumbnail by remember { mutableStateOf(true) }
+    var showPlayButton by remember { mutableStateOf(true) }
+    var showBuffering by remember { mutableStateOf(false) }
+    val onBuffering: (Boolean) -> Unit = { showBuffering = it }
+    // Create player
+    var player by remember { mutableStateOf<Player?>(null) }
+    LifecycleResumeEffect(Unit) {
+        player = createPlayer(
+            context = context,
+            onBuffering = onBuffering,
+            onPlaybackError = onPlaybackError,
+        )
+        onPauseOrDispose {
+            player?.release()
+            player = null
+        }
     }
-
-    val videoView = remember {
-        VideoView(context)
+    // Prepare media
+    LaunchedEffect(player, assetUrl) {
+        if (player != null && assetUrl != null) {
+            player?.setMediaItem(MediaItem.fromUri(assetUrl))
+            player?.prepare()
+        }
     }
-
-    val contentView = remember {
-        val frameLayout = FrameLayout(context).apply {
-            layoutParams = FrameLayout.LayoutParams(
-                ViewGroup.LayoutParams.MATCH_PARENT,
-                ViewGroup.LayoutParams.MATCH_PARENT,
+    // Draw player
+    player?.let { preparedPlayer ->
+        Box(
+            modifier = modifier,
+            contentAlignment = Alignment.Center,
+        ) {
+            // Video player
+            AndroidView(
+                modifier = Modifier
+                    .fillMaxSize()
path: stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt
user: login: andremion
body: What about using `matchParentSize` instead?
So that the `AndroidView` respects the constraints of the `MediaGalleryVideoPage`'s modifier instead of forcing max size.
created_at: 2025-05-05T08:10:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -277,180 +274,127 @@ private fun ErrorIcon(modifier: Modifier) {
  * Note: It is assumed that this component is used within a pager.
  *
  * Renders a video player with thumbnail preview, play button, and loading indicators.
- * The video playback begins when the user taps the play button, showing a loading
- * indicator until the video is ready to play. The video player includes standard
- * media controls for playback.
+ * The video playback begins when the user taps the play button. The video player includes
+ * standard media controls for playback. Shows a loading indicator while the video is
+ * buffering. The video is paused when the user navigates away from this page in the pager.
  *
  * This component handles various states:
  * - Initial state with thumbnail and play button
- * - Loading state while preparing the video
+ * - Loading state while the video is buffering
  * - Playback state with media controls
  * - Error state when video playback fails
  *
  * The video playback is automatically paused and the preview is restored when
  * the user navigates away from this page in the pager.
  *
- * @param attachment The attachment containing the video to display
- * @param pagerState The state of the pager containing this video page
- * @param page The page index of this video in the pager
- * @param onPlaybackError Callback invoked when video playback encounters an error
+ * @param assetUrl The url of the video to display.
+ * @param thumbnailUrl The url of the thumbnail to display before the video is played.
+ * @param onPlaybackError Callback invoked when video playback encounters an error.
+ * @param modifier The [Modifier] to be applied to the video player.
  */
-@Suppress("LongMethod")
 @Composable
 internal fun MediaGalleryVideoPage(
-    attachment: Attachment,
-    pagerState: PagerState,
-    page: Int,
+    assetUrl: String?,
+    thumbnailUrl: String?,
     onPlaybackError: () -> Unit,
+    modifier: Modifier = Modifier,
 ) {
     val context = LocalContext.current
-
-    var hasPrepared by remember {
-        mutableStateOf(false)
-    }
-
-    var userHasClickedPlay by remember {
-        mutableStateOf(false)
-    }
-
-    var shouldShowProgressBar by remember {
-        mutableStateOf(false)
-    }
-
-    var shouldShowPreview by remember {
-        mutableStateOf(true)
-    }
-
-    var shouldShowPlayButton by remember {
-        mutableStateOf(true)
-    }
-
-    val mediaController = remember {
-        createMediaController(context)
+    var showThumbnail by remember { mutableStateOf(true) }
+    var showPlayButton by remember { mutableStateOf(true) }
+    var showBuffering by remember { mutableStateOf(false) }
+    val onBuffering: (Boolean) -> Unit = { showBuffering = it }
+    // Create player
+    var player by remember { mutableStateOf<Player?>(null) }
+    LifecycleResumeEffect(Unit) {
+        player = createPlayer(
+            context = context,
+            onBuffering = onBuffering,
+            onPlaybackError = onPlaybackError,
+        )
+        onPauseOrDispose {
+            player?.release()
+            player = null
+        }
     }
-
-    val videoView = remember {
-        VideoView(context)
+    // Prepare media
+    LaunchedEffect(player, assetUrl) {
+        if (player != null && assetUrl != null) {
+            player?.setMediaItem(MediaItem.fromUri(assetUrl))
+            player?.prepare()
+        }
     }
-
-    val contentView = remember {
-        val frameLayout = FrameLayout(context).apply {
-            layoutParams = FrameLayout.LayoutParams(
-                ViewGroup.LayoutParams.MATCH_PARENT,
-                ViewGroup.LayoutParams.MATCH_PARENT,
+    // Draw player
+    player?.let { preparedPlayer ->
+        Box(
+            modifier = modifier,
+            contentAlignment = Alignment.Center,
+        ) {
+            // Video player
+            AndroidView(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .background(Color.Black),
+                factory = {
+                    createPlayerView(it, preparedPlayer)
+                },
             )
-        }
-        videoView.apply {
-            setVideoURI(Uri.parse(attachment.assetUrl))
-            this.setMediaController(mediaController)
-            setOnErrorListener { _, _, _ ->
-                shouldShowProgressBar = false
-                onPlaybackError()
-                true
-            }
-            setOnPreparedListener {
-                // Don't remove the preview unless the user has clicked play previously,
-                // otherwise the preview will be removed whenever the video has finished downloading.
-                if (!hasPrepared && userHasClickedPlay && page == pagerState.currentPage) {
-                    shouldShowProgressBar = false
-                    shouldShowPreview = false
-                    mediaController.show()
-                }
-                hasPrepared = true
+            // Thumbnail
+            if (showThumbnail) {
+                VideoThumbnail(
+                    thumbnailUrl = thumbnailUrl,
+                    showPlayButton = showPlayButton,
+                    onPlayClick = {
+                        showThumbnail = false
+                        showPlayButton = false
+                        preparedPlayer.play()
+                    },
+                )
             }
-
-            mediaController.setAnchorView(frameLayout)
-
-            layoutParams = FrameLayout.LayoutParams(
-                ViewGroup.LayoutParams.MATCH_PARENT,
-                ViewGroup.LayoutParams.MATCH_PARENT,
-            ).apply {
-                gravity = Gravity.CENTER
+            // Buffering indicator
+            if (showBuffering && !showThumbnail) {
+                LoadingIndicator()
             }
         }
-
-        frameLayout.apply {
-            addView(videoView)
-        }
     }
+}
 
-    Box(contentAlignment = Alignment.Center) {
-        AndroidView(
+@Composable
+private fun VideoThumbnail(
+    thumbnailUrl: String?,
+    showPlayButton: Boolean,
+    onPlayClick: () -> Unit,
+) {
+    val data = if (ChatTheme.videoThumbnailsEnabled) {
+        thumbnailUrl
+    } else {
+        null
+    }
+    Box(
+        modifier = Modifier.fillMaxSize(),
path: stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt
user: login: andremion
body: What about exposing the modifier and use `matchParentSize` in `MediaGalleryVideoPage`'s `Box`?
created_at: 2025-05-05T08:17:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -178,92 +182,57 @@ public class MediaPreviewActivity : AppCompatActivity() {
      * Represents a video player with media controls.
      *
      * @param url The URL of the stream for playback.
-     * @param onBackPressed Handler for back press action.
      * @param onPlaybackError Handler for playback errors.
      */
+    @androidx.annotation.OptIn(UnstableApi::class)
path: stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity.kt
user: login: andremion
body: nit: import `OptIn`
created_at: 2025-05-05T08:31:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -178,92 +182,57 @@ public class MediaPreviewActivity : AppCompatActivity() {
      * Represents a video player with media controls.
      *
      * @param url The URL of the stream for playback.
-     * @param onBackPressed Handler for back press action.
      * @param onPlaybackError Handler for playback errors.
      */
+    @androidx.annotation.OptIn(UnstableApi::class)
     @Composable
     private fun MediaPreviewContent(
         url: String,
-        onBackPressed: () -> Unit = {},
         onPlaybackError: () -> Unit,
     ) {
         val context = LocalContext.current
-
-        val contentView = remember {
-            val mediaController = createMediaController(context, onBackPressed)
-
-            val frameLayout = FrameLayout(context).apply {
-                layoutParams = FrameLayout.LayoutParams(
-                    ViewGroup.LayoutParams.MATCH_PARENT,
-                    ViewGroup.LayoutParams.MATCH_PARENT,
-                )
-            }
-
-            val progressBar = ProgressBar(context).apply {
-                layoutParams = FrameLayout.LayoutParams(
-                    ViewGroup.LayoutParams.WRAP_CONTENT,
-                    ViewGroup.LayoutParams.WRAP_CONTENT,
-                ).apply {
-                    gravity = Gravity.CENTER
-                }
-            }
-
-            progressBar.isVisible = true
-
-            val videoView = VideoView(context).apply {
-                setVideoURI(Uri.parse(url))
-                setMediaController(mediaController)
-                setOnErrorListener { _, _, _ ->
-                    progressBar.isVisible = false
-                    onPlaybackError()
-                    true
-                }
-                setOnPreparedListener {
-                    progressBar.isVisible = false
-                    start()
-                    mediaController.show()
-                }
-                mediaController.setAnchorView(frameLayout)
-
-                layoutParams = FrameLayout.LayoutParams(
-                    ViewGroup.LayoutParams.MATCH_PARENT,
-                    ViewGroup.LayoutParams.MATCH_PARENT,
-                ).apply {
-                    gravity = Gravity.CENTER
-                }
+        var showBuffering by remember { mutableStateOf(false) }
+        val onBuffering: (Boolean) -> Unit = { showBuffering = it }
+        // Create player
+        var player by remember { mutableStateOf<Player?>(null) }
path: stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity.kt
user: login: andremion
body: Since `MediaGalleryVideoPage` doesn't have page context anymore. What about renaming it and reuse it in `MediaPreviewContent`? So we avoid duplication.
created_at: 2025-05-05T08:36:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -277,180 +274,127 @@ private fun ErrorIcon(modifier: Modifier) {
  * Note: It is assumed that this component is used within a pager.
  *
  * Renders a video player with thumbnail preview, play button, and loading indicators.
- * The video playback begins when the user taps the play button, showing a loading
- * indicator until the video is ready to play. The video player includes standard
- * media controls for playback.
+ * The video playback begins when the user taps the play button. The video player includes
+ * standard media controls for playback. Shows a loading indicator while the video is
+ * buffering. The video is paused when the user navigates away from this page in the pager.
  *
  * This component handles various states:
  * - Initial state with thumbnail and play button
- * - Loading state while preparing the video
+ * - Loading state while the video is buffering
  * - Playback state with media controls
  * - Error state when video playback fails
  *
  * The video playback is automatically paused and the preview is restored when
  * the user navigates away from this page in the pager.
  *
- * @param attachment The attachment containing the video to display
- * @param pagerState The state of the pager containing this video page
- * @param page The page index of this video in the pager
- * @param onPlaybackError Callback invoked when video playback encounters an error
+ * @param assetUrl The url of the video to display.
+ * @param thumbnailUrl The url of the thumbnail to display before the video is played.
+ * @param onPlaybackError Callback invoked when video playback encounters an error.
+ * @param modifier The [Modifier] to be applied to the video player.
  */
-@Suppress("LongMethod")
 @Composable
 internal fun MediaGalleryVideoPage(
-    attachment: Attachment,
-    pagerState: PagerState,
-    page: Int,
+    assetUrl: String?,
+    thumbnailUrl: String?,
     onPlaybackError: () -> Unit,
+    modifier: Modifier = Modifier,
 ) {
     val context = LocalContext.current
-
-    var hasPrepared by remember {
-        mutableStateOf(false)
-    }
-
-    var userHasClickedPlay by remember {
-        mutableStateOf(false)
-    }
-
-    var shouldShowProgressBar by remember {
-        mutableStateOf(false)
-    }
-
-    var shouldShowPreview by remember {
-        mutableStateOf(true)
-    }
-
-    var shouldShowPlayButton by remember {
-        mutableStateOf(true)
-    }
-
-    val mediaController = remember {
-        createMediaController(context)
+    var showThumbnail by remember { mutableStateOf(true) }
+    var showPlayButton by remember { mutableStateOf(true) }
+    var showBuffering by remember { mutableStateOf(false) }
+    val onBuffering: (Boolean) -> Unit = { showBuffering = it }
+    // Create player
+    var player by remember { mutableStateOf<Player?>(null) }
+    LifecycleResumeEffect(Unit) {
+        player = createPlayer(
+            context = context,
+            onBuffering = onBuffering,
+            onPlaybackError = onPlaybackError,
+        )
+        onPauseOrDispose {
+            player?.release()
+            player = null
+        }
     }
-
-    val videoView = remember {
-        VideoView(context)
+    // Prepare media
+    LaunchedEffect(player, assetUrl) {
+        if (player != null && assetUrl != null) {
+            player?.setMediaItem(MediaItem.fromUri(assetUrl))
+            player?.prepare()
+        }
     }
-
-    val contentView = remember {
-        val frameLayout = FrameLayout(context).apply {
-            layoutParams = FrameLayout.LayoutParams(
-                ViewGroup.LayoutParams.MATCH_PARENT,
-                ViewGroup.LayoutParams.MATCH_PARENT,
+    // Draw player
+    player?.let { preparedPlayer ->
+        Box(
+            modifier = modifier,
+            contentAlignment = Alignment.Center,
+        ) {
+            // Video player
+            AndroidView(
+                modifier = Modifier
+                    .fillMaxSize()
path: stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt
user: login: VelikovPetar
body: I think that this should work, I will test it and update it if everything's OK.
created_at: 2025-05-05T13:01:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -277,180 +274,127 @@ private fun ErrorIcon(modifier: Modifier) {
  * Note: It is assumed that this component is used within a pager.
  *
  * Renders a video player with thumbnail preview, play button, and loading indicators.
- * The video playback begins when the user taps the play button, showing a loading
- * indicator until the video is ready to play. The video player includes standard
- * media controls for playback.
+ * The video playback begins when the user taps the play button. The video player includes
+ * standard media controls for playback. Shows a loading indicator while the video is
+ * buffering. The video is paused when the user navigates away from this page in the pager.
  *
  * This component handles various states:
  * - Initial state with thumbnail and play button
- * - Loading state while preparing the video
+ * - Loading state while the video is buffering
  * - Playback state with media controls
  * - Error state when video playback fails
  *
  * The video playback is automatically paused and the preview is restored when
  * the user navigates away from this page in the pager.
  *
- * @param attachment The attachment containing the video to display
- * @param pagerState The state of the pager containing this video page
- * @param page The page index of this video in the pager
- * @param onPlaybackError Callback invoked when video playback encounters an error
+ * @param assetUrl The url of the video to display.
+ * @param thumbnailUrl The url of the thumbnail to display before the video is played.
+ * @param onPlaybackError Callback invoked when video playback encounters an error.
+ * @param modifier The [Modifier] to be applied to the video player.
  */
-@Suppress("LongMethod")
 @Composable
 internal fun MediaGalleryVideoPage(
-    attachment: Attachment,
-    pagerState: PagerState,
-    page: Int,
+    assetUrl: String?,
+    thumbnailUrl: String?,
     onPlaybackError: () -> Unit,
+    modifier: Modifier = Modifier,
 ) {
     val context = LocalContext.current
-
-    var hasPrepared by remember {
-        mutableStateOf(false)
-    }
-
-    var userHasClickedPlay by remember {
-        mutableStateOf(false)
-    }
-
-    var shouldShowProgressBar by remember {
-        mutableStateOf(false)
-    }
-
-    var shouldShowPreview by remember {
-        mutableStateOf(true)
-    }
-
-    var shouldShowPlayButton by remember {
-        mutableStateOf(true)
-    }
-
-    val mediaController = remember {
-        createMediaController(context)
+    var showThumbnail by remember { mutableStateOf(true) }
+    var showPlayButton by remember { mutableStateOf(true) }
+    var showBuffering by remember { mutableStateOf(false) }
+    val onBuffering: (Boolean) -> Unit = { showBuffering = it }
+    // Create player
+    var player by remember { mutableStateOf<Player?>(null) }
+    LifecycleResumeEffect(Unit) {
+        player = createPlayer(
+            context = context,
+            onBuffering = onBuffering,
+            onPlaybackError = onPlaybackError,
+        )
+        onPauseOrDispose {
+            player?.release()
+            player = null
+        }
     }
-
-    val videoView = remember {
-        VideoView(context)
+    // Prepare media
+    LaunchedEffect(player, assetUrl) {
+        if (player != null && assetUrl != null) {
+            player?.setMediaItem(MediaItem.fromUri(assetUrl))
+            player?.prepare()
+        }
     }
-
-    val contentView = remember {
-        val frameLayout = FrameLayout(context).apply {
-            layoutParams = FrameLayout.LayoutParams(
-                ViewGroup.LayoutParams.MATCH_PARENT,
-                ViewGroup.LayoutParams.MATCH_PARENT,
+    // Draw player
+    player?.let { preparedPlayer ->
+        Box(
+            modifier = modifier,
+            contentAlignment = Alignment.Center,
+        ) {
+            // Video player
+            AndroidView(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .background(Color.Black),
+                factory = {
+                    createPlayerView(it, preparedPlayer)
+                },
             )
-        }
-        videoView.apply {
-            setVideoURI(Uri.parse(attachment.assetUrl))
-            this.setMediaController(mediaController)
-            setOnErrorListener { _, _, _ ->
-                shouldShowProgressBar = false
-                onPlaybackError()
-                true
-            }
-            setOnPreparedListener {
-                // Don't remove the preview unless the user has clicked play previously,
-                // otherwise the preview will be removed whenever the video has finished downloading.
-                if (!hasPrepared && userHasClickedPlay && page == pagerState.currentPage) {
-                    shouldShowProgressBar = false
-                    shouldShowPreview = false
-                    mediaController.show()
-                }
-                hasPrepared = true
+            // Thumbnail
+            if (showThumbnail) {
+                VideoThumbnail(
+                    thumbnailUrl = thumbnailUrl,
+                    showPlayButton = showPlayButton,
+                    onPlayClick = {
+                        showThumbnail = false
+                        showPlayButton = false
+                        preparedPlayer.play()
+                    },
+                )
             }
-
-            mediaController.setAnchorView(frameLayout)
-
-            layoutParams = FrameLayout.LayoutParams(
-                ViewGroup.LayoutParams.MATCH_PARENT,
-                ViewGroup.LayoutParams.MATCH_PARENT,
-            ).apply {
-                gravity = Gravity.CENTER
+            // Buffering indicator
+            if (showBuffering && !showThumbnail) {
+                LoadingIndicator()
             }
         }
-
-        frameLayout.apply {
-            addView(videoView)
-        }
     }
+}
 
-    Box(contentAlignment = Alignment.Center) {
-        AndroidView(
+@Composable
+private fun VideoThumbnail(
+    thumbnailUrl: String?,
+    showPlayButton: Boolean,
+    onPlayClick: () -> Unit,
+) {
+    val data = if (ChatTheme.videoThumbnailsEnabled) {
+        thumbnailUrl
+    } else {
+        null
+    }
+    Box(
+        modifier = Modifier.fillMaxSize(),
path: stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt
user: login: VelikovPetar
body: Sounds good, let's do it that way
created_at: 2025-05-05T13:01:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -277,180 +274,127 @@ private fun ErrorIcon(modifier: Modifier) {
  * Note: It is assumed that this component is used within a pager.
  *
  * Renders a video player with thumbnail preview, play button, and loading indicators.
- * The video playback begins when the user taps the play button, showing a loading
- * indicator until the video is ready to play. The video player includes standard
- * media controls for playback.
+ * The video playback begins when the user taps the play button. The video player includes
+ * standard media controls for playback. Shows a loading indicator while the video is
+ * buffering. The video is paused when the user navigates away from this page in the pager.
  *
  * This component handles various states:
  * - Initial state with thumbnail and play button
- * - Loading state while preparing the video
+ * - Loading state while the video is buffering
  * - Playback state with media controls
  * - Error state when video playback fails
  *
  * The video playback is automatically paused and the preview is restored when
  * the user navigates away from this page in the pager.
  *
- * @param attachment The attachment containing the video to display
- * @param pagerState The state of the pager containing this video page
- * @param page The page index of this video in the pager
- * @param onPlaybackError Callback invoked when video playback encounters an error
+ * @param assetUrl The url of the video to display.
+ * @param thumbnailUrl The url of the thumbnail to display before the video is played.
+ * @param onPlaybackError Callback invoked when video playback encounters an error.
+ * @param modifier The [Modifier] to be applied to the video player.
  */
-@Suppress("LongMethod")
 @Composable
 internal fun MediaGalleryVideoPage(
-    attachment: Attachment,
-    pagerState: PagerState,
-    page: Int,
+    assetUrl: String?,
+    thumbnailUrl: String?,
     onPlaybackError: () -> Unit,
+    modifier: Modifier = Modifier,
 ) {
     val context = LocalContext.current
-
-    var hasPrepared by remember {
-        mutableStateOf(false)
-    }
-
-    var userHasClickedPlay by remember {
-        mutableStateOf(false)
-    }
-
-    var shouldShowProgressBar by remember {
-        mutableStateOf(false)
-    }
-
-    var shouldShowPreview by remember {
-        mutableStateOf(true)
-    }
-
-    var shouldShowPlayButton by remember {
-        mutableStateOf(true)
-    }
-
-    val mediaController = remember {
-        createMediaController(context)
+    var showThumbnail by remember { mutableStateOf(true) }
+    var showPlayButton by remember { mutableStateOf(true) }
+    var showBuffering by remember { mutableStateOf(false) }
+    val onBuffering: (Boolean) -> Unit = { showBuffering = it }
+    // Create player
+    var player by remember { mutableStateOf<Player?>(null) }
+    LifecycleResumeEffect(Unit) {
+        player = createPlayer(
+            context = context,
+            onBuffering = onBuffering,
+            onPlaybackError = onPlaybackError,
+        )
+        onPauseOrDispose {
+            player?.release()
+            player = null
+        }
     }
-
-    val videoView = remember {
-        VideoView(context)
+    // Prepare media
+    LaunchedEffect(player, assetUrl) {
+        if (player != null && assetUrl != null) {
+            player?.setMediaItem(MediaItem.fromUri(assetUrl))
+            player?.prepare()
+        }
     }
-
-    val contentView = remember {
-        val frameLayout = FrameLayout(context).apply {
-            layoutParams = FrameLayout.LayoutParams(
-                ViewGroup.LayoutParams.MATCH_PARENT,
-                ViewGroup.LayoutParams.MATCH_PARENT,
+    // Draw player
+    player?.let { preparedPlayer ->
path: stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt
user: login: VelikovPetar
body: It is definitely possible, but in which case would such `LoadingIndicator` be visible? It will be between `onCreate` and `onResume` which would be a very short period of time. 
created_at: 2025-05-05T13:01:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -178,92 +182,57 @@ public class MediaPreviewActivity : AppCompatActivity() {
      * Represents a video player with media controls.
      *
      * @param url The URL of the stream for playback.
-     * @param onBackPressed Handler for back press action.
      * @param onPlaybackError Handler for playback errors.
      */
+    @androidx.annotation.OptIn(UnstableApi::class)
path: stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity.kt
user: login: VelikovPetar
body: This actually is not directly possible, because `@androidx.annotation.OptIn` clashes with `@kotlin.OptIn` which is also used in the same class (line: 148). And for some reason, the both `OptIn` annotations seem to incompatible with their other target. Ex:
- `@kotlin.OptIn` doesn't work with `UnstableApi::class`
- `@androidx.annotation.OptIn` doesn't work with `ExperimentalMaterial3Api::class`

We can do it though with some import alias if you think it would make sense.
created_at: 2025-05-05T13:21:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -178,92 +182,57 @@ public class MediaPreviewActivity : AppCompatActivity() {
      * Represents a video player with media controls.
      *
      * @param url The URL of the stream for playback.
-     * @param onBackPressed Handler for back press action.
      * @param onPlaybackError Handler for playback errors.
      */
+    @androidx.annotation.OptIn(UnstableApi::class)
     @Composable
     private fun MediaPreviewContent(
         url: String,
-        onBackPressed: () -> Unit = {},
         onPlaybackError: () -> Unit,
     ) {
         val context = LocalContext.current
-
-        val contentView = remember {
-            val mediaController = createMediaController(context, onBackPressed)
-
-            val frameLayout = FrameLayout(context).apply {
-                layoutParams = FrameLayout.LayoutParams(
-                    ViewGroup.LayoutParams.MATCH_PARENT,
-                    ViewGroup.LayoutParams.MATCH_PARENT,
-                )
-            }
-
-            val progressBar = ProgressBar(context).apply {
-                layoutParams = FrameLayout.LayoutParams(
-                    ViewGroup.LayoutParams.WRAP_CONTENT,
-                    ViewGroup.LayoutParams.WRAP_CONTENT,
-                ).apply {
-                    gravity = Gravity.CENTER
-                }
-            }
-
-            progressBar.isVisible = true
-
-            val videoView = VideoView(context).apply {
-                setVideoURI(Uri.parse(url))
-                setMediaController(mediaController)
-                setOnErrorListener { _, _, _ ->
-                    progressBar.isVisible = false
-                    onPlaybackError()
-                    true
-                }
-                setOnPreparedListener {
-                    progressBar.isVisible = false
-                    start()
-                    mediaController.show()
-                }
-                mediaController.setAnchorView(frameLayout)
-
-                layoutParams = FrameLayout.LayoutParams(
-                    ViewGroup.LayoutParams.MATCH_PARENT,
-                    ViewGroup.LayoutParams.MATCH_PARENT,
-                ).apply {
-                    gravity = Gravity.CENTER
-                }
+        var showBuffering by remember { mutableStateOf(false) }
+        val onBuffering: (Boolean) -> Unit = { showBuffering = it }
+        // Create player
+        var player by remember { mutableStateOf<Player?>(null) }
path: stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity.kt
user: login: VelikovPetar
body: I was also thinking the same thing, but the `MediaGalleryVideoPage` has some extra stuff (like the `VideoThumbnail`) and some differences in the behaviour (the `MediaGalleryVideoPage` doesn't use `playWhenReady`, and doesn't use `playerView.showController()`). I could try to extract the common view preparation logic, with some configuration passed from outside (`playWhenReady: Boolean`, `showControllerInitially: Boolean` etc), and then reuse the common composable in both places. WDYT?
created_at: 2025-05-05T13:44:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -178,92 +182,57 @@ public class MediaPreviewActivity : AppCompatActivity() {
      * Represents a video player with media controls.
      *
      * @param url The URL of the stream for playback.
-     * @param onBackPressed Handler for back press action.
      * @param onPlaybackError Handler for playback errors.
      */
+    @androidx.annotation.OptIn(UnstableApi::class)
path: stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity.kt
user: login: andremion
body: That's fine. Thanks for the context ❤️ 
created_at: 2025-05-06T07:37:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -277,180 +274,127 @@ private fun ErrorIcon(modifier: Modifier) {
  * Note: It is assumed that this component is used within a pager.
  *
  * Renders a video player with thumbnail preview, play button, and loading indicators.
- * The video playback begins when the user taps the play button, showing a loading
- * indicator until the video is ready to play. The video player includes standard
- * media controls for playback.
+ * The video playback begins when the user taps the play button. The video player includes
+ * standard media controls for playback. Shows a loading indicator while the video is
+ * buffering. The video is paused when the user navigates away from this page in the pager.
  *
  * This component handles various states:
  * - Initial state with thumbnail and play button
- * - Loading state while preparing the video
+ * - Loading state while the video is buffering
  * - Playback state with media controls
  * - Error state when video playback fails
  *
  * The video playback is automatically paused and the preview is restored when
  * the user navigates away from this page in the pager.
  *
- * @param attachment The attachment containing the video to display
- * @param pagerState The state of the pager containing this video page
- * @param page The page index of this video in the pager
- * @param onPlaybackError Callback invoked when video playback encounters an error
+ * @param assetUrl The url of the video to display.
+ * @param thumbnailUrl The url of the thumbnail to display before the video is played.
+ * @param onPlaybackError Callback invoked when video playback encounters an error.
+ * @param modifier The [Modifier] to be applied to the video player.
  */
-@Suppress("LongMethod")
 @Composable
 internal fun MediaGalleryVideoPage(
-    attachment: Attachment,
-    pagerState: PagerState,
-    page: Int,
+    assetUrl: String?,
+    thumbnailUrl: String?,
     onPlaybackError: () -> Unit,
+    modifier: Modifier = Modifier,
 ) {
     val context = LocalContext.current
-
-    var hasPrepared by remember {
-        mutableStateOf(false)
-    }
-
-    var userHasClickedPlay by remember {
-        mutableStateOf(false)
-    }
-
-    var shouldShowProgressBar by remember {
-        mutableStateOf(false)
-    }
-
-    var shouldShowPreview by remember {
-        mutableStateOf(true)
-    }
-
-    var shouldShowPlayButton by remember {
-        mutableStateOf(true)
-    }
-
-    val mediaController = remember {
-        createMediaController(context)
+    var showThumbnail by remember { mutableStateOf(true) }
+    var showPlayButton by remember { mutableStateOf(true) }
+    var showBuffering by remember { mutableStateOf(false) }
+    val onBuffering: (Boolean) -> Unit = { showBuffering = it }
+    // Create player
+    var player by remember { mutableStateOf<Player?>(null) }
+    LifecycleResumeEffect(Unit) {
+        player = createPlayer(
+            context = context,
+            onBuffering = onBuffering,
+            onPlaybackError = onPlaybackError,
+        )
+        onPauseOrDispose {
+            player?.release()
+            player = null
+        }
     }
-
-    val videoView = remember {
-        VideoView(context)
+    // Prepare media
+    LaunchedEffect(player, assetUrl) {
+        if (player != null && assetUrl != null) {
+            player?.setMediaItem(MediaItem.fromUri(assetUrl))
+            player?.prepare()
+        }
     }
-
-    val contentView = remember {
-        val frameLayout = FrameLayout(context).apply {
-            layoutParams = FrameLayout.LayoutParams(
-                ViewGroup.LayoutParams.MATCH_PARENT,
-                ViewGroup.LayoutParams.MATCH_PARENT,
+    // Draw player
+    player?.let { preparedPlayer ->
path: stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt
user: login: andremion
body: I'm not sure how long it takes to create that player in low-end devices.
The idea was to prevent any blinking.
I trust you to decide whether it is worth it or not.
created_at: 2025-05-06T07:48:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -178,92 +182,57 @@ public class MediaPreviewActivity : AppCompatActivity() {
      * Represents a video player with media controls.
      *
      * @param url The URL of the stream for playback.
-     * @param onBackPressed Handler for back press action.
      * @param onPlaybackError Handler for playback errors.
      */
+    @androidx.annotation.OptIn(UnstableApi::class)
     @Composable
     private fun MediaPreviewContent(
         url: String,
-        onBackPressed: () -> Unit = {},
         onPlaybackError: () -> Unit,
     ) {
         val context = LocalContext.current
-
-        val contentView = remember {
-            val mediaController = createMediaController(context, onBackPressed)
-
-            val frameLayout = FrameLayout(context).apply {
-                layoutParams = FrameLayout.LayoutParams(
-                    ViewGroup.LayoutParams.MATCH_PARENT,
-                    ViewGroup.LayoutParams.MATCH_PARENT,
-                )
-            }
-
-            val progressBar = ProgressBar(context).apply {
-                layoutParams = FrameLayout.LayoutParams(
-                    ViewGroup.LayoutParams.WRAP_CONTENT,
-                    ViewGroup.LayoutParams.WRAP_CONTENT,
-                ).apply {
-                    gravity = Gravity.CENTER
-                }
-            }
-
-            progressBar.isVisible = true
-
-            val videoView = VideoView(context).apply {
-                setVideoURI(Uri.parse(url))
-                setMediaController(mediaController)
-                setOnErrorListener { _, _, _ ->
-                    progressBar.isVisible = false
-                    onPlaybackError()
-                    true
-                }
-                setOnPreparedListener {
-                    progressBar.isVisible = false
-                    start()
-                    mediaController.show()
-                }
-                mediaController.setAnchorView(frameLayout)
-
-                layoutParams = FrameLayout.LayoutParams(
-                    ViewGroup.LayoutParams.MATCH_PARENT,
-                    ViewGroup.LayoutParams.MATCH_PARENT,
-                ).apply {
-                    gravity = Gravity.CENTER
-                }
+        var showBuffering by remember { mutableStateOf(false) }
+        val onBuffering: (Boolean) -> Unit = { showBuffering = it }
+        // Create player
+        var player by remember { mutableStateOf<Player?>(null) }
path: stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity.kt
user: login: andremion
body: Yes. Something like a customizable internal `StreamMediaPlayer` should do the trick.
created_at: 2025-05-06T07:54:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -178,92 +182,57 @@ public class MediaPreviewActivity : AppCompatActivity() {
      * Represents a video player with media controls.
      *
      * @param url The URL of the stream for playback.
-     * @param onBackPressed Handler for back press action.
      * @param onPlaybackError Handler for playback errors.
      */
+    @androidx.annotation.OptIn(UnstableApi::class)
     @Composable
     private fun MediaPreviewContent(
         url: String,
-        onBackPressed: () -> Unit = {},
         onPlaybackError: () -> Unit,
     ) {
         val context = LocalContext.current
-
-        val contentView = remember {
-            val mediaController = createMediaController(context, onBackPressed)
-
-            val frameLayout = FrameLayout(context).apply {
-                layoutParams = FrameLayout.LayoutParams(
-                    ViewGroup.LayoutParams.MATCH_PARENT,
-                    ViewGroup.LayoutParams.MATCH_PARENT,
-                )
-            }
-
-            val progressBar = ProgressBar(context).apply {
-                layoutParams = FrameLayout.LayoutParams(
-                    ViewGroup.LayoutParams.WRAP_CONTENT,
-                    ViewGroup.LayoutParams.WRAP_CONTENT,
-                ).apply {
-                    gravity = Gravity.CENTER
-                }
-            }
-
-            progressBar.isVisible = true
-
-            val videoView = VideoView(context).apply {
-                setVideoURI(Uri.parse(url))
-                setMediaController(mediaController)
-                setOnErrorListener { _, _, _ ->
-                    progressBar.isVisible = false
-                    onPlaybackError()
-                    true
-                }
-                setOnPreparedListener {
-                    progressBar.isVisible = false
-                    start()
-                    mediaController.show()
-                }
-                mediaController.setAnchorView(frameLayout)
-
-                layoutParams = FrameLayout.LayoutParams(
-                    ViewGroup.LayoutParams.MATCH_PARENT,
-                    ViewGroup.LayoutParams.MATCH_PARENT,
-                ).apply {
-                    gravity = Gravity.CENTER
-                }
+        var showBuffering by remember { mutableStateOf(false) }
+        val onBuffering: (Boolean) -> Unit = { showBuffering = it }
+        // Create player
+        var player by remember { mutableStateOf<Player?>(null) }
path: stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity.kt
user: login: VelikovPetar
body: Let me then write some common implementation for both places, and I will update the PR!
created_at: 2025-05-06T07:56:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -277,180 +274,127 @@ private fun ErrorIcon(modifier: Modifier) {
  * Note: It is assumed that this component is used within a pager.
  *
  * Renders a video player with thumbnail preview, play button, and loading indicators.
- * The video playback begins when the user taps the play button, showing a loading
- * indicator until the video is ready to play. The video player includes standard
- * media controls for playback.
+ * The video playback begins when the user taps the play button. The video player includes
+ * standard media controls for playback. Shows a loading indicator while the video is
+ * buffering. The video is paused when the user navigates away from this page in the pager.
  *
  * This component handles various states:
  * - Initial state with thumbnail and play button
- * - Loading state while preparing the video
+ * - Loading state while the video is buffering
  * - Playback state with media controls
  * - Error state when video playback fails
  *
  * The video playback is automatically paused and the preview is restored when
  * the user navigates away from this page in the pager.
  *
- * @param attachment The attachment containing the video to display
- * @param pagerState The state of the pager containing this video page
- * @param page The page index of this video in the pager
- * @param onPlaybackError Callback invoked when video playback encounters an error
+ * @param assetUrl The url of the video to display.
+ * @param thumbnailUrl The url of the thumbnail to display before the video is played.
+ * @param onPlaybackError Callback invoked when video playback encounters an error.
+ * @param modifier The [Modifier] to be applied to the video player.
  */
-@Suppress("LongMethod")
 @Composable
 internal fun MediaGalleryVideoPage(
-    attachment: Attachment,
-    pagerState: PagerState,
-    page: Int,
+    assetUrl: String?,
+    thumbnailUrl: String?,
     onPlaybackError: () -> Unit,
+    modifier: Modifier = Modifier,
 ) {
     val context = LocalContext.current
-
-    var hasPrepared by remember {
-        mutableStateOf(false)
-    }
-
-    var userHasClickedPlay by remember {
-        mutableStateOf(false)
-    }
-
-    var shouldShowProgressBar by remember {
-        mutableStateOf(false)
-    }
-
-    var shouldShowPreview by remember {
-        mutableStateOf(true)
-    }
-
-    var shouldShowPlayButton by remember {
-        mutableStateOf(true)
-    }
-
-    val mediaController = remember {
-        createMediaController(context)
+    var showThumbnail by remember { mutableStateOf(true) }
+    var showPlayButton by remember { mutableStateOf(true) }
+    var showBuffering by remember { mutableStateOf(false) }
+    val onBuffering: (Boolean) -> Unit = { showBuffering = it }
+    // Create player
+    var player by remember { mutableStateOf<Player?>(null) }
+    LifecycleResumeEffect(Unit) {
+        player = createPlayer(
+            context = context,
+            onBuffering = onBuffering,
+            onPlaybackError = onPlaybackError,
+        )
+        onPauseOrDispose {
+            player?.release()
+            player = null
+        }
     }
-
-    val videoView = remember {
-        VideoView(context)
+    // Prepare media
+    LaunchedEffect(player, assetUrl) {
+        if (player != null && assetUrl != null) {
+            player?.setMediaItem(MediaItem.fromUri(assetUrl))
+            player?.prepare()
+        }
     }
-
-    val contentView = remember {
-        val frameLayout = FrameLayout(context).apply {
-            layoutParams = FrameLayout.LayoutParams(
-                ViewGroup.LayoutParams.MATCH_PARENT,
-                ViewGroup.LayoutParams.MATCH_PARENT,
+    // Draw player
+    player?.let { preparedPlayer ->
path: stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt
user: login: VelikovPetar
body: I will test it out on a low-end device and get back to you.
created_at: 2025-05-06T07:57:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -178,92 +182,57 @@ public class MediaPreviewActivity : AppCompatActivity() {
      * Represents a video player with media controls.
      *
      * @param url The URL of the stream for playback.
-     * @param onBackPressed Handler for back press action.
      * @param onPlaybackError Handler for playback errors.
      */
+    @androidx.annotation.OptIn(UnstableApi::class)
     @Composable
     private fun MediaPreviewContent(
         url: String,
-        onBackPressed: () -> Unit = {},
         onPlaybackError: () -> Unit,
     ) {
         val context = LocalContext.current
-
-        val contentView = remember {
-            val mediaController = createMediaController(context, onBackPressed)
-
-            val frameLayout = FrameLayout(context).apply {
-                layoutParams = FrameLayout.LayoutParams(
-                    ViewGroup.LayoutParams.MATCH_PARENT,
-                    ViewGroup.LayoutParams.MATCH_PARENT,
-                )
-            }
-
-            val progressBar = ProgressBar(context).apply {
-                layoutParams = FrameLayout.LayoutParams(
-                    ViewGroup.LayoutParams.WRAP_CONTENT,
-                    ViewGroup.LayoutParams.WRAP_CONTENT,
-                ).apply {
-                    gravity = Gravity.CENTER
-                }
-            }
-
-            progressBar.isVisible = true
-
-            val videoView = VideoView(context).apply {
-                setVideoURI(Uri.parse(url))
-                setMediaController(mediaController)
-                setOnErrorListener { _, _, _ ->
-                    progressBar.isVisible = false
-                    onPlaybackError()
-                    true
-                }
-                setOnPreparedListener {
-                    progressBar.isVisible = false
-                    start()
-                    mediaController.show()
-                }
-                mediaController.setAnchorView(frameLayout)
-
-                layoutParams = FrameLayout.LayoutParams(
-                    ViewGroup.LayoutParams.MATCH_PARENT,
-                    ViewGroup.LayoutParams.MATCH_PARENT,
-                ).apply {
-                    gravity = Gravity.CENTER
-                }
+        var showBuffering by remember { mutableStateOf(false) }
+        val onBuffering: (Boolean) -> Unit = { showBuffering = it }
+        // Create player
+        var player by remember { mutableStateOf<Player?>(null) }
path: stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/MediaPreviewActivity.kt
user: login: VelikovPetar
body: I reworked the implementation a bit, please have a look: https://github.com/GetStream/stream-chat-android/pull/5749/commits/d5cc8fd08e438a864675b04e084683b3a0cfa258
created_at: 2025-05-06T08:28:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+/*
+ * Copyright (c) 2014-2025 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 android.content.Context
+import androidx.annotation.OptIn
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.media3.common.MediaItem
+import androidx.media3.common.PlaybackException
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.ui.PlayerView
+import io.getstream.chat.android.compose.R
+import io.getstream.chat.android.compose.ui.attachments.content.PlayButton
+import io.getstream.chat.android.compose.ui.components.LoadingIndicator
+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
+
+/**
+ * A Composable that displays a video player with support for thumbnails, play controls, and buffering indicators.
+ *
+ * This component creates a Media3 ExoPlayer instance that handles video playback while respecting
+ * the Android lifecycle. The player is automatically created when the composable enters the composition
+ * and released when it leaves or pauses.
+ *
+ * The component offers several features:
+ * - Shows an optional thumbnail with play button before playback starts
+ * - Displays media controls that can be shown initially or hidden
+ * - Shows a buffering indicator during loading states
+ * - Automatically handles player lifecycle and resource cleanup
+ *
+ * @param assetUrl The URL of the video to play.
+ * @param playWhenReady Whether playback should start automatically after preparation.
+ * @param showControllerInitially Whether to show the player controls when first displayed.
+ * @param onPlaybackError Callback invoked when video playback encounters an error.
+ * @param modifier Modifier to be applied to the player container.
+ * @param thumbnailUrl Optional URL of the thumbnail image to display before playback starts.
+ * @param thumbnailEnabled Whether to show the thumbnail before playback starts.
+ */
+@OptIn(UnstableApi::class)
+@Composable
+internal fun StreamVideoPlayerContent(
+    assetUrl: String?,
+    playWhenReady: Boolean,
+    showControllerInitially: Boolean,
+    onPlaybackError: () -> Unit,
+    modifier: Modifier = Modifier,
+    thumbnailUrl: String? = null,
+    thumbnailEnabled: Boolean = false,
path: stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamVideoPlayerContent.kt
user: login: andremion
body: A nullable `thumbnailUrl` isn't it enough to know whether the thumbnail is enabled?
Do we need the `thumbnailEnabled` parameter?

created_at: 2025-05-06T09:09:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+/*
+ * Copyright (c) 2014-2025 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 android.content.Context
+import androidx.annotation.OptIn
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.media3.common.MediaItem
+import androidx.media3.common.PlaybackException
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.ui.PlayerView
+import io.getstream.chat.android.compose.R
+import io.getstream.chat.android.compose.ui.attachments.content.PlayButton
+import io.getstream.chat.android.compose.ui.components.LoadingIndicator
+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
+
+/**
+ * A Composable that displays a video player with support for thumbnails, play controls, and buffering indicators.
+ *
+ * This component creates a Media3 ExoPlayer instance that handles video playback while respecting
+ * the Android lifecycle. The player is automatically created when the composable enters the composition
+ * and released when it leaves or pauses.
+ *
+ * The component offers several features:
+ * - Shows an optional thumbnail with play button before playback starts
+ * - Displays media controls that can be shown initially or hidden
+ * - Shows a buffering indicator during loading states
+ * - Automatically handles player lifecycle and resource cleanup
+ *
+ * @param assetUrl The URL of the video to play.
+ * @param playWhenReady Whether playback should start automatically after preparation.
+ * @param showControllerInitially Whether to show the player controls when first displayed.
+ * @param onPlaybackError Callback invoked when video playback encounters an error.
+ * @param modifier Modifier to be applied to the player container.
+ * @param thumbnailUrl Optional URL of the thumbnail image to display before playback starts.
+ * @param thumbnailEnabled Whether to show the thumbnail before playback starts.
+ */
+@OptIn(UnstableApi::class)
+@Composable
+internal fun StreamVideoPlayerContent(
path: stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamVideoPlayerContent.kt
user: login: andremion
body: This component isn't also used to play audio?
Maybe its name could be `StreamMediaPlayerContent`, WDYT?
created_at: 2025-05-06T09:11:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+/*
+ * Copyright (c) 2014-2025 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 android.content.Context
+import androidx.annotation.OptIn
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.media3.common.MediaItem
+import androidx.media3.common.PlaybackException
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.ui.PlayerView
+import io.getstream.chat.android.compose.R
+import io.getstream.chat.android.compose.ui.attachments.content.PlayButton
+import io.getstream.chat.android.compose.ui.components.LoadingIndicator
+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
+
+/**
+ * A Composable that displays a video player with support for thumbnails, play controls, and buffering indicators.
+ *
+ * This component creates a Media3 ExoPlayer instance that handles video playback while respecting
+ * the Android lifecycle. The player is automatically created when the composable enters the composition
+ * and released when it leaves or pauses.
+ *
+ * The component offers several features:
+ * - Shows an optional thumbnail with play button before playback starts
+ * - Displays media controls that can be shown initially or hidden
+ * - Shows a buffering indicator during loading states
+ * - Automatically handles player lifecycle and resource cleanup
+ *
+ * @param assetUrl The URL of the video to play.
+ * @param playWhenReady Whether playback should start automatically after preparation.
+ * @param showControllerInitially Whether to show the player controls when first displayed.
+ * @param onPlaybackError Callback invoked when video playback encounters an error.
+ * @param modifier Modifier to be applied to the player container.
+ * @param thumbnailUrl Optional URL of the thumbnail image to display before playback starts.
+ * @param thumbnailEnabled Whether to show the thumbnail before playback starts.
+ */
+@OptIn(UnstableApi::class)
+@Composable
+internal fun StreamVideoPlayerContent(
+    assetUrl: String?,
+    playWhenReady: Boolean,
+    showControllerInitially: Boolean,
+    onPlaybackError: () -> Unit,
+    modifier: Modifier = Modifier,
+    thumbnailUrl: String? = null,
+    thumbnailEnabled: Boolean = false,
+) {
+    val context = LocalContext.current
+    var showThumbnail by remember { mutableStateOf(thumbnailEnabled) }
+    var showPlayButton by remember { mutableStateOf(thumbnailEnabled) }
+    var showBuffering by remember { mutableStateOf(true) }
+    val onBuffering: (Boolean) -> Unit = { isBuffering -> showBuffering = isBuffering }
+    // Create player
+    var player by remember { mutableStateOf<Player?>(null) }
+    LifecycleResumeEffect(Unit) {
+        player = createPlayer(
+            context = context,
+            onBuffering = onBuffering,
+            onPlaybackError = onPlaybackError,
+        )
+        onPauseOrDispose {
+            player?.release()
+            player = null
+        }
+    }
+    // Prepare media
+    LaunchedEffect(player, assetUrl) {
+        if (player != null && assetUrl != null) {
+            player?.setMediaItem(MediaItem.fromUri(assetUrl))
+            player?.prepare()
+            player?.playWhenReady = playWhenReady
+        }
+    }
+    // Draw player
+    player?.let { preparedPlayer ->
+        Box(
+            modifier = modifier,
+            contentAlignment = Alignment.Center,
+        ) {
+            // Video/Audio player
+            AndroidView(
+                modifier = Modifier
+                    .matchParentSize()
+                    .background(Color.Black),
+                factory = {
+                    val playerView = createPlayerView(it, preparedPlayer)
+                    if (showControllerInitially) playerView.showController()
+                    playerView
+                },
+            )
+            // Thumbnail
+            if (thumbnailEnabled && showThumbnail) {
+                VideoThumbnail(
+                    modifier = Modifier.matchParentSize(),
+                    thumbnailUrl = thumbnailUrl,
+                    showPlayButton = showPlayButton,
+                    onPlayClick = {
+                        showThumbnail = false
+                        showPlayButton = false
+                        preparedPlayer.play()
+                    },
+                )
+            }
+            // Buffering indicator
+            if (showBuffering && !showThumbnail) {
+                LoadingIndicator()
+            }
+        }
+    }
+}
+
+/**
+ * A composable that displays a thumbnail image for a video with an optional play button overlay.
+ *
+ * This component shows a video thumbnail image with a centered play button that can be clicked
+ * to start video playback. The thumbnail image is only displayed if video thumbnails are
+ * enabled in the ChatTheme configuration.
+ *
+ * When clicked, the component triggers the provided [onPlayClick] callback to begin video playback.
+ * The thumbnail has a black background to ensure visual consistency when no thumbnail image
+ * is available or while it's loading.
+ *
+ * @param thumbnailUrl The URL of the thumbnail image to display, or null if no thumbnail is available.
+ * @param showPlayButton Whether to display the play button overlay on top of the thumbnail.
+ * @param onPlayClick Callback invoked when the user clicks on the thumbnail or play button to start playback.
+ * @param modifier Modifier to be applied to the thumbnail container.
+ */
+@Composable
+private fun VideoThumbnail(
+    thumbnailUrl: String?,
+    showPlayButton: Boolean,
+    onPlayClick: () -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    val data = if (ChatTheme.videoThumbnailsEnabled) {
path: stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamVideoPlayerContent.kt
user: login: andremion
body: Maybe this verification could be done by the composable that calls `VideoThumbnail` so this entire composable wouldn't be called if `ChatTheme.videoThumbnailsEnabled` is false.
WDYT?
created_at: 2025-05-06T09:15:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -277,180 +274,127 @@ private fun ErrorIcon(modifier: Modifier) {
  * Note: It is assumed that this component is used within a pager.
  *
  * Renders a video player with thumbnail preview, play button, and loading indicators.
- * The video playback begins when the user taps the play button, showing a loading
- * indicator until the video is ready to play. The video player includes standard
- * media controls for playback.
+ * The video playback begins when the user taps the play button. The video player includes
+ * standard media controls for playback. Shows a loading indicator while the video is
+ * buffering. The video is paused when the user navigates away from this page in the pager.
  *
  * This component handles various states:
  * - Initial state with thumbnail and play button
- * - Loading state while preparing the video
+ * - Loading state while the video is buffering
  * - Playback state with media controls
  * - Error state when video playback fails
  *
  * The video playback is automatically paused and the preview is restored when
  * the user navigates away from this page in the pager.
  *
- * @param attachment The attachment containing the video to display
- * @param pagerState The state of the pager containing this video page
- * @param page The page index of this video in the pager
- * @param onPlaybackError Callback invoked when video playback encounters an error
+ * @param assetUrl The url of the video to display.
+ * @param thumbnailUrl The url of the thumbnail to display before the video is played.
+ * @param onPlaybackError Callback invoked when video playback encounters an error.
+ * @param modifier The [Modifier] to be applied to the video player.
  */
-@Suppress("LongMethod")
 @Composable
 internal fun MediaGalleryVideoPage(
-    attachment: Attachment,
-    pagerState: PagerState,
-    page: Int,
+    assetUrl: String?,
+    thumbnailUrl: String?,
     onPlaybackError: () -> Unit,
+    modifier: Modifier = Modifier,
 ) {
     val context = LocalContext.current
-
-    var hasPrepared by remember {
-        mutableStateOf(false)
-    }
-
-    var userHasClickedPlay by remember {
-        mutableStateOf(false)
-    }
-
-    var shouldShowProgressBar by remember {
-        mutableStateOf(false)
-    }
-
-    var shouldShowPreview by remember {
-        mutableStateOf(true)
-    }
-
-    var shouldShowPlayButton by remember {
-        mutableStateOf(true)
-    }
-
-    val mediaController = remember {
-        createMediaController(context)
+    var showThumbnail by remember { mutableStateOf(true) }
+    var showPlayButton by remember { mutableStateOf(true) }
+    var showBuffering by remember { mutableStateOf(false) }
+    val onBuffering: (Boolean) -> Unit = { showBuffering = it }
+    // Create player
+    var player by remember { mutableStateOf<Player?>(null) }
+    LifecycleResumeEffect(Unit) {
+        player = createPlayer(
+            context = context,
+            onBuffering = onBuffering,
+            onPlaybackError = onPlaybackError,
+        )
+        onPauseOrDispose {
+            player?.release()
+            player = null
+        }
     }
-
-    val videoView = remember {
-        VideoView(context)
+    // Prepare media
+    LaunchedEffect(player, assetUrl) {
+        if (player != null && assetUrl != null) {
+            player?.setMediaItem(MediaItem.fromUri(assetUrl))
+            player?.prepare()
+        }
     }
-
-    val contentView = remember {
-        val frameLayout = FrameLayout(context).apply {
-            layoutParams = FrameLayout.LayoutParams(
-                ViewGroup.LayoutParams.MATCH_PARENT,
-                ViewGroup.LayoutParams.MATCH_PARENT,
+    // Draw player
+    player?.let { preparedPlayer ->
path: stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt
user: login: VelikovPetar
body: Here's a short demo done on Samsung A05:
<table>
<thead>
<tr>
<th>Audio</th>
<th>Video</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<video src="https://github.com/user-attachments/assets/d9df12c7-0f2e-4e98-9236-68915cab7061" controls="controls" muted="muted" />
</td>
<td>
<video src="https://github.com/user-attachments/assets/0b21c951-feb4-4627-8bda-47eff8df8ac2" controls="controls" muted="muted" />
</td>
</tr>
</tbody>
</table>
I don't really think it is necessary, (for video it will never be visible, because we also show a thumbnail over it initially), but if you think it improves the user experience, we could add it.




created_at: 2025-05-06T09:23:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+/*
+ * Copyright (c) 2014-2025 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 android.content.Context
+import androidx.annotation.OptIn
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.media3.common.MediaItem
+import androidx.media3.common.PlaybackException
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.ui.PlayerView
+import io.getstream.chat.android.compose.R
+import io.getstream.chat.android.compose.ui.attachments.content.PlayButton
+import io.getstream.chat.android.compose.ui.components.LoadingIndicator
+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
+
+/**
+ * A Composable that displays a video player with support for thumbnails, play controls, and buffering indicators.
+ *
+ * This component creates a Media3 ExoPlayer instance that handles video playback while respecting
+ * the Android lifecycle. The player is automatically created when the composable enters the composition
+ * and released when it leaves or pauses.
+ *
+ * The component offers several features:
+ * - Shows an optional thumbnail with play button before playback starts
+ * - Displays media controls that can be shown initially or hidden
+ * - Shows a buffering indicator during loading states
+ * - Automatically handles player lifecycle and resource cleanup
+ *
+ * @param assetUrl The URL of the video to play.
+ * @param playWhenReady Whether playback should start automatically after preparation.
+ * @param showControllerInitially Whether to show the player controls when first displayed.
+ * @param onPlaybackError Callback invoked when video playback encounters an error.
+ * @param modifier Modifier to be applied to the player container.
+ * @param thumbnailUrl Optional URL of the thumbnail image to display before playback starts.
+ * @param thumbnailEnabled Whether to show the thumbnail before playback starts.
+ */
+@OptIn(UnstableApi::class)
+@Composable
+internal fun StreamVideoPlayerContent(
+    assetUrl: String?,
+    playWhenReady: Boolean,
+    showControllerInitially: Boolean,
+    onPlaybackError: () -> Unit,
+    modifier: Modifier = Modifier,
+    thumbnailUrl: String? = null,
+    thumbnailEnabled: Boolean = false,
path: stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamVideoPlayerContent.kt
user: login: VelikovPetar
body: It is a bit different. For example, for video, even if the `thumbnailUrl` is null (not delivered from BE for example), we should still show a black background with the play button as a cover for the video before it is played. 
created_at: 2025-05-06T09:30:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+/*
+ * Copyright (c) 2014-2025 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 android.content.Context
+import androidx.annotation.OptIn
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.media3.common.MediaItem
+import androidx.media3.common.PlaybackException
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.ui.PlayerView
+import io.getstream.chat.android.compose.R
+import io.getstream.chat.android.compose.ui.attachments.content.PlayButton
+import io.getstream.chat.android.compose.ui.components.LoadingIndicator
+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
+
+/**
+ * A Composable that displays a video player with support for thumbnails, play controls, and buffering indicators.
+ *
+ * This component creates a Media3 ExoPlayer instance that handles video playback while respecting
+ * the Android lifecycle. The player is automatically created when the composable enters the composition
+ * and released when it leaves or pauses.
+ *
+ * The component offers several features:
+ * - Shows an optional thumbnail with play button before playback starts
+ * - Displays media controls that can be shown initially or hidden
+ * - Shows a buffering indicator during loading states
+ * - Automatically handles player lifecycle and resource cleanup
+ *
+ * @param assetUrl The URL of the video to play.
+ * @param playWhenReady Whether playback should start automatically after preparation.
+ * @param showControllerInitially Whether to show the player controls when first displayed.
+ * @param onPlaybackError Callback invoked when video playback encounters an error.
+ * @param modifier Modifier to be applied to the player container.
+ * @param thumbnailUrl Optional URL of the thumbnail image to display before playback starts.
+ * @param thumbnailEnabled Whether to show the thumbnail before playback starts.
+ */
+@OptIn(UnstableApi::class)
+@Composable
+internal fun StreamVideoPlayerContent(
+    assetUrl: String?,
+    playWhenReady: Boolean,
+    showControllerInitially: Boolean,
+    onPlaybackError: () -> Unit,
+    modifier: Modifier = Modifier,
+    thumbnailUrl: String? = null,
+    thumbnailEnabled: Boolean = false,
+) {
+    val context = LocalContext.current
+    var showThumbnail by remember { mutableStateOf(thumbnailEnabled) }
+    var showPlayButton by remember { mutableStateOf(thumbnailEnabled) }
+    var showBuffering by remember { mutableStateOf(true) }
+    val onBuffering: (Boolean) -> Unit = { isBuffering -> showBuffering = isBuffering }
+    // Create player
+    var player by remember { mutableStateOf<Player?>(null) }
+    LifecycleResumeEffect(Unit) {
+        player = createPlayer(
+            context = context,
+            onBuffering = onBuffering,
+            onPlaybackError = onPlaybackError,
+        )
+        onPauseOrDispose {
+            player?.release()
+            player = null
+        }
+    }
+    // Prepare media
+    LaunchedEffect(player, assetUrl) {
+        if (player != null && assetUrl != null) {
+            player?.setMediaItem(MediaItem.fromUri(assetUrl))
+            player?.prepare()
+            player?.playWhenReady = playWhenReady
+        }
+    }
+    // Draw player
+    player?.let { preparedPlayer ->
+        Box(
+            modifier = modifier,
+            contentAlignment = Alignment.Center,
+        ) {
+            // Video/Audio player
+            AndroidView(
+                modifier = Modifier
+                    .matchParentSize()
+                    .background(Color.Black),
+                factory = {
+                    val playerView = createPlayerView(it, preparedPlayer)
+                    if (showControllerInitially) playerView.showController()
+                    playerView
+                },
+            )
+            // Thumbnail
+            if (thumbnailEnabled && showThumbnail) {
+                VideoThumbnail(
+                    modifier = Modifier.matchParentSize(),
+                    thumbnailUrl = thumbnailUrl,
+                    showPlayButton = showPlayButton,
+                    onPlayClick = {
+                        showThumbnail = false
+                        showPlayButton = false
+                        preparedPlayer.play()
+                    },
+                )
+            }
+            // Buffering indicator
+            if (showBuffering && !showThumbnail) {
+                LoadingIndicator()
+            }
+        }
+    }
+}
+
+/**
+ * A composable that displays a thumbnail image for a video with an optional play button overlay.
+ *
+ * This component shows a video thumbnail image with a centered play button that can be clicked
+ * to start video playback. The thumbnail image is only displayed if video thumbnails are
+ * enabled in the ChatTheme configuration.
+ *
+ * When clicked, the component triggers the provided [onPlayClick] callback to begin video playback.
+ * The thumbnail has a black background to ensure visual consistency when no thumbnail image
+ * is available or while it's loading.
+ *
+ * @param thumbnailUrl The URL of the thumbnail image to display, or null if no thumbnail is available.
+ * @param showPlayButton Whether to display the play button overlay on top of the thumbnail.
+ * @param onPlayClick Callback invoked when the user clicks on the thumbnail or play button to start playback.
+ * @param modifier Modifier to be applied to the thumbnail container.
+ */
+@Composable
+private fun VideoThumbnail(
+    thumbnailUrl: String?,
+    showPlayButton: Boolean,
+    onPlayClick: () -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    val data = if (ChatTheme.videoThumbnailsEnabled) {
path: stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamVideoPlayerContent.kt
user: login: VelikovPetar
body: Similar to the comment above, a thumbnail should always be shown, but it depends on the `ChatTheme.videoThumbnailsEnabled` whether the thumbnail would be drawn from the URL, or an empty surface.
created_at: 2025-05-06T09:39:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+/*
+ * Copyright (c) 2014-2025 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 android.content.Context
+import androidx.annotation.OptIn
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.media3.common.MediaItem
+import androidx.media3.common.PlaybackException
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.ui.PlayerView
+import io.getstream.chat.android.compose.R
+import io.getstream.chat.android.compose.ui.attachments.content.PlayButton
+import io.getstream.chat.android.compose.ui.components.LoadingIndicator
+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
+
+/**
+ * A Composable that displays a video player with support for thumbnails, play controls, and buffering indicators.
+ *
+ * This component creates a Media3 ExoPlayer instance that handles video playback while respecting
+ * the Android lifecycle. The player is automatically created when the composable enters the composition
+ * and released when it leaves or pauses.
+ *
+ * The component offers several features:
+ * - Shows an optional thumbnail with play button before playback starts
+ * - Displays media controls that can be shown initially or hidden
+ * - Shows a buffering indicator during loading states
+ * - Automatically handles player lifecycle and resource cleanup
+ *
+ * @param assetUrl The URL of the video to play.
+ * @param playWhenReady Whether playback should start automatically after preparation.
+ * @param showControllerInitially Whether to show the player controls when first displayed.
+ * @param onPlaybackError Callback invoked when video playback encounters an error.
+ * @param modifier Modifier to be applied to the player container.
+ * @param thumbnailUrl Optional URL of the thumbnail image to display before playback starts.
+ * @param thumbnailEnabled Whether to show the thumbnail before playback starts.
+ */
+@OptIn(UnstableApi::class)
+@Composable
+internal fun StreamVideoPlayerContent(
+    assetUrl: String?,
+    playWhenReady: Boolean,
+    showControllerInitially: Boolean,
+    onPlaybackError: () -> Unit,
+    modifier: Modifier = Modifier,
+    thumbnailUrl: String? = null,
+    thumbnailEnabled: Boolean = false,
path: stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamVideoPlayerContent.kt
user: login: andremion
body: Maybe the confusion here is the play button, which should not be part of the `VideoThumbnail`.
The black background is already out of the `VideoThumbnail` and the play button should also be out.
So if thumbnailUrl is null, we would still have play button over a black background.
Does it make sense?
created_at: 2025-05-06T09:45:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -277,180 +274,127 @@ private fun ErrorIcon(modifier: Modifier) {
  * Note: It is assumed that this component is used within a pager.
  *
  * Renders a video player with thumbnail preview, play button, and loading indicators.
- * The video playback begins when the user taps the play button, showing a loading
- * indicator until the video is ready to play. The video player includes standard
- * media controls for playback.
+ * The video playback begins when the user taps the play button. The video player includes
+ * standard media controls for playback. Shows a loading indicator while the video is
+ * buffering. The video is paused when the user navigates away from this page in the pager.
  *
  * This component handles various states:
  * - Initial state with thumbnail and play button
- * - Loading state while preparing the video
+ * - Loading state while the video is buffering
  * - Playback state with media controls
  * - Error state when video playback fails
  *
  * The video playback is automatically paused and the preview is restored when
  * the user navigates away from this page in the pager.
  *
- * @param attachment The attachment containing the video to display
- * @param pagerState The state of the pager containing this video page
- * @param page The page index of this video in the pager
- * @param onPlaybackError Callback invoked when video playback encounters an error
+ * @param assetUrl The url of the video to display.
+ * @param thumbnailUrl The url of the thumbnail to display before the video is played.
+ * @param onPlaybackError Callback invoked when video playback encounters an error.
+ * @param modifier The [Modifier] to be applied to the video player.
  */
-@Suppress("LongMethod")
 @Composable
 internal fun MediaGalleryVideoPage(
-    attachment: Attachment,
-    pagerState: PagerState,
-    page: Int,
+    assetUrl: String?,
+    thumbnailUrl: String?,
     onPlaybackError: () -> Unit,
+    modifier: Modifier = Modifier,
 ) {
     val context = LocalContext.current
-
-    var hasPrepared by remember {
-        mutableStateOf(false)
-    }
-
-    var userHasClickedPlay by remember {
-        mutableStateOf(false)
-    }
-
-    var shouldShowProgressBar by remember {
-        mutableStateOf(false)
-    }
-
-    var shouldShowPreview by remember {
-        mutableStateOf(true)
-    }
-
-    var shouldShowPlayButton by remember {
-        mutableStateOf(true)
-    }
-
-    val mediaController = remember {
-        createMediaController(context)
+    var showThumbnail by remember { mutableStateOf(true) }
+    var showPlayButton by remember { mutableStateOf(true) }
+    var showBuffering by remember { mutableStateOf(false) }
+    val onBuffering: (Boolean) -> Unit = { showBuffering = it }
+    // Create player
+    var player by remember { mutableStateOf<Player?>(null) }
+    LifecycleResumeEffect(Unit) {
+        player = createPlayer(
+            context = context,
+            onBuffering = onBuffering,
+            onPlaybackError = onPlaybackError,
+        )
+        onPauseOrDispose {
+            player?.release()
+            player = null
+        }
     }
-
-    val videoView = remember {
-        VideoView(context)
+    // Prepare media
+    LaunchedEffect(player, assetUrl) {
+        if (player != null && assetUrl != null) {
+            player?.setMediaItem(MediaItem.fromUri(assetUrl))
+            player?.prepare()
+        }
     }
-
-    val contentView = remember {
-        val frameLayout = FrameLayout(context).apply {
-            layoutParams = FrameLayout.LayoutParams(
-                ViewGroup.LayoutParams.MATCH_PARENT,
-                ViewGroup.LayoutParams.MATCH_PARENT,
+    // Draw player
+    player?.let { preparedPlayer ->
path: stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/MediaGalleryPage.kt
user: login: andremion
body: That's fine.
It's an internal API, so we can always improve it later if we see it's necessary.
Thank you 🙌🏻 
created_at: 2025-05-06T09:48:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,255 @@
+/*
+ * Copyright (c) 2014-2025 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 android.content.Context
+import androidx.annotation.OptIn
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.shadow
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.compose.LifecycleResumeEffect
+import androidx.media3.common.MediaItem
+import androidx.media3.common.PlaybackException
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.ui.PlayerView
+import io.getstream.chat.android.compose.R
+import io.getstream.chat.android.compose.ui.attachments.content.PlayButton
+import io.getstream.chat.android.compose.ui.components.LoadingIndicator
+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
+
+/**
+ * A Composable that displays a video player with support for thumbnails, play controls, and buffering indicators.
+ *
+ * This component creates a Media3 ExoPlayer instance that handles video playback while respecting
+ * the Android lifecycle. The player is automatically created when the composable enters the composition
+ * and released when it leaves or pauses.
+ *
+ * The component offers several features:
+ * - Shows an optional thumbnail with play button before playback starts
+ * - Displays media controls that can be shown initially or hidden
+ * - Shows a buffering indicator during loading states
+ * - Automatically handles player lifecycle and resource cleanup
+ *
+ * @param assetUrl The URL of the video to play.
+ * @param playWhenReady Whether playback should start automatically after preparation.
+ * @param showControllerInitially Whether to show the player controls when first displayed.
+ * @param onPlaybackError Callback invoked when video playback encounters an error.
+ * @param modifier Modifier to be applied to the player container.
+ * @param thumbnailUrl Optional URL of the thumbnail image to display before playback starts.
+ * @param thumbnailEnabled Whether to show the thumbnail before playback starts.
+ */
+@OptIn(UnstableApi::class)
+@Composable
+internal fun StreamVideoPlayerContent(
+    assetUrl: String?,
+    playWhenReady: Boolean,
+    showControllerInitially: Boolean,
+    onPlaybackError: () -> Unit,
+    modifier: Modifier = Modifier,
+    thumbnailUrl: String? = null,
+    thumbnailEnabled: Boolean = false,
path: stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/preview/internal/StreamVideoPlayerContent.kt
user: login: VelikovPetar
body: But in the audio case, we don't show neither a thumbnail, nor a play button, and we need to have a way to distinguish it from the video case, where we show the play button (+ thumbnail or black surface).
created_at: 2025-05-06T09:57:06Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-04-25T10:13:26Z
body: 
## SDK Size Comparison 📏

| SDK | Before | After | Difference | Status |
|-|-|-|-|-|
| stream-chat-android-client | 3.16 MB | 3.16 MB | 0.00 MB | 🟢 |
| stream-chat-android-offline | 3.38 MB | 3.38 MB | 0.00 MB | 🟢 |
| stream-chat-android-ui-components | 7.91 MB | 7.91 MB | 0.00 MB | 🟢 |
| stream-chat-android-compose | 9.96 MB | 12.41 MB | 2.45 MB | 🔴 |

author_association: CONTRIBUTOR
user: login: sonarqubecloud[bot]
created_at: 2025-05-12T07:28:21Z
body: ## [![Quality Gate Failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-failed-20px.png 'Quality Gate Failed')](https://sonarcloud.io/dashboard?id=GetStream_stream-chat-android&pullRequest=5749) **Quality Gate failed**  
Failed conditions  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=GetStream_stream-chat-android&pullRequest=5749&metric=new_coverage&view=list) (required ≥ 80%)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=GetStream_stream-chat-android&pullRequest=5749)


author_association: NONE