Let MediaGalleryDataSource be an interface

This commit is contained in:
Benoit Marty 2025-01-16 14:46:42 +01:00 committed by Benoit Marty
parent c8ca4d7425
commit d691a3f6a2
9 changed files with 522 additions and 144 deletions

View file

@ -26,22 +26,32 @@ import kotlinx.coroutines.flow.onEach
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
interface MediaGalleryDataSource {
fun start()
fun groupedMediaItemsFlow(): Flow<AsyncData<GroupedMediaItems>>
fun getLastData(): AsyncData<GroupedMediaItems>
suspend fun loadMore(direction: Timeline.PaginationDirection)
suspend fun deleteItem(eventId: EventId)
}
@SingleIn(RoomScope::class) @SingleIn(RoomScope::class)
class MediaGalleryDataSource @Inject constructor( class TimelineMediaGalleryDataSource @Inject constructor(
private val room: MatrixRoom, private val room: MatrixRoom,
private val timelineMediaItemsFactory: TimelineMediaItemsFactory, private val timelineMediaItemsFactory: TimelineMediaItemsFactory,
private val mediaItemsPostProcessor: MediaItemsPostProcessor, private val mediaItemsPostProcessor: MediaItemsPostProcessor,
) { ) : MediaGalleryDataSource {
private var timeline: Timeline? = null private var timeline: Timeline? = null
private val groupedMediaItemsFlow = MutableSharedFlow<AsyncData<GroupedMediaItems>>(replay = 1) private val groupedMediaItemsFlow = MutableSharedFlow<AsyncData<GroupedMediaItems>>(replay = 1)
fun groupedMediaItemsFlow(): Flow<AsyncData<GroupedMediaItems>> = groupedMediaItemsFlow override fun groupedMediaItemsFlow(): Flow<AsyncData<GroupedMediaItems>> = groupedMediaItemsFlow
override fun getLastData(): AsyncData<GroupedMediaItems> = groupedMediaItemsFlow.replayCache.firstOrNull() ?: AsyncData.Uninitialized
private val isStarted = AtomicBoolean(false) private val isStarted = AtomicBoolean(false)
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
fun start() { override fun start() {
if (!isStarted.compareAndSet(false, true)) { if (!isStarted.compareAndSet(false, true)) {
return return
} }
@ -73,11 +83,11 @@ class MediaGalleryDataSource @Inject constructor(
.launchIn(room.roomCoroutineScope) .launchIn(room.roomCoroutineScope)
} }
suspend fun loadMore(direction: Timeline.PaginationDirection) { override suspend fun loadMore(direction: Timeline.PaginationDirection) {
timeline?.paginate(direction) timeline?.paginate(direction)
} }
suspend fun deleteItem(eventId: EventId) { override suspend fun deleteItem(eventId: EventId) {
timeline?.redactEvent( timeline?.redactEvent(
eventOrTransactionId = eventId.toEventOrTransactionId(), eventOrTransactionId = eventId.toEventOrTransactionId(),
reason = null, reason = null,

View file

@ -40,7 +40,7 @@ import kotlinx.coroutines.launch
class MediaGalleryPresenter @AssistedInject constructor( class MediaGalleryPresenter @AssistedInject constructor(
@Assisted private val navigator: MediaGalleryNavigator, @Assisted private val navigator: MediaGalleryNavigator,
private val room: MatrixRoom, private val room: MatrixRoom,
private val mediaGalleryDataSource: MediaGalleryDataSource, private val mediaGalleryDataSource: TimelineMediaGalleryDataSource,
private val localMediaFactory: LocalMediaFactory, private val localMediaFactory: LocalMediaFactory,
private val mediaLoader: MatrixMediaLoader, private val mediaLoader: MatrixMediaLoader,
private val localMediaActions: LocalMediaActions, private val localMediaActions: LocalMediaActions,

View file

@ -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<GroupedMediaItems> = 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,
)
),
)
}
}
)
}
}

View file

@ -8,16 +8,24 @@
package io.element.android.libraries.mediaviewer.impl.viewer package io.element.android.libraries.mediaviewer.impl.viewer
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
sealed interface MediaViewerEvents { sealed interface MediaViewerEvents {
data object SaveOnDisk : MediaViewerEvents data class LoadMedia(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data object Share : MediaViewerEvents data class SaveOnDisk(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data object OpenWith : MediaViewerEvents data class Share(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data object RetryLoading : MediaViewerEvents data class OpenWith(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data object ClearLoadingError : MediaViewerEvents data class RetryLoading(val eventId: EventId) : MediaViewerEvents
data class ClearLoadingError(val eventId: EventId) : MediaViewerEvents
data class ViewInTimeline(val eventId: EventId) : MediaViewerEvents data class ViewInTimeline(val eventId: EventId) : MediaViewerEvents
data object OpenInfo : MediaViewerEvents data class OpenInfo(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class ConfirmDelete(val eventId: EventId) : MediaViewerEvents data class ConfirmDelete(
val eventId: EventId,
val data: MediaViewerPageData.MediaViewerData,
) : MediaViewerEvents
data object CloseBottomSheet : MediaViewerEvents data object CloseBottomSheet : MediaViewerEvents
data class Delete(val eventId: EventId) : MediaViewerEvents data class Delete(val eventId: EventId) : MediaViewerEvents
data class OnNavigateTo(val index: Int) : MediaViewerEvents
data class LoadMore(val direction: Timeline.PaginationDirection) : MediaViewerEvents
} }

View file

@ -21,12 +21,15 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint 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) @ContributesNode(RoomScope::class)
class MediaViewerNode @AssistedInject constructor( class MediaViewerNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
presenterFactory: MediaViewerPresenter.Factory, presenterFactory: MediaViewerPresenter.Factory,
timelineMediaGalleryDataSource: TimelineMediaGalleryDataSource,
) : Node(buildContext, plugins = plugins), ) : Node(buildContext, plugins = plugins),
MediaViewerNavigator { MediaViewerNavigator {
private val inputs = inputs<MediaViewerEntryPoint.Params>() private val inputs = inputs<MediaViewerEntryPoint.Params>()
@ -50,6 +53,11 @@ class MediaViewerNode @AssistedInject constructor(
private val presenter = presenterFactory.create( private val presenter = presenterFactory.create(
inputs = inputs, inputs = inputs,
navigator = this, navigator = this,
mediaGalleryDataSource = if (inputs.eventId != null) {
timelineMediaGalleryDataSource
} else {
SingleMediaGalleryDataSource.createFrom(inputs)
},
) )
@Composable @Composable

View file

@ -10,7 +10,11 @@ package io.element.android.libraries.mediaviewer.impl.viewer
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableIntState
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf 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.MatrixRoom
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther 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.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.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint 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.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory 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.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.mediaviewer.impl.local.LocalMediaActions
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -45,6 +56,7 @@ import io.element.android.libraries.androidutils.R as UtilsR
class MediaViewerPresenter @AssistedInject constructor( class MediaViewerPresenter @AssistedInject constructor(
@Assisted private val inputs: MediaViewerEntryPoint.Params, @Assisted private val inputs: MediaViewerEntryPoint.Params,
@Assisted private val navigator: MediaViewerNavigator, @Assisted private val navigator: MediaViewerNavigator,
@Assisted private val mediaGalleryDataSource: MediaGalleryDataSource,
private val room: MatrixRoom, private val room: MatrixRoom,
private val localMediaFactory: LocalMediaFactory, private val localMediaFactory: LocalMediaFactory,
private val mediaLoader: MatrixMediaLoader, private val mediaLoader: MatrixMediaLoader,
@ -56,83 +68,132 @@ class MediaViewerPresenter @AssistedInject constructor(
fun create( fun create(
inputs: MediaViewerEntryPoint.Params, inputs: MediaViewerEntryPoint.Params,
navigator: MediaViewerNavigator, navigator: MediaViewerNavigator,
mediaGalleryDataSource: MediaGalleryDataSource,
): MediaViewerPresenter ): MediaViewerPresenter
} }
@Composable @Composable
override fun present(): MediaViewerState { override fun present(): MediaViewerState {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var loadMediaTrigger by remember { mutableIntStateOf(0) } LaunchedEffect(Unit) {
val mediaFile: MutableState<MediaFile?> = remember { mediaGalleryDataSource.start()
mutableStateOf(null)
} }
val localMedia: MutableState<AsyncData<LocalMedia>> = remember { val groupedMediaItem by remember { mediaGalleryDataSource.groupedMediaItemsFlow() }
mutableStateOf(AsyncData.Uninitialized) .collectAsState(mediaGalleryDataSource.getLastData())
}
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() val loadMediaTrigger: MutableMap<EventId?, MutableIntState> = remember { mutableMapOf() }
localMediaActions.Configure() val mediaFile: MutableMap<EventId?, MutableState<MediaFile?>> = remember { mutableMapOf() }
DisposableEffect(loadMediaTrigger) { val localMedia: MutableMap<EventId?, MutableState<AsyncData<LocalMedia>>> = remember { mutableMapOf() }
coroutineScope.downloadMedia(mediaFile, localMedia) DisposableEffect(Unit) {
onDispose { onDispose {
mediaFile.value?.close() mediaFile.values.forEach { it.value?.close() }
} }
} }
val data: List<MediaViewerPageData> 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<MediaItem.Event>().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>(MediaBottomSheetState.Hidden) } var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) }
fun handleEvents(mediaViewerEvents: MediaViewerEvents) { fun handleEvents(event: MediaViewerEvents) {
when (mediaViewerEvents) { when (event) {
MediaViewerEvents.RetryLoading -> loadMediaTrigger++ is MediaViewerEvents.LoadMedia -> coroutineScope.downloadMedia(
MediaViewerEvents.ClearLoadingError -> localMedia.value = AsyncData.Uninitialized data = event.data,
MediaViewerEvents.SaveOnDisk -> { mediaFile = mediaFile.getOrPut(event.data.eventId) { mutableStateOf(null) },
mediaBottomSheetState = MediaBottomSheetState.Hidden localMedia = localMedia.getOrPut(event.data.eventId) { mutableStateOf(AsyncData.Uninitialized) },
coroutineScope.saveOnDisk(localMedia.value) )
is MediaViewerEvents.RetryLoading -> {
loadMediaTrigger.getOrPut(event.eventId) { mutableIntStateOf(0) }.intValue++
} }
MediaViewerEvents.Share -> { is MediaViewerEvents.ClearLoadingError -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden localMedia.getOrPut(event.eventId) { mutableStateOf(AsyncData.Uninitialized) }.value = AsyncData.Uninitialized
coroutineScope.share(localMedia.value)
} }
MediaViewerEvents.OpenWith -> { is MediaViewerEvents.SaveOnDisk -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden 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 -> { is MediaViewerEvents.Delete -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden mediaBottomSheetState = MediaBottomSheetState.Hidden
coroutineScope.delete(mediaViewerEvents.eventId) coroutineScope.delete(event.eventId)
} }
is MediaViewerEvents.ViewInTimeline -> { is MediaViewerEvents.ViewInTimeline -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden mediaBottomSheetState = MediaBottomSheetState.Hidden
navigator.onViewInTimelineClick(mediaViewerEvents.eventId) navigator.onViewInTimelineClick(event.eventId)
} }
MediaViewerEvents.OpenInfo -> coroutineScope.launch { is MediaViewerEvents.OpenInfo -> coroutineScope.launch {
mediaBottomSheetState = MediaBottomSheetState.MediaDetailsBottomSheetState( mediaBottomSheetState = MediaBottomSheetState.MediaDetailsBottomSheetState(
eventId = inputs.eventId, eventId = event.data.eventId,
canDelete = when (inputs.mediaInfo.senderId) { canDelete = when (event.data.mediaInfo.senderId) {
null -> false null -> false
room.sessionId -> room.canRedactOwn().getOrElse { false } && inputs.eventId != null room.sessionId -> room.canRedactOwn().getOrElse { false } && event.data.eventId != null
else -> room.canRedactOther().getOrElse { false } && inputs.eventId != null else -> room.canRedactOther().getOrElse { false } && event.data.eventId != null
}, },
mediaInfo = inputs.mediaInfo, mediaInfo = event.data.mediaInfo,
thumbnailSource = inputs.thumbnailSource, thumbnailSource = event.data.thumbnailSource,
) )
} }
is MediaViewerEvents.ConfirmDelete -> { is MediaViewerEvents.ConfirmDelete -> {
mediaBottomSheetState = MediaBottomSheetState.MediaDeleteConfirmationState( mediaBottomSheetState = MediaBottomSheetState.MediaDeleteConfirmationState(
eventId = mediaViewerEvents.eventId, eventId = event.eventId,
mediaInfo = inputs.mediaInfo, mediaInfo = event.data.mediaInfo,
thumbnailSource = inputs.thumbnailSource ?: inputs.mediaSource, thumbnailSource = event.data.thumbnailSource ?: event.data.mediaSource,
) )
} }
MediaViewerEvents.CloseBottomSheet -> { MediaViewerEvents.CloseBottomSheet -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden mediaBottomSheetState = MediaBottomSheetState.Hidden
} }
is MediaViewerEvents.OnNavigateTo -> {
currentIndex = event.index
}
is MediaViewerEvents.LoadMore -> coroutineScope.launch {
mediaGalleryDataSource.loadMore(event.direction)
}
} }
} }
return MediaViewerState( return MediaViewerState(
eventId = inputs.eventId, listData = data,
mediaInfo = inputs.mediaInfo, currentIndex = currentIndex,
thumbnailSource = inputs.thumbnailSource,
downloadedMedia = localMedia.value,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
canShowInfo = inputs.canShowInfo, canShowInfo = inputs.canShowInfo,
mediaBottomSheetState = mediaBottomSheetState, mediaBottomSheetState = mediaBottomSheetState,
@ -140,12 +201,16 @@ class MediaViewerPresenter @AssistedInject constructor(
) )
} }
private fun CoroutineScope.downloadMedia(mediaFile: MutableState<MediaFile?>, localMedia: MutableState<AsyncData<LocalMedia>>) = launch { private fun CoroutineScope.downloadMedia(
data: MediaViewerPageData.MediaViewerData,
mediaFile: MutableState<MediaFile?>,
localMedia: MutableState<AsyncData<LocalMedia>>,
) = launch {
localMedia.value = AsyncData.Loading() localMedia.value = AsyncData.Loading()
mediaLoader.downloadMediaFile( mediaLoader.downloadMediaFile(
source = inputs.mediaSource, source = data.mediaSource,
mimeType = inputs.mediaInfo.mimeType, mimeType = data.mediaInfo.mimeType,
filename = inputs.mediaInfo.filename filename = data.mediaInfo.filename
) )
.onSuccess { .onSuccess {
mediaFile.value = it mediaFile.value = it
@ -153,7 +218,7 @@ class MediaViewerPresenter @AssistedInject constructor(
.mapCatching { mediaFile -> .mapCatching { mediaFile ->
localMediaFactory.createFromMediaFile( localMediaFactory.createFromMediaFile(
mediaFile = mediaFile, mediaFile = mediaFile,
mediaInfo = inputs.mediaInfo mediaInfo = data.mediaInfo
) )
} }
.onSuccess { .onSuccess {
@ -217,3 +282,14 @@ class MediaViewerPresenter @AssistedInject constructor(
} }
} }
} }
private fun searchIndex(data: List<MediaViewerPageData>, eventId: EventId?): Int {
if (eventId == null) {
return 0
}
return data.indexOfFirst {
(it as? MediaViewerPageData.MediaViewerData)?.eventId == eventId
}
.takeIf { it != -1 }
?: 0
}

View file

@ -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.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.EventId 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.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
data class MediaViewerState( data class MediaViewerState(
val eventId: EventId?, val listData: List<MediaViewerPageData>,
val mediaInfo: MediaInfo, val currentIndex: Int,
val thumbnailSource: MediaSource?,
val downloadedMedia: AsyncData<LocalMedia>,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,
val canShowInfo: Boolean, val canShowInfo: Boolean,
val mediaBottomSheetState: MediaBottomSheetState, val mediaBottomSheetState: MediaBottomSheetState,
val eventSink: (MediaViewerEvents) -> Unit, 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<LocalMedia>,
) : MediaViewerPageData
}

View file

@ -11,6 +11,7 @@ import android.net.Uri
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.media.MediaSource
import io.element.android.libraries.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
@ -26,18 +27,22 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
override val values: Sequence<MediaViewerState> override val values: Sequence<MediaViewerState>
get() = sequenceOf( get() = sequenceOf(
aMediaViewerState(), aMediaViewerState(),
aMediaViewerState(AsyncData.Loading()), aMediaViewerState(listOf(aMediaViewerPageData(AsyncData.Loading()))),
aMediaViewerState(AsyncData.Failure(IllegalStateException("error"))), aMediaViewerState(listOf(aMediaViewerPageData(AsyncData.Failure(IllegalStateException("error"))))),
anImageMediaInfo( anImageMediaInfo(
senderName = "Sally Sanderson", senderName = "Sally Sanderson",
dateSent = "21 NOV, 2024", dateSent = "21 NOV, 2024",
caption = "A caption", caption = "A caption",
).let { ).let {
aMediaViewerState( aMediaViewerState(
downloadedMedia = AsyncData.Success( listOf(
LocalMedia(Uri.EMPTY, it) aMediaViewerPageData(
), downloadedMedia = AsyncData.Success(
mediaInfo = it, LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
) )
}, },
aVideoMediaInfo( aVideoMediaInfo(
@ -46,50 +51,78 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
caption = "A caption", caption = "A caption",
).let { ).let {
aMediaViewerState( aMediaViewerState(
downloadedMedia = AsyncData.Success( listOf(
LocalMedia(Uri.EMPTY, it) aMediaViewerPageData(
), downloadedMedia = AsyncData.Success(
mediaInfo = it, LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
) )
}, },
aPdfMediaInfo().let { aPdfMediaInfo().let {
aMediaViewerState( aMediaViewerState(
downloadedMedia = AsyncData.Success( listOf(
LocalMedia(Uri.EMPTY, it) aMediaViewerPageData(
), downloadedMedia = AsyncData.Success(
mediaInfo = it, LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
) )
}, },
aMediaViewerState( aMediaViewerState(
downloadedMedia = AsyncData.Loading(), listOf(
mediaInfo = anApkMediaInfo(), aMediaViewerPageData(
downloadedMedia = AsyncData.Loading(),
mediaInfo = anApkMediaInfo(),
)
)
), ),
anApkMediaInfo().let { anApkMediaInfo().let {
aMediaViewerState( aMediaViewerState(
downloadedMedia = AsyncData.Success( listOf(
LocalMedia(Uri.EMPTY, it) aMediaViewerPageData(
), downloadedMedia = AsyncData.Success(
mediaInfo = it, LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
) )
}, },
aMediaViewerState( aMediaViewerState(
downloadedMedia = AsyncData.Loading(), listOf(
mediaInfo = anAudioMediaInfo(), aMediaViewerPageData(
downloadedMedia = AsyncData.Loading(),
mediaInfo = anAudioMediaInfo(),
)
)
), ),
anAudioMediaInfo().let { anAudioMediaInfo().let {
aMediaViewerState( aMediaViewerState(
downloadedMedia = AsyncData.Success( listOf(
LocalMedia(Uri.EMPTY, it) aMediaViewerPageData(
), downloadedMedia = AsyncData.Success(
mediaInfo = it, LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
) )
}, },
anImageMediaInfo().let { anImageMediaInfo().let {
aMediaViewerState( aMediaViewerState(
downloadedMedia = AsyncData.Success( listOf(
LocalMedia(Uri.EMPTY, it) aMediaViewerPageData(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
), ),
mediaInfo = it,
canShowInfo = false, canShowInfo = false,
) )
}, },
@ -103,26 +136,40 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
waveForm = aWaveForm(), waveForm = aWaveForm(),
).let { ).let {
aMediaViewerState( aMediaViewerState(
downloadedMedia = AsyncData.Success( listOf(
LocalMedia(Uri.EMPTY, it) aMediaViewerPageData(
), downloadedMedia = AsyncData.Success(
mediaInfo = it, LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
) )
}, },
) )
} }
fun aMediaViewerState( fun aMediaViewerPageData(
downloadedMedia: AsyncData<LocalMedia> = AsyncData.Uninitialized, downloadedMedia: AsyncData<LocalMedia> = AsyncData.Uninitialized,
mediaInfo: MediaInfo = anImageMediaInfo(), mediaInfo: MediaInfo = anImageMediaInfo(),
mediaSource: MediaSource = MediaSource(""),
): MediaViewerPageData.MediaViewerData = MediaViewerPageData.MediaViewerData(
eventId = null,
mediaInfo = mediaInfo,
mediaSource = mediaSource,
thumbnailSource = null,
downloadedMedia = downloadedMedia,
)
fun aMediaViewerState(
listData: List<MediaViewerPageData> = listOf(aMediaViewerPageData()),
currentIndex: Int = 0,
canShowInfo: Boolean = true, canShowInfo: Boolean = true,
mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden, mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden,
eventSink: (MediaViewerEvents) -> Unit = {}, eventSink: (MediaViewerEvents) -> Unit = {},
) = MediaViewerState( ) = MediaViewerState(
eventId = null, listData = listData,
mediaInfo = mediaInfo, currentIndex = currentIndex,
thumbnailSource = null,
downloadedMedia = downloadedMedia,
snackbarMessage = null, snackbarMessage = null,
canShowInfo = canShowInfo, canShowInfo = canShowInfo,
mediaBottomSheetState = mediaBottomSheetState, mediaBottomSheetState = mediaBottomSheetState,

View file

@ -22,6 +22,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding 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.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -35,6 +37,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.architecture.AsyncData
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo 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.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@ -94,50 +98,105 @@ fun MediaViewerView(
val defaultBottomPaddingInPixels = if (LocalInspectionMode.current) 303 else 0 val defaultBottomPaddingInPixels = if (LocalInspectionMode.current) 303 else 0
var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) } var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) }
val currentData = state.listData[state.currentIndex]
BackHandler { onBackClick() } BackHandler { onBackClick() }
Scaffold( Scaffold(
modifier, modifier,
containerColor = Color.Transparent, containerColor = Color.Transparent,
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
) { ) {
MediaViewerPage( val pagerState = rememberPagerState(state.currentIndex, 0f) {
showOverlay = showOverlay, state.listData.size
bottomPaddingInPixels = bottomPaddingInPixels, }
state = state, LaunchedEffect(pagerState) {
onDismiss = { snapshotFlow { pagerState.currentPage }.collect { page ->
onBackClick() state.eventSink(MediaViewerEvents.OnNavigateTo(page))
},
onShowOverlayChange = {
showOverlay = it
} }
) }
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()) { AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.navigationBarsPadding() .navigationBarsPadding()
) { ) {
MediaViewerTopBar( when (currentData) {
actionsEnabled = state.downloadedMedia is AsyncData.Success, is MediaViewerPageData.Loading -> {
mimeType = state.mediaInfo.mimeType, TopAppBar(
senderName = state.mediaInfo.senderName, title = {},
dateSent = state.mediaInfo.dateSent, colors = TopAppBarDefaults.topAppBarColors(
canShowInfo = state.canShowInfo, containerColor = Color.Transparent.copy(0.6f),
onBackClick = onBackClick, ),
onInfoClick = { navigationIcon = { BackButton(onClick = onBackClick) },
state.eventSink(MediaViewerEvents.OpenInfo) )
}, }
eventSink = state.eventSink is MediaViewerPageData.MediaViewerData -> {
) MediaViewerTopBar(
MediaViewerBottomBar( data = currentData,
modifier = Modifier.align(Alignment.BottomCenter), canShowInfo = state.canShowInfo,
showDivider = state.mediaInfo.mimeType.isMimeTypeVideo(), onBackClick = onBackClick,
caption = state.mediaInfo.caption, onInfoClick = {
onHeightChange = { bottomPaddingInPixels = it }, state.eventSink(MediaViewerEvents.OpenInfo(currentData))
) },
eventSink = state.eventSink
)
}
}
} }
} }
} }
when (val bottomSheetState = state.mediaBottomSheetState) { when (val bottomSheetState = state.mediaBottomSheetState) {
MediaBottomSheetState.Hidden -> Unit MediaBottomSheetState.Hidden -> Unit
is MediaBottomSheetState.MediaDetailsBottomSheetState -> { is MediaBottomSheetState.MediaDetailsBottomSheetState -> {
@ -147,13 +206,24 @@ fun MediaViewerView(
state.eventSink(MediaViewerEvents.ViewInTimeline(it)) state.eventSink(MediaViewerEvents.ViewInTimeline(it))
}, },
onShare = { onShare = {
state.eventSink(MediaViewerEvents.Share) (currentData as? MediaViewerPageData.MediaViewerData)?.let {
state.eventSink(MediaViewerEvents.Share(currentData))
}
}, },
onDownload = { onDownload = {
state.eventSink(MediaViewerEvents.SaveOnDisk) (currentData as? MediaViewerPageData.MediaViewerData)?.let {
state.eventSink(MediaViewerEvents.SaveOnDisk(currentData))
}
}, },
onDelete = { eventId -> onDelete = { eventId ->
state.eventSink(MediaViewerEvents.ConfirmDelete(eventId)) (currentData as? MediaViewerPageData.MediaViewerData)?.let {
state.eventSink(
MediaViewerEvents.ConfirmDelete(
eventId,
currentData,
)
)
}
}, },
onDismiss = { onDismiss = {
state.eventSink(MediaViewerEvents.CloseBottomSheet) state.eventSink(MediaViewerEvents.CloseBottomSheet)
@ -179,16 +249,21 @@ private fun MediaViewerPage(
showOverlay: Boolean, showOverlay: Boolean,
bottomPaddingInPixels: Int, bottomPaddingInPixels: Int,
state: MediaViewerState, state: MediaViewerState,
data: MediaViewerPageData.MediaViewerData,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onShowOverlayChange: (Boolean) -> Unit, onShowOverlayChange: (Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun onRetry() { fun onRetry() {
state.eventSink(MediaViewerEvents.RetryLoading) data.eventId?.let {
state.eventSink(MediaViewerEvents.RetryLoading(it))
}
} }
fun onDismissError() { fun onDismissError() {
state.eventSink(MediaViewerEvents.ClearLoadingError) data.eventId?.let {
state.eventSink(MediaViewerEvents.ClearLoadingError(it))
}
} }
val currentShowOverlay by rememberUpdatedState(showOverlay) val currentShowOverlay by rememberUpdatedState(showOverlay)
@ -210,7 +285,7 @@ private fun MediaViewerPage(
state = flickState, state = flickState,
modifier = modifier.background(backgroundColorFor(flickState)) modifier = modifier.background(backgroundColorFor(flickState))
) { ) {
val showProgress = rememberShowProgress(state.downloadedMedia) val showProgress = rememberShowProgress(data.downloadedMedia)
Box( Box(
modifier = Modifier modifier = Modifier
@ -224,7 +299,7 @@ private fun MediaViewerPage(
val localMediaViewState = rememberLocalMediaViewState(zoomableState) val localMediaViewState = rememberLocalMediaViewState(zoomableState)
val showThumbnail = !localMediaViewState.isReady val showThumbnail = !localMediaViewState.isReady
val playableState = localMediaViewState.playableState val playableState = localMediaViewState.playableState
val showError = state.downloadedMedia is AsyncData.Failure val showError = data.downloadedMedia is AsyncData.Failure
LaunchedEffect(playableState) { LaunchedEffect(playableState) {
if (playableState is PlayableState.Playable) { if (playableState is PlayableState.Playable) {
@ -236,8 +311,8 @@ private fun MediaViewerPage(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
bottomPaddingInPixels = bottomPaddingInPixels, bottomPaddingInPixels = bottomPaddingInPixels,
localMediaViewState = localMediaViewState, localMediaViewState = localMediaViewState,
localMedia = state.downloadedMedia.dataOrNull(), localMedia = data.downloadedMedia.dataOrNull(),
mediaInfo = state.mediaInfo, mediaInfo = data.mediaInfo,
onClick = { onClick = {
if (playableState is PlayableState.NotPlayable) { if (playableState is PlayableState.NotPlayable) {
currentOnShowOverlayChange(!currentShowOverlay) currentOnShowOverlayChange(!currentShowOverlay)
@ -245,8 +320,8 @@ private fun MediaViewerPage(
}, },
) )
ThumbnailView( ThumbnailView(
mediaInfo = state.mediaInfo, mediaInfo = data.mediaInfo,
thumbnailSource = state.thumbnailSource, thumbnailSource = data.thumbnailSource,
isVisible = showThumbnail, isVisible = showThumbnail,
) )
if (showError) { 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 @Composable
private fun DismissFlickEffects( private fun DismissFlickEffects(
flickState: FlickToDismissState, flickState: FlickToDismissState,
@ -316,15 +422,16 @@ private fun rememberShowProgress(downloadedMedia: AsyncData<LocalMedia>): Boolea
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun MediaViewerTopBar( private fun MediaViewerTopBar(
actionsEnabled: Boolean, data: MediaViewerPageData.MediaViewerData,
mimeType: String,
senderName: String?,
dateSent: String?,
canShowInfo: Boolean, canShowInfo: Boolean,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onInfoClick: () -> Unit, onInfoClick: () -> Unit,
eventSink: (MediaViewerEvents) -> 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( TopAppBar(
title = { title = {
if (senderName != null && dateSent != null) { if (senderName != null && dateSent != null) {
@ -357,7 +464,7 @@ private fun MediaViewerTopBar(
IconButton( IconButton(
enabled = actionsEnabled, enabled = actionsEnabled,
onClick = { onClick = {
eventSink(MediaViewerEvents.OpenWith) eventSink(MediaViewerEvents.OpenWith(data))
}, },
) { ) {
when (mimeType) { when (mimeType) {