From d691a3f6a26ce54334d8c845ce61ea54d0113a70 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 16 Jan 2025 14:46:42 +0100 Subject: [PATCH] Let MediaGalleryDataSource be an interface --- .../impl/gallery/MediaGalleryDataSource.kt | 22 +- .../impl/gallery/MediaGalleryPresenter.kt | 2 +- .../gallery/SingleMediaGalleryDataSource.kt | 109 ++++++++++ .../impl/viewer/MediaViewerEvents.kt | 22 +- .../impl/viewer/MediaViewerNode.kt | 8 + .../impl/viewer/MediaViewerPresenter.kt | 164 +++++++++++---- .../impl/viewer/MediaViewerState.kt | 21 +- .../impl/viewer/MediaViewerStateProvider.kt | 123 +++++++---- .../impl/viewer/MediaViewerView.kt | 195 ++++++++++++++---- 9 files changed, 522 insertions(+), 144 deletions(-) create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSource.kt diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt index b83fda1c65..a2f827636b 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt @@ -26,22 +26,32 @@ import kotlinx.coroutines.flow.onEach import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject +interface MediaGalleryDataSource { + fun start() + fun groupedMediaItemsFlow(): Flow> + fun getLastData(): AsyncData + suspend fun loadMore(direction: Timeline.PaginationDirection) + suspend fun deleteItem(eventId: EventId) +} + @SingleIn(RoomScope::class) -class MediaGalleryDataSource @Inject constructor( +class TimelineMediaGalleryDataSource @Inject constructor( private val room: MatrixRoom, private val timelineMediaItemsFactory: TimelineMediaItemsFactory, private val mediaItemsPostProcessor: MediaItemsPostProcessor, -) { +) : MediaGalleryDataSource { private var timeline: Timeline? = null private val groupedMediaItemsFlow = MutableSharedFlow>(replay = 1) - fun groupedMediaItemsFlow(): Flow> = groupedMediaItemsFlow + override fun groupedMediaItemsFlow(): Flow> = groupedMediaItemsFlow + + override fun getLastData(): AsyncData = groupedMediaItemsFlow.replayCache.firstOrNull() ?: AsyncData.Uninitialized private val isStarted = AtomicBoolean(false) @OptIn(ExperimentalCoroutinesApi::class) - fun start() { + override fun start() { if (!isStarted.compareAndSet(false, true)) { return } @@ -73,11 +83,11 @@ class MediaGalleryDataSource @Inject constructor( .launchIn(room.roomCoroutineScope) } - suspend fun loadMore(direction: Timeline.PaginationDirection) { + override suspend fun loadMore(direction: Timeline.PaginationDirection) { timeline?.paginate(direction) } - suspend fun deleteItem(eventId: EventId) { + override suspend fun deleteItem(eventId: EventId) { timeline?.redactEvent( eventOrTransactionId = eventId.toEventOrTransactionId(), reason = null, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt index 476e86c5c5..247014db01 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt @@ -40,7 +40,7 @@ import kotlinx.coroutines.launch class MediaGalleryPresenter @AssistedInject constructor( @Assisted private val navigator: MediaGalleryNavigator, private val room: MatrixRoom, - private val mediaGalleryDataSource: MediaGalleryDataSource, + private val mediaGalleryDataSource: TimelineMediaGalleryDataSource, private val localMediaFactory: LocalMediaFactory, private val mediaLoader: MatrixMediaLoader, private val localMediaActions: LocalMediaActions, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSource.kt new file mode 100644 index 0000000000..118f2f4559 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSource.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.gallery + +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.UniqueId +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject + +class SingleMediaGalleryDataSource @Inject constructor( + private val data: GroupedMediaItems, +) : MediaGalleryDataSource { + override fun start() = Unit + override fun groupedMediaItemsFlow() = flowOf(AsyncData.Success(data)) + override fun getLastData(): AsyncData = AsyncData.Success(data) + override suspend fun loadMore(direction: Timeline.PaginationDirection) = Unit + override suspend fun deleteItem(eventId: EventId) = Unit + + companion object { + fun createFrom(params: MediaViewerEntryPoint.Params) = SingleMediaGalleryDataSource( + data = when { + params.mediaInfo.mimeType.isMimeTypeImage() -> { + GroupedMediaItems( + imageAndVideoItems = persistentListOf( + MediaItem.Image( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, + thumbnailSource = params.thumbnailSource, + ) + ), + fileItems = persistentListOf(), + ) + } + params.mediaInfo.mimeType.isMimeTypeVideo() -> { + GroupedMediaItems( + imageAndVideoItems = persistentListOf( + MediaItem.Video( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, + thumbnailSource = params.thumbnailSource, + duration = "TODO", // TODO Duration + ) + ), + fileItems = persistentListOf(), + ) + } + params.mediaInfo.mimeType.isMimeTypeAudio() -> { + if (params.mediaInfo.waveform == null) { + GroupedMediaItems( + imageAndVideoItems = persistentListOf( + MediaItem.Audio( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, + ) + ), + fileItems = persistentListOf(), + ) + } else { + GroupedMediaItems( + imageAndVideoItems = persistentListOf( + MediaItem.Voice( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, + duration = "TODO", // TODO Duration + waveform = params.mediaInfo.waveform.orEmpty().toImmutableList(), + ) + ), + fileItems = persistentListOf(), + ) + } + } + else -> { + GroupedMediaItems( + imageAndVideoItems = persistentListOf(), + fileItems = persistentListOf( + MediaItem.File( + id = UniqueId("dummy"), + eventId = params.eventId, + mediaInfo = params.mediaInfo, + mediaSource = params.mediaSource, + ) + ), + ) + } + } + ) + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt index f9fb32d325..5c07c09da0 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt @@ -8,16 +8,24 @@ package io.element.android.libraries.mediaviewer.impl.viewer import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.timeline.Timeline sealed interface MediaViewerEvents { - data object SaveOnDisk : MediaViewerEvents - data object Share : MediaViewerEvents - data object OpenWith : MediaViewerEvents - data object RetryLoading : MediaViewerEvents - data object ClearLoadingError : MediaViewerEvents + data class LoadMedia(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents + data class SaveOnDisk(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents + data class Share(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents + data class OpenWith(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents + data class RetryLoading(val eventId: EventId) : MediaViewerEvents + data class ClearLoadingError(val eventId: EventId) : MediaViewerEvents data class ViewInTimeline(val eventId: EventId) : MediaViewerEvents - data object OpenInfo : MediaViewerEvents - data class ConfirmDelete(val eventId: EventId) : MediaViewerEvents + data class OpenInfo(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents + data class ConfirmDelete( + val eventId: EventId, + val data: MediaViewerPageData.MediaViewerData, + ) : MediaViewerEvents + data object CloseBottomSheet : MediaViewerEvents data class Delete(val eventId: EventId) : MediaViewerEvents + data class OnNavigateTo(val index: Int) : MediaViewerEvents + data class LoadMore(val direction: Timeline.PaginationDirection) : MediaViewerEvents } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt index 4cd528457d..adadc3ef78 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt @@ -21,12 +21,15 @@ import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.RoomScope import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint +import io.element.android.libraries.mediaviewer.impl.gallery.SingleMediaGalleryDataSource +import io.element.android.libraries.mediaviewer.impl.gallery.TimelineMediaGalleryDataSource @ContributesNode(RoomScope::class) class MediaViewerNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, presenterFactory: MediaViewerPresenter.Factory, + timelineMediaGalleryDataSource: TimelineMediaGalleryDataSource, ) : Node(buildContext, plugins = plugins), MediaViewerNavigator { private val inputs = inputs() @@ -50,6 +53,11 @@ class MediaViewerNode @AssistedInject constructor( private val presenter = presenterFactory.create( inputs = inputs, navigator = this, + mediaGalleryDataSource = if (inputs.eventId != null) { + timelineMediaGalleryDataSource + } else { + SingleMediaGalleryDataSource.createFrom(inputs) + }, ) @Composable diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt index 7a8b006da1..7bde6892be 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt @@ -10,7 +10,11 @@ package io.element.android.libraries.mediaviewer.impl.viewer import android.content.ActivityNotFoundException import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableIntState import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -31,11 +35,18 @@ import io.element.android.libraries.matrix.api.media.MediaFile import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn +import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState +import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryDataSource +import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem +import io.element.android.libraries.mediaviewer.impl.gallery.eventId +import io.element.android.libraries.mediaviewer.impl.gallery.mediaInfo +import io.element.android.libraries.mediaviewer.impl.gallery.mediaSource +import io.element.android.libraries.mediaviewer.impl.gallery.thumbnailSource import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.CoroutineScope @@ -45,6 +56,7 @@ import io.element.android.libraries.androidutils.R as UtilsR class MediaViewerPresenter @AssistedInject constructor( @Assisted private val inputs: MediaViewerEntryPoint.Params, @Assisted private val navigator: MediaViewerNavigator, + @Assisted private val mediaGalleryDataSource: MediaGalleryDataSource, private val room: MatrixRoom, private val localMediaFactory: LocalMediaFactory, private val mediaLoader: MatrixMediaLoader, @@ -56,83 +68,132 @@ class MediaViewerPresenter @AssistedInject constructor( fun create( inputs: MediaViewerEntryPoint.Params, navigator: MediaViewerNavigator, + mediaGalleryDataSource: MediaGalleryDataSource, ): MediaViewerPresenter } @Composable override fun present(): MediaViewerState { val coroutineScope = rememberCoroutineScope() - var loadMediaTrigger by remember { mutableIntStateOf(0) } - val mediaFile: MutableState = remember { - mutableStateOf(null) + LaunchedEffect(Unit) { + mediaGalleryDataSource.start() } - val localMedia: MutableState> = remember { - mutableStateOf(AsyncData.Uninitialized) - } - val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() - localMediaActions.Configure() - DisposableEffect(loadMediaTrigger) { - coroutineScope.downloadMedia(mediaFile, localMedia) + val groupedMediaItem by remember { mediaGalleryDataSource.groupedMediaItemsFlow() } + .collectAsState(mediaGalleryDataSource.getLastData()) + + val loadMediaTrigger: MutableMap = remember { mutableMapOf() } + val mediaFile: MutableMap> = remember { mutableMapOf() } + val localMedia: MutableMap>> = remember { mutableMapOf() } + DisposableEffect(Unit) { onDispose { - mediaFile.value?.close() + mediaFile.values.forEach { it.value?.close() } } } + + val data: List by remember { + derivedStateOf { + buildList { + val data = groupedMediaItem.dataOrNull() + if (data != null) { + if (data.imageAndVideoItems.firstOrNull() is MediaItem.LoadingIndicator) { + add(MediaViewerPageData.Loading(Timeline.PaginationDirection.FORWARDS)) + } + data.imageAndVideoItems.filterIsInstance().forEach { mediaItem -> + val eventId = mediaItem.eventId() + add( + MediaViewerPageData.MediaViewerData( + eventId = eventId, + mediaInfo = mediaItem.mediaInfo(), + mediaSource = mediaItem.mediaSource(), + thumbnailSource = mediaItem.thumbnailSource(), + downloadedMedia = localMedia.getOrPut(eventId) { + mutableStateOf(AsyncData.Uninitialized) + }.value, + ) + ) + } + if (data.imageAndVideoItems.lastOrNull() is MediaItem.LoadingIndicator) { + add(MediaViewerPageData.Loading(Timeline.PaginationDirection.BACKWARDS)) + } + } + if (isEmpty()) { + add(MediaViewerPageData.Loading(Timeline.PaginationDirection.BACKWARDS)) + } + } + } + } + var currentIndex by remember { mutableIntStateOf(searchIndex(data, inputs.eventId)) } + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() + localMediaActions.Configure() var mediaBottomSheetState by remember { mutableStateOf(MediaBottomSheetState.Hidden) } - fun handleEvents(mediaViewerEvents: MediaViewerEvents) { - when (mediaViewerEvents) { - MediaViewerEvents.RetryLoading -> loadMediaTrigger++ - MediaViewerEvents.ClearLoadingError -> localMedia.value = AsyncData.Uninitialized - MediaViewerEvents.SaveOnDisk -> { - mediaBottomSheetState = MediaBottomSheetState.Hidden - coroutineScope.saveOnDisk(localMedia.value) + fun handleEvents(event: MediaViewerEvents) { + when (event) { + is MediaViewerEvents.LoadMedia -> coroutineScope.downloadMedia( + data = event.data, + mediaFile = mediaFile.getOrPut(event.data.eventId) { mutableStateOf(null) }, + localMedia = localMedia.getOrPut(event.data.eventId) { mutableStateOf(AsyncData.Uninitialized) }, + ) + is MediaViewerEvents.RetryLoading -> { + loadMediaTrigger.getOrPut(event.eventId) { mutableIntStateOf(0) }.intValue++ } - MediaViewerEvents.Share -> { - mediaBottomSheetState = MediaBottomSheetState.Hidden - coroutineScope.share(localMedia.value) + is MediaViewerEvents.ClearLoadingError -> { + localMedia.getOrPut(event.eventId) { mutableStateOf(AsyncData.Uninitialized) }.value = AsyncData.Uninitialized } - MediaViewerEvents.OpenWith -> { + is MediaViewerEvents.SaveOnDisk -> { mediaBottomSheetState = MediaBottomSheetState.Hidden - coroutineScope.open(localMedia.value) + coroutineScope.saveOnDisk(event.data.downloadedMedia) + } + is MediaViewerEvents.Share -> { + mediaBottomSheetState = MediaBottomSheetState.Hidden + coroutineScope.share(event.data.downloadedMedia) + } + is MediaViewerEvents.OpenWith -> { + mediaBottomSheetState = MediaBottomSheetState.Hidden + coroutineScope.open(event.data.downloadedMedia) } is MediaViewerEvents.Delete -> { mediaBottomSheetState = MediaBottomSheetState.Hidden - coroutineScope.delete(mediaViewerEvents.eventId) + coroutineScope.delete(event.eventId) } is MediaViewerEvents.ViewInTimeline -> { mediaBottomSheetState = MediaBottomSheetState.Hidden - navigator.onViewInTimelineClick(mediaViewerEvents.eventId) + navigator.onViewInTimelineClick(event.eventId) } - MediaViewerEvents.OpenInfo -> coroutineScope.launch { + is MediaViewerEvents.OpenInfo -> coroutineScope.launch { mediaBottomSheetState = MediaBottomSheetState.MediaDetailsBottomSheetState( - eventId = inputs.eventId, - canDelete = when (inputs.mediaInfo.senderId) { + eventId = event.data.eventId, + canDelete = when (event.data.mediaInfo.senderId) { null -> false - room.sessionId -> room.canRedactOwn().getOrElse { false } && inputs.eventId != null - else -> room.canRedactOther().getOrElse { false } && inputs.eventId != null + room.sessionId -> room.canRedactOwn().getOrElse { false } && event.data.eventId != null + else -> room.canRedactOther().getOrElse { false } && event.data.eventId != null }, - mediaInfo = inputs.mediaInfo, - thumbnailSource = inputs.thumbnailSource, + mediaInfo = event.data.mediaInfo, + thumbnailSource = event.data.thumbnailSource, ) } is MediaViewerEvents.ConfirmDelete -> { mediaBottomSheetState = MediaBottomSheetState.MediaDeleteConfirmationState( - eventId = mediaViewerEvents.eventId, - mediaInfo = inputs.mediaInfo, - thumbnailSource = inputs.thumbnailSource ?: inputs.mediaSource, + eventId = event.eventId, + mediaInfo = event.data.mediaInfo, + thumbnailSource = event.data.thumbnailSource ?: event.data.mediaSource, ) } MediaViewerEvents.CloseBottomSheet -> { mediaBottomSheetState = MediaBottomSheetState.Hidden } + is MediaViewerEvents.OnNavigateTo -> { + currentIndex = event.index + } + is MediaViewerEvents.LoadMore -> coroutineScope.launch { + mediaGalleryDataSource.loadMore(event.direction) + } } } return MediaViewerState( - eventId = inputs.eventId, - mediaInfo = inputs.mediaInfo, - thumbnailSource = inputs.thumbnailSource, - downloadedMedia = localMedia.value, + listData = data, + currentIndex = currentIndex, snackbarMessage = snackbarMessage, canShowInfo = inputs.canShowInfo, mediaBottomSheetState = mediaBottomSheetState, @@ -140,12 +201,16 @@ class MediaViewerPresenter @AssistedInject constructor( ) } - private fun CoroutineScope.downloadMedia(mediaFile: MutableState, localMedia: MutableState>) = launch { + private fun CoroutineScope.downloadMedia( + data: MediaViewerPageData.MediaViewerData, + mediaFile: MutableState, + localMedia: MutableState>, + ) = launch { localMedia.value = AsyncData.Loading() mediaLoader.downloadMediaFile( - source = inputs.mediaSource, - mimeType = inputs.mediaInfo.mimeType, - filename = inputs.mediaInfo.filename + source = data.mediaSource, + mimeType = data.mediaInfo.mimeType, + filename = data.mediaInfo.filename ) .onSuccess { mediaFile.value = it @@ -153,7 +218,7 @@ class MediaViewerPresenter @AssistedInject constructor( .mapCatching { mediaFile -> localMediaFactory.createFromMediaFile( mediaFile = mediaFile, - mediaInfo = inputs.mediaInfo + mediaInfo = data.mediaInfo ) } .onSuccess { @@ -217,3 +282,14 @@ class MediaViewerPresenter @AssistedInject constructor( } } } + +private fun searchIndex(data: List, eventId: EventId?): Int { + if (eventId == null) { + return 0 + } + return data.indexOfFirst { + (it as? MediaViewerPageData.MediaViewerData)?.eventId == eventId + } + .takeIf { it != -1 } + ?: 0 +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt index b9779b0fd4..7ad1bc9982 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt @@ -11,17 +11,30 @@ import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage 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.timeline.Timeline import io.element.android.libraries.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState data class MediaViewerState( - val eventId: EventId?, - val mediaInfo: MediaInfo, - val thumbnailSource: MediaSource?, - val downloadedMedia: AsyncData, + val listData: List, + val currentIndex: Int, val snackbarMessage: SnackbarMessage?, val canShowInfo: Boolean, val mediaBottomSheetState: MediaBottomSheetState, val eventSink: (MediaViewerEvents) -> Unit, ) + +sealed interface MediaViewerPageData { + data class Loading( + val direction: Timeline.PaginationDirection, + ) : MediaViewerPageData + + data class MediaViewerData( + val eventId: EventId?, + val mediaInfo: MediaInfo, + val mediaSource: MediaSource, + val thumbnailSource: MediaSource?, + val downloadedMedia: AsyncData, + ) : MediaViewerPageData +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt index 70c6e62e7e..6ba6bb74ae 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt @@ -11,6 +11,7 @@ import android.net.Uri import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.media.aWaveForm +import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo @@ -26,18 +27,22 @@ open class MediaViewerStateProvider : PreviewParameterProvider override val values: Sequence get() = sequenceOf( aMediaViewerState(), - aMediaViewerState(AsyncData.Loading()), - aMediaViewerState(AsyncData.Failure(IllegalStateException("error"))), + aMediaViewerState(listOf(aMediaViewerPageData(AsyncData.Loading()))), + aMediaViewerState(listOf(aMediaViewerPageData(AsyncData.Failure(IllegalStateException("error"))))), anImageMediaInfo( senderName = "Sally Sanderson", dateSent = "21 NOV, 2024", caption = "A caption", ).let { aMediaViewerState( - downloadedMedia = AsyncData.Success( - LocalMedia(Uri.EMPTY, it) - ), - mediaInfo = it, + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Success( + LocalMedia(Uri.EMPTY, it) + ), + mediaInfo = it, + ) + ) ) }, aVideoMediaInfo( @@ -46,50 +51,78 @@ open class MediaViewerStateProvider : PreviewParameterProvider caption = "A caption", ).let { aMediaViewerState( - downloadedMedia = AsyncData.Success( - LocalMedia(Uri.EMPTY, it) - ), - mediaInfo = it, + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Success( + LocalMedia(Uri.EMPTY, it) + ), + mediaInfo = it, + ) + ) ) }, aPdfMediaInfo().let { aMediaViewerState( - downloadedMedia = AsyncData.Success( - LocalMedia(Uri.EMPTY, it) - ), - mediaInfo = it, + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Success( + LocalMedia(Uri.EMPTY, it) + ), + mediaInfo = it, + ) + ) ) }, aMediaViewerState( - downloadedMedia = AsyncData.Loading(), - mediaInfo = anApkMediaInfo(), + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Loading(), + mediaInfo = anApkMediaInfo(), + ) + ) ), anApkMediaInfo().let { aMediaViewerState( - downloadedMedia = AsyncData.Success( - LocalMedia(Uri.EMPTY, it) - ), - mediaInfo = it, + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Success( + LocalMedia(Uri.EMPTY, it) + ), + mediaInfo = it, + ) + ) ) }, aMediaViewerState( - downloadedMedia = AsyncData.Loading(), - mediaInfo = anAudioMediaInfo(), + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Loading(), + mediaInfo = anAudioMediaInfo(), + ) + ) ), anAudioMediaInfo().let { aMediaViewerState( - downloadedMedia = AsyncData.Success( - LocalMedia(Uri.EMPTY, it) - ), - mediaInfo = it, + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Success( + LocalMedia(Uri.EMPTY, it) + ), + mediaInfo = it, + ) + ) ) }, anImageMediaInfo().let { aMediaViewerState( - downloadedMedia = AsyncData.Success( - LocalMedia(Uri.EMPTY, it) + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Success( + LocalMedia(Uri.EMPTY, it) + ), + mediaInfo = it, + ) ), - mediaInfo = it, canShowInfo = false, ) }, @@ -103,26 +136,40 @@ open class MediaViewerStateProvider : PreviewParameterProvider waveForm = aWaveForm(), ).let { aMediaViewerState( - downloadedMedia = AsyncData.Success( - LocalMedia(Uri.EMPTY, it) - ), - mediaInfo = it, + listOf( + aMediaViewerPageData( + downloadedMedia = AsyncData.Success( + LocalMedia(Uri.EMPTY, it) + ), + mediaInfo = it, + ) + ) ) }, ) } -fun aMediaViewerState( +fun aMediaViewerPageData( downloadedMedia: AsyncData = AsyncData.Uninitialized, mediaInfo: MediaInfo = anImageMediaInfo(), + mediaSource: MediaSource = MediaSource(""), +): MediaViewerPageData.MediaViewerData = MediaViewerPageData.MediaViewerData( + eventId = null, + mediaInfo = mediaInfo, + mediaSource = mediaSource, + thumbnailSource = null, + downloadedMedia = downloadedMedia, +) + +fun aMediaViewerState( + listData: List = listOf(aMediaViewerPageData()), + currentIndex: Int = 0, canShowInfo: Boolean = true, mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden, eventSink: (MediaViewerEvents) -> Unit = {}, ) = MediaViewerState( - eventId = null, - mediaInfo = mediaInfo, - thumbnailSource = null, - downloadedMedia = downloadedMedia, + listData = listData, + currentIndex = currentIndex, snackbarMessage = null, canShowInfo = canShowInfo, mediaBottomSheetState = mediaBottomSheetState, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index a05d46c72c..31c89e844d 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -22,6 +22,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material3.ExperimentalMaterial3Api @@ -35,6 +37,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -52,6 +55,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo +import io.element.android.libraries.designsystem.components.async.AsyncLoading import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.dialogs.RetryDialog import io.element.android.libraries.designsystem.preview.ElementPreviewDark @@ -94,50 +98,105 @@ fun MediaViewerView( val defaultBottomPaddingInPixels = if (LocalInspectionMode.current) 303 else 0 var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) } + val currentData = state.listData[state.currentIndex] BackHandler { onBackClick() } Scaffold( modifier, containerColor = Color.Transparent, snackbarHost = { SnackbarHost(snackbarHostState) }, ) { - MediaViewerPage( - showOverlay = showOverlay, - bottomPaddingInPixels = bottomPaddingInPixels, - state = state, - onDismiss = { - onBackClick() - }, - onShowOverlayChange = { - showOverlay = it + val pagerState = rememberPagerState(state.currentIndex, 0f) { + state.listData.size + } + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage }.collect { page -> + state.eventSink(MediaViewerEvents.OnNavigateTo(page)) } - ) + } + HorizontalPager( + state = pagerState, + modifier = Modifier, + // Pre-load previous and next pages + beyondViewportPageCount = 1, + ) { page -> + when (val dataForPage = state.listData[page]) { + is MediaViewerPageData.Loading -> { + LaunchedEffect(Unit) { + state.eventSink(MediaViewerEvents.LoadMore(dataForPage.direction)) + } + MediaViewerLoadingPage( + onDismiss = { + onBackClick() + }, + ) + } + is MediaViewerPageData.MediaViewerData -> { + LaunchedEffect(Unit) { + state.eventSink(MediaViewerEvents.LoadMedia(dataForPage)) + } + MediaViewerPage( + showOverlay = showOverlay, + bottomPaddingInPixels = bottomPaddingInPixels, + data = dataForPage, + state = state, + onDismiss = { + onBackClick() + }, + onShowOverlayChange = { + showOverlay = it + } + ) + // Bottom bar + AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) { + Box( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + ) { + MediaViewerBottomBar( + modifier = Modifier.align(Alignment.BottomCenter), + showDivider = dataForPage.mediaInfo.mimeType.isMimeTypeVideo(), + caption = dataForPage.mediaInfo.caption, + onHeightChange = { bottomPaddingInPixels = it }, + ) + } + } + } + } + } + // Top bar AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) { Box( modifier = Modifier .fillMaxSize() .navigationBarsPadding() ) { - MediaViewerTopBar( - actionsEnabled = state.downloadedMedia is AsyncData.Success, - mimeType = state.mediaInfo.mimeType, - senderName = state.mediaInfo.senderName, - dateSent = state.mediaInfo.dateSent, - canShowInfo = state.canShowInfo, - onBackClick = onBackClick, - onInfoClick = { - state.eventSink(MediaViewerEvents.OpenInfo) - }, - eventSink = state.eventSink - ) - MediaViewerBottomBar( - modifier = Modifier.align(Alignment.BottomCenter), - showDivider = state.mediaInfo.mimeType.isMimeTypeVideo(), - caption = state.mediaInfo.caption, - onHeightChange = { bottomPaddingInPixels = it }, - ) + when (currentData) { + is MediaViewerPageData.Loading -> { + TopAppBar( + title = {}, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent.copy(0.6f), + ), + navigationIcon = { BackButton(onClick = onBackClick) }, + ) + } + is MediaViewerPageData.MediaViewerData -> { + MediaViewerTopBar( + data = currentData, + canShowInfo = state.canShowInfo, + onBackClick = onBackClick, + onInfoClick = { + state.eventSink(MediaViewerEvents.OpenInfo(currentData)) + }, + eventSink = state.eventSink + ) + } + } } } } + when (val bottomSheetState = state.mediaBottomSheetState) { MediaBottomSheetState.Hidden -> Unit is MediaBottomSheetState.MediaDetailsBottomSheetState -> { @@ -147,13 +206,24 @@ fun MediaViewerView( state.eventSink(MediaViewerEvents.ViewInTimeline(it)) }, onShare = { - state.eventSink(MediaViewerEvents.Share) + (currentData as? MediaViewerPageData.MediaViewerData)?.let { + state.eventSink(MediaViewerEvents.Share(currentData)) + } }, onDownload = { - state.eventSink(MediaViewerEvents.SaveOnDisk) + (currentData as? MediaViewerPageData.MediaViewerData)?.let { + state.eventSink(MediaViewerEvents.SaveOnDisk(currentData)) + } }, onDelete = { eventId -> - state.eventSink(MediaViewerEvents.ConfirmDelete(eventId)) + (currentData as? MediaViewerPageData.MediaViewerData)?.let { + state.eventSink( + MediaViewerEvents.ConfirmDelete( + eventId, + currentData, + ) + ) + } }, onDismiss = { state.eventSink(MediaViewerEvents.CloseBottomSheet) @@ -179,16 +249,21 @@ private fun MediaViewerPage( showOverlay: Boolean, bottomPaddingInPixels: Int, state: MediaViewerState, + data: MediaViewerPageData.MediaViewerData, onDismiss: () -> Unit, onShowOverlayChange: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { fun onRetry() { - state.eventSink(MediaViewerEvents.RetryLoading) + data.eventId?.let { + state.eventSink(MediaViewerEvents.RetryLoading(it)) + } } fun onDismissError() { - state.eventSink(MediaViewerEvents.ClearLoadingError) + data.eventId?.let { + state.eventSink(MediaViewerEvents.ClearLoadingError(it)) + } } val currentShowOverlay by rememberUpdatedState(showOverlay) @@ -210,7 +285,7 @@ private fun MediaViewerPage( state = flickState, modifier = modifier.background(backgroundColorFor(flickState)) ) { - val showProgress = rememberShowProgress(state.downloadedMedia) + val showProgress = rememberShowProgress(data.downloadedMedia) Box( modifier = Modifier @@ -224,7 +299,7 @@ private fun MediaViewerPage( val localMediaViewState = rememberLocalMediaViewState(zoomableState) val showThumbnail = !localMediaViewState.isReady val playableState = localMediaViewState.playableState - val showError = state.downloadedMedia is AsyncData.Failure + val showError = data.downloadedMedia is AsyncData.Failure LaunchedEffect(playableState) { if (playableState is PlayableState.Playable) { @@ -236,8 +311,8 @@ private fun MediaViewerPage( modifier = Modifier.fillMaxSize(), bottomPaddingInPixels = bottomPaddingInPixels, localMediaViewState = localMediaViewState, - localMedia = state.downloadedMedia.dataOrNull(), - mediaInfo = state.mediaInfo, + localMedia = data.downloadedMedia.dataOrNull(), + mediaInfo = data.mediaInfo, onClick = { if (playableState is PlayableState.NotPlayable) { currentOnShowOverlayChange(!currentShowOverlay) @@ -245,8 +320,8 @@ private fun MediaViewerPage( }, ) ThumbnailView( - mediaInfo = state.mediaInfo, - thumbnailSource = state.thumbnailSource, + mediaInfo = data.mediaInfo, + thumbnailSource = data.thumbnailSource, isVisible = showThumbnail, ) if (showError) { @@ -268,6 +343,37 @@ private fun MediaViewerPage( } } +@Composable +private fun MediaViewerLoadingPage( + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val flickState = rememberFlickToDismissState(dismissThresholdRatio = 0.1f, rotateOnDrag = false) + + DismissFlickEffects( + flickState = flickState, + onDismissing = { animationDuration -> + delay(animationDuration / 3) + onDismiss() + }, + onDragging = {}, + ) + + FlickToDismiss( + state = flickState, + modifier = modifier.background(backgroundColorFor(flickState)) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding(), + contentAlignment = Alignment.Center + ) { + AsyncLoading() + } + } +} + @Composable private fun DismissFlickEffects( flickState: FlickToDismissState, @@ -316,15 +422,16 @@ private fun rememberShowProgress(downloadedMedia: AsyncData): Boolea @OptIn(ExperimentalMaterial3Api::class) @Composable private fun MediaViewerTopBar( - actionsEnabled: Boolean, - mimeType: String, - senderName: String?, - dateSent: String?, + data: MediaViewerPageData.MediaViewerData, canShowInfo: Boolean, onBackClick: () -> Unit, onInfoClick: () -> Unit, eventSink: (MediaViewerEvents) -> Unit, ) { + val actionsEnabled = data.downloadedMedia is AsyncData.Success + val mimeType = data.mediaInfo.mimeType + val senderName = data.mediaInfo.senderName + val dateSent = data.mediaInfo.dateSent TopAppBar( title = { if (senderName != null && dateSent != null) { @@ -357,7 +464,7 @@ private fun MediaViewerTopBar( IconButton( enabled = actionsEnabled, onClick = { - eventSink(MediaViewerEvents.OpenWith) + eventSink(MediaViewerEvents.OpenWith(data)) }, ) { when (mimeType) {