media viewer : create MediaViewerDataSource
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ sealed interface 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 ClearLoadingError(val eventId: EventId) : MediaViewerEvents
|
||||
data class ClearLoadingError(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
|
||||
data class ViewInTimeline(val eventId: EventId) : MediaViewerEvents
|
||||
data class OpenInfo(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
|
||||
data class ConfirmDelete(
|
||||
|
||||
@@ -18,9 +18,13 @@ import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.compound.theme.ForcedDarkElementTheme
|
||||
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.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.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.TimelineMediaGalleryDataSource
|
||||
|
||||
@@ -30,6 +34,9 @@ class MediaViewerNode @AssistedInject constructor(
|
||||
@Assisted plugins: List<Plugin>,
|
||||
presenterFactory: MediaViewerPresenter.Factory,
|
||||
timelineMediaGalleryDataSource: TimelineMediaGalleryDataSource,
|
||||
mediaLoader: MatrixMediaLoader,
|
||||
localMediaFactory: LocalMediaFactory,
|
||||
coroutineDispatchers: CoroutineDispatchers,
|
||||
) : Node(buildContext, plugins = plugins),
|
||||
MediaViewerNavigator {
|
||||
private val inputs = inputs<MediaViewerEntryPoint.Params>()
|
||||
@@ -50,14 +57,28 @@ class MediaViewerNode @AssistedInject constructor(
|
||||
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(
|
||||
inputs = inputs,
|
||||
navigator = this,
|
||||
mediaGalleryDataSource = if (inputs.mode == MediaViewerEntryPoint.MediaViewerMode.SingleMedia) {
|
||||
SingleMediaGalleryDataSource.createFrom(inputs)
|
||||
} else {
|
||||
timelineMediaGalleryDataSource
|
||||
},
|
||||
dataSource = MediaViewerDataSource(
|
||||
dispatcher = coroutineDispatchers.computation,
|
||||
galleryMode = galleryMode,
|
||||
galleryDataSource = mediaGallerySource,
|
||||
mediaLoader = mediaLoader,
|
||||
localMediaFactory = localMediaFactory
|
||||
)
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -10,10 +10,7 @@ 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.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
|
||||
@@ -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.collectSnackbarMessageAsState
|
||||
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.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.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.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
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(
|
||||
@Assisted private val inputs: MediaViewerEntryPoint.Params,
|
||||
@Assisted private val navigator: MediaViewerNavigator,
|
||||
@Assisted private val mediaGalleryDataSource: MediaGalleryDataSource,
|
||||
@Assisted private val dataSource: MediaViewerDataSource,
|
||||
private val room: MatrixRoom,
|
||||
private val localMediaFactory: LocalMediaFactory,
|
||||
private val mediaLoader: MatrixMediaLoader,
|
||||
private val localMediaActions: LocalMediaActions,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
) : Presenter<MediaViewerState> {
|
||||
@@ -68,97 +54,46 @@ class MediaViewerPresenter @AssistedInject constructor(
|
||||
fun create(
|
||||
inputs: MediaViewerEntryPoint.Params,
|
||||
navigator: MediaViewerNavigator,
|
||||
mediaGalleryDataSource: MediaGalleryDataSource,
|
||||
dataSource: MediaViewerDataSource,
|
||||
): MediaViewerPresenter
|
||||
}
|
||||
|
||||
private val galleryMode = when (inputs.mode) {
|
||||
MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
|
||||
MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images
|
||||
MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): MediaViewerState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
LaunchedEffect(Unit) {
|
||||
mediaGalleryDataSource.start()
|
||||
}
|
||||
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 data: ImmutableList<MediaViewerPageData> by dataSource.dataFlow().collectAsState(persistentListOf())
|
||||
var currentIndex by remember { mutableIntStateOf(dataSource.initialPageIndex(inputs.eventId)) }
|
||||
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||
localMediaActions.Configure()
|
||||
|
||||
var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) }
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
dataSource.setup()
|
||||
onDispose {
|
||||
dataSource.dispose()
|
||||
}
|
||||
}
|
||||
localMediaActions.Configure()
|
||||
|
||||
fun handleEvents(event: MediaViewerEvents) {
|
||||
when (event) {
|
||||
is MediaViewerEvents.LoadMedia -> {
|
||||
// It's OK to suppress the warning since mediaFile and localMedia are remembered
|
||||
@Suppress("RememberMissing")
|
||||
coroutineScope.downloadMedia(
|
||||
data = event.data,
|
||||
mediaFile = mediaFile.getOrPut(event.data.eventId) { mutableStateOf(null) },
|
||||
localMedia = localMedia.getOrPut(event.data.eventId) { mutableStateOf(AsyncData.Uninitialized) },
|
||||
)
|
||||
coroutineScope.downloadMedia(data = event.data)
|
||||
}
|
||||
is MediaViewerEvents.ClearLoadingError -> {
|
||||
// It's OK to suppress the warning since localMedia is remembered
|
||||
@Suppress("RememberMissing")
|
||||
localMedia.getOrPut(event.eventId) { mutableStateOf(AsyncData.Uninitialized) }.value = AsyncData.Uninitialized
|
||||
dataSource.clearLoadingError(event.data)
|
||||
}
|
||||
is MediaViewerEvents.SaveOnDisk -> {
|
||||
mediaBottomSheetState = MediaBottomSheetState.Hidden
|
||||
coroutineScope.saveOnDisk(event.data.downloadedMedia)
|
||||
coroutineScope.saveOnDisk(event.data.downloadedMedia.value)
|
||||
}
|
||||
is MediaViewerEvents.Share -> {
|
||||
mediaBottomSheetState = MediaBottomSheetState.Hidden
|
||||
coroutineScope.share(event.data.downloadedMedia)
|
||||
coroutineScope.share(event.data.downloadedMedia.value)
|
||||
}
|
||||
is MediaViewerEvents.OpenWith -> {
|
||||
mediaBottomSheetState = MediaBottomSheetState.Hidden
|
||||
coroutineScope.open(event.data.downloadedMedia)
|
||||
coroutineScope.open(event.data.downloadedMedia.value)
|
||||
}
|
||||
is MediaViewerEvents.Delete -> {
|
||||
mediaBottomSheetState = MediaBottomSheetState.Hidden
|
||||
@@ -194,7 +129,7 @@ class MediaViewerPresenter @AssistedInject constructor(
|
||||
currentIndex = event.index
|
||||
}
|
||||
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(
|
||||
data: MediaViewerPageData.MediaViewerData,
|
||||
mediaFile: MutableState<MediaFile?>,
|
||||
localMedia: MutableState<AsyncData<LocalMedia>>,
|
||||
) = launch {
|
||||
localMedia.value = AsyncData.Loading()
|
||||
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)
|
||||
}
|
||||
dataSource.loadMedia(data)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
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.designsystem.utils.snackbar.SnackbarMessage
|
||||
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.local.LocalMedia
|
||||
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class MediaViewerState(
|
||||
val listData: List<MediaViewerPageData>,
|
||||
val listData: ImmutableList<MediaViewerPageData>,
|
||||
val currentIndex: Int,
|
||||
val snackbarMessage: SnackbarMessage?,
|
||||
val canShowInfo: Boolean,
|
||||
@@ -35,6 +37,6 @@ sealed interface MediaViewerPageData {
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val downloadedMedia: AsyncData<LocalMedia>,
|
||||
val downloadedMedia: State<AsyncData<LocalMedia>>,
|
||||
) : MediaViewerPageData
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
package io.element.android.libraries.mediaviewer.impl.viewer
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
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.aMediaDeleteConfirmationState
|
||||
import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> {
|
||||
override val values: Sequence<MediaViewerState>
|
||||
@@ -158,7 +160,7 @@ fun aMediaViewerPageData(
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = null,
|
||||
downloadedMedia = downloadedMedia,
|
||||
downloadedMedia = mutableStateOf(downloadedMedia),
|
||||
)
|
||||
|
||||
fun aMediaViewerState(
|
||||
@@ -168,7 +170,7 @@ fun aMediaViewerState(
|
||||
mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden,
|
||||
eventSink: (MediaViewerEvents) -> Unit = {},
|
||||
) = MediaViewerState(
|
||||
listData = listData,
|
||||
listData = listData.toPersistentList(),
|
||||
currentIndex = currentIndex,
|
||||
snackbarMessage = null,
|
||||
canShowInfo = canShowInfo,
|
||||
|
||||
@@ -85,6 +85,7 @@ import me.saket.telephoto.flick.FlickToDismissState
|
||||
import me.saket.telephoto.flick.rememberFlickToDismissState
|
||||
import me.saket.telephoto.zoomable.ZoomSpec
|
||||
import me.saket.telephoto.zoomable.rememberZoomableState
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration
|
||||
|
||||
@Composable
|
||||
@@ -98,7 +99,7 @@ fun MediaViewerView(
|
||||
|
||||
val defaultBottomPaddingInPixels = if (LocalInspectionMode.current) 303 else 0
|
||||
var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) }
|
||||
val currentData = state.listData[state.currentIndex]
|
||||
val currentData = state.listData.getOrNull(state.currentIndex)
|
||||
BackHandler { onBackClick() }
|
||||
Scaffold(
|
||||
modifier,
|
||||
@@ -113,6 +114,9 @@ fun MediaViewerView(
|
||||
state.eventSink(MediaViewerEvents.OnNavigateTo(page))
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state.listData) {
|
||||
Timber.d("MediaViewerView: state.listData: ${state.listData}")
|
||||
}
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier,
|
||||
@@ -139,10 +143,15 @@ fun MediaViewerView(
|
||||
showOverlay = showOverlay,
|
||||
bottomPaddingInPixels = bottomPaddingInPixels,
|
||||
data = dataForPage,
|
||||
state = state,
|
||||
onDismiss = {
|
||||
onBackClick()
|
||||
},
|
||||
onRetry = {
|
||||
state.eventSink(MediaViewerEvents.LoadMedia(dataForPage))
|
||||
},
|
||||
onDismissError = {
|
||||
state.eventSink(MediaViewerEvents.ClearLoadingError(dataForPage))
|
||||
},
|
||||
onShowOverlayChange = {
|
||||
showOverlay = it
|
||||
}
|
||||
@@ -151,8 +160,8 @@ fun MediaViewerView(
|
||||
AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
MediaViewerBottomBar(
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
@@ -169,19 +178,10 @@ fun MediaViewerView(
|
||||
AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
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,
|
||||
@@ -193,6 +193,15 @@ fun MediaViewerView(
|
||||
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,
|
||||
showOverlay: Boolean,
|
||||
bottomPaddingInPixels: Int,
|
||||
state: MediaViewerState,
|
||||
data: MediaViewerPageData.MediaViewerData,
|
||||
onDismiss: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onDismissError: () -> Unit,
|
||||
onShowOverlayChange: (Boolean) -> Unit,
|
||||
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 currentOnShowOverlayChange by rememberUpdatedState(onShowOverlayChange)
|
||||
@@ -285,12 +286,13 @@ private fun MediaViewerPage(
|
||||
state = flickState,
|
||||
modifier = modifier.background(backgroundColorFor(flickState))
|
||||
) {
|
||||
val showProgress = rememberShowProgress(data.downloadedMedia)
|
||||
val downloadedMedia by data.downloadedMedia
|
||||
val showProgress = rememberShowProgress(downloadedMedia)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
val zoomableState = rememberZoomableState(
|
||||
@@ -299,7 +301,7 @@ private fun MediaViewerPage(
|
||||
val localMediaViewState = rememberLocalMediaViewState(zoomableState)
|
||||
val showThumbnail = !localMediaViewState.isReady
|
||||
val playableState = localMediaViewState.playableState
|
||||
val showError = data.downloadedMedia is AsyncData.Failure
|
||||
val showError = downloadedMedia.isFailure()
|
||||
|
||||
LaunchedEffect(playableState) {
|
||||
if (playableState is PlayableState.Playable) {
|
||||
@@ -312,7 +314,7 @@ private fun MediaViewerPage(
|
||||
isDisplayed = isDisplayed,
|
||||
bottomPaddingInPixels = bottomPaddingInPixels,
|
||||
localMediaViewState = localMediaViewState,
|
||||
localMedia = data.downloadedMedia.dataOrNull(),
|
||||
localMedia = downloadedMedia.dataOrNull(),
|
||||
mediaInfo = data.mediaInfo,
|
||||
onClick = {
|
||||
if (playableState is PlayableState.NotPlayable) {
|
||||
@@ -328,16 +330,16 @@ private fun MediaViewerPage(
|
||||
if (showError) {
|
||||
ErrorView(
|
||||
errorMessage = stringResource(id = CommonStrings.error_unknown),
|
||||
onRetry = ::onRetry,
|
||||
onDismiss = ::onDismissError
|
||||
onRetry = onRetry,
|
||||
onDismiss = onDismissError
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showProgress) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -366,8 +368,8 @@ private fun MediaViewerLoadingPage(
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding(),
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AsyncLoading()
|
||||
@@ -429,7 +431,8 @@ private fun MediaViewerTopBar(
|
||||
onInfoClick: () -> 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 senderName = data.mediaInfo.senderName
|
||||
val dateSent = data.mediaInfo.dateSent
|
||||
@@ -503,11 +506,11 @@ private fun MediaViewerBottomBar(
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color(0x99101317))
|
||||
.onSizeChanged {
|
||||
onHeightChange(it.height)
|
||||
},
|
||||
.fillMaxWidth()
|
||||
.background(Color(0x99101317))
|
||||
.onSizeChanged {
|
||||
onHeightChange(it.height)
|
||||
},
|
||||
) {
|
||||
if (caption != null) {
|
||||
if (showDivider) {
|
||||
@@ -515,8 +518,8 @@ private fun MediaViewerBottomBar(
|
||||
}
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
text = caption,
|
||||
maxLines = 5,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
|
||||
Reference in New Issue
Block a user