media viewer : create MediaViewerDataSource

This commit is contained in:
ganfra 2025-01-20 22:35:59 +01:00 committed by Benoit Marty
parent b02c12d92a
commit 24a2458e4a
7 changed files with 255 additions and 174 deletions

View file

@ -0,0 +1,151 @@
/*
* 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.viewer
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.matrix.api.timeline.Timeline
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.gallery.MediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode
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 kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import timber.log.Timber
class MediaViewerDataSource(
private val galleryMode: MediaGalleryMode,
private val dispatcher: CoroutineDispatcher,
private val galleryDataSource: MediaGalleryDataSource,
private val mediaLoader: MatrixMediaLoader,
private val localMediaFactory: LocalMediaFactory,
) {
// List of media files that are currently being loaded
private val mediaFiles: MutableList<MediaFile> = mutableListOf()
// Map of sourceUrl to local media state
private val localMediaStates: MutableMap<String, MutableState<AsyncData<LocalMedia>>> =
mutableMapOf()
fun setup() {
galleryDataSource.start()
}
fun dispose() {
mediaFiles.forEach { it.close() }
mediaFiles.clear()
localMediaStates.clear()
}
fun initialPageIndex(eventId: EventId?): Int {
if (eventId == null) {
return 0
}
val mediaItems =
galleryDataSource.getLastData().dataOrNull()?.getItems(galleryMode).orEmpty()
val pageList = buildMediaViewerPageList(mediaItems)
return pageList.indexOfFirst { data ->
when (data) {
is MediaViewerPageData.MediaViewerData -> data.eventId == eventId
else -> false
}
}
.takeIf { it != -1 }
?: 0
}
fun dataFlow(): Flow<PersistentList<MediaViewerPageData>> {
return galleryDataSource.groupedMediaItemsFlow()
.map {
val groupedItems = it.dataOrNull()?.getItems(galleryMode).orEmpty()
withContext(dispatcher) {
buildMediaViewerPageList(groupedItems)
}
}
}
private fun buildMediaViewerPageList(groupedItems: List<MediaItem>) = buildList {
groupedItems.forEach { mediaItem ->
when (mediaItem) {
is MediaItem.DateSeparator -> Unit
is MediaItem.Event -> {
val sourceUrl = mediaItem.mediaSource().url
val localMedia = localMediaStates.getOrPut(sourceUrl) {
mutableStateOf(AsyncData.Uninitialized)
}
add(
MediaViewerPageData.MediaViewerData(
eventId = mediaItem.eventId(),
mediaInfo = mediaItem.mediaInfo(),
mediaSource = mediaItem.mediaSource(),
thumbnailSource = mediaItem.thumbnailSource(),
downloadedMedia = localMedia,
)
)
}
is MediaItem.LoadingIndicator -> add(
MediaViewerPageData.Loading(mediaItem.direction)
)
}
}
if (isEmpty()) {
MediaViewerPageData.Loading(Timeline.PaginationDirection.BACKWARDS)
}
}.toPersistentList()
suspend fun loadMedia(data: MediaViewerPageData.MediaViewerData) {
Timber.d("loadMedia for ${data.eventId}")
val localMediaState = localMediaStates.getOrPut(data.mediaSource.url) {
mutableStateOf(AsyncData.Uninitialized)
}
localMediaState.value = AsyncData.Loading()
mediaLoader
.downloadMediaFile(
source = data.mediaSource,
mimeType = data.mediaInfo.mimeType,
filename = data.mediaInfo.filename
)
.onSuccess { mediaFile ->
mediaFiles.add(mediaFile)
}
.mapCatching { mediaFile ->
localMediaFactory.createFromMediaFile(
mediaFile = mediaFile,
mediaInfo = data.mediaInfo
)
}
.onSuccess {
localMediaState.value = AsyncData.Success(it)
}
.onFailure {
localMediaState.value = AsyncData.Failure(it)
}
}
fun clearLoadingError(data: MediaViewerPageData.MediaViewerData) {
localMediaStates[data.mediaSource.url]?.value = AsyncData.Uninitialized
}
suspend fun loadMore(direction: Timeline.PaginationDirection) {
galleryDataSource.loadMore(direction)
}
}

View file

@ -15,7 +15,7 @@ sealed interface MediaViewerEvents {
data class SaveOnDisk(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents data class SaveOnDisk(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class Share(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents data class Share(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class OpenWith(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents data class OpenWith(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class ClearLoadingError(val eventId: EventId) : MediaViewerEvents data class ClearLoadingError(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class ViewInTimeline(val eventId: EventId) : MediaViewerEvents data class ViewInTimeline(val eventId: EventId) : MediaViewerEvents
data class OpenInfo(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents data class OpenInfo(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class ConfirmDelete( data class ConfirmDelete(

View file

@ -18,9 +18,13 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ForcedDarkElementTheme import io.element.android.compound.theme.ForcedDarkElementTheme
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
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.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode
import io.element.android.libraries.mediaviewer.impl.gallery.SingleMediaGalleryDataSource import io.element.android.libraries.mediaviewer.impl.gallery.SingleMediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.gallery.TimelineMediaGalleryDataSource import io.element.android.libraries.mediaviewer.impl.gallery.TimelineMediaGalleryDataSource
@ -30,6 +34,9 @@ class MediaViewerNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
presenterFactory: MediaViewerPresenter.Factory, presenterFactory: MediaViewerPresenter.Factory,
timelineMediaGalleryDataSource: TimelineMediaGalleryDataSource, timelineMediaGalleryDataSource: TimelineMediaGalleryDataSource,
mediaLoader: MatrixMediaLoader,
localMediaFactory: LocalMediaFactory,
coroutineDispatchers: CoroutineDispatchers,
) : Node(buildContext, plugins = plugins), ) : Node(buildContext, plugins = plugins),
MediaViewerNavigator { MediaViewerNavigator {
private val inputs = inputs<MediaViewerEntryPoint.Params>() private val inputs = inputs<MediaViewerEntryPoint.Params>()
@ -50,14 +57,28 @@ class MediaViewerNode @AssistedInject constructor(
onDone() onDone()
} }
private val mediaGallerySource = if (inputs.mode == MediaViewerEntryPoint.MediaViewerMode.SingleMedia) {
SingleMediaGalleryDataSource.createFrom(inputs)
} else {
timelineMediaGalleryDataSource
}
private val galleryMode = when (inputs.mode) {
MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images
MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files
}
private val presenter = presenterFactory.create( private val presenter = presenterFactory.create(
inputs = inputs, inputs = inputs,
navigator = this, navigator = this,
mediaGalleryDataSource = if (inputs.mode == MediaViewerEntryPoint.MediaViewerMode.SingleMedia) { dataSource = MediaViewerDataSource(
SingleMediaGalleryDataSource.createFrom(inputs) dispatcher = coroutineDispatchers.computation,
} else { galleryMode = galleryMode,
timelineMediaGalleryDataSource galleryDataSource = mediaGallerySource,
}, mediaLoader = mediaLoader,
localMediaFactory = localMediaFactory
)
) )
@Composable @Composable

View file

@ -10,10 +10,7 @@ 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.MutableState
import androidx.compose.runtime.collectAsState 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
@ -29,26 +26,17 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatch
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
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.MatrixMediaLoader
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.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.MediaGalleryMode
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.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import io.element.android.libraries.androidutils.R as UtilsR import io.element.android.libraries.androidutils.R as UtilsR
@ -56,10 +44,8 @@ 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, @Assisted private val dataSource: MediaViewerDataSource,
private val room: MatrixRoom, private val room: MatrixRoom,
private val localMediaFactory: LocalMediaFactory,
private val mediaLoader: MatrixMediaLoader,
private val localMediaActions: LocalMediaActions, private val localMediaActions: LocalMediaActions,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<MediaViewerState> { ) : Presenter<MediaViewerState> {
@ -68,97 +54,46 @@ class MediaViewerPresenter @AssistedInject constructor(
fun create( fun create(
inputs: MediaViewerEntryPoint.Params, inputs: MediaViewerEntryPoint.Params,
navigator: MediaViewerNavigator, navigator: MediaViewerNavigator,
mediaGalleryDataSource: MediaGalleryDataSource, dataSource: MediaViewerDataSource,
): MediaViewerPresenter ): MediaViewerPresenter
} }
private val galleryMode = when (inputs.mode) {
MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images
MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files
}
@Composable @Composable
override fun present(): MediaViewerState { override fun present(): MediaViewerState {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
LaunchedEffect(Unit) { val data: ImmutableList<MediaViewerPageData> by dataSource.dataFlow().collectAsState(persistentListOf())
mediaGalleryDataSource.start() var currentIndex by remember { mutableIntStateOf(dataSource.initialPageIndex(inputs.eventId)) }
}
val groupedMediaItem by remember { mediaGalleryDataSource.groupedMediaItemsFlow() }
.collectAsState(mediaGalleryDataSource.getLastData())
val mediaFile: MutableMap<EventId?, MutableState<MediaFile?>> = remember { mutableMapOf() }
val localMedia: MutableMap<EventId?, MutableState<AsyncData<LocalMedia>>> = remember { mutableMapOf() }
DisposableEffect(Unit) {
onDispose {
mediaFile.values.forEach { it.value?.close() }
}
}
val data: List<MediaViewerPageData> by remember {
derivedStateOf {
buildList {
val data = groupedMediaItem.dataOrNull()
if (data != null) {
if (data.getItems(galleryMode).firstOrNull() is MediaItem.LoadingIndicator) {
add(MediaViewerPageData.Loading(Timeline.PaginationDirection.FORWARDS))
}
data.getItems(galleryMode).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.getItems(galleryMode).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() val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
localMediaActions.Configure()
var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) } var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) }
DisposableEffect(Unit) {
dataSource.setup()
onDispose {
dataSource.dispose()
}
}
localMediaActions.Configure()
fun handleEvents(event: MediaViewerEvents) { fun handleEvents(event: MediaViewerEvents) {
when (event) { when (event) {
is MediaViewerEvents.LoadMedia -> { is MediaViewerEvents.LoadMedia -> {
// It's OK to suppress the warning since mediaFile and localMedia are remembered coroutineScope.downloadMedia(data = event.data)
@Suppress("RememberMissing")
coroutineScope.downloadMedia(
data = event.data,
mediaFile = mediaFile.getOrPut(event.data.eventId) { mutableStateOf(null) },
localMedia = localMedia.getOrPut(event.data.eventId) { mutableStateOf(AsyncData.Uninitialized) },
)
} }
is MediaViewerEvents.ClearLoadingError -> { is MediaViewerEvents.ClearLoadingError -> {
// It's OK to suppress the warning since localMedia is remembered dataSource.clearLoadingError(event.data)
@Suppress("RememberMissing")
localMedia.getOrPut(event.eventId) { mutableStateOf(AsyncData.Uninitialized) }.value = AsyncData.Uninitialized
} }
is MediaViewerEvents.SaveOnDisk -> { is MediaViewerEvents.SaveOnDisk -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden mediaBottomSheetState = MediaBottomSheetState.Hidden
coroutineScope.saveOnDisk(event.data.downloadedMedia) coroutineScope.saveOnDisk(event.data.downloadedMedia.value)
} }
is MediaViewerEvents.Share -> { is MediaViewerEvents.Share -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden mediaBottomSheetState = MediaBottomSheetState.Hidden
coroutineScope.share(event.data.downloadedMedia) coroutineScope.share(event.data.downloadedMedia.value)
} }
is MediaViewerEvents.OpenWith -> { is MediaViewerEvents.OpenWith -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden mediaBottomSheetState = MediaBottomSheetState.Hidden
coroutineScope.open(event.data.downloadedMedia) coroutineScope.open(event.data.downloadedMedia.value)
} }
is MediaViewerEvents.Delete -> { is MediaViewerEvents.Delete -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden mediaBottomSheetState = MediaBottomSheetState.Hidden
@ -194,7 +129,7 @@ class MediaViewerPresenter @AssistedInject constructor(
currentIndex = event.index currentIndex = event.index
} }
is MediaViewerEvents.LoadMore -> coroutineScope.launch { is MediaViewerEvents.LoadMore -> coroutineScope.launch {
mediaGalleryDataSource.loadMore(event.direction) dataSource.loadMore(event.direction)
} }
} }
} }
@ -211,30 +146,8 @@ class MediaViewerPresenter @AssistedInject constructor(
private fun CoroutineScope.downloadMedia( private fun CoroutineScope.downloadMedia(
data: MediaViewerPageData.MediaViewerData, data: MediaViewerPageData.MediaViewerData,
mediaFile: MutableState<MediaFile?>,
localMedia: MutableState<AsyncData<LocalMedia>>,
) = launch { ) = launch {
localMedia.value = AsyncData.Loading() dataSource.loadMedia(data)
mediaLoader.downloadMediaFile(
source = data.mediaSource,
mimeType = data.mediaInfo.mimeType,
filename = data.mediaInfo.filename
)
.onSuccess {
mediaFile.value = it
}
.mapCatching { mediaFile ->
localMediaFactory.createFromMediaFile(
mediaFile = mediaFile,
mediaInfo = data.mediaInfo
)
}
.onSuccess {
localMedia.value = AsyncData.Success(it)
}
.onFailure {
localMedia.value = AsyncData.Failure(it)
}
} }
private fun CoroutineScope.saveOnDisk(localMedia: AsyncData<LocalMedia>) = launch { private fun CoroutineScope.saveOnDisk(localMedia: AsyncData<LocalMedia>) = launch {
@ -290,14 +203,3 @@ 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

@ -7,6 +7,7 @@
package io.element.android.libraries.mediaviewer.impl.viewer package io.element.android.libraries.mediaviewer.impl.viewer
import androidx.compose.runtime.State
import io.element.android.libraries.architecture.AsyncData 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
@ -15,9 +16,10 @@ 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
import kotlinx.collections.immutable.ImmutableList
data class MediaViewerState( data class MediaViewerState(
val listData: List<MediaViewerPageData>, val listData: ImmutableList<MediaViewerPageData>,
val currentIndex: Int, val currentIndex: Int,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,
val canShowInfo: Boolean, val canShowInfo: Boolean,
@ -35,6 +37,6 @@ sealed interface MediaViewerPageData {
val mediaInfo: MediaInfo, val mediaInfo: MediaInfo,
val mediaSource: MediaSource, val mediaSource: MediaSource,
val thumbnailSource: MediaSource?, val thumbnailSource: MediaSource?,
val downloadedMedia: AsyncData<LocalMedia>, val downloadedMedia: State<AsyncData<LocalMedia>>,
) : MediaViewerPageData ) : MediaViewerPageData
} }

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.mediaviewer.impl.viewer package io.element.android.libraries.mediaviewer.impl.viewer
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.media.aWaveForm import io.element.android.libraries.designsystem.components.media.aWaveForm
@ -22,6 +23,7 @@ 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
import io.element.android.libraries.mediaviewer.impl.details.aMediaDeleteConfirmationState import io.element.android.libraries.mediaviewer.impl.details.aMediaDeleteConfirmationState
import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState
import kotlinx.collections.immutable.toPersistentList
open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> { open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> {
override val values: Sequence<MediaViewerState> override val values: Sequence<MediaViewerState>
@ -158,7 +160,7 @@ fun aMediaViewerPageData(
mediaInfo = mediaInfo, mediaInfo = mediaInfo,
mediaSource = mediaSource, mediaSource = mediaSource,
thumbnailSource = null, thumbnailSource = null,
downloadedMedia = downloadedMedia, downloadedMedia = mutableStateOf(downloadedMedia),
) )
fun aMediaViewerState( fun aMediaViewerState(
@ -168,7 +170,7 @@ fun aMediaViewerState(
mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden, mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden,
eventSink: (MediaViewerEvents) -> Unit = {}, eventSink: (MediaViewerEvents) -> Unit = {},
) = MediaViewerState( ) = MediaViewerState(
listData = listData, listData = listData.toPersistentList(),
currentIndex = currentIndex, currentIndex = currentIndex,
snackbarMessage = null, snackbarMessage = null,
canShowInfo = canShowInfo, canShowInfo = canShowInfo,

View file

@ -85,6 +85,7 @@ import me.saket.telephoto.flick.FlickToDismissState
import me.saket.telephoto.flick.rememberFlickToDismissState import me.saket.telephoto.flick.rememberFlickToDismissState
import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.rememberZoomableState import me.saket.telephoto.zoomable.rememberZoomableState
import timber.log.Timber
import kotlin.time.Duration import kotlin.time.Duration
@Composable @Composable
@ -98,7 +99,7 @@ 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] val currentData = state.listData.getOrNull(state.currentIndex)
BackHandler { onBackClick() } BackHandler { onBackClick() }
Scaffold( Scaffold(
modifier, modifier,
@ -113,6 +114,9 @@ fun MediaViewerView(
state.eventSink(MediaViewerEvents.OnNavigateTo(page)) state.eventSink(MediaViewerEvents.OnNavigateTo(page))
} }
} }
LaunchedEffect(state.listData) {
Timber.d("MediaViewerView: state.listData: ${state.listData}")
}
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
modifier = Modifier, modifier = Modifier,
@ -139,10 +143,15 @@ fun MediaViewerView(
showOverlay = showOverlay, showOverlay = showOverlay,
bottomPaddingInPixels = bottomPaddingInPixels, bottomPaddingInPixels = bottomPaddingInPixels,
data = dataForPage, data = dataForPage,
state = state,
onDismiss = { onDismiss = {
onBackClick() onBackClick()
}, },
onRetry = {
state.eventSink(MediaViewerEvents.LoadMedia(dataForPage))
},
onDismissError = {
state.eventSink(MediaViewerEvents.ClearLoadingError(dataForPage))
},
onShowOverlayChange = { onShowOverlayChange = {
showOverlay = it showOverlay = it
} }
@ -151,8 +160,8 @@ fun MediaViewerView(
AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) { AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.navigationBarsPadding() .navigationBarsPadding()
) { ) {
MediaViewerBottomBar( MediaViewerBottomBar(
modifier = Modifier.align(Alignment.BottomCenter), modifier = Modifier.align(Alignment.BottomCenter),
@ -169,19 +178,10 @@ fun MediaViewerView(
AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) { AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.navigationBarsPadding() .navigationBarsPadding()
) { ) {
when (currentData) { when (currentData) {
is MediaViewerPageData.Loading -> {
TopAppBar(
title = {},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent.copy(0.6f),
),
navigationIcon = { BackButton(onClick = onBackClick) },
)
}
is MediaViewerPageData.MediaViewerData -> { is MediaViewerPageData.MediaViewerData -> {
MediaViewerTopBar( MediaViewerTopBar(
data = currentData, data = currentData,
@ -193,6 +193,15 @@ fun MediaViewerView(
eventSink = state.eventSink eventSink = state.eventSink
) )
} }
else -> {
TopAppBar(
title = {},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent.copy(0.6f),
),
navigationIcon = { BackButton(onClick = onBackClick) },
)
}
} }
} }
} }
@ -250,21 +259,13 @@ private fun MediaViewerPage(
isDisplayed: Boolean, isDisplayed: Boolean,
showOverlay: Boolean, showOverlay: Boolean,
bottomPaddingInPixels: Int, bottomPaddingInPixels: Int,
state: MediaViewerState,
data: MediaViewerPageData.MediaViewerData, data: MediaViewerPageData.MediaViewerData,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onRetry: () -> Unit,
onDismissError: () -> Unit,
onShowOverlayChange: (Boolean) -> Unit, onShowOverlayChange: (Boolean) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun onRetry() {
state.eventSink(MediaViewerEvents.LoadMedia(data))
}
fun onDismissError() {
data.eventId?.let {
state.eventSink(MediaViewerEvents.ClearLoadingError(it))
}
}
val currentShowOverlay by rememberUpdatedState(showOverlay) val currentShowOverlay by rememberUpdatedState(showOverlay)
val currentOnShowOverlayChange by rememberUpdatedState(onShowOverlayChange) val currentOnShowOverlayChange by rememberUpdatedState(onShowOverlayChange)
@ -285,12 +286,13 @@ private fun MediaViewerPage(
state = flickState, state = flickState,
modifier = modifier.background(backgroundColorFor(flickState)) modifier = modifier.background(backgroundColorFor(flickState))
) { ) {
val showProgress = rememberShowProgress(data.downloadedMedia) val downloadedMedia by data.downloadedMedia
val showProgress = rememberShowProgress(downloadedMedia)
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.navigationBarsPadding() .navigationBarsPadding()
) { ) {
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
val zoomableState = rememberZoomableState( val zoomableState = rememberZoomableState(
@ -299,7 +301,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 = data.downloadedMedia is AsyncData.Failure val showError = downloadedMedia.isFailure()
LaunchedEffect(playableState) { LaunchedEffect(playableState) {
if (playableState is PlayableState.Playable) { if (playableState is PlayableState.Playable) {
@ -312,7 +314,7 @@ private fun MediaViewerPage(
isDisplayed = isDisplayed, isDisplayed = isDisplayed,
bottomPaddingInPixels = bottomPaddingInPixels, bottomPaddingInPixels = bottomPaddingInPixels,
localMediaViewState = localMediaViewState, localMediaViewState = localMediaViewState,
localMedia = data.downloadedMedia.dataOrNull(), localMedia = downloadedMedia.dataOrNull(),
mediaInfo = data.mediaInfo, mediaInfo = data.mediaInfo,
onClick = { onClick = {
if (playableState is PlayableState.NotPlayable) { if (playableState is PlayableState.NotPlayable) {
@ -328,16 +330,16 @@ private fun MediaViewerPage(
if (showError) { if (showError) {
ErrorView( ErrorView(
errorMessage = stringResource(id = CommonStrings.error_unknown), errorMessage = stringResource(id = CommonStrings.error_unknown),
onRetry = ::onRetry, onRetry = onRetry,
onDismiss = ::onDismissError onDismiss = onDismissError
) )
} }
} }
if (showProgress) { if (showProgress) {
LinearProgressIndicator( LinearProgressIndicator(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(2.dp) .height(2.dp)
) )
} }
} }
@ -366,8 +368,8 @@ private fun MediaViewerLoadingPage(
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.navigationBarsPadding(), .navigationBarsPadding(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
AsyncLoading() AsyncLoading()
@ -429,7 +431,8 @@ private fun MediaViewerTopBar(
onInfoClick: () -> Unit, onInfoClick: () -> Unit,
eventSink: (MediaViewerEvents) -> Unit, eventSink: (MediaViewerEvents) -> Unit,
) { ) {
val actionsEnabled = data.downloadedMedia is AsyncData.Success val downloadedMedia by data.downloadedMedia
val actionsEnabled = downloadedMedia.isSuccess()
val mimeType = data.mediaInfo.mimeType val mimeType = data.mediaInfo.mimeType
val senderName = data.mediaInfo.senderName val senderName = data.mediaInfo.senderName
val dateSent = data.mediaInfo.dateSent val dateSent = data.mediaInfo.dateSent
@ -503,11 +506,11 @@ private fun MediaViewerBottomBar(
) { ) {
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.background(Color(0x99101317)) .background(Color(0x99101317))
.onSizeChanged { .onSizeChanged {
onHeightChange(it.height) onHeightChange(it.height)
}, },
) { ) {
if (caption != null) { if (caption != null) {
if (showDivider) { if (showDivider) {
@ -515,8 +518,8 @@ private fun MediaViewerBottomBar(
} }
Text( Text(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
text = caption, text = caption,
maxLines = 5, maxLines = 5,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,