Add video autoplay to media gallery (#4499)

* Add video autoplay when opening the video from either the timeline or the media gallery

* Only autoplay when the video is first displayed
This commit is contained in:
Jorge Martin Espinosa 2025-04-01 09:44:06 +02:00 committed by GitHub
parent bb4933e4c3
commit 1eff15d6e4
11 changed files with 52 additions and 11 deletions

View file

@ -19,6 +19,7 @@ import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -85,11 +86,13 @@ class TimelineMediaGalleryDataSource @Inject constructor(
} }
}.flatMapLatest { }.flatMapLatest {
timelineMediaItemsFactory.timelineItems timelineMediaItemsFactory.timelineItems
}.map { timelineItems -> }
mediaItemsPostProcessor.process(mediaItems = timelineItems) .distinctUntilChanged()
}.map { .map { timelineItems ->
mediaTimeline.orCache(it) val groupedItems = mediaItemsPostProcessor.process(mediaItems = timelineItems)
}.onEach { groupedMediaItems -> mediaTimeline.orCache(groupedItems)
}
.onEach { groupedMediaItems ->
groupedMediaItemsFlow.emit(AsyncData.Success(groupedMediaItems)) groupedMediaItemsFlow.emit(AsyncData.Success(groupedMediaItems))
} }
.onCompletion { .onCompletion {

View file

@ -31,6 +31,7 @@ fun LocalMediaView(
textFileViewer: TextFileViewer, textFileViewer: TextFileViewer,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isDisplayed: Boolean = true, isDisplayed: Boolean = true,
isUserSelected: Boolean = false,
localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(), localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(),
mediaInfo: MediaInfo? = localMedia?.info, mediaInfo: MediaInfo? = localMedia?.info,
) { ) {
@ -47,6 +48,7 @@ fun LocalMediaView(
localMediaViewState = localMediaViewState, localMediaViewState = localMediaViewState,
bottomPaddingInPixels = bottomPaddingInPixels, bottomPaddingInPixels = bottomPaddingInPixels,
localMedia = localMedia, localMedia = localMedia,
autoplay = isUserSelected,
modifier = modifier, modifier = modifier,
) )
mimeType == MimeTypes.PlainText -> TextFileView( mimeType == MimeTypes.PlainText -> TextFileView(

View file

@ -111,6 +111,7 @@ private fun ExoPlayerMediaAudioView(
MediaPlayerControllerState( MediaPlayerControllerState(
isVisible = true, isVisible = true,
isPlaying = false, isPlaying = false,
isReady = false,
progressInMillis = 0, progressInMillis = 0,
durationInMillis = 0, durationInMillis = 0,
canMute = false, canMute = false,

View file

@ -12,6 +12,7 @@ import androidx.annotation.FloatRange
data class MediaPlayerControllerState( data class MediaPlayerControllerState(
val isVisible: Boolean, val isVisible: Boolean,
val isPlaying: Boolean, val isPlaying: Boolean,
val isReady: Boolean,
val progressInMillis: Long, val progressInMillis: Long,
val durationInMillis: Long, val durationInMillis: Long,
val canMute: Boolean, val canMute: Boolean,

View file

@ -27,6 +27,7 @@ open class MediaPlayerControllerStateProvider : PreviewParameterProvider<MediaPl
private fun aMediaPlayerControllerState( private fun aMediaPlayerControllerState(
isVisible: Boolean = true, isVisible: Boolean = true,
isPlaying: Boolean = false, isPlaying: Boolean = false,
isReady: Boolean = false,
progressInMillis: Long = 0, progressInMillis: Long = 0,
// Default to 1 minute and 23 seconds // Default to 1 minute and 23 seconds
durationInMillis: Long = 83_000, durationInMillis: Long = 83_000,
@ -35,6 +36,7 @@ private fun aMediaPlayerControllerState(
) = MediaPlayerControllerState( ) = MediaPlayerControllerState(
isVisible = isVisible, isVisible = isVisible,
isPlaying = isPlaying, isPlaying = isPlaying,
isReady = isReady,
progressInMillis = progressInMillis, progressInMillis = progressInMillis,
durationInMillis = durationInMillis, durationInMillis = durationInMillis,
canMute = canMute, canMute = canMute,

View file

@ -31,6 +31,7 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.Player.STATE_READY
import androidx.media3.common.Timeline import androidx.media3.common.Timeline
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
@ -61,6 +62,7 @@ fun MediaVideoView(
localMediaViewState: LocalMediaViewState, localMediaViewState: LocalMediaViewState,
bottomPaddingInPixels: Int, bottomPaddingInPixels: Int,
localMedia: LocalMedia?, localMedia: LocalMedia?,
autoplay: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val exoPlayer = rememberExoPlayer() val exoPlayer = rememberExoPlayer()
@ -70,6 +72,7 @@ fun MediaVideoView(
bottomPaddingInPixels = bottomPaddingInPixels, bottomPaddingInPixels = bottomPaddingInPixels,
exoPlayer = exoPlayer, exoPlayer = exoPlayer,
localMedia = localMedia, localMedia = localMedia,
autoplay = autoplay,
modifier = modifier, modifier = modifier,
) )
} }
@ -82,6 +85,7 @@ private fun ExoPlayerMediaVideoView(
bottomPaddingInPixels: Int, bottomPaddingInPixels: Int,
exoPlayer: ExoPlayer, exoPlayer: ExoPlayer,
localMedia: LocalMedia?, localMedia: LocalMedia?,
autoplay: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var mediaPlayerControllerState: MediaPlayerControllerState by remember { var mediaPlayerControllerState: MediaPlayerControllerState by remember {
@ -89,6 +93,7 @@ private fun ExoPlayerMediaVideoView(
MediaPlayerControllerState( MediaPlayerControllerState(
isVisible = true, isVisible = true,
isPlaying = false, isPlaying = false,
isReady = false,
progressInMillis = 0, progressInMillis = 0,
durationInMillis = 0, durationInMillis = 0,
canMute = true, canMute = true,
@ -135,6 +140,12 @@ private fun ExoPlayerMediaVideoView(
} }
} }
} }
override fun onPlaybackStateChanged(playbackState: Int) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
isReady = playbackState == STATE_READY,
)
}
} }
} }
@ -164,9 +175,17 @@ private fun ExoPlayerMediaVideoView(
) )
} }
} }
LaunchedEffect(isDisplayed) {
// If not displayed, make sure to pause the video var needsAutoPlay by remember { mutableStateOf(autoplay) }
if (!isDisplayed) {
LaunchedEffect(needsAutoPlay, isDisplayed, mediaPlayerControllerState.isReady) {
val isReadyAndNotPlaying = mediaPlayerControllerState.isReady && !mediaPlayerControllerState.isPlaying
if (needsAutoPlay && isDisplayed && isReadyAndNotPlaying) {
// When displayed, start autoplaying
exoPlayer.play()
needsAutoPlay = false
} else if (!isDisplayed && mediaPlayerControllerState.isPlaying) {
// If not displayed, make sure to pause the video
exoPlayer.pause() exoPlayer.pause()
} }
} }
@ -259,5 +278,6 @@ internal fun MediaVideoViewPreview() = ElementPreview {
bottomPaddingInPixels = 0, bottomPaddingInPixels = 0,
localMediaViewState = rememberLocalMediaViewState(), localMediaViewState = rememberLocalMediaViewState(),
localMedia = null, localMedia = null,
autoplay = false,
) )
} }

