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 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)
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<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)
@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,

View File

@@ -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,

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
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
}

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.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<Plugin>,
presenterFactory: MediaViewerPresenter.Factory,
timelineMediaGalleryDataSource: TimelineMediaGalleryDataSource,
) : Node(buildContext, plugins = plugins),
MediaViewerNavigator {
private val inputs = inputs<MediaViewerEntryPoint.Params>()
@@ -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

View File

@@ -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<MediaFile?> = remember {
mutableStateOf(null)
LaunchedEffect(Unit) {
mediaGalleryDataSource.start()
}
val localMedia: MutableState<AsyncData<LocalMedia>> = 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<EventId?, MutableIntState> = remember { mutableMapOf() }
val mediaFile: MutableMap<EventId?, MutableState<MediaFile?>> = remember { mutableMapOf() }
val localMedia: MutableMap<EventId?, MutableState<AsyncData<LocalMedia>>> = remember { mutableMapOf() }
DisposableEffect(Unit) {
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) }
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<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()
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<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.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<LocalMedia>,
val listData: List<MediaViewerPageData>,
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<LocalMedia>,
) : MediaViewerPageData
}

View File

@@ -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<MediaViewerState>
override val values: Sequence<MediaViewerState>
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<MediaViewerState>
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<MediaViewerState>
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<LocalMedia> = 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<MediaViewerPageData> = 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,

View File

@@ -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<LocalMedia>): 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) {