Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Media navigation with swipe gesture #4161

Merged
merged 38 commits into from
Jan 23, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
35cc5df
Create MediaGalleryDataSource and extract logic from MediaGalleryPres…
bmarty Jan 15, 2025
cbf7bf0
Let MediaGalleryDataSource be an interface
bmarty Jan 16, 2025
a06607f
Remove RetryLoading (use LoadMedia)
bmarty Jan 17, 2025
32db42a
Suppress Detekt false positive (?)
bmarty Jan 17, 2025
7df65b0
Add support for files navigation (when coming from the gallery)
bmarty Jan 17, 2025
ca5c06f
Open in SingleMedia mode when coming from the timeline
bmarty Jan 17, 2025
8d6550b
If not displayed, make sure to pause the audio / video
bmarty Jan 17, 2025
98a786b
media viewer : create MediaViewerDataSource
ganfra Jan 20, 2025
d26414f
Provide duration
bmarty Jan 17, 2025
bad4566
Remove unused import
bmarty Jan 20, 2025
3248041
media viewer : use collectAsState in the DataSource
ganfra Jan 21, 2025
c0542c8
Fix and write tests
bmarty Jan 21, 2025
d6ebec8
Improve loading state and add preview.
bmarty Jan 21, 2025
6c904a4
Add exception for Konsist
bmarty Jan 21, 2025
aa68a35
sync strings
bmarty Jan 21, 2025
a4cf1d7
Restore caption rendering
bmarty Jan 22, 2025
f286f6d
Small cleanup
bmarty Jan 22, 2025
428b81c
Add test on SingleMediaGalleryDataSource
bmarty Jan 22, 2025
18c3b5b
Add test on MediaViewerDataSource
bmarty Jan 22, 2025
621558a
MediaViewer: add error case in the UI.
bmarty Jan 22, 2025
e496bc3
Introduce MediaViewerFlickToDismiss and extract to its own file
bmarty Jan 22, 2025
dfda13a
Restore overlay when user cancel the dragging
bmarty Jan 22, 2025
bac69c4
Add timestamp to trigger back pagination.
bmarty Jan 22, 2025
b72f4bb
Fix tests.
bmarty Jan 22, 2025
74be5a5
Update screenshots
ElementBot Jan 22, 2025
1580c73
Ensure gallery is paginating to get new items.
bmarty Jan 23, 2025
77887f9
Fix formatting.
bmarty Jan 23, 2025
dff9005
Remove useless parameter
bmarty Jan 23, 2025
54182b0
Simplify with code `coerceAtLeast(0)``
bmarty Jan 23, 2025
2fde015
Simplify
bmarty Jan 23, 2025
afd8161
Add documentation on buildMediaViewerPageList.
bmarty Jan 23, 2025
a5515b0
Add name to argument for clarity.
bmarty Jan 23, 2025
7dd797b
Use Black for code clarity.
bmarty Jan 23, 2025
beb835c
Fix color for media viewer according to Figma.
bmarty Jan 23, 2025
05660fb
Cleanup
bmarty Jan 23, 2025
4d2edfa
Improve code clarity
bmarty Jan 23, 2025
ba0502c
Update screenshots
ElementBot Jan 23, 2025
da22758
Fix pagination restart issue and cover by unit test.
bmarty Jan 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

coerceAtLeast(0) here too?

}

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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add some comments to this function? It wasn't easy to understand why passing an empty list was still necessary and the side effects this method has with localMediaStates.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, done

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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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>()
Expand All @@ -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
Expand Down
Loading