View file

@ -76,7 +76,7 @@ class MediaViewerDataSource(
} }
@VisibleForTesting @VisibleForTesting
fun dataFlow(): Flow<PersistentList<MediaViewerPageData>> { internal fun dataFlow(): Flow<PersistentList<MediaViewerPageData>> {
return galleryDataSource.groupedMediaItemsFlow() return galleryDataSource.groupedMediaItemsFlow()
.map { groupedItems -> .map { groupedItems ->
when (groupedItems) { when (groupedItems) {

View file

@ -148,6 +148,7 @@ class MediaViewerPresenter @AssistedInject constructor(
} }
return MediaViewerState( return MediaViewerState(
initiallySelectedEventId = inputs.eventId,
listData = data.value, listData = data.value,
currentIndex = currentIndex.intValue, currentIndex = currentIndex.intValue,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,

View file

@ -19,6 +19,7 @@ import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetSta
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
data class MediaViewerState( data class MediaViewerState(
val initiallySelectedEventId: EventId?,
val listData: ImmutableList<MediaViewerPageData>, val listData: ImmutableList<MediaViewerPageData>,
val currentIndex: Int, val currentIndex: Int,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,

View file

@ -12,6 +12,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.media.aWaveForm import io.element.android.libraries.designsystem.components.media.aWaveForm
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.api.MediaInfo
@ -202,6 +203,7 @@ fun aMediaViewerState(
mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden, mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden,
eventSink: (MediaViewerEvents) -> Unit = {}, eventSink: (MediaViewerEvents) -> Unit = {},
) = MediaViewerState( ) = MediaViewerState(
initiallySelectedEventId = EventId("\$a:b"),
listData = listData.toPersistentList(), listData = listData.toPersistentList(),
currentIndex = currentIndex, currentIndex = currentIndex,
snackbarMessage = null, snackbarMessage = null,

View file

@ -142,8 +142,13 @@ fun MediaViewerView(
Box( Box(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
val isDisplayed = remember(pagerState.settledPage) {
// This 'item provider' lambda will be called when the data source changes with an outdated `settlePage` value
// So we need to update this value only when the `settledPage` value changes. It seems like a bug that needs to be fixed in Compose.
page == pagerState.settledPage
}
MediaViewerPage( MediaViewerPage(
isDisplayed = page == pagerState.settledPage, isDisplayed = isDisplayed,
showOverlay = showOverlay, showOverlay = showOverlay,
bottomPaddingInPixels = bottomPaddingInPixels, bottomPaddingInPixels = bottomPaddingInPixels,
data = dataForPage, data = dataForPage,
@ -157,7 +162,8 @@ fun MediaViewerView(
}, },
onShowOverlayChange = { onShowOverlayChange = {
showOverlay = it showOverlay = it
} },
isUserSelected = (state.listData[page] as? MediaViewerPageData.MediaViewerData)?.eventId == state.initiallySelectedEventId,
) )
// Bottom bar // Bottom bar
AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) { AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) {
@ -273,6 +279,7 @@ private fun MediaViewerPage(
bottomPaddingInPixels: Int, bottomPaddingInPixels: Int,
data: MediaViewerPageData.MediaViewerData, data: MediaViewerPageData.MediaViewerData,
textFileViewer: TextFileViewer, textFileViewer: TextFileViewer,
isUserSelected: Boolean,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onRetry: () -> Unit, onRetry: () -> Unit,
onDismissError: () -> Unit, onDismissError: () -> Unit,
@ -328,6 +335,7 @@ private fun MediaViewerPage(
currentOnShowOverlayChange(!currentShowOverlay) currentOnShowOverlayChange(!currentShowOverlay)
} }
}, },
isUserSelected = isUserSelected,
) )
ThumbnailView( ThumbnailView(
mediaInfo = data.mediaInfo, mediaInfo = data.mediaInfo,