Add ability to swipe between media when opened from the timeline.
This commit is contained in:
parent
9e5c5fa48a
commit
1776d93a20
20 changed files with 485 additions and 82 deletions
|
|
@ -123,6 +123,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
|
||||
@Parcelize
|
||||
data class MediaViewer(
|
||||
val mode: MediaViewerEntryPoint.MediaViewerMode,
|
||||
val eventId: EventId?,
|
||||
val mediaInfo: MediaInfo,
|
||||
val mediaSource: MediaSource,
|
||||
|
|
@ -248,8 +249,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
}
|
||||
is NavTarget.MediaViewer -> {
|
||||
val params = MediaViewerEntryPoint.Params(
|
||||
// TODO When we will be able to load a media timeline from a EventId, change mode here (and use a mixed mode?)
|
||||
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
|
||||
mode = navTarget.mode,
|
||||
eventId = navTarget.eventId,
|
||||
mediaInfo = navTarget.mediaInfo,
|
||||
mediaSource = navTarget.mediaSource,
|
||||
|
|
@ -362,6 +362,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
val navTarget = when (event.content) {
|
||||
is TimelineItemImageContent -> {
|
||||
buildMediaViewerNavTarget(
|
||||
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos,
|
||||
event = event,
|
||||
content = event.content,
|
||||
mediaSource = event.content.mediaSource,
|
||||
|
|
@ -373,6 +374,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
if encrypted on certain bridges */
|
||||
event.content.preferredMediaSource?.let { preferredMediaSource ->
|
||||
buildMediaViewerNavTarget(
|
||||
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos,
|
||||
event = event,
|
||||
content = event.content,
|
||||
mediaSource = preferredMediaSource,
|
||||
|
|
@ -382,6 +384,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
}
|
||||
is TimelineItemVideoContent -> {
|
||||
buildMediaViewerNavTarget(
|
||||
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos,
|
||||
event = event,
|
||||
content = event.content,
|
||||
mediaSource = event.content.mediaSource,
|
||||
|
|
@ -390,6 +393,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
}
|
||||
is TimelineItemFileContent -> {
|
||||
buildMediaViewerNavTarget(
|
||||
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios,
|
||||
event = event,
|
||||
content = event.content,
|
||||
mediaSource = event.content.mediaSource,
|
||||
|
|
@ -398,6 +402,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
}
|
||||
is TimelineItemAudioContent -> {
|
||||
buildMediaViewerNavTarget(
|
||||
mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios,
|
||||
event = event,
|
||||
content = event.content,
|
||||
mediaSource = event.content.mediaSource,
|
||||
|
|
@ -426,12 +431,14 @@ class MessagesFlowNode @AssistedInject constructor(
|
|||
}
|
||||
|
||||
private fun buildMediaViewerNavTarget(
|
||||
mode: MediaViewerEntryPoint.MediaViewerMode,
|
||||
event: TimelineItem.Event,
|
||||
content: TimelineItemEventContentWithAttachment,
|
||||
mediaSource: MediaSource,
|
||||
thumbnailSource: MediaSource?,
|
||||
): NavTarget {
|
||||
return NavTarget.MediaViewer(
|
||||
mode = mode,
|
||||
eventId = event.eventId,
|
||||
mediaInfo = MediaInfo(
|
||||
filename = content.filename,
|
||||
|
|
|
|||
|
|
@ -118,8 +118,9 @@ interface MatrixRoom : Closeable {
|
|||
|
||||
/**
|
||||
* Create a new timeline for the media events of the room.
|
||||
* @param eventId The event to focus on, if any.
|
||||
*/
|
||||
suspend fun mediaTimeline(): Result<Timeline>
|
||||
suspend fun mediaTimeline(eventId: EventId?): Result<Timeline>
|
||||
|
||||
fun destroy()
|
||||
|
||||
|
|
|
|||
|
|
@ -253,11 +253,21 @@ class RustMatrixRoom(
|
|||
}
|
||||
}
|
||||
|
||||
override suspend fun mediaTimeline(): Result<Timeline> = withContext(roomDispatcher) {
|
||||
override suspend fun mediaTimeline(
|
||||
eventId: EventId?,
|
||||
): Result<Timeline> = withContext(roomDispatcher) {
|
||||
val focus = if (eventId != null) {
|
||||
TimelineFocus.Event(
|
||||
eventId = eventId.value,
|
||||
numContextEvents = 50u,
|
||||
)
|
||||
} else {
|
||||
TimelineFocus.Live
|
||||
}
|
||||
runCatching {
|
||||
innerRoom.timelineWithConfiguration(
|
||||
configuration = TimelineConfiguration(
|
||||
focus = TimelineFocus.Live,
|
||||
focus = focus,
|
||||
allowedMessageTypes = AllowedMessageTypes.Only(
|
||||
types = listOf(
|
||||
RoomMessageEventMessageType.FILE,
|
||||
|
|
@ -270,7 +280,7 @@ class RustMatrixRoom(
|
|||
dateDividerMode = DateDividerMode.MONTHLY,
|
||||
)
|
||||
).let { inner ->
|
||||
createTimeline(inner, mode = Timeline.Mode.MEDIA)
|
||||
createTimeline(inner, mode = if (eventId != null) Timeline.Mode.FOCUSED_ON_EVENT else Timeline.Mode.MEDIA)
|
||||
}
|
||||
}.onFailure {
|
||||
if (it is CancellationException) {
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ class FakeMatrixRoom(
|
|||
private val getMembersResult: (Int) -> Result<List<RoomMember>> = { lambdaError() },
|
||||
private val timelineFocusedOnEventResult: (EventId) -> Result<Timeline> = { lambdaError() },
|
||||
private val pinnedEventsTimelineResult: () -> Result<Timeline> = { lambdaError() },
|
||||
private val mediaTimelineResult: () -> Result<Timeline> = { lambdaError() },
|
||||
private val mediaTimelineResult: (EventId?) -> Result<Timeline> = { lambdaError() },
|
||||
private val setSendQueueEnabledLambda: (Boolean) -> Unit = { _: Boolean -> },
|
||||
private val saveComposerDraftLambda: (ComposerDraft) -> Result<Unit> = { _: ComposerDraft -> Result.success(Unit) },
|
||||
private val loadComposerDraftLambda: () -> Result<ComposerDraft?> = { Result.success<ComposerDraft?>(null) },
|
||||
|
|
@ -215,8 +215,8 @@ class FakeMatrixRoom(
|
|||
pinnedEventsTimelineResult()
|
||||
}
|
||||
|
||||
override suspend fun mediaTimeline(): Result<Timeline> = simulateLongTask {
|
||||
mediaTimelineResult()
|
||||
override suspend fun mediaTimeline(eventId: EventId?): Result<Timeline> = simulateLongTask {
|
||||
mediaTimelineResult(eventId)
|
||||
}
|
||||
|
||||
override suspend fun subscribeToSync() {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ interface MediaViewerEntryPoint : FeatureEntryPoint {
|
|||
val canShowInfo: Boolean,
|
||||
) : NodeInputs
|
||||
|
||||
// TODO convert to sealed class and add eventId to the 2nd and 3rd items
|
||||
enum class MediaViewerMode {
|
||||
SingleMedia,
|
||||
TimelineImagesAndVideos,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* 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 com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import javax.inject.Inject
|
||||
|
||||
interface FocusedTimelineMediaGalleryDataSourceFactory {
|
||||
fun createFor(
|
||||
eventId: EventId,
|
||||
mediaItem: MediaItem.Event,
|
||||
): MediaGalleryDataSource
|
||||
}
|
||||
|
||||
@ContributesBinding(RoomScope::class)
|
||||
class DefaultFocusedTimelineMediaGalleryDataSourceFactory @Inject constructor(
|
||||
private val room: MatrixRoom,
|
||||
private val timelineMediaItemsFactory: TimelineMediaItemsFactory,
|
||||
private val mediaItemsPostProcessor: MediaItemsPostProcessor,
|
||||
) : FocusedTimelineMediaGalleryDataSourceFactory {
|
||||
override fun createFor(
|
||||
eventId: EventId,
|
||||
mediaItem: MediaItem.Event,
|
||||
): MediaGalleryDataSource {
|
||||
return TimelineMediaGalleryDataSource(
|
||||
room = room,
|
||||
mediaTimeline = FocusedMediaTimeline(
|
||||
room = room,
|
||||
eventId = eventId,
|
||||
initialMediaItem = mediaItem,
|
||||
),
|
||||
timelineMediaItemsFactory = timelineMediaItemsFactory,
|
||||
mediaItemsPostProcessor = mediaItemsPostProcessor,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ interface MediaGalleryDataSource {
|
|||
@ContributesBinding(RoomScope::class)
|
||||
class TimelineMediaGalleryDataSource @Inject constructor(
|
||||
private val room: MatrixRoom,
|
||||
private val mediaTimeline: MediaTimeline,
|
||||
private val timelineMediaItemsFactory: TimelineMediaItemsFactory,
|
||||
private val mediaItemsPostProcessor: MediaItemsPostProcessor,
|
||||
) : MediaGalleryDataSource {
|
||||
|
|
@ -48,7 +49,9 @@ class TimelineMediaGalleryDataSource @Inject constructor(
|
|||
|
||||
override fun groupedMediaItemsFlow(): Flow<AsyncData<GroupedMediaItems>> = groupedMediaItemsFlow
|
||||
|
||||
override fun getLastData(): AsyncData<GroupedMediaItems> = groupedMediaItemsFlow.replayCache.firstOrNull() ?: AsyncData.Uninitialized
|
||||
override fun getLastData(): AsyncData<GroupedMediaItems> = groupedMediaItemsFlow.replayCache.firstOrNull()
|
||||
?: mediaTimeline.getCache()?.let { AsyncData.Success(it) }
|
||||
?: AsyncData.Uninitialized
|
||||
|
||||
private val isStarted = AtomicBoolean(false)
|
||||
|
||||
|
|
@ -58,8 +61,13 @@ class TimelineMediaGalleryDataSource @Inject constructor(
|
|||
return
|
||||
}
|
||||
flow {
|
||||
groupedMediaItemsFlow.emit(AsyncData.Loading())
|
||||
room.mediaTimeline().fold(
|
||||
val cache = mediaTimeline.getCache()
|
||||
if (cache != null) {
|
||||
groupedMediaItemsFlow.emit(AsyncData.Success(cache))
|
||||
} else {
|
||||
groupedMediaItemsFlow.emit(AsyncData.Loading())
|
||||
}
|
||||
mediaTimeline.getTimeline().fold(
|
||||
{
|
||||
timeline = it
|
||||
emit(it)
|
||||
|
|
@ -78,6 +86,8 @@ class TimelineMediaGalleryDataSource @Inject constructor(
|
|||
timelineMediaItemsFactory.timelineItems
|
||||
}.map { timelineItems ->
|
||||
mediaItemsPostProcessor.process(mediaItems = timelineItems)
|
||||
}.map {
|
||||
mediaTimeline.orCache(it)
|
||||
}.onEach { groupedMediaItems ->
|
||||
groupedMediaItemsFlow.emit(AsyncData.Success(groupedMediaItems))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,15 +108,15 @@ fun MediaGalleryView(
|
|||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.consumeWindowInsets(paddingValues)
|
||||
.fillMaxSize(),
|
||||
.padding(paddingValues)
|
||||
.consumeWindowInsets(paddingValues)
|
||||
.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
SingleChoiceSegmentedButtonRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
MediaGalleryMode.entries.forEach { mode ->
|
||||
SegmentedButton(
|
||||
|
|
@ -354,8 +354,8 @@ private fun MediaGalleryImageGrid(
|
|||
) {
|
||||
LazyVerticalGrid(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
columns = GridCells.Adaptive(80.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
|
|
@ -426,9 +426,9 @@ private fun LoadingMoreIndicator(
|
|||
Timeline.PaginationDirection.FORWARDS -> {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 2.dp)
|
||||
.height(1.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(top = 2.dp)
|
||||
.height(1.dp)
|
||||
)
|
||||
}
|
||||
Timeline.PaginationDirection.BACKWARDS -> {
|
||||
|
|
@ -440,7 +440,10 @@ private fun LoadingMoreIndicator(
|
|||
}
|
||||
val latestEventSink by rememberUpdatedState(eventSink)
|
||||
LaunchedEffect(item.timestamp) {
|
||||
latestEventSink(MediaGalleryEvents.LoadMore(item.direction))
|
||||
// TODO Add isFake to the model instead of using -1 for timestamp
|
||||
if (item.timestamp != -1L) {
|
||||
latestEventSink(MediaGalleryEvents.LoadMore(item.direction))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -466,9 +469,9 @@ private fun EmptyContent(
|
|||
OnboardingBackground()
|
||||
PageTitle(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 44.dp)
|
||||
.padding(24.dp),
|
||||
.fillMaxWidth()
|
||||
.padding(top = 44.dp)
|
||||
.padding(24.dp),
|
||||
title = stringResource(titleRes),
|
||||
iconStyle = BigIcon.Style.Default(icon),
|
||||
subtitle = stringResource(subtitleRes),
|
||||
|
|
@ -486,9 +489,9 @@ private fun LoadingContent(
|
|||
OnboardingBackground()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 48.dp)
|
||||
.padding(24.dp),
|
||||
.fillMaxSize()
|
||||
.padding(top = 48.dp)
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,115 @@
|
|||
/*
|
||||
* 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 com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
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.room.MatrixRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import javax.inject.Inject
|
||||
|
||||
interface MediaTimeline {
|
||||
suspend fun getTimeline(): Result<Timeline>
|
||||
fun getCache(): GroupedMediaItems?
|
||||
fun orCache(data: GroupedMediaItems): GroupedMediaItems
|
||||
}
|
||||
|
||||
/**
|
||||
* A timeline holder that can be used by the gallery and the media viewer.
|
||||
* When opening the Media Viewer, if the held timeline knows the Event, it will
|
||||
* be used, else a FocusedMediaTimeline will be used.
|
||||
*/
|
||||
@SingleIn(RoomScope::class)
|
||||
@ContributesBinding(RoomScope::class)
|
||||
class LiveMediaTimeline @Inject constructor(
|
||||
private val room: MatrixRoom,
|
||||
) : MediaTimeline {
|
||||
private var timeline: Timeline? = null
|
||||
override suspend fun getTimeline(): Result<Timeline> {
|
||||
return if (timeline == null) {
|
||||
room.mediaTimeline(null).fold(
|
||||
{
|
||||
timeline = it
|
||||
Result.success(it)
|
||||
},
|
||||
{
|
||||
Result.failure(it)
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Result.success(timeline!!)
|
||||
}
|
||||
}
|
||||
|
||||
// No cache for LiveMediaTimeline
|
||||
override fun getCache(): GroupedMediaItems? = null
|
||||
override fun orCache(data: GroupedMediaItems) = data
|
||||
}
|
||||
|
||||
/**
|
||||
* A class that will provide a media timeline that is focused on a particular event.
|
||||
*/
|
||||
class FocusedMediaTimeline(
|
||||
private val room: MatrixRoom,
|
||||
private val eventId: EventId,
|
||||
private val initialMediaItem: MediaItem.Event,
|
||||
) : MediaTimeline {
|
||||
override suspend fun getTimeline(): Result<Timeline> {
|
||||
return room.mediaTimeline(eventId)
|
||||
}
|
||||
|
||||
override fun getCache(): GroupedMediaItems {
|
||||
// TODO Cleanup
|
||||
return GroupedMediaItems(
|
||||
fileItems = persistentListOf(
|
||||
MediaItem.LoadingIndicator(
|
||||
id = UniqueId("loading_forwards"),
|
||||
direction = Timeline.PaginationDirection.FORWARDS,
|
||||
timestamp = -1L,
|
||||
),
|
||||
initialMediaItem,
|
||||
MediaItem.LoadingIndicator(
|
||||
id = UniqueId("loading_backwards"),
|
||||
direction = Timeline.PaginationDirection.BACKWARDS,
|
||||
timestamp = -1L,
|
||||
),
|
||||
),
|
||||
imageAndVideoItems = persistentListOf(
|
||||
MediaItem.LoadingIndicator(
|
||||
id = UniqueId("loading_forwards"),
|
||||
direction = Timeline.PaginationDirection.FORWARDS,
|
||||
timestamp = -1L,
|
||||
),
|
||||
initialMediaItem,
|
||||
MediaItem.LoadingIndicator(
|
||||
id = UniqueId("loading_backwards"),
|
||||
direction = Timeline.PaginationDirection.BACKWARDS,
|
||||
timestamp = -1L,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override fun orCache(data: GroupedMediaItems): GroupedMediaItems {
|
||||
return if (data.hasEvent(eventId)) {
|
||||
data
|
||||
} else {
|
||||
getCache()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun GroupedMediaItems.hasEvent(eventId: EventId): Boolean {
|
||||
return (fileItems + imageAndVideoItems)
|
||||
.filterIsInstance<MediaItem.Event>()
|
||||
.any { it.eventId() == eventId }
|
||||
}
|
||||
|
|
@ -29,57 +29,57 @@ class SingleMediaGalleryDataSource(
|
|||
|
||||
companion object {
|
||||
fun createFrom(params: MediaViewerEntryPoint.Params) = SingleMediaGalleryDataSource(
|
||||
data = when {
|
||||
params.mediaInfo.mimeType.isMimeTypeImage() -> {
|
||||
MediaItem.Image(
|
||||
id = UniqueId("dummy"),
|
||||
eventId = params.eventId,
|
||||
mediaInfo = params.mediaInfo,
|
||||
mediaSource = params.mediaSource,
|
||||
thumbnailSource = params.thumbnailSource,
|
||||
)
|
||||
}
|
||||
params.mediaInfo.mimeType.isMimeTypeVideo() -> {
|
||||
MediaItem.Video(
|
||||
id = UniqueId("dummy"),
|
||||
eventId = params.eventId,
|
||||
mediaInfo = params.mediaInfo,
|
||||
mediaSource = params.mediaSource,
|
||||
thumbnailSource = params.thumbnailSource,
|
||||
)
|
||||
}
|
||||
params.mediaInfo.mimeType.isMimeTypeAudio() -> {
|
||||
if (params.mediaInfo.waveform == null) {
|
||||
MediaItem.Audio(
|
||||
id = UniqueId("dummy"),
|
||||
eventId = params.eventId,
|
||||
mediaInfo = params.mediaInfo,
|
||||
mediaSource = params.mediaSource,
|
||||
)
|
||||
} else {
|
||||
MediaItem.Voice(
|
||||
id = UniqueId("dummy"),
|
||||
eventId = params.eventId,
|
||||
mediaInfo = params.mediaInfo,
|
||||
mediaSource = params.mediaSource,
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
MediaItem.File(
|
||||
id = UniqueId("dummy"),
|
||||
eventId = params.eventId,
|
||||
mediaInfo = params.mediaInfo,
|
||||
mediaSource = params.mediaSource,
|
||||
)
|
||||
}
|
||||
}.let { mediaItem ->
|
||||
GroupedMediaItems(
|
||||
// Always use imageAndVideoItems, in Single mode, this is the data that will be used
|
||||
imageAndVideoItems = persistentListOf(mediaItem),
|
||||
fileItems = persistentListOf(),
|
||||
)
|
||||
}
|
||||
data = GroupedMediaItems(
|
||||
// Always use imageAndVideoItems, in Single mode, this is the data that will be used
|
||||
imageAndVideoItems = persistentListOf(params.toMediaItem()),
|
||||
fileItems = persistentListOf(),
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun MediaViewerEntryPoint.Params.toMediaItem() = when {
|
||||
mediaInfo.mimeType.isMimeTypeImage() -> {
|
||||
MediaItem.Image(
|
||||
id = UniqueId("dummy"),
|
||||
eventId = eventId,
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = thumbnailSource,
|
||||
)
|
||||
}
|
||||
mediaInfo.mimeType.isMimeTypeVideo() -> {
|
||||
MediaItem.Video(
|
||||
id = UniqueId("dummy"),
|
||||
eventId = eventId,
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = mediaSource,
|
||||
thumbnailSource = thumbnailSource,
|
||||
)
|
||||
}
|
||||
mediaInfo.mimeType.isMimeTypeAudio() -> {
|
||||
if (mediaInfo.waveform == null) {
|
||||
MediaItem.Audio(
|
||||
id = UniqueId("dummy"),
|
||||
eventId = eventId,
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = mediaSource,
|
||||
)
|
||||
} else {
|
||||
MediaItem.Voice(
|
||||
id = UniqueId("dummy"),
|
||||
eventId = eventId,
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = mediaSource,
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
MediaItem.File(
|
||||
id = UniqueId("dummy"),
|
||||
eventId = eventId,
|
||||
mediaInfo = mediaInfo,
|
||||
mediaSource = mediaSource,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ class MediaViewerDataSource(
|
|||
private val mediaLoader: MatrixMediaLoader,
|
||||
private val localMediaFactory: LocalMediaFactory,
|
||||
private val systemClock: SystemClock,
|
||||
private val pagerKeysHandler: PagerKeysHandler,
|
||||
) {
|
||||
// List of media files that are currently being loaded
|
||||
private val mediaFiles: MutableList<MediaFile> = mutableListOf()
|
||||
|
|
@ -78,6 +79,7 @@ class MediaViewerDataSource(
|
|||
MediaViewerPageData.Loading(
|
||||
direction = Timeline.PaginationDirection.BACKWARDS,
|
||||
timestamp = systemClock.epochMillis(),
|
||||
pagerKey = Long.MIN_VALUE,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -108,7 +110,10 @@ class MediaViewerDataSource(
|
|||
* will be used to render the downloaded media (see [loadMedia] which will update this value).
|
||||
*/
|
||||
private fun buildMediaViewerPageList(groupedItems: List<MediaItem>) = buildList {
|
||||
groupedItems.forEach { mediaItem ->
|
||||
// Filter out DateSeparator items, we do not need them for the media viewer
|
||||
val groupedItemsNoDateSeparator = groupedItems.filterNot { it is MediaItem.DateSeparator }
|
||||
pagerKeysHandler.accept(groupedItemsNoDateSeparator)
|
||||
groupedItemsNoDateSeparator.forEach { mediaItem ->
|
||||
when (mediaItem) {
|
||||
is MediaItem.DateSeparator -> Unit
|
||||
is MediaItem.Event -> {
|
||||
|
|
@ -123,6 +128,7 @@ class MediaViewerDataSource(
|
|||
mediaSource = mediaItem.mediaSource(),
|
||||
thumbnailSource = mediaItem.thumbnailSource(),
|
||||
downloadedMedia = localMedia,
|
||||
pagerKey = pagerKeysHandler.getKey(mediaItem),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -130,6 +136,7 @@ class MediaViewerDataSource(
|
|||
MediaViewerPageData.Loading(
|
||||
direction = mediaItem.direction,
|
||||
timestamp = systemClock.epochMillis(),
|
||||
pagerKey = pagerKeysHandler.getKey(mediaItem),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,9 +24,12 @@ 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.FocusedTimelineMediaGalleryDataSourceFactory
|
||||
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
|
||||
import io.element.android.libraries.mediaviewer.impl.gallery.hasEvent
|
||||
import io.element.android.libraries.mediaviewer.impl.gallery.toMediaItem
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
|
|
@ -35,10 +38,12 @@ class MediaViewerNode @AssistedInject constructor(
|
|||
@Assisted plugins: List<Plugin>,
|
||||
presenterFactory: MediaViewerPresenter.Factory,
|
||||
timelineMediaGalleryDataSource: TimelineMediaGalleryDataSource,
|
||||
focusedTimelineMediaGalleryDataSourceFactory: FocusedTimelineMediaGalleryDataSourceFactory,
|
||||
mediaLoader: MatrixMediaLoader,
|
||||
localMediaFactory: LocalMediaFactory,
|
||||
coroutineDispatchers: CoroutineDispatchers,
|
||||
systemClock: SystemClock,
|
||||
pagerKeysHandler: PagerKeysHandler,
|
||||
) : Node(buildContext, plugins = plugins),
|
||||
MediaViewerNavigator {
|
||||
private val inputs = inputs<MediaViewerEntryPoint.Params>()
|
||||
|
|
@ -62,7 +67,23 @@ class MediaViewerNode @AssistedInject constructor(
|
|||
private val mediaGallerySource = if (inputs.mode == MediaViewerEntryPoint.MediaViewerMode.SingleMedia) {
|
||||
SingleMediaGalleryDataSource.createFrom(inputs)
|
||||
} else {
|
||||
timelineMediaGalleryDataSource
|
||||
val eventId = inputs.eventId
|
||||
if (eventId == null) {
|
||||
// Should not happen
|
||||
timelineMediaGalleryDataSource
|
||||
} else {
|
||||
// Does timelineMediaGalleryDataSource knows the eventId?
|
||||
val lastData = timelineMediaGalleryDataSource.getLastData().dataOrNull()
|
||||
val isEventKnown = lastData?.hasEvent(eventId) == true
|
||||
if (isEventKnown) {
|
||||
timelineMediaGalleryDataSource
|
||||
} else {
|
||||
focusedTimelineMediaGalleryDataSourceFactory.createFor(
|
||||
eventId = eventId,
|
||||
mediaItem = inputs.toMediaItem(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val galleryMode = when (inputs.mode) {
|
||||
|
|
@ -81,6 +102,7 @@ class MediaViewerNode @AssistedInject constructor(
|
|||
mediaLoader = mediaLoader,
|
||||
localMediaFactory = localMediaFactory,
|
||||
systemClock = systemClock,
|
||||
pagerKeysHandler = pagerKeysHandler,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -28,13 +28,17 @@ data class MediaViewerState(
|
|||
)
|
||||
|
||||
sealed interface MediaViewerPageData {
|
||||
val pagerKey: Long
|
||||
|
||||
data class Failure(
|
||||
val throwable: Throwable,
|
||||
override val pagerKey: Long = 0,
|
||||
) : MediaViewerPageData
|
||||
|
||||
data class Loading(
|
||||
val direction: Timeline.PaginationDirection,
|
||||
val timestamp: Long,
|
||||
override val pagerKey: Long,
|
||||
) : MediaViewerPageData
|
||||
|
||||
data class MediaViewerData(
|
||||
|
|
@ -43,5 +47,14 @@ sealed interface MediaViewerPageData {
|
|||
val mediaSource: MediaSource,
|
||||
val thumbnailSource: MediaSource?,
|
||||
val downloadedMedia: State<AsyncData<LocalMedia>>,
|
||||
override val pagerKey: Long,
|
||||
) : MediaViewerPageData
|
||||
}
|
||||
|
||||
fun MediaViewerPageData.toKey(): String {
|
||||
return when (this) {
|
||||
is MediaViewerPageData.Failure -> "Failure"
|
||||
is MediaViewerPageData.Loading -> "Loading_${direction}"
|
||||
is MediaViewerPageData.MediaViewerData -> eventId?.value ?: mediaSource.url
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@ fun aMediaViewerPageDataLoading(
|
|||
return MediaViewerPageData.Loading(
|
||||
direction = direction,
|
||||
timestamp = timestamp,
|
||||
pagerKey = 0L,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -182,6 +183,7 @@ fun aMediaViewerPageData(
|
|||
mediaSource = mediaSource,
|
||||
thumbnailSource = null,
|
||||
downloadedMedia = mutableStateOf(downloadedMedia),
|
||||
pagerKey = 0L,
|
||||
)
|
||||
|
||||
fun aMediaViewerState(
|
||||
|
|
|
|||
|
|
@ -114,6 +114,7 @@ fun MediaViewerView(
|
|||
modifier = Modifier,
|
||||
// Pre-load previous and next pages
|
||||
beyondViewportPageCount = 1,
|
||||
key = { index -> state.listData[index].pagerKey },
|
||||
) { page ->
|
||||
when (val dataForPage = state.listData[page]) {
|
||||
is MediaViewerPageData.Failure -> {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* 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 io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
|
||||
import io.element.android.libraries.mediaviewer.impl.gallery.eventId
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* x and y are loading items.
|
||||
* Capital letters are media items.
|
||||
* First list emitted
|
||||
* x F G H y
|
||||
* indexes will be
|
||||
* 0 1 2 3 4
|
||||
* (keyOffset = 0)
|
||||
* New items added to the end of the list
|
||||
* x F G H I J K y
|
||||
* indexes will be
|
||||
* 0 1 2 3 4 5 6 7
|
||||
* (keyOffset = 0)
|
||||
* New items added to the beginning of the list
|
||||
* x D E F G H I J K y
|
||||
* indexes will be
|
||||
* -2 -1 0 1 2 3 4 5 6 7
|
||||
* (keyOffset = -2)
|
||||
* loader item vanishes
|
||||
* D E F G H I J K
|
||||
* indexes will be
|
||||
* -1 0 1 2 3 4 5 6
|
||||
* (keyOffset = -1)
|
||||
*/
|
||||
class PagerKeysHandler @Inject constructor() {
|
||||
private data class Data(
|
||||
val mediaItems: List<MediaItem>,
|
||||
val keyOffset: Long,
|
||||
)
|
||||
|
||||
// Will store the list of media items and the key offset of the first item in the list
|
||||
private var cachedData: Data = Data(emptyList(), 0)
|
||||
|
||||
fun accept(mediaItems: List<MediaItem>) {
|
||||
if (cachedData.mediaItems.isEmpty()) {
|
||||
cachedData = Data(mediaItems, 0)
|
||||
} else {
|
||||
// Search a common item in both lists, i.e. an item with the same eventId
|
||||
val itemInCacheIndex = cachedData.mediaItems.indexOfFirst { mediaItem ->
|
||||
mediaItem is MediaItem.Event && mediaItems
|
||||
.filterIsInstance<MediaItem.Event>()
|
||||
.any { mediaItem.eventId() == it.eventId() }
|
||||
}
|
||||
cachedData = if (itemInCacheIndex == -1) {
|
||||
// If the item is not found, start with a new cache
|
||||
Data(mediaItems, 0)
|
||||
} else {
|
||||
val cachedItem = cachedData.mediaItems[itemInCacheIndex]
|
||||
val eventId = (cachedItem as? MediaItem.Event)?.eventId()
|
||||
if (eventId == null) {
|
||||
// Should not happen, but in this case, start with a new cache
|
||||
Data(mediaItems, 0)
|
||||
} else {
|
||||
// Search the index of the item in the new list
|
||||
val itemIndex = mediaItems.indexOfFirst { mediaItem ->
|
||||
mediaItem is MediaItem.Event && mediaItem.eventId() == eventId
|
||||
}
|
||||
if (itemIndex == -1) {
|
||||
// If the item is not found, start with a new cache
|
||||
Data(mediaItems, 0)
|
||||
} else {
|
||||
// Update the cache with the new list and the new offset
|
||||
Data(mediaItems, cachedData.keyOffset + itemInCacheIndex - itemIndex.toLong())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getKey(mediaItem: MediaItem): Long {
|
||||
return cachedData.mediaItems.indexOf(mediaItem) + cachedData.keyOffset
|
||||
}
|
||||
}
|
||||
|
|
@ -260,6 +260,7 @@ class TimelineMediaGalleryDataSourceTest {
|
|||
): TimelineMediaGalleryDataSource {
|
||||
return TimelineMediaGalleryDataSource(
|
||||
room = room,
|
||||
mediaTimeline = LiveMediaTimeline(room),
|
||||
timelineMediaItemsFactory = TimelineMediaItemsFactory(
|
||||
dispatchers = testCoroutineDispatchers(),
|
||||
virtualItemFactory = VirtualItemFactory(
|
||||
|
|
|
|||
|
|
@ -122,10 +122,12 @@ class MediaViewerDataSourceTest {
|
|||
MediaViewerPageData.Loading(
|
||||
direction = Timeline.PaginationDirection.BACKWARDS,
|
||||
timestamp = A_FAKE_TIMESTAMP,
|
||||
pagerKey = 0L,
|
||||
),
|
||||
MediaViewerPageData.Loading(
|
||||
direction = Timeline.PaginationDirection.FORWARDS,
|
||||
timestamp = A_FAKE_TIMESTAMP,
|
||||
pagerKey = 1L,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -274,5 +276,6 @@ class MediaViewerDataSourceTest {
|
|||
mediaLoader = mediaLoader,
|
||||
localMediaFactory = localMediaFactory,
|
||||
systemClock = FakeSystemClock(),
|
||||
pagerKeysHandler = PagerKeysHandler(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -792,6 +792,7 @@ class MediaViewerPresenterTest {
|
|||
mediaLoader = matrixMediaLoader,
|
||||
localMediaFactory = localMediaFactory,
|
||||
systemClock = FakeSystemClock(),
|
||||
pagerKeysHandler = PagerKeysHandler(),
|
||||
),
|
||||
room = room,
|
||||
localMediaActions = localMediaActions,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* 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 com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
|
||||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage
|
||||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemLoadingIndicator
|
||||
import org.junit.Test
|
||||
|
||||
class PagerKeysHandlerTest {
|
||||
private val image1 = aMediaItemImage(
|
||||
eventId = AN_EVENT_ID,
|
||||
)
|
||||
private val image2 = aMediaItemImage(
|
||||
eventId = AN_EVENT_ID_2,
|
||||
)
|
||||
private val aBackwardLoadingIndicator = aMediaItemLoadingIndicator(
|
||||
direction = Timeline.PaginationDirection.BACKWARDS
|
||||
)
|
||||
private val aForwardLoadingIndicator = aMediaItemLoadingIndicator(
|
||||
direction = Timeline.PaginationDirection.FORWARDS
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `when new items are inserted after existing items, keys are not shifted`() {
|
||||
val sut = PagerKeysHandler()
|
||||
sut.accept(listOf(aBackwardLoadingIndicator, image1, aForwardLoadingIndicator))
|
||||
assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(0)
|
||||
assertThat(sut.getKey(image1)).isEqualTo(1)
|
||||
assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(2)
|
||||
sut.accept(listOf(aBackwardLoadingIndicator, image1, image2, aForwardLoadingIndicator))
|
||||
assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(0)
|
||||
assertThat(sut.getKey(image1)).isEqualTo(1)
|
||||
assertThat(sut.getKey(image2)).isEqualTo(2)
|
||||
assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when new items are inserted before existing items, keys are not shifted`() {
|
||||
val sut = PagerKeysHandler()
|
||||
sut.accept(listOf(aBackwardLoadingIndicator, image1, aForwardLoadingIndicator))
|
||||
assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(0)
|
||||
assertThat(sut.getKey(image1)).isEqualTo(1)
|
||||
assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(2)
|
||||
sut.accept(listOf(aBackwardLoadingIndicator, image2, image1, aForwardLoadingIndicator))
|
||||
assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(-1)
|
||||
assertThat(sut.getKey(image2)).isEqualTo(0)
|
||||
assertThat(sut.getKey(image1)).isEqualTo(1)
|
||||
assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(2)
|
||||
// Accepting the same list should not change the keys
|
||||
sut.accept(listOf(aBackwardLoadingIndicator, image2, image1, aForwardLoadingIndicator))
|
||||
assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(-1)
|
||||
assertThat(sut.getKey(image2)).isEqualTo(0)
|
||||
assertThat(sut.getKey(image1)).isEqualTo(1)
|
||||
assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when loaders are removed, keys are not shifted`() {
|
||||
val sut = PagerKeysHandler()
|
||||
sut.accept(listOf(aBackwardLoadingIndicator, image1, aForwardLoadingIndicator))
|
||||
assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(0)
|
||||
assertThat(sut.getKey(image1)).isEqualTo(1)
|
||||
assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(2)
|
||||
sut.accept(listOf(image1))
|
||||
assertThat(sut.getKey(image1)).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue