Media navigation with swipe gesture

<!-- Please read [CONTRIBUTING.md](https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md) before submitting your pull request -->

## Content

<!-- Describe shortly what has been changed -->
Add ability to swipe between media (images and videos) and files (files, audio and voice message) when navigating from the gallery.

Note: for now, when opening a media from the room, swipe is not supported. This support will come later.

## Motivation and context

<!-- Provide link to the corresponding issue if applicable or explain the context -->
Closes #4174
Related to #1484

## Screenshots / GIFs

<!--
We have screenshot tests in the project, so attaching screenshots to a PR is not mandatory, as far as there
is a Composable Preview covering the changes. In this case, the change will appear in the file diff.
Note that all the UI composables should be covered by a Composable Preview.

Providing a video of the change is still very useful for the reviewer and for the history of the project.

You can use a table like this to show screenshots comparison.
Uncomment this markdown table below and edit the last line `|||`:
|copy screenshot of before here|copy screenshot of after here|

|Before|After|
|-|-|
|||
 -->

https://github.com/user-attachments/assets/38eeaa17-c210-425f-9238-b4e10c8143fd

## Tests

<!-- Explain how you tested your development -->

- Open a room and navigate to settings/Media and files
- Open an image
- Swipe left and right
- press back
- open the files tab
- open a file
- Swipe left and right

## Tested devices

- [ ] Physical
- [x] Emulator
- OS version(s):

## Checklist

<!-- Depending on the Pull Request content, it can be acceptable if some of the following checkboxes stay unchecked. -->

- [ ] Changes have been tested on an Android device or Android emulator with API 24
- [ ] UI change has been tested on both light and dark themes
- [ ] Accessibility has been taken into account. See https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#accessibility
- [ ] Pull request is based on the develop branch
- [ ] Pull request title will be used in the release note, it clearly define what will change for the user
- [ ] Pull request includes screenshots or videos if containing UI changes
- [ ] You've made a self review of your PR

[Task] Attachment viewer: navigation by swiping left and right
Please see and discuss the details in the meta issue.

(Note: this is actually #1484)

diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
index 61f6426978d..f9d9bbf27f0 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
@@ -48,6 +48,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
 import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
 import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
 import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
+import io.element.android.features.messages.impl.timeline.model.event.duration
 import io.element.android.features.poll.api.create.CreatePollEntryPoint
 import io.element.android.features.poll.api.create.CreatePollMode
 import io.element.android.libraries.architecture.BackstackWithOverlayBox
@@ -58,6 +59,7 @@ import io.element.android.libraries.architecture.overlay.operation.hide
 import io.element.android.libraries.architecture.overlay.operation.show
 import io.element.android.libraries.dateformatter.api.DateFormatter
 import io.element.android.libraries.dateformatter.api.DateFormatterMode
+import io.element.android.libraries.dateformatter.api.toHumanReadableDuration
 import io.element.android.libraries.di.RoomScope
 import io.element.android.libraries.matrix.api.MatrixClient
 import io.element.android.libraries.matrix.api.core.EventId
@@ -246,6 +248,8 @@ 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,
                     eventId = navTarget.eventId,
                     mediaInfo = navTarget.mediaInfo,
                     mediaSource = navTarget.mediaSource,
@@ -447,6 +451,7 @@ class MessagesFlowNode @AssistedInject constructor(
                     mode = DateFormatterMode.Full,
                 ),
                 waveform = (content as? TimelineItemVoiceContent)?.waveform,
+                duration = content.duration()?.toHumanReadableDuration(),
             ),
             mediaSource = mediaSource,
             thumbnailSource = thumbnailSource,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt
index aeb847b80ac..9eda2e72536 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt
@@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.timeline.model.event

 import androidx.compose.runtime.Immutable
 import io.element.android.libraries.matrix.api.media.MediaSource
+import kotlin.time.Duration

 @Immutable
 sealed interface TimelineItemEventContent {
@@ -90,3 +91,12 @@ fun TimelineItemEventContent.isEdited(): Boolean = when (this) {
     is TimelineItemEventMutableContent -> isEdited
     else -> false
 }
+
+fun TimelineItemEventContentWithAttachment.duration(): Duration? {
+    return when (this) {
+        is TimelineItemAudioContent -> duration
+        is TimelineItemVideoContent -> duration
+        is TimelineItemVoiceContent -> duration
+        else -> null
+    }
+}
diff --git a/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt b/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt
index 63e30d49285..04c6df1d022 100644
--- a/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt
+++ b/libraries/dateformatter/api/src/main/kotlin/io/element/android/libraries/dateformatter/api/DurationFormatter.kt
@@ -8,6 +8,7 @@
 package io.element.android.libraries.dateformatter.api

 import java.util.Locale
+import kotlin.time.Duration

 /**
  * Convert milliseconds to human readable duration.
@@ -38,3 +39,5 @@ fun Long.toHumanReadableDuration(): String {
         String.format(Locale.US, "%d:%02d", minutes, seconds)
     }
 }
+
+fun Duration.toHumanReadableDuration() = inWholeMilliseconds.toHumanReadableDuration()
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
index 3e10ce1a5bf..4908d73d2e8 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt
@@ -55,8 +55,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.filter
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.getAndUpdate
 import kotlinx.coroutines.flow.launchIn
@@ -213,8 +211,8 @@ class RustTimeline(

     override val timelineItems: Flow<List<MatrixTimelineItem>> = combine(
         _timelineItems,
-        backPaginationStatus.filter { !it.isPaginating }.distinctUntilChanged(),
-        forwardPaginationStatus.filter { !it.isPaginating }.distinctUntilChanged(),
+        backPaginationStatus,
+        forwardPaginationStatus,
         matrixRoom.roomInfoFlow.map { it.creator },
         isTimelineInitialized,
     ) { timelineItems,
diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt
index 374bf701a6c..7426251ca0f 100644
--- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt
+++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt
@@ -25,6 +25,7 @@ data class MediaInfo(
     val dateSent: String?,
     val dateSentFull: String?,
     val waveform: List<Float>?,
+    val duration: String?,
 ) : Parcelable

 fun anImageMediaInfo(
@@ -45,6 +46,7 @@ fun anImageMediaInfo(
     dateSent = dateSent,
     dateSentFull = dateSentFull,
     waveform = null,
+    duration = null,
 )

 fun aVideoMediaInfo(
@@ -52,6 +54,7 @@ fun aVideoMediaInfo(
     senderName: String? = null,
     dateSent: String? = null,
     dateSentFull: String? = null,
+    duration: String? = null,
 ): MediaInfo = MediaInfo(
     filename = "a video file.mp4",
     caption = caption,
@@ -64,6 +67,7 @@ fun aVideoMediaInfo(
     dateSent = dateSent,
     dateSentFull = dateSentFull,
     waveform = null,
+    duration = duration,
 )

 fun aPdfMediaInfo(
@@ -84,6 +88,7 @@ fun aPdfMediaInfo(
     dateSent = dateSent,
     dateSentFull = dateSentFull,
     waveform = null,
+    duration = null,
 )

 fun anApkMediaInfo(
@@ -103,6 +108,7 @@ fun anApkMediaInfo(
     dateSent = dateSent,
     dateSentFull = dateSentFull,
     waveform = null,
+    duration = null,
 )

 fun anAudioMediaInfo(
@@ -112,6 +118,7 @@ fun anAudioMediaInfo(
     dateSent: String? = null,
     dateSentFull: String? = null,
     waveForm: List<Float>? = null,
+    duration: String? = null,
 ): MediaInfo = MediaInfo(
     filename = filename,
     caption = caption,
@@ -124,6 +131,7 @@ fun anAudioMediaInfo(
     dateSent = dateSent,
     dateSentFull = dateSentFull,
     waveform = waveForm,
+    duration = duration,
 )

 fun aVoiceMediaInfo(
@@ -133,6 +141,7 @@ fun aVoiceMediaInfo(
     dateSent: String? = null,
     dateSentFull: String? = null,
     waveForm: List<Float>? = null,
+    duration: String? = null,
 ): MediaInfo = MediaInfo(
     filename = filename,
     caption = caption,
@@ -145,4 +154,5 @@ fun aVoiceMediaInfo(
     dateSent = dateSent,
     dateSentFull = dateSentFull,
     waveform = waveForm,
+    duration = duration,
 )
diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt
index d76cd9e2d85..a824fc55406 100644
--- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt
+++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaViewerEntryPoint.kt
@@ -31,10 +31,17 @@ interface MediaViewerEntryPoint : FeatureEntryPoint {
     }

     data class Params(
+        val mode: MediaViewerMode,
         val eventId: EventId?,
         val mediaInfo: MediaInfo,
         val mediaSource: MediaSource,
         val thumbnailSource: MediaSource?,
         val canShowInfo: Boolean,
     ) : NodeInputs
+
+    enum class MediaViewerMode {
+        SingleMedia,
+        TimelineImagesAndVideos,
+        TimelineFilesAndAudios,
+    }
 }
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt
index 5a2912fdbe7..64cd9093a2b 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt
@@ -42,6 +42,7 @@ class DefaultMediaViewerEntryPoint @Inject constructor() : MediaViewerEntryPoint
                 val mimeType = MimeTypes.Images
                 return params(
                     MediaViewerEntryPoint.Params(
+                        mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
                         eventId = null,
                         mediaInfo = MediaInfo(
                             filename = filename,
@@ -55,6 +56,7 @@ class DefaultMediaViewerEntryPoint @Inject constructor() : MediaViewerEntryPoint
                             dateSent = null,
                             dateSentFull = null,
                             waveform = null,
+                            duration = null,
                         ),
                         mediaSource = MediaSource(url = avatarUrl),
                         thumbnailSource = null,
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt
index e8e242d2e0f..293329c2dd6 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt
@@ -40,7 +40,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.getAvatarUrl
 import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
 import io.element.android.libraries.mediaviewer.api.MediaInfo
 import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
-import kotlinx.collections.immutable.persistentListOf
 import timber.log.Timber
 import javax.inject.Inject

@@ -102,6 +101,7 @@ class EventItemFactory @Inject constructor(
                             dateSent = dateSent,
                             dateSentFull = dateSentFull,
                             waveform = null,
+                            duration = null,
                         ),
                         mediaSource = type.source,
                     )
@@ -120,8 +120,10 @@ class EventItemFactory @Inject constructor(
                             dateSent = dateSent,
                             dateSentFull = dateSentFull,
                             waveform = null,
+                            duration = null,
                         ),
                         mediaSource = type.source,
+                        // TODO We may want to add a thumbnailSource and set it to type.info?.thumbnailSource
                     )
                     is ImageMessageType -> MediaItem.Image(
                         id = currentTimelineItem.uniqueId,
@@ -138,9 +140,10 @@ class EventItemFactory @Inject constructor(
                             dateSent = dateSent,
                             dateSentFull = dateSentFull,
                             waveform = null,
+                            duration = null,
                         ),
                         mediaSource = type.source,
-                        thumbnailSource = null,
+                        thumbnailSource = type.info?.thumbnailSource,
                     )
                     is StickerMessageType -> MediaItem.Image(
                         id = currentTimelineItem.uniqueId,
@@ -157,9 +160,10 @@ class EventItemFactory @Inject constructor(
                             dateSent = dateSent,
                             dateSentFull = dateSentFull,
                             waveform = null,
+                            duration = null,
                         ),
                         mediaSource = type.source,
-                        thumbnailSource = null,
+                        thumbnailSource = type.info?.thumbnailSource,
                     )
                     is VideoMessageType -> MediaItem.Video(
                         id = currentTimelineItem.uniqueId,
@@ -176,10 +180,10 @@ class EventItemFactory @Inject constructor(
                             dateSent = dateSent,
                             dateSentFull = dateSentFull,
                             waveform = null,
+                            duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
                         ),
                         mediaSource = type.source,
                         thumbnailSource = type.info?.thumbnailSource,
-                        duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
                     )
                     is VoiceMessageType -> MediaItem.Voice(
                         id = currentTimelineItem.uniqueId,
@@ -196,10 +200,9 @@ class EventItemFactory @Inject constructor(
                             dateSent = dateSent,
                             dateSentFull = dateSentFull,
                             waveform = type.details?.waveform.orEmpty(),
+                            duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
                         ),
                         mediaSource = type.source,
-                        duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
-                        waveform = type.details?.waveform ?: persistentListOf(),
                     )
                 }
             }
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt
new file mode 100644
index 00000000000..0e126531086
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt
@@ -0,0 +1,100 @@
+/*
+ * 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.architecture.AsyncData
+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.room.MatrixRoom
+import io.element.android.libraries.matrix.api.timeline.Timeline
+import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onCompletion
+import kotlinx.coroutines.flow.onEach
+import java.util.concurrent.atomic.AtomicBoolean
+import javax.inject.Inject
+
+interface MediaGalleryDataSource {
+    fun start()
+    fun groupedMediaItemsFlow(): Flow<AsyncData<GroupedMediaItems>>
+    fun getLastData(): AsyncData<GroupedMediaItems>
+    suspend fun loadMore(direction: Timeline.PaginationDirection)
+    suspend fun deleteItem(eventId: EventId)
+}
+
+@SingleIn(RoomScope::class)
+@ContributesBinding(RoomScope::class)
+class TimelineMediaGalleryDataSource @Inject constructor(
+    private val room: MatrixRoom,
+    private val timelineMediaItemsFactory: TimelineMediaItemsFactory,
+    private val mediaItemsPostProcessor: MediaItemsPostProcessor,
+) : MediaGalleryDataSource {
+    private var timeline: Timeline? = null
+
+    private val groupedMediaItemsFlow = MutableSharedFlow<AsyncData<GroupedMediaItems>>(replay = 1)
+
+    override fun groupedMediaItemsFlow(): Flow<AsyncData<GroupedMediaItems>> = groupedMediaItemsFlow
+
+    override fun getLastData(): AsyncData<GroupedMediaItems> = groupedMediaItemsFlow.replayCache.firstOrNull() ?: AsyncData.Uninitialized
+
+    private val isStarted = AtomicBoolean(false)
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    override fun start() {
+        if (!isStarted.compareAndSet(false, true)) {
+            return
+        }
+        flow {
+            groupedMediaItemsFlow.emit(AsyncData.Loading())
+            room.mediaTimeline().fold(
+                {
+                    timeline = it
+                    emit(it)
+                },
+                {
+                    groupedMediaItemsFlow.emit(AsyncData.Failure(it))
+                },
+            )
+        }.flatMapLatest { timeline ->
+            timeline.timelineItems.onEach {
+                timelineMediaItemsFactory.replaceWith(
+                    timelineItems = it,
+                )
+            }
+        }.flatMapLatest {
+            timelineMediaItemsFactory.timelineItems
+        }.map { timelineItems ->
+            mediaItemsPostProcessor.process(mediaItems = timelineItems)
+        }.onEach { groupedMediaItems ->
+            groupedMediaItemsFlow.emit(AsyncData.Success(groupedMediaItems))
+        }
+            .onCompletion {
+                timeline?.close()
+            }
+            .launchIn(room.roomCoroutineScope)
+    }
+
+    override suspend fun loadMore(direction: Timeline.PaginationDirection) {
+        timeline?.paginate(direction)
+    }
+
+    override suspend fun deleteItem(eventId: EventId) {
+        timeline?.redactEvent(
+            eventOrTransactionId = eventId.toEventOrTransactionId(),
+            reason = null,
+        )
+    }
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt
index f9d1e9c5472..476e86c5c58 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt
@@ -9,15 +9,12 @@ package io.element.android.libraries.mediaviewer.impl.gallery

 import android.content.ActivityNotFoundException
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.rememberUpdatedState
 import androidx.compose.runtime.setValue
 import dagger.assisted.Assisted
 import dagger.assisted.AssistedFactory
@@ -33,30 +30,21 @@ import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
 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.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.local.LocalMediaActions
 import io.element.android.libraries.ui.strings.CommonStrings
-import kotlinx.collections.immutable.ImmutableList
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.flow.flowOf
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.launch

 class MediaGalleryPresenter @AssistedInject constructor(
     @Assisted private val navigator: MediaGalleryNavigator,
     private val room: MatrixRoom,
-    private val timelineMediaItemsFactory: TimelineMediaItemsFactory,
+    private val mediaGalleryDataSource: MediaGalleryDataSource,
     private val localMediaFactory: LocalMediaFactory,
     private val mediaLoader: MatrixMediaLoader,
     private val localMediaActions: LocalMediaActions,
     private val snackbarDispatcher: SnackbarDispatcher,
-    private val mediaItemsPostProcessor: MediaItemsPostProcessor,
 ) : Presenter<MediaGalleryState> {
     @AssistedFactory
     interface Factory {
@@ -74,39 +62,17 @@ class MediaGalleryPresenter @AssistedInject constructor(

         var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) }

-        var mediaItems by remember {
-            mutableStateOf<AsyncData<ImmutableList<MediaItem>>>(AsyncData.Uninitialized)
-        }
         val groupedMediaItems by remember {
-            derivedStateOf {
-                mediaItemsPostProcessor.process(
-                    mediaItems = mediaItems,
-                )
-            }
+            mediaGalleryDataSource.groupedMediaItemsFlow()
         }
-        val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
-        localMediaActions.Configure()
+            .collectAsState(AsyncData.Uninitialized)

-        var timeline by remember { mutableStateOf<AsyncData<Timeline>>(AsyncData.Uninitialized) }
         LaunchedEffect(Unit) {
-            room.mediaTimeline()
-                .fold(
-                    { timeline = AsyncData.Success(it) },
-                    { timeline = AsyncData.Failure(it) },
-                )
-        }
-        DisposableEffect(Unit) {
-            onDispose {
-                timeline.dataOrNull()?.close()
-            }
+            mediaGalleryDataSource.start()
         }

-        MediaListEffect(
-            timeline = timeline,
-            onItemsChange = { newItems ->
-                mediaItems = newItems
-            }
-        )
+        val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
+        localMediaActions.Configure()

         fun handleEvents(event: MediaGalleryEvents) {
             when (event) {
@@ -114,16 +80,18 @@ class MediaGalleryPresenter @AssistedInject constructor(
                     mode = event.mode
                 }
                 is MediaGalleryEvents.LoadMore -> coroutineScope.launch {
-                    timeline.dataOrNull()?.paginate(event.direction)
+                    mediaGalleryDataSource.loadMore(event.direction)
+                }
+                is MediaGalleryEvents.Delete -> coroutineScope.launch {
+                    mediaGalleryDataSource.deleteItem(event.eventId)
                 }
-                is MediaGalleryEvents.Delete -> coroutineScope.delete(timeline, event.eventId)
                 is MediaGalleryEvents.SaveOnDisk -> coroutineScope.launch {
-                    mediaItems.dataOrNull().find(event.eventId)?.let {
+                    groupedMediaItems.dataOrNull().find(event.eventId)?.let {
                         saveOnDisk(it)
                     }
                 }
                 is MediaGalleryEvents.Share -> coroutineScope.launch {
-                    mediaItems.dataOrNull().find(event.eventId)?.let {
+                    groupedMediaItems.dataOrNull().find(event.eventId)?.let {
                         share(it)
                     }
                 }
@@ -172,49 +140,6 @@ class MediaGalleryPresenter @AssistedInject constructor(
         )
     }

-    @Composable
-    private fun MediaListEffect(
-        timeline: AsyncData<Timeline>,
-        onItemsChange: (AsyncData<ImmutableList<MediaItem>>) -> Unit,
-    ) {
-        val updatedOnItemsChange by rememberUpdatedState(onItemsChange)
-
-        LaunchedEffect(timeline) {
-            when (timeline) {
-                AsyncData.Uninitialized -> flowOf(AsyncData.Uninitialized)
-                is AsyncData.Failure -> flowOf(AsyncData.Failure(timeline.error))
-                is AsyncData.Loading -> flowOf(AsyncData.Loading())
-                is AsyncData.Success -> {
-                    timeline.data.timelineItems
-                        .onEach { items ->
-                            timelineMediaItemsFactory.replaceWith(
-                                timelineItems = items,
-                            )
-                        }
-                        .launchIn(this)
-
-                    timelineMediaItemsFactory.timelineItems.map { timelineItems ->
-                        AsyncData.Success(timelineItems)
-                    }
-                }
-            }
-                .onEach { items ->
-                    updatedOnItemsChange(items)
-                }
-                .launchIn(this)
-        }
-    }
-
-    private fun CoroutineScope.delete(
-        timeline: AsyncData<Timeline>,
-        eventId: EventId,
-    ) = launch {
-        timeline.dataOrNull()?.redactEvent(
-            eventOrTransactionId = eventId.toEventOrTransactionId(),
-            reason = null,
-        )
-    }
-
     private suspend fun downloadMedia(mediaItem: MediaItem.Event): Result<LocalMedia> {
         return mediaLoader.downloadMediaFile(
             source = mediaItem.mediaSource(),
@@ -264,10 +189,10 @@ class MediaGalleryPresenter @AssistedInject constructor(
     }
 }

-private fun List<MediaItem>?.find(eventId: EventId?): MediaItem.Event? {
+private fun GroupedMediaItems?.find(eventId: EventId?): MediaItem.Event? {
     if (this == null || eventId == null) {
         return null
     }
-    return filterIsInstance<MediaItem.Event>()
+    return (imageAndVideoItems + fileItems).filterIsInstance<MediaItem.Event>()
         .firstOrNull { it.eventId() == eventId }
 }
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt
index 949c8818147..5a1b5fcca80 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt
@@ -122,7 +122,7 @@ private fun aMediaGalleryState(
     eventSink = {}
 )

-private fun aGroupedMediaItems(
+fun aGroupedMediaItems(
     imageAndVideoItems: List<MediaItem> = emptyList(),
     fileItems: List<MediaItem> = emptyList(),
 ) = GroupedMediaItems(
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt
index bcd9b2a2388..3f5e1fc1072 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt
@@ -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(
@@ -137,7 +137,6 @@ fun MediaGalleryView(
             HorizontalPager(
                 state = pagerState,
                 userScrollEnabled = false,
-                modifier = Modifier,
             ) { page ->
                 val mode = MediaGalleryMode.entries[page]
                 MediaGalleryPage(
@@ -198,6 +197,13 @@ private fun MediaGalleryPage(
 ) {
     val groupedMediaItems = state.groupedMediaItems
     if (groupedMediaItems.isLoadingItems(mode)) {
+        // Need to trigger a pagination now if there is only one LoadingIndicator.
+        val loadingItem = groupedMediaItems.dataOrNull()?.getItems(mode)?.singleOrNull() as? MediaItem.LoadingIndicator
+        if (loadingItem != null) {
+            LaunchedEffect(loadingItem.timestamp) {
+                state.eventSink(MediaGalleryEvents.LoadMore(loadingItem.direction))
+            }
+        }
         LoadingContent(mode)
     } else {
         when (groupedMediaItems) {
@@ -348,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),
@@ -420,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 -> {
@@ -460,9 +466,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),
@@ -480,9 +486,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,
         ) {
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItem.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItem.kt
index 2f2054ebe06..e1bd4d779ac 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItem.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItem.kt
@@ -13,7 +13,6 @@ import io.element.android.libraries.matrix.api.media.MediaSource
 import io.element.android.libraries.matrix.api.timeline.Timeline
 import io.element.android.libraries.matrix.ui.media.MediaRequestData
 import io.element.android.libraries.mediaviewer.api.MediaInfo
-import kotlinx.collections.immutable.ImmutableList

 sealed interface MediaItem {
     data class DateSeparator(
@@ -46,7 +45,6 @@ sealed interface MediaItem {
         val mediaInfo: MediaInfo,
         val mediaSource: MediaSource,
         val thumbnailSource: MediaSource?,
-        val duration: String?,
     ) : Event {
         val thumbnailMediaRequestData: MediaRequestData
             get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
@@ -64,8 +62,6 @@ sealed interface MediaItem {
         val eventId: EventId?,
         val mediaInfo: MediaInfo,
         val mediaSource: MediaSource,
-        val duration: String?,
-        val waveform: ImmutableList<Float>,
     ) : Event

     data class File(
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt
index e5de5bbd58e..3fb8d81b1fd 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt
@@ -7,32 +7,19 @@

 package io.element.android.libraries.mediaviewer.impl.gallery

-import io.element.android.libraries.architecture.AsyncData
-import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.toImmutableList
 import javax.inject.Inject

 class MediaItemsPostProcessor @Inject constructor() {
     fun process(
-        mediaItems: AsyncData<ImmutableList<MediaItem>>,
-    ): AsyncData<GroupedMediaItems> {
-        return when (mediaItems) {
-            is AsyncData.Uninitialized -> AsyncData.Uninitialized
-            is AsyncData.Loading -> AsyncData.Loading()
-            is AsyncData.Failure -> AsyncData.Failure(mediaItems.error)
-            is AsyncData.Success -> AsyncData.Success(
-                mediaItems.data.process()
-            )
-        }
-    }
-
-    private fun List<MediaItem>.process(): GroupedMediaItems {
+        mediaItems: List<MediaItem>,
+    ): GroupedMediaItems {
         val imageAndVideoItems = mutableListOf<MediaItem>()
         val fileItems = mutableListOf<MediaItem>()

         val imageAndVideoItemsSubList = mutableListOf<MediaItem.Event>()
         val fileItemsSublist = mutableListOf<MediaItem.Event>()
-        forEach { item ->
+        mediaItems.forEach { item ->
             when (item) {
                 is MediaItem.DateSeparator -> {
                     if (imageAndVideoItemsSubList.isNotEmpty()) {
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSource.kt
new file mode 100644
index 00000000000..8e6b708a7f1
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSource.kt
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.gallery
+
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
+import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
+import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.UniqueId
+import io.element.android.libraries.matrix.api.timeline.Timeline
+import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.flow.flowOf
+
+class SingleMediaGalleryDataSource(
+    private val data: GroupedMediaItems,
+) : MediaGalleryDataSource {
+    override fun start() = Unit
+    override fun groupedMediaItemsFlow() = flowOf(AsyncData.Success(data))
+    override fun getLastData(): AsyncData<GroupedMediaItems> = AsyncData.Success(data)
+    override suspend fun loadMore(direction: Timeline.PaginationDirection) = Unit
+    override suspend fun deleteItem(eventId: EventId) = Unit
+
+    companion object {
+        fun createFrom(params: MediaViewerEntryPoint.Params) = SingleMediaGalleryDataSource(
+            data = when {
+                params.mediaInfo.mimeType.isMimeTypeImage() -> {
+                    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(),
+                )
+            }
+        )
+    }
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryRootNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryRootNode.kt
index 357e314d1c5..71a4057f4b1 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryRootNode.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/root/MediaGalleryRootNode.kt
@@ -60,6 +60,7 @@ class MediaGalleryRootNode @AssistedInject constructor(

         @Parcelize
         data class MediaViewer(
+            val mode: MediaViewerEntryPoint.MediaViewerMode,
             val eventId: EventId?,
             val mediaInfo: MediaInfo,
             val mediaSource: MediaSource,
@@ -92,8 +93,16 @@ class MediaGalleryRootNode @AssistedInject constructor(
                     }

                     override fun onItemClick(item: MediaItem.Event) {
+                        val mode = when (item) {
+                            is MediaItem.Audio,
+                            is MediaItem.Voice,
+                            is MediaItem.File -> MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios
+                            is MediaItem.Image,
+                            is MediaItem.Video -> MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos
+                        }
                         overlay.show(
                             NavTarget.MediaViewer(
+                                mode = mode,
                                 eventId = item.eventId(),
                                 mediaInfo = item.mediaInfo(),
                                 mediaSource = item.mediaSource(),
@@ -117,6 +126,7 @@ class MediaGalleryRootNode @AssistedInject constructor(
                 mediaViewerEntryPoint.nodeBuilder(this, buildContext)
                     .params(
                         MediaViewerEntryPoint.Params(
+                            mode = navTarget.mode,
                             eventId = navTarget.eventId,
                             mediaInfo = navTarget.mediaInfo,
                             mediaSource = navTarget.mediaSource,
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemFileProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemFileProvider.kt
index 99fcb790c43..c80e287c301 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemFileProvider.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemFileProvider.kt
@@ -9,6 +9,7 @@ package io.element.android.libraries.mediaviewer.impl.gallery.ui

 import androidx.compose.ui.tooling.preview.PreviewParameterProvider
 import io.element.android.libraries.core.preview.loremIpsum
+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.media.MediaSource
 import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
@@ -30,12 +31,13 @@ class MediaItemFileProvider : PreviewParameterProvider<MediaItem.File> {

 fun aMediaItemFile(
     id: UniqueId = UniqueId("fileId"),
+    eventId: EventId? = null,
     filename: String = "filename",
     caption: String? = null,
 ): MediaItem.File {
     return MediaItem.File(
         id = id,
-        eventId = null,
+        eventId = eventId,
         mediaInfo = aPdfMediaInfo(
             filename = filename,
             caption = caption,
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemImageProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemImageProvider.kt
index 2ed781c7aee..ceb934fbe2b 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemImageProvider.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemImageProvider.kt
@@ -18,6 +18,7 @@ fun aMediaItemImage(
     id: UniqueId = UniqueId("imageId"),
     eventId: EventId? = null,
     senderId: UserId? = null,
+    mediaSourceUrl: String = "",
 ): MediaItem.Image {
     return MediaItem.Image(
         id = id,
@@ -25,7 +26,7 @@ fun aMediaItemImage(
         mediaInfo = anImageMediaInfo(
             senderId = senderId,
         ),
-        mediaSource = MediaSource(""),
+        mediaSource = MediaSource(mediaSourceUrl),
         thumbnailSource = null,
     )
 }
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemLoadingIndicatorProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemLoadingIndicatorProvider.kt
index 546d2f0127c..2c788983255 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemLoadingIndicatorProvider.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemLoadingIndicatorProvider.kt
@@ -13,10 +13,11 @@ import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem

 fun aMediaItemLoadingIndicator(
     id: UniqueId = UniqueId("loadingId"),
+    direction: Timeline.PaginationDirection = Timeline.PaginationDirection.BACKWARDS,
 ): MediaItem.LoadingIndicator {
     return MediaItem.LoadingIndicator(
         id = id,
-        direction = Timeline.PaginationDirection.BACKWARDS,
+        direction = direction,
         timestamp = 123,
     )
 }
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVideoProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVideoProvider.kt
index 47642d48374..8e59b925b75 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVideoProvider.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVideoProvider.kt
@@ -31,9 +31,10 @@ fun aMediaItemVideo(
     return MediaItem.Video(
         id = id,
         eventId = null,
-        mediaInfo = aVideoMediaInfo(),
+        mediaInfo = aVideoMediaInfo(
+            duration = duration
+        ),
         mediaSource = mediaSource,
         thumbnailSource = null,
-        duration = duration,
     )
 }
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVoiceProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVoiceProvider.kt
index c84a74c7a1f..43e04491de4 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVoiceProvider.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemVoiceProvider.kt
@@ -14,7 +14,6 @@ import io.element.android.libraries.matrix.api.core.UniqueId
 import io.element.android.libraries.matrix.api.media.MediaSource
 import io.element.android.libraries.mediaviewer.api.aVoiceMediaInfo
 import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
-import kotlinx.collections.immutable.toImmutableList

 class MediaItemVoiceProvider : PreviewParameterProvider<MediaItem.Voice> {
     override val values: Sequence<MediaItem.Voice>
@@ -46,9 +45,9 @@ fun aMediaItemVoice(
         mediaInfo = aVoiceMediaInfo(
             filename = filename,
             caption = caption,
+            duration = duration,
+            waveForm = waveform,
         ),
         mediaSource = MediaSource(""),
-        duration = duration,
-        waveform = waveform.toImmutableList(),
     )
 }
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt
index 0adeabd20f3..6b394e7c55c 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VideoItemView.kt
@@ -101,10 +101,10 @@ private fun VideoInfoRow(
             imageVector = CompoundIcons.VideoCallSolid(),
             contentDescription = null
         )
-        if (video.duration != null) {
+        video.mediaInfo.duration?.let { duration ->
             Spacer(Modifier.weight(1f))
             Text(
-                text = video.duration,
+                text = duration,
                 style = ElementTheme.typography.fontBodySmMedium,
                 color = ElementTheme.colors.textPrimary,
             )
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt
index a5f55875bd8..d34555e1758 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt
@@ -115,7 +115,7 @@ private fun VoiceInfoRow(
         }
         Spacer(Modifier.width(8.dp))
         Text(
-            text = if (state.progress > 0f) state.time else voice.duration ?: state.time,
+            text = if (state.progress > 0f) state.time else voice.mediaInfo.duration ?: state.time,
             color = ElementTheme.colors.textSecondary,
             style = ElementTheme.typography.fontBodyMdMedium,
             maxLines = 1,
@@ -128,7 +128,7 @@ private fun VoiceInfoRow(
                 .height(34.dp),
             showCursor = state.showCursor,
             playbackProgress = state.progress,
-            waveform = voice.waveform.toPersistentList(),
+            waveform = voice.mediaInfo.waveform.orEmpty().toPersistentList(),
             onSeek = {
                 state.eventSink(VoiceMessageEvents.Seek(it))
             },
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt
index 297ee4dac2b..b7ae566ab11 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt
@@ -48,6 +48,7 @@ class AndroidLocalMediaFactory @Inject constructor(
         dateSent = mediaInfo.dateSent,
         dateSentFull = mediaInfo.dateSentFull,
         waveform = mediaInfo.waveform,
+        duration = mediaInfo.duration,
     )

     override fun createFromUri(
@@ -67,6 +68,7 @@ class AndroidLocalMediaFactory @Inject constructor(
         dateSent = null,
         dateSentFull = null,
         waveform = null,
+        duration = null,
     )

     private fun createFromUri(
@@ -81,6 +83,7 @@ class AndroidLocalMediaFactory @Inject constructor(
         dateSent: String?,
         dateSentFull: String?,
         waveform: List<Float>?,
+        duration: String?,
     ): LocalMedia {
         val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream
         val fileName = name ?: context.getFileName(uri) ?: ""
@@ -100,6 +103,7 @@ class AndroidLocalMediaFactory @Inject constructor(
                 dateSent = dateSent,
                 dateSentFull = dateSentFull,
                 waveform = waveform,
+                duration = duration,
             )
         )
     }
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt
index 8e90e84a155..8752b19080a 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt
@@ -27,6 +27,7 @@ fun LocalMediaView(
     bottomPaddingInPixels: Int,
     onClick: () -> Unit,
     modifier: Modifier = Modifier,
+    isDisplayed: Boolean = true,
     localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(),
     mediaInfo: MediaInfo? = localMedia?.info,
 ) {
@@ -39,6 +40,7 @@ fun LocalMediaView(
             onClick = onClick,
         )
         mimeType.isMimeTypeVideo() -> MediaVideoView(
+            isDisplayed = isDisplayed,
             localMediaViewState = localMediaViewState,
             bottomPaddingInPixels = bottomPaddingInPixels,
             localMedia = localMedia,
@@ -51,6 +53,7 @@ fun LocalMediaView(
             onClick = onClick,
         )
         mimeType.isMimeTypeAudio() -> MediaAudioView(
+            isDisplayed = isDisplayed,
             localMediaViewState = localMediaViewState,
             bottomPaddingInPixels = bottomPaddingInPixels,
             localMedia = localMedia,
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt
index a1ffc169e33..226aa85234e 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt
@@ -83,9 +83,11 @@ fun MediaAudioView(
     localMedia: LocalMedia?,
     info: MediaInfo?,
     modifier: Modifier = Modifier,
+    isDisplayed: Boolean = true,
 ) {
     val exoPlayer = rememberExoPlayer()
     ExoPlayerMediaAudioView(
+        isDisplayed = isDisplayed,
         localMediaViewState = localMediaViewState,
         bottomPaddingInPixels = bottomPaddingInPixels,
         exoPlayer = exoPlayer,
@@ -98,6 +100,7 @@ fun MediaAudioView(
 @SuppressLint("UnsafeOptInUsageError")
 @Composable
 private fun ExoPlayerMediaAudioView(
+    isDisplayed: Boolean,
     localMediaViewState: LocalMediaViewState,
     bottomPaddingInPixels: Int,
     exoPlayer: ExoPlayer,
@@ -176,6 +179,12 @@ private fun ExoPlayerMediaAudioView(
             )
         }
     }
+    LaunchedEffect(isDisplayed) {
+        // If not displayed, make sure to pause the audio
+        if (!isDisplayed) {
+            exoPlayer.pause()
+        }
+    }
     if (localMedia?.uri != null) {
         LaunchedEffect(localMedia.uri) {
             val mediaItem = MediaItem.fromUri(localMedia.uri)
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerView.kt
index 3d1cef88e80..67868be7dc4 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerView.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/MediaPlayerControllerView.kt
@@ -26,7 +26,6 @@ import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -40,6 +39,7 @@ import io.element.android.libraries.designsystem.theme.components.Icon
 import io.element.android.libraries.designsystem.theme.components.IconButton
 import io.element.android.libraries.designsystem.theme.components.Slider
 import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.mediaviewer.impl.util.bgCanvasWithTransparency
 import io.element.android.libraries.ui.strings.CommonStrings

 @Composable
@@ -58,7 +58,7 @@ fun MediaPlayerControllerView(
     ) {
         Box(
             modifier = Modifier
-                .background(color = Color(0x99101317))
+                .background(color = bgCanvasWithTransparency)
                 .padding(horizontal = 8.dp, vertical = 4.dp),
             contentAlignment = Alignment.Center,
         ) {
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt
index 5dd7427e1a5..934f7e8352d 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt
@@ -57,6 +57,7 @@ import kotlin.time.Duration.Companion.seconds
 @SuppressLint("UnsafeOptInUsageError")
 @Composable
 fun MediaVideoView(
+    isDisplayed: Boolean,
     localMediaViewState: LocalMediaViewState,
     bottomPaddingInPixels: Int,
     localMedia: LocalMedia?,
@@ -64,6 +65,7 @@ fun MediaVideoView(
 ) {
     val exoPlayer = rememberExoPlayer()
     ExoPlayerMediaVideoView(
+        isDisplayed = isDisplayed,
         localMediaViewState = localMediaViewState,
         bottomPaddingInPixels = bottomPaddingInPixels,
         exoPlayer = exoPlayer,
@@ -75,6 +77,7 @@ fun MediaVideoView(
 @SuppressLint("UnsafeOptInUsageError")
 @Composable
 private fun ExoPlayerMediaVideoView(
+    isDisplayed: Boolean,
     localMediaViewState: LocalMediaViewState,
     bottomPaddingInPixels: Int,
     exoPlayer: ExoPlayer,
@@ -161,6 +164,12 @@ private fun ExoPlayerMediaVideoView(
             )
         }
     }
+    LaunchedEffect(isDisplayed) {
+        // If not displayed, make sure to pause the video
+        if (!isDisplayed) {
+            exoPlayer.pause()
+        }
+    }
     if (localMedia?.uri != null) {
         LaunchedEffect(localMedia.uri) {
             val mediaItem = MediaItem.fromUri(localMedia.uri)
@@ -245,6 +254,7 @@ private fun ExoPlayerMediaVideoView(
 @Composable
 internal fun MediaVideoViewPreview() = ElementPreview {
     MediaVideoView(
+        isDisplayed = true,
         modifier = Modifier.fillMaxSize(),
         bottomPaddingInPixels = 0,
         localMediaViewState = rememberLocalMediaViewState(),
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/util/Colors.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/util/Colors.kt
new file mode 100644
index 00000000000..5105f1ba9b1
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/util/Colors.kt
@@ -0,0 +1,16 @@
+/*
+ * 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.util
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import io.element.android.compound.theme.ElementTheme
+
+val bgCanvasWithTransparency: Color
+    @Composable
+    get() = ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.6f)
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt
new file mode 100644
index 00000000000..b185834c3e4
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSource.kt
@@ -0,0 +1,175 @@
+/*
+ * 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.annotation.VisibleForTesting
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.State
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import io.element.android.libraries.architecture.AsyncData
+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 io.element.android.services.toolbox.api.systemclock.SystemClock
+import kotlinx.collections.immutable.PersistentList
+import kotlinx.collections.immutable.persistentListOf
+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,
+    private val systemClock: SystemClock,
+) {
+    // 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()
+    }
+
+    @Composable
+    fun collectAsState(): State<PersistentList<MediaViewerPageData>> {
+        return remember { dataFlow() }.collectAsState(initialData())
+    }
+
+    @VisibleForTesting
+    fun dataFlow(): Flow<PersistentList<MediaViewerPageData>> {
+        return galleryDataSource.groupedMediaItemsFlow()
+            .map { groupedItems ->
+                when (groupedItems) {
+                    AsyncData.Uninitialized,
+                    is AsyncData.Loading -> {
+                        persistentListOf(
+                            MediaViewerPageData.Loading(
+                                direction = Timeline.PaginationDirection.BACKWARDS,
+                                timestamp = systemClock.epochMillis(),
+                            )
+                        )
+                    }
+                    is AsyncData.Failure -> {
+                        persistentListOf(
+                            MediaViewerPageData.Failure(groupedItems.error),
+                        )
+                    }
+                    is AsyncData.Success -> {
+                        withContext(dispatcher) {
+                            val mediaItems = groupedItems.data.getItems(galleryMode)
+                            buildMediaViewerPageList(mediaItems)
+                        }
+                    }
+                }
+            }
+    }
+
+    private fun initialData(): PersistentList<MediaViewerPageData> {
+        val initialMediaItems =
+            galleryDataSource.getLastData().dataOrNull()?.getItems(galleryMode).orEmpty()
+        return buildMediaViewerPageList(initialMediaItems)
+    }
+
+    /**
+     * Build a list of [MediaViewerPageData] from a list of [MediaItem].
+     * In particular, create a mutable state of AsyncData<LocalMedia> for each media item, which
+     * 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 ->
+            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(
+                        direction = mediaItem.direction,
+                        timestamp = systemClock.epochMillis(),
+                    )
+                )
+            }
+        }
+    }.toPersistentList()
+
+    fun clearLoadingError(data: MediaViewerPageData.MediaViewerData) {
+        localMediaStates[data.mediaSource.url]?.value = AsyncData.Uninitialized
+    }
+
+    suspend fun loadMore(direction: Timeline.PaginationDirection) {
+        galleryDataSource.loadMore(direction)
+    }
+
+    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)
+            }
+    }
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt
index f9fb32d3250..708c423d363 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerEvents.kt
@@ -8,16 +8,23 @@
 package io.element.android.libraries.mediaviewer.impl.viewer

 import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.timeline.Timeline

 sealed interface MediaViewerEvents {
-    data object SaveOnDisk : MediaViewerEvents
-    data object Share : MediaViewerEvents
-    data object OpenWith : MediaViewerEvents
-    data object RetryLoading : MediaViewerEvents
-    data object ClearLoadingError : MediaViewerEvents
+    data class LoadMedia(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
+    data class SaveOnDisk(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
+    data class Share(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
+    data class OpenWith(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
+    data class ClearLoadingError(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
     data class ViewInTimeline(val eventId: EventId) : MediaViewerEvents
-    data object OpenInfo : MediaViewerEvents
-    data class ConfirmDelete(val eventId: EventId) : MediaViewerEvents
+    data class OpenInfo(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
+    data class ConfirmDelete(
+        val eventId: EventId,
+        val data: MediaViewerPageData.MediaViewerData,
+    ) : MediaViewerEvents
+
     data object CloseBottomSheet : MediaViewerEvents
     data class Delete(val eventId: EventId) : MediaViewerEvents
+    data class OnNavigateTo(val index: Int) : MediaViewerEvents
+    data class LoadMore(val direction: Timeline.PaginationDirection) : MediaViewerEvents
 }
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerFlickToDismiss.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerFlickToDismiss.kt
new file mode 100644
index 00000000000..1b99cdfa03c
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerFlickToDismiss.kt
@@ -0,0 +1,95 @@
+/*
+ * 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.animation.core.animateFloatAsState
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import io.element.android.compound.theme.ElementTheme
+import kotlinx.coroutines.delay
+import me.saket.telephoto.flick.FlickToDismiss
+import me.saket.telephoto.flick.FlickToDismissState
+import me.saket.telephoto.flick.rememberFlickToDismissState
+import kotlin.time.Duration
+
+@Composable
+fun MediaViewerFlickToDismiss(
+    onDismiss: () -> Unit,
+    modifier: Modifier = Modifier,
+    onDragging: () -> Unit = {},
+    onResetting: () -> Unit = {},
+    content: @Composable BoxScope.() -> Unit,
+) {
+    val flickState = rememberFlickToDismissState(dismissThresholdRatio = 0.1f, rotateOnDrag = false)
+    DismissFlickEffects(
+        flickState = flickState,
+        onDismissing = { animationDuration ->
+            delay(animationDuration / 3)
+            onDismiss()
+        },
+        onDragging = onDragging,
+        onResetting = onResetting,
+    )
+    FlickToDismiss(
+        state = flickState,
+        modifier = modifier.background(backgroundColorFor(flickState)),
+        content = content,
+    )
+}
+
+@Composable
+private fun DismissFlickEffects(
+    flickState: FlickToDismissState,
+    onDismissing: suspend (Duration) -> Unit,
+    onDragging: suspend () -> Unit,
+    onResetting: suspend () -> Unit,
+) {
+    val currentOnDismissing by rememberUpdatedState(onDismissing)
+    val currentOnDragging by rememberUpdatedState(onDragging)
+    val currentOnResetting by rememberUpdatedState(onResetting)
+
+    when (val gestureState = flickState.gestureState) {
+        is FlickToDismissState.GestureState.Dismissing -> {
+            LaunchedEffect(Unit) {
+                currentOnDismissing(gestureState.animationDuration)
+            }
+        }
+        is FlickToDismissState.GestureState.Dragging -> {
+            LaunchedEffect(Unit) {
+                currentOnDragging()
+            }
+        }
+        is FlickToDismissState.GestureState.Resetting -> {
+            LaunchedEffect(Unit) {
+                currentOnResetting()
+            }
+        }
+        else -> Unit
+    }
+}
+
+@Composable
+private fun backgroundColorFor(flickState: FlickToDismissState): Color {
+    val animatedAlpha by animateFloatAsState(
+        targetValue = when (flickState.gestureState) {
+            is FlickToDismissState.GestureState.Dismissed,
+            is FlickToDismissState.GestureState.Dismissing -> 0f
+            is FlickToDismissState.GestureState.Dragging,
+            is FlickToDismissState.GestureState.Idle,
+            is FlickToDismissState.GestureState.Resetting -> 1f - flickState.offsetFraction
+        },
+        label = "Background alpha",
+    )
+    return ElementTheme.colors.bgCanvasDefault.copy(alpha = animatedAlpha)
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt
index 4cd528457dc..e06b6915202 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerNode.kt
@@ -18,15 +18,27 @@ 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
+import io.element.android.services.toolbox.api.systemclock.SystemClock

 @ContributesNode(RoomScope::class)
 class MediaViewerNode @AssistedInject constructor(
     @Assisted buildContext: BuildContext,
     @Assisted plugins: List<Plugin>,
     presenterFactory: MediaViewerPresenter.Factory,
+    timelineMediaGalleryDataSource: TimelineMediaGalleryDataSource,
+    mediaLoader: MatrixMediaLoader,
+    localMediaFactory: LocalMediaFactory,
+    coroutineDispatchers: CoroutineDispatchers,
+    systemClock: SystemClock,
 ) : Node(buildContext, plugins = plugins),
     MediaViewerNavigator {
     private val inputs = inputs<MediaViewerEntryPoint.Params>()
@@ -47,9 +59,29 @@ 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,
+        dataSource = MediaViewerDataSource(
+            dispatcher = coroutineDispatchers.computation,
+            galleryMode = galleryMode,
+            galleryDataSource = mediaGallerySource,
+            mediaLoader = mediaLoader,
+            localMediaFactory = localMediaFactory,
+            systemClock = systemClock,
+        )
     )

     @Composable
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt
index 7a8b006da1f..a801437e825 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt
@@ -10,7 +10,6 @@ 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.MutableState
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.mutableStateOf
@@ -26,15 +25,12 @@ 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.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.local.LocalMediaActions
 import io.element.android.libraries.ui.strings.CommonStrings
@@ -45,9 +41,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 dataSource: MediaViewerDataSource,
     private val room: MatrixRoom,
-    private val localMediaFactory: LocalMediaFactory,
-    private val mediaLoader: MatrixMediaLoader,
     private val localMediaActions: LocalMediaActions,
     private val snackbarDispatcher: SnackbarDispatcher,
 ) : Presenter<MediaViewerState> {
@@ -56,83 +51,89 @@ class MediaViewerPresenter @AssistedInject constructor(
         fun create(
             inputs: MediaViewerEntryPoint.Params,
             navigator: MediaViewerNavigator,
+            dataSource: MediaViewerDataSource,
         ): MediaViewerPresenter
     }

     @Composable
     override fun present(): MediaViewerState {
         val coroutineScope = rememberCoroutineScope()
-        var loadMediaTrigger by remember { mutableIntStateOf(0) }
-        val mediaFile: MutableState<MediaFile?> = remember {
-            mutableStateOf(null)
-        }
-        val localMedia: MutableState<AsyncData<LocalMedia>> = remember {
-            mutableStateOf(AsyncData.Uninitialized)
-        }
+        val data by dataSource.collectAsState()
+        var currentIndex by remember { mutableIntStateOf(searchIndex(data, inputs.eventId)) }
         val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
-        localMediaActions.Configure()
-        DisposableEffect(loadMediaTrigger) {
-            coroutineScope.downloadMedia(mediaFile, localMedia)
+
+        var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) }
+
+        DisposableEffect(Unit) {
+            dataSource.setup()
             onDispose {
-                mediaFile.value?.close()
+                dataSource.dispose()
             }
         }
-        var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) }
+        localMediaActions.Configure()

-        fun handleEvents(mediaViewerEvents: MediaViewerEvents) {
-            when (mediaViewerEvents) {
-                MediaViewerEvents.RetryLoading -> loadMediaTrigger++
-                MediaViewerEvents.ClearLoadingError -> localMedia.value = AsyncData.Uninitialized
-                MediaViewerEvents.SaveOnDisk -> {
+        fun handleEvents(event: MediaViewerEvents) {
+            when (event) {
+                is MediaViewerEvents.LoadMedia -> {
+                    coroutineScope.downloadMedia(data = event.data)
+                }
+                is MediaViewerEvents.ClearLoadingError -> {
+                    dataSource.clearLoadingError(event.data)
+                }
+                is MediaViewerEvents.SaveOnDisk -> {
                     mediaBottomSheetState = MediaBottomSheetState.Hidden
-                    coroutineScope.saveOnDisk(localMedia.value)
+                    coroutineScope.saveOnDisk(event.data.downloadedMedia.value)
                 }
-                MediaViewerEvents.Share -> {
+                is MediaViewerEvents.Share -> {
                     mediaBottomSheetState = MediaBottomSheetState.Hidden
-                    coroutineScope.share(localMedia.value)
+                    coroutineScope.share(event.data.downloadedMedia.value)
                 }
-                MediaViewerEvents.OpenWith -> {
+                is MediaViewerEvents.OpenWith -> {
                     mediaBottomSheetState = MediaBottomSheetState.Hidden
-                    coroutineScope.open(localMedia.value)
+                    coroutineScope.open(event.data.downloadedMedia.value)
                 }
                 is MediaViewerEvents.Delete -> {
                     mediaBottomSheetState = MediaBottomSheetState.Hidden
-                    coroutineScope.delete(mediaViewerEvents.eventId)
+                    coroutineScope.delete(event.eventId)
                 }
                 is MediaViewerEvents.ViewInTimeline -> {
                     mediaBottomSheetState = MediaBottomSheetState.Hidden
-                    navigator.onViewInTimelineClick(mediaViewerEvents.eventId)
+                    navigator.onViewInTimelineClick(event.eventId)
                 }
-                MediaViewerEvents.OpenInfo -> coroutineScope.launch {
+                is MediaViewerEvents.OpenInfo -> coroutineScope.launch {
                     mediaBottomSheetState = MediaBottomSheetState.MediaDetailsBottomSheetState(
-                        eventId = inputs.eventId,
-                        canDelete = when (inputs.mediaInfo.senderId) {
+                        eventId = event.data.eventId,
+                        canDelete = when (event.data.mediaInfo.senderId) {
                             null -> false
-                            room.sessionId -> room.canRedactOwn().getOrElse { false } && inputs.eventId != null
-                            else -> room.canRedactOther().getOrElse { false } && inputs.eventId != null
+                            room.sessionId -> room.canRedactOwn().getOrElse { false } && event.data.eventId != null
+                            else -> room.canRedactOther().getOrElse { false } && event.data.eventId != null
                         },
-                        mediaInfo = inputs.mediaInfo,
-                        thumbnailSource = inputs.thumbnailSource,
+                        mediaInfo = event.data.mediaInfo,
+                        thumbnailSource = event.data.thumbnailSource,
                     )
                 }
                 is MediaViewerEvents.ConfirmDelete -> {
                     mediaBottomSheetState = MediaBottomSheetState.MediaDeleteConfirmationState(
-                        eventId = mediaViewerEvents.eventId,
-                        mediaInfo = inputs.mediaInfo,
-                        thumbnailSource = inputs.thumbnailSource ?: inputs.mediaSource,
+                        eventId = event.eventId,
+                        mediaInfo = event.data.mediaInfo,
+                        thumbnailSource = event.data.thumbnailSource ?: event.data.mediaSource,
                     )
                 }
                 MediaViewerEvents.CloseBottomSheet -> {
                     mediaBottomSheetState = MediaBottomSheetState.Hidden
                 }
+                is MediaViewerEvents.OnNavigateTo -> {
+                    currentIndex = event.index
+                }
+                is MediaViewerEvents.LoadMore -> coroutineScope.launch {
+                    dataSource.loadMore(event.direction)
+                }
             }
         }

         return MediaViewerState(
-            eventId = inputs.eventId,
-            mediaInfo = inputs.mediaInfo,
-            thumbnailSource = inputs.thumbnailSource,
-            downloadedMedia = localMedia.value,
+            listData = data,
+            currentIndex = currentIndex,
             snackbarMessage = snackbarMessage,
             canShowInfo = inputs.canShowInfo,
             mediaBottomSheetState = mediaBottomSheetState,
@@ -140,28 +141,10 @@ class MediaViewerPresenter @AssistedInject constructor(
         )
     }

-    private fun CoroutineScope.downloadMedia(mediaFile: MutableState<MediaFile?>, localMedia: MutableState<AsyncData<LocalMedia>>) = launch {
-        localMedia.value = AsyncData.Loading()
-        mediaLoader.downloadMediaFile(
-            source = inputs.mediaSource,
-            mimeType = inputs.mediaInfo.mimeType,
-            filename = inputs.mediaInfo.filename
-        )
-            .onSuccess {
-                mediaFile.value = it
-            }
-            .mapCatching { mediaFile ->
-                localMediaFactory.createFromMediaFile(
-                    mediaFile = mediaFile,
-                    mediaInfo = inputs.mediaInfo
-                )
-            }
-            .onSuccess {
-                localMedia.value = AsyncData.Success(it)
-            }
-            .onFailure {
-                localMedia.value = AsyncData.Failure(it)
-            }
+    private fun CoroutineScope.downloadMedia(
+        data: MediaViewerPageData.MediaViewerData,
+    ) = launch {
+        dataSource.loadMedia(data)
     }

     private fun CoroutineScope.saveOnDisk(localMedia: AsyncData<LocalMedia>) = launch {
@@ -216,4 +199,13 @@ class MediaViewerPresenter @AssistedInject constructor(
             CommonStrings.error_unknown
         }
     }
+
+    private fun searchIndex(data: List<MediaViewerPageData>, eventId: EventId?): Int {
+        if (eventId == null) {
+            return 0
+        }
+        return data.indexOfFirst {
+            (it as? MediaViewerPageData.MediaViewerData)?.eventId == eventId
+        }.coerceAtLeast(0)
+    }
 }
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt
index b9779b0fd47..cf363a70f18 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerState.kt
@@ -7,21 +7,41 @@

 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
 import io.element.android.libraries.matrix.api.media.MediaSource
+import io.element.android.libraries.matrix.api.timeline.Timeline
 import io.element.android.libraries.mediaviewer.api.MediaInfo
 import io.element.android.libraries.mediaviewer.api.local.LocalMedia
 import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
+import kotlinx.collections.immutable.ImmutableList

 data class MediaViewerState(
-    val eventId: EventId?,
-    val mediaInfo: MediaInfo,
-    val thumbnailSource: MediaSource?,
-    val downloadedMedia: AsyncData<LocalMedia>,
+    val listData: ImmutableList<MediaViewerPageData>,
+    val currentIndex: Int,
     val snackbarMessage: SnackbarMessage?,
     val canShowInfo: Boolean,
     val mediaBottomSheetState: MediaBottomSheetState,
     val eventSink: (MediaViewerEvents) -> Unit,
 )
+
+sealed interface MediaViewerPageData {
+    data class Failure(
+        val throwable: Throwable,
+    ) : MediaViewerPageData
+
+    data class Loading(
+        val direction: Timeline.PaginationDirection,
+        val timestamp: Long,
+    ) : MediaViewerPageData
+
+    data class MediaViewerData(
+        val eventId: EventId?,
+        val mediaInfo: MediaInfo,
+        val mediaSource: MediaSource,
+        val thumbnailSource: MediaSource?,
+        val downloadedMedia: State<AsyncData<LocalMedia>>,
+    ) : MediaViewerPageData
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt
index 70c6e62e7ee..2c54751fed0 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerStateProvider.kt
@@ -8,9 +8,12 @@
 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
+import io.element.android.libraries.matrix.api.media.MediaSource
+import io.element.android.libraries.matrix.api.timeline.Timeline
 import io.element.android.libraries.mediaviewer.api.MediaInfo
 import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
 import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
@@ -21,23 +24,28 @@ 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>
         get() = sequenceOf(
             aMediaViewerState(),
-            aMediaViewerState(AsyncData.Loading()),
-            aMediaViewerState(AsyncData.Failure(IllegalStateException("error"))),
+            aMediaViewerState(listOf(aMediaViewerPageData(AsyncData.Loading()))),
+            aMediaViewerState(listOf(aMediaViewerPageData(AsyncData.Failure(IllegalStateException("error"))))),
             anImageMediaInfo(
                 senderName = "Sally Sanderson",
                 dateSent = "21 NOV, 2024",
                 caption = "A caption",
             ).let {
                 aMediaViewerState(
-                    downloadedMedia = AsyncData.Success(
-                        LocalMedia(Uri.EMPTY, it)
-                    ),
-                    mediaInfo = it,
+                    listOf(
+                        aMediaViewerPageData(
+                            downloadedMedia = AsyncData.Success(
+                                LocalMedia(Uri.EMPTY, it)
+                            ),
+                            mediaInfo = it,
+                        )
+                    )
                 )
             },
             aVideoMediaInfo(
@@ -46,50 +54,78 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
                 caption = "A caption",
             ).let {
                 aMediaViewerState(
-                    downloadedMedia = AsyncData.Success(
-                        LocalMedia(Uri.EMPTY, it)
-                    ),
-                    mediaInfo = it,
+                    listOf(
+                        aMediaViewerPageData(
+                            downloadedMedia = AsyncData.Success(
+                                LocalMedia(Uri.EMPTY, it)
+                            ),
+                            mediaInfo = it,
+                        )
+                    )
                 )
             },
             aPdfMediaInfo().let {
                 aMediaViewerState(
-                    downloadedMedia = AsyncData.Success(
-                        LocalMedia(Uri.EMPTY, it)
-                    ),
-                    mediaInfo = it,
+                    listOf(
+                        aMediaViewerPageData(
+                            downloadedMedia = AsyncData.Success(
+                                LocalMedia(Uri.EMPTY, it)
+                            ),
+                            mediaInfo = it,
+                        )
+                    )
                 )
             },
             aMediaViewerState(
-                downloadedMedia = AsyncData.Loading(),
-                mediaInfo = anApkMediaInfo(),
+                listOf(
+                    aMediaViewerPageData(
+                        downloadedMedia = AsyncData.Loading(),
+                        mediaInfo = anApkMediaInfo(),
+                    )
+                )
             ),
             anApkMediaInfo().let {
                 aMediaViewerState(
-                    downloadedMedia = AsyncData.Success(
-                        LocalMedia(Uri.EMPTY, it)
-                    ),
-                    mediaInfo = it,
+                    listOf(
+                        aMediaViewerPageData(
+                            downloadedMedia = AsyncData.Success(
+                                LocalMedia(Uri.EMPTY, it)
+                            ),
+                            mediaInfo = it,
+                        )
+                    )
                 )
             },
             aMediaViewerState(
-                downloadedMedia = AsyncData.Loading(),
-                mediaInfo = anAudioMediaInfo(),
+                listOf(
+                    aMediaViewerPageData(
+                        downloadedMedia = AsyncData.Loading(),
+                        mediaInfo = anAudioMediaInfo(),
+                    )
+                )
             ),
             anAudioMediaInfo().let {
                 aMediaViewerState(
-                    downloadedMedia = AsyncData.Success(
-                        LocalMedia(Uri.EMPTY, it)
-                    ),
-                    mediaInfo = it,
+                    listOf(
+                        aMediaViewerPageData(
+                            downloadedMedia = AsyncData.Success(
+                                LocalMedia(Uri.EMPTY, it)
+                            ),
+                            mediaInfo = it,
+                        )
+                    )
                 )
             },
             anImageMediaInfo().let {
                 aMediaViewerState(
-                    downloadedMedia = AsyncData.Success(
-                        LocalMedia(Uri.EMPTY, it)
+                    listOf(
+                        aMediaViewerPageData(
+                            downloadedMedia = AsyncData.Success(
+                                LocalMedia(Uri.EMPTY, it)
+                            ),
+                            mediaInfo = it,
+                        )
                     ),
-                    mediaInfo = it,
                     canShowInfo = false,
                 )
             },
@@ -103,26 +139,60 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
                 waveForm = aWaveForm(),
             ).let {
                 aMediaViewerState(
-                    downloadedMedia = AsyncData.Success(
-                        LocalMedia(Uri.EMPTY, it)
-                    ),
-                    mediaInfo = it,
+                    listOf(
+                        aMediaViewerPageData(
+                            downloadedMedia = AsyncData.Success(
+                                LocalMedia(Uri.EMPTY, it)
+                            ),
+                            mediaInfo = it,
+                        )
+                    )
                 )
             },
+            aMediaViewerState(
+                listOf(
+                    aMediaViewerPageDataLoading()
+                ),
+            ),
+            aMediaViewerState(
+                listOf(
+                    MediaViewerPageData.Failure(Exception("error"))
+                ),
+            ),
         )
 }

-fun aMediaViewerState(
+fun aMediaViewerPageDataLoading(
+    direction: Timeline.PaginationDirection = Timeline.PaginationDirection.BACKWARDS,
+    timestamp: Long = 0L,
+): MediaViewerPageData {
+    return MediaViewerPageData.Loading(
+        direction = direction,
+        timestamp = timestamp,
+    )
+}
+
+fun aMediaViewerPageData(
     downloadedMedia: AsyncData<LocalMedia> = AsyncData.Uninitialized,
     mediaInfo: MediaInfo = anImageMediaInfo(),
+    mediaSource: MediaSource = MediaSource(""),
+): MediaViewerPageData.MediaViewerData = MediaViewerPageData.MediaViewerData(
+    eventId = null,
+    mediaInfo = mediaInfo,
+    mediaSource = mediaSource,
+    thumbnailSource = null,
+    downloadedMedia = mutableStateOf(downloadedMedia),
+)
+
+fun aMediaViewerState(
+    listData: List<MediaViewerPageData> = listOf(aMediaViewerPageData()),
+    currentIndex: Int = 0,
     canShowInfo: Boolean = true,
     mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden,
     eventSink: (MediaViewerEvents) -> Unit = {},
 ) = MediaViewerState(
-    eventId = null,
-    mediaInfo = mediaInfo,
-    thumbnailSource = null,
-    downloadedMedia = downloadedMedia,
+    listData = listData.toPersistentList(),
+    currentIndex = currentIndex,
     snackbarMessage = null,
     canShowInfo = canShowInfo,
     mediaBottomSheetState = mediaBottomSheetState,
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt
index a05d46c72c5..695725009f1 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt
@@ -11,7 +11,6 @@ package io.element.android.libraries.mediaviewer.impl.viewer

 import androidx.activity.compose.BackHandler
 import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.core.animateFloatAsState
 import androidx.compose.animation.fadeIn
 import androidx.compose.animation.fadeOut
 import androidx.compose.foundation.background
@@ -22,6 +21,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.navigationBarsPadding
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.OpenInNew
 import androidx.compose.material3.ExperimentalMaterial3Api
@@ -35,6 +36,7 @@ import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberUpdatedState
 import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
@@ -52,6 +54,8 @@ import io.element.android.compound.tokens.generated.CompoundIcons
 import io.element.android.libraries.architecture.AsyncData
 import io.element.android.libraries.core.mimetype.MimeTypes
 import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
+import io.element.android.libraries.designsystem.components.async.AsyncFailure
+import io.element.android.libraries.designsystem.components.async.AsyncLoading
 import io.element.android.libraries.designsystem.components.button.BackButton
 import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
 import io.element.android.libraries.designsystem.preview.ElementPreviewDark
@@ -74,14 +78,12 @@ import io.element.android.libraries.mediaviewer.impl.details.MediaDetailsBottomS
 import io.element.android.libraries.mediaviewer.impl.local.LocalMediaView
 import io.element.android.libraries.mediaviewer.impl.local.PlayableState
 import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
+import io.element.android.libraries.mediaviewer.impl.util.bgCanvasWithTransparency
 import io.element.android.libraries.ui.strings.CommonStrings
 import kotlinx.coroutines.delay
-import me.saket.telephoto.flick.FlickToDismiss
-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 kotlin.time.Duration
+import timber.log.Timber

 @Composable
 fun MediaViewerView(
@@ -93,51 +95,129 @@ fun MediaViewerView(
     var showOverlay by remember { mutableStateOf(true) }

     val defaultBottomPaddingInPixels = if (LocalInspectionMode.current) 303 else 0
-    var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) }
+    val currentData = state.listData.getOrNull(state.currentIndex)
     BackHandler { onBackClick() }
     Scaffold(
         modifier,
         containerColor = Color.Transparent,
         snackbarHost = { SnackbarHost(snackbarHostState) },
     ) {
-        MediaViewerPage(
-            showOverlay = showOverlay,
-            bottomPaddingInPixels = bottomPaddingInPixels,
-            state = state,
-            onDismiss = {
-                onBackClick()
-            },
-            onShowOverlayChange = {
-                showOverlay = it
+        val pagerState = rememberPagerState(state.currentIndex, 0f) {
+            state.listData.size
+        }
+        LaunchedEffect(pagerState) {
+            snapshotFlow { pagerState.currentPage }.collect { page ->
+                state.eventSink(MediaViewerEvents.OnNavigateTo(page))
             }
-        )
+        }
+        LaunchedEffect(state.listData) {
+            Timber.d("MediaViewerView: state.listData: ${state.listData}")
+        }
+        HorizontalPager(
+            state = pagerState,
+            modifier = Modifier,
+            // Pre-load previous and next pages
+            beyondViewportPageCount = 1,
+        ) { page ->
+            when (val dataForPage = state.listData[page]) {
+                is MediaViewerPageData.Failure -> {
+                    MediaViewerErrorPage(
+                        throwable = dataForPage.throwable,
+                        onDismiss = onBackClick,
+                    )
+                }
+                is MediaViewerPageData.Loading -> {
+                    LaunchedEffect(dataForPage.timestamp) {
+                        state.eventSink(MediaViewerEvents.LoadMore(dataForPage.direction))
+                    }
+                    MediaViewerLoadingPage(
+                        onDismiss = onBackClick,
+                    )
+                }
+                is MediaViewerPageData.MediaViewerData -> {
+                    var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) }
+                    LaunchedEffect(Unit) {
+                        state.eventSink(MediaViewerEvents.LoadMedia(dataForPage))
+                    }
+                    Box(
+                        modifier = Modifier.fillMaxSize()
+                    ) {
+                        MediaViewerPage(
+                            isDisplayed = page == pagerState.settledPage,
+                            showOverlay = showOverlay,
+                            bottomPaddingInPixels = bottomPaddingInPixels,
+                            data = dataForPage,
+                            onDismiss = onBackClick,
+                            onRetry = {
+                                state.eventSink(MediaViewerEvents.LoadMedia(dataForPage))
+                            },
+                            onDismissError = {
+                                state.eventSink(MediaViewerEvents.ClearLoadingError(dataForPage))
+                            },
+                            onShowOverlayChange = {
+                                showOverlay = it
+                            }
+                        )
+                        // Bottom bar
+                        AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) {
+                            Box(
+                                modifier = Modifier
+                                    .fillMaxSize()
+                                    .navigationBarsPadding()
+                            ) {
+                                MediaViewerBottomBar(
+                                    modifier = Modifier.align(Alignment.BottomCenter),
+                                    showDivider = dataForPage.mediaInfo.mimeType.isMimeTypeVideo(),
+                                    caption = dataForPage.mediaInfo.caption,
+                                    onHeightChange = { bottomPaddingInPixels = it },
+                                )
+                            }
+                        }
+                    }
+                }
+            }
+        }
+        // Top bar
         AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) {
             Box(
                 modifier = Modifier
                     .fillMaxSize()
                     .navigationBarsPadding()
             ) {
-                MediaViewerTopBar(
-                    actionsEnabled = state.downloadedMedia is AsyncData.Success,
-                    mimeType = state.mediaInfo.mimeType,
-                    senderName = state.mediaInfo.senderName,
-                    dateSent = state.mediaInfo.dateSent,
-                    canShowInfo = state.canShowInfo,
-                    onBackClick = onBackClick,
-                    onInfoClick = {
-                        state.eventSink(MediaViewerEvents.OpenInfo)
-                    },
-                    eventSink = state.eventSink
-                )
-                MediaViewerBottomBar(
-                    modifier = Modifier.align(Alignment.BottomCenter),
-                    showDivider = state.mediaInfo.mimeType.isMimeTypeVideo(),
-                    caption = state.mediaInfo.caption,
-                    onHeightChange = { bottomPaddingInPixels = it },
-                )
+                when (currentData) {
+                    is MediaViewerPageData.MediaViewerData -> {
+                        MediaViewerTopBar(
+                            data = currentData,
+                            canShowInfo = state.canShowInfo,
+                            onBackClick = onBackClick,
+                            onInfoClick = {
+                                state.eventSink(MediaViewerEvents.OpenInfo(currentData))
+                            },
+                            eventSink = state.eventSink
+                        )
+                    }
+                    else -> {
+                        TopAppBar(
+                            title = {
+                                if (currentData is MediaViewerPageData.Loading) {
+                                    Text(
+                                        text = stringResource(id = CommonStrings.common_loading_more),
+                                        style = ElementTheme.typography.fontBodyMdMedium,
+                                        color = ElementTheme.colors.textPrimary,
+                                    )
+                                }
+                            },
+                            colors = TopAppBarDefaults.topAppBarColors(
+                                containerColor = bgCanvasWithTransparency,
+                            ),
+                            navigationIcon = { BackButton(onClick = onBackClick) },
+                        )
+                    }
+                }
             }
         }
     }
+
     when (val bottomSheetState = state.mediaBottomSheetState) {
         MediaBottomSheetState.Hidden -> Unit
         is MediaBottomSheetState.MediaDetailsBottomSheetState -> {
@@ -147,13 +227,24 @@ fun MediaViewerView(
                     state.eventSink(MediaViewerEvents.ViewInTimeline(it))
                 },
                 onShare = {
-                    state.eventSink(MediaViewerEvents.Share)
+                    (currentData as? MediaViewerPageData.MediaViewerData)?.let {
+                        state.eventSink(MediaViewerEvents.Share(currentData))
+                    }
                 },
                 onDownload = {
-                    state.eventSink(MediaViewerEvents.SaveOnDisk)
+                    (currentData as? MediaViewerPageData.MediaViewerData)?.let {
+                        state.eventSink(MediaViewerEvents.SaveOnDisk(currentData))
+                    }
                 },
                 onDelete = { eventId ->
-                    state.eventSink(MediaViewerEvents.ConfirmDelete(eventId))
+                    (currentData as? MediaViewerPageData.MediaViewerData)?.let {
+                        state.eventSink(
+                            MediaViewerEvents.ConfirmDelete(
+                                eventId,
+                                currentData,
+                            )
+                        )
+                    }
                 },
                 onDismiss = {
                     state.eventSink(MediaViewerEvents.CloseBottomSheet)
@@ -176,41 +267,31 @@ fun MediaViewerView(

 @Composable
 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.RetryLoading)
-    }
-
-    fun onDismissError() {
-        state.eventSink(MediaViewerEvents.ClearLoadingError)
-    }
-
     val currentShowOverlay by rememberUpdatedState(showOverlay)
     val currentOnShowOverlayChange by rememberUpdatedState(onShowOverlayChange)
-    val flickState = rememberFlickToDismissState(dismissThresholdRatio = 0.1f, rotateOnDrag = false)

-    DismissFlickEffects(
-        flickState = flickState,
-        onDismissing = { animationDuration ->
-            delay(animationDuration / 3)
-            onDismiss()
-        },
+    MediaViewerFlickToDismiss(
+        onDismiss = onDismiss,
         onDragging = {
             currentOnShowOverlayChange(false)
-        }
-    )
-
-    FlickToDismiss(
-        state = flickState,
-        modifier = modifier.background(backgroundColorFor(flickState))
+        },
+        onResetting = {
+            currentOnShowOverlayChange(true)
+        },
+        modifier = modifier,
     ) {
-        val showProgress = rememberShowProgress(state.downloadedMedia)
+        val downloadedMedia by data.downloadedMedia
+        val showProgress = rememberShowProgress(downloadedMedia)

         Box(
             modifier = Modifier
@@ -224,7 +305,7 @@ private fun MediaViewerPage(
                 val localMediaViewState = rememberLocalMediaViewState(zoomableState)
                 val showThumbnail = !localMediaViewState.isReady
                 val playableState = localMediaViewState.playableState
-                val showError = state.downloadedMedia is AsyncData.Failure
+                val showError = downloadedMedia.isFailure()

                 LaunchedEffect(playableState) {
                     if (playableState is PlayableState.Playable) {
@@ -234,10 +315,11 @@ private fun MediaViewerPage(

                 LocalMediaView(
                     modifier = Modifier.fillMaxSize(),
+                    isDisplayed = isDisplayed,
                     bottomPaddingInPixels = bottomPaddingInPixels,
                     localMediaViewState = localMediaViewState,
-                    localMedia = state.downloadedMedia.dataOrNull(),
-                    mediaInfo = state.mediaInfo,
+                    localMedia = downloadedMedia.dataOrNull(),
+                    mediaInfo = data.mediaInfo,
                     onClick = {
                         if (playableState is PlayableState.NotPlayable) {
                             currentOnShowOverlayChange(!currentShowOverlay)
@@ -245,15 +327,15 @@ private fun MediaViewerPage(
                     },
                 )
                 ThumbnailView(
-                    mediaInfo = state.mediaInfo,
-                    thumbnailSource = state.thumbnailSource,
+                    mediaInfo = data.mediaInfo,
+                    thumbnailSource = data.thumbnailSource,
                     isVisible = showThumbnail,
                 )
                 if (showError) {
                     ErrorView(
                         errorMessage = stringResource(id = CommonStrings.error_unknown),
-                        onRetry = ::onRetry,
-                        onDismiss = ::onDismissError
+                        onRetry = onRetry,
+                        onDismiss = onDismissError
                     )
                 }
             }
@@ -269,26 +351,46 @@ private fun MediaViewerPage(
 }

 @Composable
-private fun DismissFlickEffects(
-    flickState: FlickToDismissState,
-    onDismissing: suspend (Duration) -> Unit,
-    onDragging: suspend () -> Unit,
+private fun MediaViewerLoadingPage(
+    onDismiss: () -> Unit,
+    modifier: Modifier = Modifier,
 ) {
-    val currentOnDismissing by rememberUpdatedState(onDismissing)
-    val currentOnDragging by rememberUpdatedState(onDragging)
-
-    when (val gestureState = flickState.gestureState) {
-        is FlickToDismissState.GestureState.Dismissing -> {
-            LaunchedEffect(Unit) {
-                currentOnDismissing(gestureState.animationDuration)
-            }
+    MediaViewerFlickToDismiss(
+        onDismiss = onDismiss,
+        modifier = modifier,
+    ) {
+        Box(
+            modifier = Modifier
+                .fillMaxSize()
+                .navigationBarsPadding(),
+            contentAlignment = Alignment.Center
+        ) {
+            AsyncLoading()
         }
-        is FlickToDismissState.GestureState.Dragging -> {
-            LaunchedEffect(Unit) {
-                currentOnDragging()
-            }
+    }
+}
+
+@Composable
+private fun MediaViewerErrorPage(
+    throwable: Throwable,
+    onDismiss: () -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    MediaViewerFlickToDismiss(
+        onDismiss = onDismiss,
+        modifier = modifier,
+    ) {
+        Box(
+            modifier = Modifier
+                .fillMaxSize()
+                .navigationBarsPadding(),
+            contentAlignment = Alignment.Center
+        ) {
+            AsyncFailure(
+                throwable = throwable,
+                onRetry = null
+            )
         }
-        else -> Unit
     }
 }

@@ -316,15 +418,17 @@ private fun rememberShowProgress(downloadedMedia: AsyncData<LocalMedia>): Boolea
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
 private fun MediaViewerTopBar(
-    actionsEnabled: Boolean,
-    mimeType: String,
-    senderName: String?,
-    dateSent: String?,
+    data: MediaViewerPageData.MediaViewerData,
     canShowInfo: Boolean,
     onBackClick: () -> Unit,
     onInfoClick: () -> Unit,
     eventSink: (MediaViewerEvents) -> Unit,
 ) {
+    val downloadedMedia by data.downloadedMedia
+    val actionsEnabled = downloadedMedia.isSuccess()
+    val mimeType = data.mediaInfo.mimeType
+    val senderName = data.mediaInfo.senderName
+    val dateSent = data.mediaInfo.dateSent
     TopAppBar(
         title = {
             if (senderName != null && dateSent != null) {
@@ -350,14 +454,14 @@ private fun MediaViewerTopBar(
             }
         },
         colors = TopAppBarDefaults.topAppBarColors(
-            containerColor = Color.Transparent.copy(0.6f),
+            containerColor = bgCanvasWithTransparency,
         ),
         navigationIcon = { BackButton(onClick = onBackClick) },
         actions = {
             IconButton(
                 enabled = actionsEnabled,
                 onClick = {
-                    eventSink(MediaViewerEvents.OpenWith)
+                    eventSink(MediaViewerEvents.OpenWith(data))
                 },
             ) {
                 when (mimeType) {
@@ -378,7 +482,7 @@ private fun MediaViewerTopBar(
                 ) {
                     Icon(
                         imageVector = CompoundIcons.Info(),
-                        contentDescription = null,
+                        contentDescription = stringResource(id = CommonStrings.a11y_view_details),
                     )
                 }
             }
@@ -396,7 +500,7 @@ private fun MediaViewerBottomBar(
     Column(
         modifier = modifier
             .fillMaxWidth()
-            .background(Color(0x99101317))
+            .background(bgCanvasWithTransparency)
             .onSizeChanged {
                 onHeightChange(it.height)
             },
@@ -457,21 +561,6 @@ private fun ErrorView(
     )
 }

-@Composable
-private fun backgroundColorFor(flickState: FlickToDismissState): Color {
-    val animatedAlpha by animateFloatAsState(
-        targetValue = when (flickState.gestureState) {
-            is FlickToDismissState.GestureState.Dismissed,
-            is FlickToDismissState.GestureState.Dismissing -> 0f
-            is FlickToDismissState.GestureState.Dragging,
-            is FlickToDismissState.GestureState.Idle,
-            is FlickToDismissState.GestureState.Resetting -> 1f - flickState.offsetFraction
-        },
-        label = "Background alpha",
-    )
-    return Color.Black.copy(alpha = animatedAlpha)
-}
-
 // Only preview in dark, dark theme is forced on the Node.
 @Preview
 @Composable
diff --git a/libraries/mediaviewer/impl/src/main/res/values/localazy.xml b/libraries/mediaviewer/impl/src/main/res/values/localazy.xml
index 11d6219565e..52a22183318 100644
--- a/libraries/mediaviewer/impl/src/main/res/values/localazy.xml
+++ b/libraries/mediaviewer/impl/src/main/res/values/localazy.xml
@@ -14,6 +14,8 @@
   <string name="screen_media_browser_title">"Media and files"</string>
   <string name="screen_media_details_file_format">"File format"</string>
   <string name="screen_media_details_filename">"File name"</string>
+  <string name="screen_media_details_no_more_files_to_show">"No more files to show"</string>
+  <string name="screen_media_details_no_more_media_to_show">"No more media to show"</string>
   <string name="screen_media_details_redact_confirmation_message">"This file will be removed from the room and members won’t have access to it."</string>
   <string name="screen_media_details_redact_confirmation_title">"Delete file?"</string>
   <string name="screen_media_details_uploaded_by">"Uploaded by"</string>
diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml
index 4a31e05852e..26a87bde89a 100644
--- a/libraries/ui-strings/src/main/res/values/localazy.xml
+++ b/libraries/ui-strings/src/main/res/values/localazy.xml
@@ -29,6 +29,7 @@
   <string name="a11y_show_password">"Show password"</string>
   <string name="a11y_start_call">"Start a call"</string>
   <string name="a11y_user_menu">"User menu"</string>
+  <string name="a11y_view_details">"View details"</string>
   <string name="a11y_voice_message_record">"Record voice message."</string>
   <string name="a11y_voice_message_stop_recording">"Stop recording"</string>
   <string name="action_accept">"Accept"</string>
@@ -181,6 +182,7 @@ Reason: %1$s."</string>
   <string name="common_light">"Light"</string>
   <string name="common_link_copied_to_clipboard">"Link copied to clipboard"</string>
   <string name="common_loading">"Loading…"</string>
+  <string name="common_loading_more">"Loading more…"</string>
   <plurals name="common_member_count">
     <item quantity="one">"%1$d member"</item>
     <item quantity="other">"%1$d members"</item>

diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustTimeline.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustTimeline.kt
index e269a30b1b1..042d4c98a19 100644
--- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustTimeline.kt
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeRustTimeline.kt
@@ -8,10 +8,12 @@
 package io.element.android.libraries.matrix.impl.fixtures.fakes

 import org.matrix.rustcomponents.sdk.NoPointer
+import org.matrix.rustcomponents.sdk.PaginationStatusListener
 import org.matrix.rustcomponents.sdk.TaskHandle
 import org.matrix.rustcomponents.sdk.Timeline
 import org.matrix.rustcomponents.sdk.TimelineDiff
 import org.matrix.rustcomponents.sdk.TimelineListener
+import uniffi.matrix_sdk_ui.LiveBackPaginationStatus

 class FakeRustTimeline : Timeline(NoPointer) {
     private var listener: TimelineListener? = null
@@ -23,4 +25,16 @@ class FakeRustTimeline : Timeline(NoPointer) {
     fun emitDiff(diff: List<TimelineDiff>) {
         listener!!.onUpdate(diff)
     }
+
+    private var paginationStatusListener: PaginationStatusListener? = null
+    override suspend fun subscribeToBackPaginationStatus(listener: PaginationStatusListener): TaskHandle {
+        this.paginationStatusListener = listener
+        return FakeRustTaskHandle()
+    }
+
+    fun emitPaginationStatus(status: LiveBackPaginationStatus) {
+        paginationStatusListener!!.onUpdate(status)
+    }
+
+    override suspend fun fetchMembers() = Unit
 }
diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimelineTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimelineTest.kt
new file mode 100644
index 00000000000..8e9120a8399
--- /dev/null
+++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimelineTest.kt
@@ -0,0 +1,124 @@
+/*
+ * 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.
+ */
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package io.element.android.libraries.matrix.impl.timeline
+
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
+import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
+import io.element.android.libraries.matrix.api.timeline.Timeline
+import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
+import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeRustRoomListService
+import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeRustTimeline
+import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeRustTimelineDiff
+import io.element.android.libraries.matrix.impl.room.RoomContentForwarder
+import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.matrix.test.room.aRoomInfo
+import io.element.android.services.toolbox.api.systemclock.SystemClock
+import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
+import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
+import io.element.android.tests.testutils.testCoroutineDispatchers
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.matrix.rustcomponents.sdk.TimelineChange
+import uniffi.matrix_sdk_ui.LiveBackPaginationStatus
+import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline
+
+class RustTimelineTest {
+    @Test
+    fun `ensure that the timeline emits new loading item when pagination does not bring new events`() = runTest {
+        val inner = FakeRustTimeline()
+        val systemClock = FakeSystemClock()
+        val sut = createRustTimeline(
+            inner = inner,
+            systemClock = systemClock,
+        )
+        sut.timelineItems.test {
+            // Give time for the listener to be set
+            runCurrent()
+            inner.emitDiff(
+                listOf(
+                    FakeRustTimelineDiff(
+                        item = null,
+                        change = TimelineChange.RESET,
+                    )
+                )
+            )
+            with(awaitItem()) {
+                assertThat(size).isEqualTo(1)
+                // Typing notification
+                assertThat((get(0) as MatrixTimelineItem.Virtual).virtual).isEqualTo(VirtualTimelineItem.TypingNotification)
+            }
+            with(awaitItem()) {
+                assertThat(size).isEqualTo(2)
+                // The loading
+                assertThat((get(0) as MatrixTimelineItem.Virtual).virtual).isEqualTo(
+                    VirtualTimelineItem.LoadingIndicator(
+                        direction = Timeline.PaginationDirection.BACKWARDS,
+                        timestamp = A_FAKE_TIMESTAMP,
+                    )
+                )
+                // Typing notification
+                assertThat((get(1) as MatrixTimelineItem.Virtual).virtual).isEqualTo(VirtualTimelineItem.TypingNotification)
+            }
+            systemClock.epochMillisResult = A_FAKE_TIMESTAMP + 1
+            // Start pagination
+            sut.paginate(Timeline.PaginationDirection.BACKWARDS)
+            // Simulate SDK starting pagination
+            inner.emitPaginationStatus(LiveBackPaginationStatus.Paginating)
+            // No new events received
+            // Simulate SDK stopping pagination, more event to load
+            inner.emitPaginationStatus(LiveBackPaginationStatus.Idle(hitStartOfTimeline = false))
+            // expect an item to be emitted, with an updated timestamp
+            with(awaitItem()) {
+                assertThat(size).isEqualTo(2)
+                // The loading
+                assertThat((get(0) as MatrixTimelineItem.Virtual).virtual).isEqualTo(
+                    VirtualTimelineItem.LoadingIndicator(
+                        direction = Timeline.PaginationDirection.BACKWARDS,
+                        timestamp = A_FAKE_TIMESTAMP + 1,
+                    )
+                )
+                // Typing notification
+                assertThat((get(1) as MatrixTimelineItem.Virtual).virtual).isEqualTo(VirtualTimelineItem.TypingNotification)
+            }
+        }
+    }
+}
+
+private fun TestScope.createRustTimeline(
+    inner: InnerTimeline,
+    mode: Timeline.Mode = Timeline.Mode.LIVE,
+    systemClock: SystemClock = FakeSystemClock(),
+    matrixRoom: MatrixRoom = FakeMatrixRoom().apply { givenRoomInfo(aRoomInfo()) },
+    coroutineScope: CoroutineScope = backgroundScope,
+    dispatcher: CoroutineDispatcher = testCoroutineDispatchers().io,
+    roomContentForwarder: RoomContentForwarder = RoomContentForwarder(FakeRustRoomListService()),
+    featureFlagsService: FeatureFlagService = FakeFeatureFlagService(),
+    onNewSyncedEvent: () -> Unit = {},
+): RustTimeline {
+    return RustTimeline(
+        inner = inner,
+        mode = mode,
+        systemClock = systemClock,
+        matrixRoom = matrixRoom,
+        coroutineScope = coroutineScope,
+        dispatcher = dispatcher,
+        roomContentForwarder = roomContentForwarder,
+        featureFlagsService = featureFlagsService,
+        onNewSyncedEvent = onNewSyncedEvent,
+    )
+}
diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt
index 9b9a6092b5d..a0bc4c1f0fc 100644
--- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt
+++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt
@@ -165,6 +165,7 @@ class DefaultEventItemFactoryTest {
                     dateSent = "0 Day false",
                     dateSentFull = "0 Full false",
                     waveform = null,
+                    duration = null,
                 ),
                 mediaSource = MediaSource(""),
             )
@@ -214,6 +215,7 @@ class DefaultEventItemFactoryTest {
                     dateSent = "0 Day false",
                     dateSentFull = "0 Full false",
                     waveform = null,
+                    duration = null,
                 ),
                 mediaSource = MediaSource(""),
                 thumbnailSource = null,
@@ -260,6 +262,7 @@ class DefaultEventItemFactoryTest {
                     dateSent = "0 Day false",
                     dateSentFull = "0 Full false",
                     waveform = null,
+                    duration = null,
                 ),
                 mediaSource = MediaSource(""),
             )
@@ -310,10 +313,10 @@ class DefaultEventItemFactoryTest {
                     dateSent = "0 Day false",
                     dateSentFull = "0 Full false",
                     waveform = null,
+                    duration = "2:03",
                 ),
                 mediaSource = MediaSource(""),
                 thumbnailSource = null,
-                duration = "2:03",
             )
         )
     }
@@ -361,10 +364,9 @@ class DefaultEventItemFactoryTest {
                     dateSent = "0 Day false",
                     dateSentFull = "0 Full false",
                     waveform = listOf(1f, 2f).toImmutableList(),
+                    duration = "7:36",
                 ),
                 mediaSource = MediaSource(""),
-                duration = "7:36",
-                waveform = listOf(1f, 2f).toImmutableList(),
             )
         )
     }
@@ -412,6 +414,7 @@ class DefaultEventItemFactoryTest {
                     dateSent = "0 Day false",
                     dateSentFull = "0 Full false",
                     waveform = null,
+                    duration = null,
                 ),
                 mediaSource = MediaSource(""),
                 thumbnailSource = null,
diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaGalleryDataSource.kt
new file mode 100644
index 00000000000..419c2c568a3
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaGalleryDataSource.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.gallery
+
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.timeline.Timeline
+import io.element.android.tests.testutils.lambda.lambdaError
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+
+class FakeMediaGalleryDataSource(
+    private val startLambda: () -> Unit = { lambdaError() },
+    private val loadMoreLambda: (Timeline.PaginationDirection) -> Unit = { lambdaError() },
+    private val deleteItemLambda: (EventId) -> Unit = { lambdaError() },
+    ) : MediaGalleryDataSource {
+    override fun start() = startLambda()
+
+    private val groupedMediaItemsFlow = MutableSharedFlow<AsyncData<GroupedMediaItems>>(
+        replay = 1
+    )
+
+    override fun groupedMediaItemsFlow(): Flow<AsyncData<GroupedMediaItems>> {
+        return groupedMediaItemsFlow
+    }
+
+    suspend fun emitGroupedMediaItems(groupedMediaItems: AsyncData<GroupedMediaItems>) {
+        groupedMediaItemsFlow.emit(groupedMediaItems)
+    }
+
+    override fun getLastData(): AsyncData<GroupedMediaItems> {
+        return groupedMediaItemsFlow.replayCache.firstOrNull() ?: AsyncData.Uninitialized
+    }
+
+    override suspend fun loadMore(direction: Timeline.PaginationDirection) {
+        loadMoreLambda(direction)
+    }
+
+    override suspend fun deleteItem(eventId: EventId) {
+        deleteItemLambda(eventId)
+    }
+}
diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt
index 3a206dc244a..0f304f209e9 100644
--- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt
+++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt
@@ -8,12 +8,12 @@
 package io.element.android.libraries.mediaviewer.impl.gallery

 import android.net.Uri
+import app.cash.turbine.ReceiveTurbine
 import com.google.common.truth.Truth.assertThat
-import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
-import io.element.android.libraries.dateformatter.test.FakeDateFormatter
 import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
 import io.element.android.libraries.matrix.api.core.EventId
 import io.element.android.libraries.matrix.api.room.MatrixRoom
+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.A_ROOM_NAME
 import io.element.android.libraries.matrix.test.A_USER_ID
@@ -25,15 +25,11 @@ import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetSta
 import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage
 import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions
 import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
-import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
 import io.element.android.tests.testutils.WarmUpRule
 import io.element.android.tests.testutils.lambda.lambdaRecorder
 import io.element.android.tests.testutils.lambda.value
 import io.element.android.tests.testutils.test
-import io.element.android.tests.testutils.testCoroutineDispatchers
 import io.mockk.mockk
-import kotlinx.collections.immutable.persistentListOf
-import kotlinx.coroutines.test.TestScope
 import kotlinx.coroutines.test.runTest
 import org.junit.Rule
 import org.junit.Test
@@ -47,49 +43,37 @@ class MediaGalleryPresenterTest {

     @Test
     fun `present - initial state`() = runTest {
-        val onViewInTimelineClickLambda = lambdaRecorder<EventId, Unit> { }
-        val navigator = FakeMediaGalleryNavigator(
-            onViewInTimelineClickLambda = onViewInTimelineClickLambda,
-        )
+        val startLambda = lambdaRecorder<Unit> { }
         val presenter = createMediaGalleryPresenter(
-            navigator = navigator,
+            mediaGalleryDataSource = FakeMediaGalleryDataSource(
+                startLambda = startLambda,
+            ),
             room = FakeMatrixRoom(
                 displayName = A_ROOM_NAME,
                 mediaTimelineResult = { Result.success(FakeTimeline()) },
             )
         )
         presenter.test {
-            skipItems(2)
-            val initialState = awaitItem()
+            val initialState = awaitFirstItem()
             assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images)
             assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
             assertThat(initialState.roomName).isEqualTo(A_ROOM_NAME)
-            assertThat(initialState.groupedMediaItems.dataOrNull()).isEqualTo(
-                GroupedMediaItems(
-                    imageAndVideoItems = persistentListOf(),
-                    fileItems = persistentListOf(),
-                )
-            )
+            assertThat(initialState.groupedMediaItems.isUninitialized()).isTrue()
             assertThat(initialState.snackbarMessage).isNull()
         }
+        startLambda.assertions().isCalledOnce()
     }

     @Test
     fun `present - change mode`() = runTest {
-        val onViewInTimelineClickLambda = lambdaRecorder<EventId, Unit> { }
-        val navigator = FakeMediaGalleryNavigator(
-            onViewInTimelineClickLambda = onViewInTimelineClickLambda,
-        )
         val presenter = createMediaGalleryPresenter(
-            navigator = navigator,
             room = FakeMatrixRoom(
                 displayName = A_ROOM_NAME,
                 mediaTimelineResult = { Result.success(FakeTimeline()) },
             )
         )
         presenter.test {
-            skipItems(2)
-            val initialState = awaitItem()
+            val initialState = awaitFirstItem()
             assertThat(initialState.mode).isEqualTo(MediaGalleryMode.Images)
             initialState.eventSink(MediaGalleryEvents.ChangeMode(MediaGalleryMode.Files))
             val state = awaitItem()
@@ -110,7 +94,7 @@ class MediaGalleryPresenterTest {
         `present - bottom sheet state - own message`(canDeleteOwn = false)
     }

-    private suspend fun TestScope.`present - bottom sheet state - own message`(canDeleteOwn: Boolean) {
+    private suspend fun `present - bottom sheet state - own message`(canDeleteOwn: Boolean) {
         val presenter = createMediaGalleryPresenter(
             room = FakeMatrixRoom(
                 sessionId = A_USER_ID,
@@ -120,8 +104,7 @@ class MediaGalleryPresenterTest {
             )
         )
         presenter.test {
-            skipItems(2)
-            val initialState = awaitItem()
+            val initialState = awaitFirstItem()
             assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
             val item = aMediaItemImage(
                 eventId = AN_EVENT_ID,
@@ -154,7 +137,7 @@ class MediaGalleryPresenterTest {
         `present - bottom sheet state - other message`(canDeleteOther = false)
     }

-    private suspend fun TestScope.`present - bottom sheet state - other message`(canDeleteOther: Boolean) {
+    private suspend fun `present - bottom sheet state - other message`(canDeleteOther: Boolean) {
         val presenter = createMediaGalleryPresenter(
             room = FakeMatrixRoom(
                 sessionId = A_USER_ID,
@@ -164,8 +147,7 @@ class MediaGalleryPresenterTest {
             )
         )
         presenter.test {
-            skipItems(2)
-            val initialState = awaitItem()
+            val initialState = awaitFirstItem()
             assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
             val item = aMediaItemImage(
                 eventId = AN_EVENT_ID,
@@ -197,8 +179,7 @@ class MediaGalleryPresenterTest {
             )
         )
         presenter.test {
-            skipItems(2)
-            val initialState = awaitItem()
+            val initialState = awaitFirstItem()
             // Delete bottom sheet
             val item = aMediaItemImage()
             initialState.eventSink(MediaGalleryEvents.ConfirmDelete(AN_EVENT_ID, item.mediaInfo, item.thumbnailSource))
@@ -217,6 +198,42 @@ class MediaGalleryPresenterTest {
         }
     }

+    @Test
+    fun `present - delete item`() = runTest {
+        val deleteItemLambda = lambdaRecorder<EventId, Unit> { }
+        val presenter = createMediaGalleryPresenter(
+            mediaGalleryDataSource = FakeMediaGalleryDataSource(
+                startLambda = { },
+                deleteItemLambda = deleteItemLambda,
+            ),
+        )
+        presenter.test {
+            val initialState = awaitFirstItem()
+            initialState.eventSink(MediaGalleryEvents.Delete(AN_EVENT_ID))
+            deleteItemLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
+        }
+    }
+
+    @Test
+    fun `present - share item`() = runTest {
+        val presenter = createMediaGalleryPresenter()
+        presenter.test {
+            val initialState = awaitFirstItem()
+            initialState.eventSink(MediaGalleryEvents.Share(AN_EVENT_ID))
+        }
+        // TODO Add more test on this part
+    }
+
+    @Test
+    fun `present - save on disk`() = runTest {
+        val presenter = createMediaGalleryPresenter()
+        presenter.test {
+            val initialState = awaitFirstItem()
+            initialState.eventSink(MediaGalleryEvents.SaveOnDisk(AN_EVENT_ID))
+        }
+        // TODO Add more test on this part
+    }
+
     @Test
     fun `present - view in timeline invokes the navigator`() = runTest {
         val onViewInTimelineClickLambda = lambdaRecorder<EventId, Unit> { }
@@ -230,15 +247,37 @@ class MediaGalleryPresenterTest {
             navigator = navigator,
         )
         presenter.test {
-            skipItems(2)
-            val initialState = awaitItem()
+            val initialState = awaitFirstItem()
             initialState.eventSink(MediaGalleryEvents.ViewInTimeline(AN_EVENT_ID))
             onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
         }
     }

-    private fun TestScope.createMediaGalleryPresenter(
+    @Test
+    fun `present - load more`() = runTest {
+        val loadMoreLambda = lambdaRecorder<Timeline.PaginationDirection, Unit> { }
+        val presenter = createMediaGalleryPresenter(
+            mediaGalleryDataSource = FakeMediaGalleryDataSource(
+                startLambda = { },
+                loadMoreLambda = loadMoreLambda,
+            ),
+        )
+        presenter.test {
+            val initialState = awaitFirstItem()
+            initialState.eventSink(MediaGalleryEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS))
+            loadMoreLambda.assertions().isCalledOnce().with(value(Timeline.PaginationDirection.BACKWARDS))
+        }
+    }
+
+    private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
+        return awaitItem()
+    }
+
+    private fun createMediaGalleryPresenter(
         matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(),
+        mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(
+            startLambda = { },
+        ),
         localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(),
         snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
         navigator: MediaGalleryNavigator = FakeMediaGalleryNavigator(),
@@ -249,22 +288,11 @@ class MediaGalleryPresenterTest {
         return MediaGalleryPresenter(
             navigator = navigator,
             room = room,
-            timelineMediaItemsFactory = TimelineMediaItemsFactory(
-                dispatchers = testCoroutineDispatchers(),
-                virtualItemFactory = VirtualItemFactory(
-                    dateFormatter = FakeDateFormatter(),
-                ),
-                eventItemFactory = EventItemFactory(
-                    fileSizeFormatter = FakeFileSizeFormatter(),
-                    fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
-                    dateFormatter = FakeDateFormatter(),
-                ),
-            ),
+            mediaGalleryDataSource = mediaGalleryDataSource,
             localMediaFactory = localMediaFactory,
             mediaLoader = matrixMediaLoader,
             localMediaActions = localMediaActions,
             snackbarDispatcher = snackbarDispatcher,
-            mediaItemsPostProcessor = MediaItemsPostProcessor(),
         )
     }
 }
diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessorTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessorTest.kt
index 9a8fc615fff..4c823350ce6 100644
--- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessorTest.kt
+++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessorTest.kt
@@ -8,9 +8,7 @@
 package io.element.android.libraries.mediaviewer.impl.gallery

 import com.google.common.truth.Truth.assertThat
-import io.element.android.libraries.architecture.AsyncData
 import io.element.android.libraries.matrix.api.core.UniqueId
-import io.element.android.libraries.matrix.test.AN_EXCEPTION
 import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemAudio
 import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemDateSeparator
 import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile
@@ -42,27 +40,6 @@ class MediaItemsPostProcessorTest {
     private val date3 = aMediaItemDateSeparator(id = UniqueId("3"))
     private val loading1 = aMediaItemLoadingIndicator(id = UniqueId("1"))

-    @Test
-    fun `process Uninitialized`() {
-        val sut = MediaItemsPostProcessor()
-        val result = sut.process(AsyncData.Uninitialized)
-        assertThat(result).isEqualTo(AsyncData.Uninitialized)
-    }
-
-    @Test
-    fun `process Loading`() {
-        val sut = MediaItemsPostProcessor()
-        val result = sut.process(AsyncData.Loading())
-        assertThat(result).isEqualTo(AsyncData.Loading<GroupedMediaItems>())
-    }
-
-    @Test
-    fun `process Failure`() {
-        val sut = MediaItemsPostProcessor()
-        val result = sut.process(AsyncData.Failure(AN_EXCEPTION))
-        assertThat(result).isEqualTo(AsyncData.Failure<GroupedMediaItems>(AN_EXCEPTION))
-    }
-
     @Test
     fun `process Empty`() {
         test(
@@ -215,19 +192,16 @@ class MediaItemsPostProcessorTest {
         expectedFileItems: List<MediaItem>,
     ) {
         val sut = MediaItemsPostProcessor()
-        val result = sut.process(AsyncData.Success(mediaItems.toImmutableList()))
-        val data = result.dataOrNull()!!
+        val result = sut.process(mediaItems.toImmutableList())

         // Compare the lists to have better failure info
-        assertThat(data.imageAndVideoItems.toList()).isEqualTo(expectedImageAndVideoItems)
-        assertThat(data.fileItems.toList()).isEqualTo(expectedFileItems)
+        assertThat(result.imageAndVideoItems.toList()).isEqualTo(expectedImageAndVideoItems)
+        assertThat(result.fileItems.toList()).isEqualTo(expectedFileItems)

         assertThat(result).isEqualTo(
-            AsyncData.Success(
-                GroupedMediaItems(
-                    imageAndVideoItems = expectedImageAndVideoItems.toImmutableList(),
-                    fileItems = expectedFileItems.toImmutableList(),
-                )
+            GroupedMediaItems(
+                imageAndVideoItems = expectedImageAndVideoItems.toImmutableList(),
+                fileItems = expectedFileItems.toImmutableList(),
             )
         )
     }
diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSourceTest.kt
new file mode 100644
index 00000000000..d616322ddca
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSourceTest.kt
@@ -0,0 +1,179 @@
+/*
+ * 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 app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.designsystem.components.media.createFakeWaveform
+import io.element.android.libraries.matrix.api.core.UniqueId
+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.media.aMediaSource
+import io.element.android.libraries.mediaviewer.api.MediaInfo
+import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
+import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
+import io.element.android.libraries.mediaviewer.api.aVoiceMediaInfo
+import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
+import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo
+import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
+import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile
+import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage
+import io.element.android.tests.testutils.WarmUpRule
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class SingleMediaGalleryDataSourceTest {
+    @get:Rule
+    val warmUpRule = WarmUpRule()
+
+    @Test
+    fun `function start is no op`() {
+        val sut = SingleMediaGalleryDataSource(aGroupedMediaItems())
+        sut.start()
+    }
+
+    @Test
+    fun `function loadMore is no op`() = runTest {
+        val sut = SingleMediaGalleryDataSource(aGroupedMediaItems())
+        sut.loadMore(Timeline.PaginationDirection.BACKWARDS)
+        sut.loadMore(Timeline.PaginationDirection.FORWARDS)
+    }
+
+    @Test
+    fun `function deleteItem is no op`() = runTest {
+        val sut = SingleMediaGalleryDataSource(aGroupedMediaItems())
+        sut.deleteItem(AN_EVENT_ID)
+    }
+
+    @Test
+    fun `getLastData should return the data`() {
+        val data = aGroupedMediaItems(
+            imageAndVideoItems = listOf(aMediaItemImage()),
+            fileItems = listOf(aMediaItemFile()),
+        )
+        val sut = SingleMediaGalleryDataSource(data)
+        assertThat(sut.getLastData()).isEqualTo(AsyncData.Success(data))
+    }
+
+    @Test
+    fun `groupedMediaItemsFlow emit a single item`() = runTest {
+        val data = aGroupedMediaItems(
+            imageAndVideoItems = listOf(aMediaItemImage()),
+            fileItems = listOf(aMediaItemFile()),
+        )
+        val sut = SingleMediaGalleryDataSource(data)
+        sut.groupedMediaItemsFlow().test {
+            assertThat(awaitItem()).isEqualTo(AsyncData.Success(data))
+            awaitComplete()
+        }
+    }
+
+    @Test
+    fun `createFrom should create a SingleMediaGalleryDataSource with an image item`() {
+        testFactory(
+            mediaInfo = anImageMediaInfo(),
+            expectedResult = { params ->
+                MediaItem.Image(
+                    id = UniqueId("dummy"),
+                    eventId = params.eventId,
+                    mediaInfo = params.mediaInfo,
+                    mediaSource = params.mediaSource,
+                    thumbnailSource = params.thumbnailSource,
+                )
+            }
+        )
+    }
+
+    @Test
+    fun `createFrom should create a SingleMediaGalleryDataSource with a video item`() {
+        testFactory(
+            mediaInfo = aVideoMediaInfo(),
+            expectedResult = { params ->
+                MediaItem.Video(
+                    id = UniqueId("dummy"),
+                    eventId = params.eventId,
+                    mediaInfo = params.mediaInfo,
+                    mediaSource = params.mediaSource,
+                    thumbnailSource = params.thumbnailSource,
+                )
+            }
+        )
+    }
+
+    @Test
+    fun `createFrom should create a SingleMediaGalleryDataSource with an audio item`() {
+        testFactory(
+            mediaInfo = anAudioMediaInfo(),
+            expectedResult = { params ->
+                MediaItem.Audio(
+                    id = UniqueId("dummy"),
+                    eventId = params.eventId,
+                    mediaInfo = params.mediaInfo,
+                    mediaSource = params.mediaSource,
+                )
+            }
+        )
+    }
+
+    @Test
+    fun `createFrom should create a SingleMediaGalleryDataSource with a voice item`() {
+        testFactory(
+            mediaInfo = aVoiceMediaInfo(
+                waveForm = createFakeWaveform(),
+                duration = "12:34",
+            ),
+            expectedResult = { params ->
+                MediaItem.Voice(
+                    id = UniqueId("dummy"),
+                    eventId = params.eventId,
+                    mediaInfo = params.mediaInfo,
+                    mediaSource = params.mediaSource,
+                )
+            }
+        )
+    }
+
+    @Test
+    fun `createFrom should create a SingleMediaGalleryDataSource with a file item`() {
+        testFactory(
+            mediaInfo = anApkMediaInfo(),
+            expectedResult = { params ->
+                MediaItem.File(
+                    id = UniqueId("dummy"),
+                    eventId = params.eventId,
+                    mediaInfo = params.mediaInfo,
+                    mediaSource = params.mediaSource,
+                )
+            }
+        )
+    }
+
+    private fun testFactory(
+        mediaInfo: MediaInfo,
+        expectedResult: (MediaViewerEntryPoint.Params) -> MediaItem,
+    ) {
+        val params = aMediaViewerEntryPointParams(mediaInfo)
+        val result = SingleMediaGalleryDataSource.createFrom(params)
+        val resultData = result.getLastData().dataOrNull()
+        assertThat(resultData!!.imageAndVideoItems.first()).isEqualTo(expectedResult(params))
+        assertThat(resultData.fileItems).isEmpty()
+    }
+
+    private fun aMediaViewerEntryPointParams(
+        mediaInfo: MediaInfo,
+    ) = MediaViewerEntryPoint.Params(
+        mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
+        eventId = AN_EVENT_ID,
+        mediaInfo = mediaInfo,
+        mediaSource = aMediaSource(url = "aUrl"),
+        thumbnailSource = aMediaSource(url = "aThumbnailUrl"),
+        canShowInfo = true,
+    )
+}
diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaGalleryDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaGalleryDataSourceTest.kt
new file mode 100644
index 00000000000..2f8cd634eca
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaGalleryDataSourceTest.kt
@@ -0,0 +1,277 @@
+/*
+ * 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 app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.core.mimetype.MimeTypes
+import io.element.android.libraries.dateformatter.test.FakeDateFormatter
+import io.element.android.libraries.matrix.api.media.ImageInfo
+import io.element.android.libraries.matrix.api.media.MediaSource
+import io.element.android.libraries.matrix.api.media.ThumbnailInfo
+import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
+import io.element.android.libraries.matrix.api.timeline.Timeline
+import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
+import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
+import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
+import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
+import io.element.android.libraries.matrix.test.AN_EVENT_ID
+import io.element.android.libraries.matrix.test.AN_EXCEPTION
+import io.element.android.libraries.matrix.test.A_UNIQUE_ID
+import io.element.android.libraries.matrix.test.A_USER_ID
+import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.matrix.test.timeline.FakeTimeline
+import io.element.android.libraries.matrix.test.timeline.aMessageContent
+import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
+import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
+import io.element.android.libraries.mediaviewer.api.MediaInfo
+import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
+import io.element.android.tests.testutils.testCoroutineDispatchers
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class TimelineMediaGalleryDataSourceTest {
+    @get:Rule
+    val warmUpRule = WarmUpRule()
+
+    @Test
+    fun `test - not started TimelineMediaGalleryDataSource emits no events`() = runTest {
+        val fakeTimeline = FakeTimeline()
+        val sut = createTimelineMediaGalleryDataSource(
+            room = FakeMatrixRoom(
+                mediaTimelineResult = { Result.success(fakeTimeline) },
+                roomCoroutineScope = backgroundScope,
+            )
+        )
+        sut.groupedMediaItemsFlow().test {
+            // Also, loadMore and deleteItem should be no-op
+            sut.loadMore(Timeline.PaginationDirection.BACKWARDS)
+            sut.deleteItem(AN_EVENT_ID)
+            expectNoEvents()
+        }
+    }
+
+    @Test
+    fun `test - getLastData should return the previous emitted data`() {
+        val fakeTimeline = FakeTimeline()
+        runTest {
+            val sut = createTimelineMediaGalleryDataSource(
+                room = FakeMatrixRoom(
+                    mediaTimelineResult = { Result.success(fakeTimeline) },
+                    roomCoroutineScope = backgroundScope,
+                )
+            )
+            sut.start()
+            assertThat(sut.getLastData()).isEqualTo(AsyncData.Uninitialized)
+            sut.groupedMediaItemsFlow().test {
+                assertThat(awaitItem().isLoading()).isTrue()
+                assertThat(sut.getLastData().isLoading()).isTrue()
+                assertThat(awaitItem()).isEqualTo(
+                    AsyncData.Success(
+                        GroupedMediaItems(
+                            imageAndVideoItems = persistentListOf(),
+                            fileItems = persistentListOf(),
+                        )
+                    )
+                )
+                assertThat(sut.getLastData().isSuccess()).isTrue()
+                // Also test that starting again should have no effect
+                sut.start()
+            }
+        }
+        // Ensure that the timeline has been closed on flow completion
+        assertThat(fakeTimeline.closeCounter).isEqualTo(1)
+    }
+
+    @Test
+    fun `test - load more should call the timeline paginate method`() = runTest {
+        val paginateLambdaRecorder =
+            lambdaRecorder<Timeline.PaginationDirection, Result<Boolean>> { _ ->
+                Result.success(true)
+            }
+        val fakeTimeline = FakeTimeline().apply {
+            paginateLambda = paginateLambdaRecorder
+        }
+        val sut = createTimelineMediaGalleryDataSource(
+            room = FakeMatrixRoom(
+                mediaTimelineResult = { Result.success(fakeTimeline) },
+                roomCoroutineScope = backgroundScope,
+            )
+        )
+        sut.start()
+        sut.groupedMediaItemsFlow().test {
+            skipItems(2)
+            sut.loadMore(Timeline.PaginationDirection.BACKWARDS)
+            paginateLambdaRecorder.assertions().isCalledOnce().with(value(Timeline.PaginationDirection.BACKWARDS))
+        }
+    }
+
+    @Test
+    fun `test - delete item should call the timeline redact method`() = runTest {
+        val redactEventLambdaRecorder =
+            lambdaRecorder<EventOrTransactionId, String?, Result<Unit>> { _, _ ->
+                Result.success(Unit)
+            }
+        val fakeTimeline = FakeTimeline().apply {
+            redactEventLambda = redactEventLambdaRecorder
+        }
+        val sut = createTimelineMediaGalleryDataSource(
+            room = FakeMatrixRoom(
+                mediaTimelineResult = { Result.success(fakeTimeline) },
+                roomCoroutineScope = backgroundScope,
+            )
+        )
+        sut.start()
+        sut.groupedMediaItemsFlow().test {
+            skipItems(2)
+            sut.deleteItem(AN_EVENT_ID)
+            redactEventLambdaRecorder.assertions().isCalledOnce().with(
+                value(AN_EVENT_ID.toEventOrTransactionId()),
+                value(null),
+            )
+        }
+    }
+
+    @Test
+    fun `test - failing to load timeline should emit an error`() = runTest {
+        val sut = createTimelineMediaGalleryDataSource(
+            room = FakeMatrixRoom(
+                mediaTimelineResult = { Result.failure(AN_EXCEPTION) },
+                roomCoroutineScope = backgroundScope,
+            )
+        )
+        sut.start()
+        sut.groupedMediaItemsFlow().test {
+            assertThat(awaitItem().isLoading()).isTrue()
+            assertThat(sut.getLastData().isLoading()).isTrue()
+            assertThat(awaitItem()).isEqualTo(
+                AsyncData.Failure<GroupedMediaItems>(AN_EXCEPTION)
+            )
+        }
+    }
+
+    @Test
+    fun `test - when timeline emits new data, the flow emits the data`() = runTest {
+        val timelineItems = MutableStateFlow<List<MatrixTimelineItem>>(emptyList())
+        val fakeTimeline = FakeTimeline(
+            timelineItems = timelineItems,
+        )
+        val sut = createTimelineMediaGalleryDataSource(
+            room = FakeMatrixRoom(
+                mediaTimelineResult = { Result.success(fakeTimeline) },
+                roomCoroutineScope = backgroundScope,
+            )
+        )
+        sut.start()
+        sut.groupedMediaItemsFlow().test {
+            assertThat(awaitItem().isLoading()).isTrue()
+            assertThat(sut.getLastData().isLoading()).isTrue()
+            assertThat(awaitItem()).isEqualTo(
+                AsyncData.Success(
+                    GroupedMediaItems(
+                        imageAndVideoItems = persistentListOf(),
+                        fileItems = persistentListOf(),
+                    )
+                )
+            )
+            timelineItems.emit(
+                listOf(
+                    MatrixTimelineItem.Event(
+                        uniqueId = A_UNIQUE_ID,
+                        event = anEventTimelineItem(
+                            content = aMessageContent(
+                                messageType = ImageMessageType(
+                                    filename = "body.jpg",
+                                    caption = "body.jpg caption",
+                                    formattedCaption = FormattedBody(MessageFormat.HTML, "formatted"),
+                                    source = MediaSource("url"),
+                                    info = ImageInfo(
+                                        height = 10L,
+                                        width = 5L,
+                                        mimetype = MimeTypes.Jpeg,
+                                        size = 888L,
+                                        thumbnailInfo = ThumbnailInfo(
+                                            height = 10L,
+                                            width = 5L,
+                                            mimetype = MimeTypes.Jpeg,
+                                            size = 111L,
+                                        ),
+                                        thumbnailSource = MediaSource("url_thumbnail"),
+                                        blurhash = A_BLUR_HASH,
+                                    )
+                                )
+                            )
+                        ),
+                    )
+                )
+            )
+            assertThat(awaitItem()).isEqualTo(
+                AsyncData.Success(
+                    GroupedMediaItems(
+                        imageAndVideoItems = persistentListOf(
+                            MediaItem.Image(
+                                id = A_UNIQUE_ID,
+                                eventId = AN_EVENT_ID,
+                                mediaInfo = MediaInfo(
+                                    filename = "body.jpg",
+                                    caption = "body.jpg caption",
+                                    mimeType = MimeTypes.Jpeg,
+                                    formattedFileSize = "888 Bytes",
+                                    fileExtension = "jpg",
+                                    senderId = A_USER_ID,
+                                    senderName = "alice",
+                                    senderAvatar = null,
+                                    dateSent = "0 Day false",
+                                    dateSentFull = "0 Full false",
+                                    waveform = null,
+                                    duration = null
+                                ),
+                                mediaSource = MediaSource("url"),
+                                thumbnailSource = MediaSource("url_thumbnail"),
+                            )
+                        ),
+                        fileItems = persistentListOf()
+                    )
+                )
+            )
+        }
+    }
+
+    private fun TestScope.createTimelineMediaGalleryDataSource(
+        room: MatrixRoom = FakeMatrixRoom(
+            liveTimeline = FakeTimeline(),
+        ),
+    ): TimelineMediaGalleryDataSource {
+        return TimelineMediaGalleryDataSource(
+            room = room,
+            timelineMediaItemsFactory = TimelineMediaItemsFactory(
+                dispatchers = testCoroutineDispatchers(),
+                virtualItemFactory = VirtualItemFactory(
+                    dateFormatter = FakeDateFormatter(),
+                ),
+                eventItemFactory = EventItemFactory(
+                    fileSizeFormatter = FakeFileSizeFormatter(),
+                    fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
+                    dateFormatter = FakeDateFormatter(),
+                ),
+            ),
+            mediaItemsPostProcessor = MediaItemsPostProcessor(),
+        )
+    }
+}
diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt
index 829efd7a155..48c636297bc 100644
--- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt
+++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactoryTest.kt
@@ -50,6 +50,7 @@ class AndroidLocalMediaFactoryTest {
                 dateSent = "12:34",
                 dateSentFull = "full",
                 waveform = null,
+                duration = null,
             )
         )
     }
diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt
new file mode 100644
index 00000000000..5348eb2aa34
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerDataSourceTest.kt
@@ -0,0 +1,278 @@
+/*
+ * 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 android.net.Uri
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.architecture.AsyncData
+import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
+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.matrix.test.AN_EXCEPTION
+import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
+import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
+import io.element.android.libraries.mediaviewer.impl.gallery.FakeMediaGalleryDataSource
+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.aGroupedMediaItems
+import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemDateSeparator
+import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile
+import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage
+import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemLoadingIndicator
+import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
+import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
+import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
+import io.element.android.tests.testutils.testCoroutineDispatchers
+import io.mockk.mockk
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class MediaViewerDataSourceTest {
+    private val mockMediaUrl: Uri = mockk("localMediaUri")
+
+    @Test
+    fun `setup should start the gallery data source`() = runTest {
+        val startLambda = lambdaRecorder<Unit> { }
+        val galleryDataSource = FakeMediaGalleryDataSource(
+            startLambda = startLambda
+        )
+        val sut = createMediaViewerDataSource(
+            galleryDataSource = galleryDataSource,
+        )
+        sut.setup()
+        startLambda.assertions().isCalledOnce()
+    }
+
+    @Test
+    fun `test dispose`() = runTest {
+        val sut = createMediaViewerDataSource()
+        sut.dispose()
+    }
+
+    @Test
+    fun `test dataFlow uninitialized, loading and error`() = runTest {
+        val galleryDataSource = FakeMediaGalleryDataSource()
+        val sut = createMediaViewerDataSource(
+            galleryDataSource = galleryDataSource,
+        )
+        sut.dataFlow().test {
+            galleryDataSource.emitGroupedMediaItems(AsyncData.Uninitialized)
+            assertThat(awaitItem().first()).isInstanceOf(MediaViewerPageData.Loading::class.java)
+            galleryDataSource.emitGroupedMediaItems(AsyncData.Loading())
+            assertThat(awaitItem().first()).isInstanceOf(MediaViewerPageData.Loading::class.java)
+            galleryDataSource.emitGroupedMediaItems(AsyncData.Failure(AN_EXCEPTION))
+            assertThat(awaitItem().first()).isEqualTo(MediaViewerPageData.Failure(AN_EXCEPTION))
+        }
+    }
+
+    @Test
+    fun `test dataFlow empty`() = runTest {
+        val galleryDataSource = FakeMediaGalleryDataSource()
+        val sut = createMediaViewerDataSource(
+            galleryDataSource = galleryDataSource,
+        )
+        sut.dataFlow().test {
+            galleryDataSource.emitGroupedMediaItems(
+                AsyncData.Success(
+                    aGroupedMediaItems(
+                        imageAndVideoItems = listOf(),
+                        fileItems = listOf(),
+                    )
+                )
+            )
+            val result = awaitItem()
+            assertThat(result).isEmpty()
+        }
+    }
+
+    @Test
+    fun `test dataFlow loading items`() = runTest {
+        val galleryDataSource = FakeMediaGalleryDataSource()
+        val sut = createMediaViewerDataSource(
+            galleryDataSource = galleryDataSource,
+        )
+        sut.dataFlow().test {
+            galleryDataSource.emitGroupedMediaItems(
+                AsyncData.Success(
+                    aGroupedMediaItems(
+                        imageAndVideoItems = listOf(
+                            aMediaItemLoadingIndicator(
+                                direction = Timeline.PaginationDirection.BACKWARDS,
+                            ),
+                            aMediaItemLoadingIndicator(
+                                direction = Timeline.PaginationDirection.FORWARDS,
+                            ),
+                        ),
+                        fileItems = listOf(),
+                    )
+                )
+            )
+            val result = awaitItem()
+            assertThat(result).containsExactly(
+                MediaViewerPageData.Loading(
+                    direction = Timeline.PaginationDirection.BACKWARDS,
+                    timestamp = A_FAKE_TIMESTAMP,
+                ),
+                MediaViewerPageData.Loading(
+                    direction = Timeline.PaginationDirection.FORWARDS,
+                    timestamp = A_FAKE_TIMESTAMP,
+                ),
+            )
+        }
+    }
+
+    @Test
+    fun `test dataFlow with data galleryMode image`() = runTest {
+        val galleryDataSource = FakeMediaGalleryDataSource()
+        val sut = createMediaViewerDataSource(
+            galleryMode = MediaGalleryMode.Images,
+            galleryDataSource = galleryDataSource,
+        )
+        sut.dataFlow().test {
+            galleryDataSource.emitGroupedMediaItems(
+                AsyncData.Success(
+                    aGroupedMediaItems(
+                        imageAndVideoItems = listOf(aMediaItemImage(eventId = AN_EVENT_ID)),
+                        fileItems = listOf(aMediaItemFile(eventId = AN_EVENT_ID_2)),
+                    )
+                )
+            )
+            val result = awaitItem()
+            assertThat(result).hasSize(1)
+            assertThat((result.first() as MediaViewerPageData.MediaViewerData).eventId).isEqualTo(AN_EVENT_ID)
+        }
+    }
+
+    @Test
+    fun `test dataFlow with data galleryMode files`() = runTest {
+        val galleryDataSource = FakeMediaGalleryDataSource()
+        val sut = createMediaViewerDataSource(
+            galleryMode = MediaGalleryMode.Files,
+            galleryDataSource = galleryDataSource,
+        )
+        sut.dataFlow().test {
+            galleryDataSource.emitGroupedMediaItems(
+                AsyncData.Success(
+                    aGroupedMediaItems(
+                        imageAndVideoItems = listOf(aMediaItemImage(eventId = AN_EVENT_ID)),
+                        fileItems = listOf(aMediaItemFile(eventId = AN_EVENT_ID_2)),
+                    )
+                )
+            )
+            val result = awaitItem()
+            assertThat(result).hasSize(1)
+            assertThat((result.first() as MediaViewerPageData.MediaViewerData).eventId).isEqualTo(AN_EVENT_ID_2)
+        }
+    }
+
+    @Test
+    fun `test dataFlow - date separator are filtered out`() = runTest {
+        val galleryDataSource = FakeMediaGalleryDataSource()
+        val sut = createMediaViewerDataSource(
+            galleryDataSource = galleryDataSource,
+        )
+        sut.dataFlow().test {
+            galleryDataSource.emitGroupedMediaItems(
+                AsyncData.Success(
+                    aGroupedMediaItems(
+                        imageAndVideoItems = listOf(aMediaItemDateSeparator(), aMediaItemImage(), aMediaItemDateSeparator()),
+                        fileItems = emptyList(),
+                    )
+                )
+            )
+            val result = awaitItem()
+            assertThat(result).hasSize(1)
+        }
+    }
+
+    @Test
+    fun `loadMore invokes the gallery data source loadMore`() = runTest {
+        val loadMoreLambda = lambdaRecorder<Timeline.PaginationDirection, Unit> { }
+        val galleryDataSource = FakeMediaGalleryDataSource(
+            loadMoreLambda = loadMoreLambda
+        )
+        val sut = createMediaViewerDataSource(
+            galleryDataSource = galleryDataSource,
+        )
+        sut.loadMore(Timeline.PaginationDirection.BACKWARDS)
+        loadMoreLambda.assertions().isCalledOnce().with(value(Timeline.PaginationDirection.BACKWARDS))
+    }
+
+    @Test
+    fun `test dataFlow with data galleryMode image and load media`() = runTest {
+        val galleryDataSource = FakeMediaGalleryDataSource()
+        val sut = createMediaViewerDataSource(
+            galleryDataSource = galleryDataSource,
+        )
+        sut.dataFlow().test {
+            galleryDataSource.emitGroupedMediaItems(
+                AsyncData.Success(
+                    aGroupedMediaItems(
+                        imageAndVideoItems = listOf(aMediaItemImage(eventId = AN_EVENT_ID)),
+                    )
+                )
+            )
+            val result = awaitItem()
+            val mediaViewerData = result.first() as MediaViewerPageData.MediaViewerData
+            assertThat(mediaViewerData.downloadedMedia.value).isEqualTo(AsyncData.Uninitialized)
+            sut.loadMedia(mediaViewerData)
+            assertThat(mediaViewerData.downloadedMedia.value.isSuccess()).isTrue()
+        }
+    }
+
+    @Test
+    fun `test dataFlow with data galleryMode image and load media with failure then success`() = runTest {
+        val galleryDataSource = FakeMediaGalleryDataSource()
+        val mediaLoader = FakeMatrixMediaLoader()
+        val sut = createMediaViewerDataSource(
+            galleryDataSource = galleryDataSource,
+            mediaLoader = mediaLoader,
+        )
+        sut.dataFlow().test {
+            galleryDataSource.emitGroupedMediaItems(
+                AsyncData.Success(
+                    aGroupedMediaItems(
+                        imageAndVideoItems = listOf(aMediaItemImage(eventId = AN_EVENT_ID)),
+                    )
+                )
+            )
+            val result = awaitItem()
+            val mediaViewerData = result.first() as MediaViewerPageData.MediaViewerData
+            assertThat(mediaViewerData.downloadedMedia.value).isEqualTo(AsyncData.Uninitialized)
+            mediaLoader.shouldFail = true
+            sut.loadMedia(mediaViewerData)
+            assertThat(mediaViewerData.downloadedMedia.value.isFailure()).isTrue()
+            // clear the error
+            sut.clearLoadingError(mediaViewerData)
+            assertThat(mediaViewerData.downloadedMedia.value).isEqualTo(AsyncData.Uninitialized)
+            // load again with success
+            mediaLoader.shouldFail = false
+            sut.loadMedia(mediaViewerData)
+            assertThat(mediaViewerData.downloadedMedia.value.isSuccess()).isTrue()
+        }
+    }
+
+    private fun TestScope.createMediaViewerDataSource(
+        galleryMode: MediaGalleryMode = MediaGalleryMode.Images,
+        galleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(),
+        mediaLoader: MatrixMediaLoader = FakeMatrixMediaLoader(),
+        localMediaFactory: LocalMediaFactory = FakeLocalMediaFactory(mockMediaUrl),
+    ) = MediaViewerDataSource(
+        galleryMode = galleryMode,
+        dispatcher = testCoroutineDispatchers().computation,
+        galleryDataSource = galleryDataSource,
+        mediaLoader = mediaLoader,
+        localMediaFactory = localMediaFactory,
+        systemClock = FakeSystemClock(),
+    )
+}
diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt
index 3f62fe463cf..ac0acfe7193 100644
--- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt
+++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt
@@ -10,14 +10,14 @@
 package io.element.android.libraries.mediaviewer.impl.viewer

 import android.net.Uri
-import app.cash.molecule.RecompositionMode
-import app.cash.molecule.moleculeFlow
-import app.cash.turbine.test
+import app.cash.turbine.ReceiveTurbine
 import com.google.common.truth.Truth.assertThat
 import io.element.android.libraries.architecture.AsyncData
 import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
 import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.media.MediaSource
 import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.timeline.Timeline
 import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
 import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
 import io.element.android.libraries.matrix.test.AN_EVENT_ID
@@ -30,14 +30,23 @@ import io.element.android.libraries.matrix.test.timeline.FakeTimeline
 import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
 import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
 import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
+import io.element.android.libraries.mediaviewer.impl.gallery.FakeMediaGalleryDataSource
+import io.element.android.libraries.mediaviewer.impl.gallery.GroupedMediaItems
+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.ui.aMediaItemImage
 import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions
 import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
+import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
 import io.element.android.tests.testutils.WarmUpRule
 import io.element.android.tests.testutils.lambda.lambdaRecorder
 import io.element.android.tests.testutils.lambda.value
 import io.element.android.tests.testutils.test
+import io.element.android.tests.testutils.testCoroutineDispatchers
 import io.mockk.mockk
+import kotlinx.collections.immutable.persistentListOf
 import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestScope
 import kotlinx.coroutines.test.runTest
 import org.junit.Rule
 import org.junit.Test
@@ -52,6 +61,7 @@ class MediaViewerPresenterTest {

     private val mockMediaUri: Uri = mockk("localMediaUri")
     private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri)
+    private val aUrl = "aUrl"

     @Test
     fun `present - initial state null Event`() = runTest {
@@ -61,9 +71,9 @@ class MediaViewerPresenterTest {
             )
         )
         presenter.test {
-            skipItems(2)
-            val initialState = awaitItem()
-            assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
+            val initialState = awaitFirstItem()
+            assertThat(initialState.listData).isEmpty()
+            assertThat(initialState.currentIndex).isEqualTo(0)
             assertThat(initialState.snackbarMessage).isNull()
             assertThat(initialState.canShowInfo).isTrue()
             assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
@@ -79,9 +89,9 @@ class MediaViewerPresenterTest {
             )
         )
         presenter.test {
-            skipItems(2)
-            val initialState = awaitItem()
-            assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
+            val initialState = awaitFirstItem()
+            assertThat(initialState.listData).isEmpty()
+            assertThat(initialState.currentIndex).isEqualTo(0)
             assertThat(initialState.snackbarMessage).isNull()
             assertThat(initialState.canShowInfo).isFalse()
             assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
@@ -97,9 +107,9 @@ class MediaViewerPresenterTest {
             )
         )
         presenter.test {
-            skipItems(2)
-            val initialState = awaitItem()
-            assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
+            val initialState = awaitFirstItem()
+            assertThat(initialState.listData).isEmpty()
+            assertThat(initialState.currentIndex).isEqualTo(0)
             assertThat(initialState.snackbarMessage).isNull()
             assertThat(initialState.canShowInfo).isTrue()
             assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
@@ -116,9 +126,9 @@ class MediaViewerPresenterTest {
             )
         )
         presenter.test {
-            skipItems(2)
-            val initialState = awaitItem()
-            assertThat(initialState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
+            val initialState = awaitFirstItem()
+            assertThat(initialState.listData).isEmpty()
+            assertThat(initialState.currentIndex).isEqualTo(0)
             assertThat(initialState.snackbarMessage).isNull()
             assertThat(initialState.canShowInfo).isTrue()
             assertThat(initialState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
@@ -126,114 +136,280 @@ class MediaViewerPresenterTest {
     }

     @Test
-    fun `present - download media success scenario`() = runTest {
+    fun `present - data source update`() = runTest {
+        val mediaGalleryDataSource = FakeMediaGalleryDataSource(
+            startLambda = { },
+        )
         val presenter = createMediaViewerPresenter(
-            room = FakeMatrixRoom(
-                canRedactOwnResult = { Result.success(true) },
+            mediaGalleryDataSource = mediaGalleryDataSource,
+        )
+        val anImage = aMediaItemImage()
+        presenter.test {
+            val initialState = awaitFirstItem()
+            assertThat(initialState.listData).isEmpty()
+            mediaGalleryDataSource.emitGroupedMediaItems(
+                AsyncData.Success(
+                    GroupedMediaItems(
+                        imageAndVideoItems = persistentListOf(anImage),
+                        fileItems = persistentListOf(),
+                    )
+                )
             )
+            val updatedState = awaitFirstItem()
+            assertThat(updatedState.listData).hasSize(1)
+            val item = updatedState.listData.first() as MediaViewerPageData.MediaViewerData
+            assertThat(item.eventId).isNull()
+            assertThat(item.mediaInfo).isEqualTo(anImage.mediaInfo)
+            assertThat(item.mediaSource).isEqualTo(anImage.mediaSource)
+            assertThat(item.thumbnailSource).isEqualTo(anImage.thumbnailSource)
+            assertThat(item.downloadedMedia.value).isEqualTo(AsyncData.Uninitialized)
+        }
+    }
+
+    @Test
+    fun `present - load media`() = runTest {
+        val mediaGalleryDataSource = FakeMediaGalleryDataSource(
+            startLambda = { },
         )
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
-            var state = awaitItem()
-            assertThat(state.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
-            assertThat(state.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
-            state = awaitItem()
-            assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
-            state = awaitItem()
-            val successData = state.downloadedMedia.dataOrNull()
-            assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
-            assertThat(successData).isNotNull()
+        val presenter = createMediaViewerPresenter(
+            mediaGalleryDataSource = mediaGalleryDataSource,
+        )
+        val anImage = aMediaItemImage(
+            mediaSourceUrl = aUrl,
+        )
+        presenter.test {
+            awaitFirstItem()
+            mediaGalleryDataSource.emitGroupedMediaItems(
+                AsyncData.Success(
+                    GroupedMediaItems(
+                        imageAndVideoItems = persistentListOf(anImage),
+                        fileItems = persistentListOf(),
+                    )
+                )
+            )
+            val updatedState = awaitItem()
+            updatedState.eventSink(
+                MediaViewerEvents.LoadMedia(
+                    aMediaViewerPageData(
+                        mediaSource = MediaSource(aUrl)
+                    )
+                )
+            )
         }
     }

     @Test
-    fun `present - check all actions`() = runTest {
-        val mediaActions = FakeLocalMediaActions()
-        val snackbarDispatcher = SnackbarDispatcher()
+    fun `present - open info`() = runTest {
+        val mediaGalleryDataSource = FakeMediaGalleryDataSource(
+            startLambda = { },
+        )
         val presenter = createMediaViewerPresenter(
-            localMediaActions = mediaActions,
-            snackbarDispatcher = snackbarDispatcher,
+            mediaGalleryDataSource = mediaGalleryDataSource,
             room = FakeMatrixRoom(
                 canRedactOwnResult = { Result.success(true) },
             )
         )
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
-            var state = awaitItem()
-            assertThat(state.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
-            state = awaitItem()
-            assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
-            // no state changes while media is loading
-            state.eventSink(MediaViewerEvents.OpenWith)
-            state.eventSink(MediaViewerEvents.Share)
-            state.eventSink(MediaViewerEvents.SaveOnDisk)
-            state = awaitItem()
-            assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
-            // Should succeed without change of state
-            state.eventSink(MediaViewerEvents.OpenWith)
-            // Should succeed without change of state
-            state.eventSink(MediaViewerEvents.Share)
-            state.eventSink(MediaViewerEvents.SaveOnDisk)
-            state = awaitItem()
-            assertThat(state.snackbarMessage).isNotNull()
-            snackbarDispatcher.clear()
-            assertThat(awaitItem().snackbarMessage).isNull()
+        val anImage = aMediaItemImage(
+            mediaSourceUrl = aUrl,
+        )
+        presenter.test {
+            awaitFirstItem()
+            mediaGalleryDataSource.emitGroupedMediaItems(
+                AsyncData.Success(
+                    GroupedMediaItems(
+                        imageAndVideoItems = persistentListOf(anImage),
+                        fileItems = persistentListOf(),
+                    )
+                )
+            )
+            val updatedState = awaitItem()
+            updatedState.eventSink(
+                MediaViewerEvents.OpenInfo(
+                    aMediaViewerPageData(
+                        mediaSource = MediaSource(aUrl)
+                    )
+                )
+            )
+            val withInfoState = awaitItem()
+            assertThat(withInfoState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java)
+            withInfoState.eventSink(
+                MediaViewerEvents.CloseBottomSheet
+            )
+            val finalState = awaitItem()
+            assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
+        }
+    }

-            // Check failures
-            mediaActions.shouldFail = true
-            state.eventSink(MediaViewerEvents.OpenWith)
-            state = awaitItem()
-            assertThat(state.snackbarMessage).isNotNull()
-            snackbarDispatcher.clear()
-            assertThat(awaitItem().snackbarMessage).isNull()
-            state.eventSink(MediaViewerEvents.Share)
-            state = awaitItem()
-            assertThat(state.snackbarMessage).isNotNull()
-            snackbarDispatcher.clear()
-            assertThat(awaitItem().snackbarMessage).isNull()
-            state.eventSink(MediaViewerEvents.SaveOnDisk)
-            state = awaitItem()
-            assertThat(state.snackbarMessage).isNotNull()
+    @Test
+    fun `present - clear loading error`() = runTest {
+        val mediaGalleryDataSource = FakeMediaGalleryDataSource(
+            startLambda = { },
+        )
+        val presenter = createMediaViewerPresenter(
+            mediaGalleryDataSource = mediaGalleryDataSource,
+        )
+        val anImage = aMediaItemImage(
+            mediaSourceUrl = aUrl,
+        )
+        presenter.test {
+            awaitFirstItem()
+            mediaGalleryDataSource.emitGroupedMediaItems(
+                AsyncData.Success(
+                    GroupedMediaItems(
+                        imageAndVideoItems = persistentListOf(anImage),
+                        fileItems = persistentListOf(),
+                    )
+                )
+            )
+            val updatedState = awaitItem()
+            updatedState.eventSink(
+                MediaViewerEvents.ClearLoadingError(
+                    aMediaViewerPageData(
+                        mediaSource = MediaSource(aUrl)
+                    )
+                )
+            )
         }
     }

     @Test
-    fun `present - download media failure then retry with success scenario`() = runTest {
-        val matrixMediaLoader = FakeMatrixMediaLoader()
+    fun `present - share`() = runTest {
+        val mediaGalleryDataSource = FakeMediaGalleryDataSource(
+            startLambda = { },
+        )
         val presenter = createMediaViewerPresenter(
-            matrixMediaLoader = matrixMediaLoader,
-            room = FakeMatrixRoom(
-                canRedactOwnResult = { Result.success(true) },
+            mediaGalleryDataSource = mediaGalleryDataSource,
+        )
+        val anImage = aMediaItemImage(
+            mediaSourceUrl = aUrl,
+        )
+        presenter.test {
+            awaitFirstItem()
+            mediaGalleryDataSource.emitGroupedMediaItems(
+                AsyncData.Success(
+                    GroupedMediaItems(
+                        imageAndVideoItems = persistentListOf(anImage),
+                        fileItems = persistentListOf(),
+                    )
+                )
+            )
+            val updatedState = awaitItem()
+            updatedState.eventSink(
+                MediaViewerEvents.Share(
+                    aMediaViewerPageData(
+                        mediaSource = MediaSource(aUrl)
+                    )
+                )
             )
+        }
+    }
+
+    @Test
+    fun `present - save on disk`() = runTest {
+        val mediaGalleryDataSource = FakeMediaGalleryDataSource(
+            startLambda = { },
         )
-        moleculeFlow(RecompositionMode.Immediate) {
-            presenter.present()
-        }.test {
-            matrixMediaLoader.shouldFail = true
-            val initialState = awaitItem()
-            assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
-            assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
-            val loadingState = awaitItem()
-            assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
-            val failureState = awaitItem()
-            assertThat(failureState.downloadedMedia).isInstanceOf(AsyncData.Failure::class.java)
-            matrixMediaLoader.shouldFail = false
-            failureState.eventSink(MediaViewerEvents.RetryLoading)
-            // There is one recomposition because of the retry mechanism
-            skipItems(1)
-            val retryLoadingState = awaitItem()
-            assertThat(retryLoadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
-            val successState = awaitItem()
-            val successData = successState.downloadedMedia.dataOrNull()
-            assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
-            assertThat(successData).isNotNull()
+        val presenter = createMediaViewerPresenter(
+            mediaGalleryDataSource = mediaGalleryDataSource,
+        )
+        val anImage = aMediaItemImage(
+            mediaSourceUrl = aUrl,
+        )
+        presenter.test {
+            awaitFirstItem()
+            mediaGalleryDataSource.emitGroupedMediaItems(
+                AsyncData.Success(
+                    GroupedMediaItems(
+                        imageAndVideoItems = persistentListOf(anImage),
+                        fileItems = persistentListOf(),
+                    )
+                )
+            )
+            val updatedState = awaitItem()
+            updatedState.eventSink(
+                MediaViewerEvents.SaveOnDisk(
+                    aMediaViewerPageData(
+                        mediaSource = MediaSource(aUrl)
+                    )
+                )
+            )
+        }
+    }
+
+    @Test
+    fun `present - open with`() = runTest {
+        val mediaGalleryDataSource = FakeMediaGalleryDataSource(
+            startLambda = { },
+        )
+        val presenter = createMediaViewerPresenter(
+            mediaGalleryDataSource = mediaGalleryDataSource,
+        )
+        val anImage = aMediaItemImage(
+            mediaSourceUrl = aUrl,
+        )
+        presenter.test {
+            awaitFirstItem()
+            mediaGalleryDataSource.emitGroupedMediaItems(
+                AsyncData.Success(
+                    GroupedMediaItems(
+                        imageAndVideoItems = persistentListOf(anImage),
+                        fileItems = persistentListOf(),
+                    )
+                )
+            )
+            val updatedState = awaitItem()
+            updatedState.eventSink(
+                MediaViewerEvents.OpenWith(
+                    aMediaViewerPageData(
+                        mediaSource = MediaSource(aUrl)
+                    )
+                )
+            )
         }
     }

     @Test
-    fun `present - delete media success scenario`() = runTest {
+    fun `present - delete and cancel`() = runTest {
+        val mediaGalleryDataSource = FakeMediaGalleryDataSource(
+            startLambda = { },
+        )
+        val presenter = createMediaViewerPresenter(
+            mediaGalleryDataSource = mediaGalleryDataSource,
+        )
+        val anImage = aMediaItemImage(
+            mediaSourceUrl = aUrl,
+        )
+        presenter.test {
+            awaitFirstItem()
+            mediaGalleryDataSource.emitGroupedMediaItems(
+                AsyncData.Success(
+                    GroupedMediaItems(
+                        imageAndVideoItems = persistentListOf(anImage),
+                        fileItems = persistentListOf(),
+                    )
+                )
+            )
+            val updatedState = awaitItem()
+            updatedState.eventSink(
+                MediaViewerEvents.ConfirmDelete(
+                    eventId = AN_EVENT_ID,
+                    data = aMediaViewerPageData(
+                        mediaSource = MediaSource(aUrl)
+                    )
+                )
+            )
+            val withBottomSheetState = awaitItem()
+            assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDeleteConfirmationState::class.java)
+            withBottomSheetState.eventSink(
+                MediaViewerEvents.CloseBottomSheet
+            )
+            val finalState = awaitItem()
+            assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
+        }
+    }
+
+    @Test
+    fun `present - delete`() = runTest {
         val redactEventLambda = lambdaRecorder<EventOrTransactionId, String?, Result<Unit>> { _, _ ->
             Result.success(Unit)
         }
@@ -241,26 +417,51 @@ class MediaViewerPresenterTest {
             this.redactEventLambda = redactEventLambda
         }
         val onItemDeletedLambda = lambdaRecorder<Unit> { }
-        val navigator = FakeMediaViewerNavigator(
-            onItemDeletedLambda = onItemDeletedLambda,
+        val mediaGalleryDataSource = FakeMediaGalleryDataSource(
+            startLambda = { },
         )
-
         val presenter = createMediaViewerPresenter(
             room = FakeMatrixRoom(
                 liveTimeline = timeline,
                 canRedactOwnResult = { Result.success(true) },
             ),
-            mediaViewerNavigator = navigator,
+            mediaGalleryDataSource = mediaGalleryDataSource,
+            mediaViewerNavigator = FakeMediaViewerNavigator(
+                onItemDeletedLambda = onItemDeletedLambda
+            )
+        )
+        val anImage = aMediaItemImage(
+            eventId = AN_EVENT_ID,
+            mediaSourceUrl = aUrl,
         )
         presenter.test {
-            val initialState = awaitItem()
-            assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
-            assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
-            val loadingState = awaitItem()
-            assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
-            val successState = awaitItem()
-            assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
-            successState.eventSink(MediaViewerEvents.Delete(AN_EVENT_ID))
+            awaitFirstItem()
+            mediaGalleryDataSource.emitGroupedMediaItems(
+                AsyncData.Success(
+                    GroupedMediaItems(
+                        imageAndVideoItems = persistentListOf(anImage),
+                        fileItems = persistentListOf(),
+                    )
+                )
+            )
+            val updatedState = awaitItem()
+            updatedState.eventSink(
+                MediaViewerEvents.ConfirmDelete(
+                    eventId = AN_EVENT_ID,
+                    data = aMediaViewerPageData(
+                        mediaSource = MediaSource(aUrl)
+                    )
+                )
+            )
+            val withBottomSheetState = awaitItem()
+            assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDeleteConfirmationState::class.java)
+            updatedState.eventSink(
+                MediaViewerEvents.Delete(
+                    eventId = AN_EVENT_ID,
+                )
+            )
+            val finalState = awaitItem()
+            assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
             redactEventLambda.assertions()
                 .isCalledOnce()
                 .with(
@@ -272,7 +473,71 @@ class MediaViewerPresenterTest {
     }

     @Test
-    fun `present - view in timeline invokes the navigator`() = runTest {
+    fun `present - on navigate to`() = runTest {
+        val mediaGalleryDataSource = FakeMediaGalleryDataSource(
+            startLambda = { },
+        )
+        val presenter = createMediaViewerPresenter(
+            mediaGalleryDataSource = mediaGalleryDataSource,
+        )
+        val anImage = aMediaItemImage(
+            mediaSourceUrl = aUrl,
+        )
+        val anImage2 = aMediaItemImage(
+            mediaSourceUrl = aUrl,
+        )
+        presenter.test {
+            awaitFirstItem()
+            mediaGalleryDataSource.emitGroupedMediaItems(
+                AsyncData.Success(
+                    GroupedMediaItems(
+                        imageAndVideoItems = persistentListOf(anImage, anImage2),
+                        fileItems = persistentListOf(),
+                    )
+                )
+            )
+            val updatedState = awaitItem()
+            updatedState.eventSink(
+                MediaViewerEvents.OnNavigateTo(1)
+            )
+            val finalState = awaitItem()
+            assertThat(finalState.currentIndex).isEqualTo(1)
+        }
+    }
+
+    @Test
+    fun `present - load more`() = runTest {
+        val loadMoreLambda = lambdaRecorder<Timeline.PaginationDirection, Unit> { }
+        val mediaGalleryDataSource = FakeMediaGalleryDataSource(
+            startLambda = { },
+            loadMoreLambda = loadMoreLambda,
+        )
+        val presenter = createMediaViewerPresenter(
+            mediaGalleryDataSource = mediaGalleryDataSource,
+        )
+        val anImage = aMediaItemImage(
+            mediaSourceUrl = aUrl,
+        )
+        presenter.test {
+            awaitFirstItem()
+            mediaGalleryDataSource.emitGroupedMediaItems(
+                AsyncData.Success(
+                    GroupedMediaItems(
+                        imageAndVideoItems = persistentListOf(anImage),
+                        fileItems = persistentListOf(),
+                    )
+                )
+            )
+            val updatedState = awaitItem()
+            updatedState.eventSink(
+                MediaViewerEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS)
+            )
+            loadMoreLambda.assertions().isCalledOnce().with(value(Timeline.PaginationDirection.BACKWARDS))
+        }
+    }
+
+    @Test
+    fun `present - view in timeline hide the bottom sheet and invokes the navigator`() = runTest {
         val onViewInTimelineClickLambda = lambdaRecorder<EventId, Unit> { }
         val navigator = FakeMediaViewerNavigator(
             onViewInTimelineClickLambda = onViewInTimelineClickLambda,
@@ -285,22 +550,28 @@ class MediaViewerPresenterTest {
         )
         presenter.test {
             val initialState = awaitItem()
-            assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
-            assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
-            val loadingState = awaitItem()
-            assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
-            val successState = awaitItem()
-            assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
-            successState.eventSink(MediaViewerEvents.ViewInTimeline(AN_EVENT_ID))
+            initialState.eventSink(MediaViewerEvents.OpenInfo(aMediaViewerPageData()))
+            val withBottomSheetState = awaitItem()
+            assertThat(withBottomSheetState.mediaBottomSheetState).isInstanceOf(MediaBottomSheetState.MediaDetailsBottomSheetState::class.java)
+            initialState.eventSink(MediaViewerEvents.ViewInTimeline(AN_EVENT_ID))
+            val finalState = awaitItem()
+            assertThat(finalState.mediaBottomSheetState).isEqualTo(MediaBottomSheetState.Hidden)
             onViewInTimelineClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID))
         }
     }

-    private fun createMediaViewerPresenter(
+    private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
+        return awaitItem()
+    }
+
+    private fun TestScope.createMediaViewerPresenter(
         eventId: EventId? = null,
         matrixMediaLoader: FakeMatrixMediaLoader = FakeMatrixMediaLoader(),
         localMediaActions: FakeLocalMediaActions = FakeLocalMediaActions(),
         snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
+        mediaGalleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(
+            startLambda = { },
+        ),
         canShowInfo: Boolean = true,
         mediaViewerNavigator: MediaViewerNavigator = FakeMediaViewerNavigator(),
         room: MatrixRoom = FakeMatrixRoom(
@@ -309,18 +580,25 @@ class MediaViewerPresenterTest {
     ): MediaViewerPresenter {
         return MediaViewerPresenter(
             inputs = MediaViewerEntryPoint.Params(
+                mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
                 eventId = eventId,
                 mediaInfo = TESTED_MEDIA_INFO,
                 mediaSource = aMediaSource(),
                 thumbnailSource = null,
                 canShowInfo = canShowInfo,
             ),
-            localMediaFactory = localMediaFactory,
-            mediaLoader = matrixMediaLoader,
-            localMediaActions = localMediaActions,
-            snackbarDispatcher = snackbarDispatcher,
             navigator = mediaViewerNavigator,
+            dataSource = MediaViewerDataSource(
+                galleryMode = MediaGalleryMode.Images,
+                dispatcher = testCoroutineDispatchers().computation,
+                galleryDataSource = mediaGalleryDataSource,
+                mediaLoader = matrixMediaLoader,
+                localMediaFactory = localMediaFactory,
+                systemClock = FakeSystemClock(),
+            ),
             room = room,
+            localMediaActions = localMediaActions,
+            snackbarDispatcher = snackbarDispatcher,
         )
     }
 }
diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt
index 89dfc0dd91f..bfc294098b5 100644
--- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt
+++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt
@@ -18,15 +18,15 @@ import androidx.compose.ui.test.performTouchInput
 import androidx.compose.ui.test.swipeDown
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import io.element.android.libraries.architecture.AsyncData
-import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
-import io.element.android.libraries.mediaviewer.api.local.LocalMedia
 import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState
+import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
 import io.element.android.libraries.ui.strings.CommonStrings
 import io.element.android.tests.testutils.EnsureNeverCalled
 import io.element.android.tests.testutils.EventsRecorder
 import io.element.android.tests.testutils.clickOn
 import io.element.android.tests.testutils.ensureCalledOnce
 import io.element.android.tests.testutils.pressBack
+import io.mockk.mockk
 import org.junit.Rule
 import org.junit.Test
 import org.junit.rules.TestRule
@@ -36,78 +36,127 @@ import org.junit.runner.RunWith
 class MediaViewerViewTest {
     @get:Rule val rule = createAndroidComposeRule<ComponentActivity>()

+    private val mockMediaUrl: Uri = mockk("localMediaUri")
+
     @Test
     fun `clicking on back invokes expected callback`() {
-        val eventsRecorder = EventsRecorder<MediaViewerEvents>(expectEvents = false)
+        val eventsRecorder = EventsRecorder<MediaViewerEvents>()
+        val state = aMediaViewerState(
+            eventSink = eventsRecorder
+        )
         ensureCalledOnce { callback ->
             rule.setMediaViewerView(
-                aMediaViewerState(
-                    eventSink = eventsRecorder
-                ),
+                state = state,
                 onBackClick = callback,
             )
             rule.pressBack()
         }
+        eventsRecorder.assertList(
+            listOf(
+                MediaViewerEvents.OnNavigateTo(0),
+                MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData),
+            )
+        )
     }

     @Test
     fun `clicking on open emit expected Event`() {
-        testMenuAction(CommonStrings.action_open_with, MediaViewerEvents.OpenWith)
+        val data = aMediaViewerPageData(
+            downloadedMedia = AsyncData.Success(aLocalMedia(uri = mockMediaUrl)),
+        )
+        testMenuAction(
+            data,
+            CommonStrings.action_open_with,
+            MediaViewerEvents.OpenWith(data),
+        )
+    }
+
+    @Test
+    fun `clicking on info emit expected Event`() {
+        val data = aMediaViewerPageData(
+            downloadedMedia = AsyncData.Success(aLocalMedia(uri = mockMediaUrl)),
+        )
+        testMenuAction(
+            data,
+            CommonStrings.a11y_view_details,
+            MediaViewerEvents.OpenInfo(data),
+        )
     }

-    private fun testMenuAction(contentDescriptionRes: Int, expectedEvent: MediaViewerEvents) {
+    private fun testMenuAction(
+        data: MediaViewerPageData.MediaViewerData,
+        contentDescriptionRes: Int,
+        expectedEvent: MediaViewerEvents,
+    ) {
         val eventsRecorder = EventsRecorder<MediaViewerEvents>()
         rule.setMediaViewerView(
             aMediaViewerState(
-                downloadedMedia = AsyncData.Success(
-                    LocalMedia(Uri.EMPTY, anImageMediaInfo())
-                ),
-                mediaInfo = anImageMediaInfo(),
+                listData = listOf(data),
                 eventSink = eventsRecorder
             ),
         )
         val contentDescription = rule.activity.getString(contentDescriptionRes)
         rule.onNodeWithContentDescription(contentDescription).performClick()
-        eventsRecorder.assertSingle(expectedEvent)
+        eventsRecorder.assertList(
+            listOf(
+                MediaViewerEvents.OnNavigateTo(0),
+                MediaViewerEvents.LoadMedia(data),
+                expectedEvent,
+            )
+        )
     }

     @Test
     fun `clicking on save emit expected Event`() {
-        testBottomSheetAction(CommonStrings.action_save, MediaViewerEvents.SaveOnDisk)
+        val data = aMediaViewerPageData()
+        testBottomSheetAction(
+            data,
+            CommonStrings.action_save,
+            MediaViewerEvents.SaveOnDisk(data),
+        )
     }

     @Test
     fun `clicking on share emit expected Event`() {
-        testBottomSheetAction(CommonStrings.action_share, MediaViewerEvents.Share)
+        val data = aMediaViewerPageData()
+        testBottomSheetAction(
+            data,
+            CommonStrings.action_share,
+            MediaViewerEvents.Share(data),
+        )
     }

-    private fun testBottomSheetAction(contentDescriptionRes: Int, expectedEvent: MediaViewerEvents) {
+    private fun testBottomSheetAction(
+        data: MediaViewerPageData.MediaViewerData,
+        contentDescriptionRes: Int,
+        expectedEvent: MediaViewerEvents,
+    ) {
         val eventsRecorder = EventsRecorder<MediaViewerEvents>()
         rule.setMediaViewerView(
             aMediaViewerState(
-                downloadedMedia = AsyncData.Success(
-                    LocalMedia(Uri.EMPTY, anImageMediaInfo())
-                ),
-                mediaInfo = anImageMediaInfo(),
+                listData = listOf(data),
                 mediaBottomSheetState = aMediaDetailsBottomSheetState(),
                 eventSink = eventsRecorder
             ),
         )
         rule.clickOn(contentDescriptionRes)
-        eventsRecorder.assertSingle(expectedEvent)
+        eventsRecorder.assertList(
+            listOf(
+                MediaViewerEvents.OnNavigateTo(0),
+                MediaViewerEvents.LoadMedia(data),
+                expectedEvent,
+            )
+        )
     }

     @Test
     fun `clicking on image hides the overlay`() {
-        val eventsRecorder = EventsRecorder<MediaViewerEvents>(expectEvents = false)
+        val eventsRecorder = EventsRecorder<MediaViewerEvents>()
+        val state = aMediaViewerState(
+            eventSink = eventsRecorder
+        )
         rule.setMediaViewerView(
-            aMediaViewerState(
-                downloadedMedia = AsyncData.Success(
-                    LocalMedia(Uri.EMPTY, anImageMediaInfo())
-                ),
-                mediaInfo = anImageMediaInfo(),
-                eventSink = eventsRecorder
-            ),
+            state = state,
         )
         // Ensure that the action are visible
         val contentDescription = rule.activity.getString(CommonStrings.action_open_with)
@@ -120,54 +169,79 @@ class MediaViewerViewTest {
         rule.mainClock.advanceTimeBy(1_000)
         rule.onNodeWithContentDescription(contentDescription)
             .assertDoesNotExist()
+        eventsRecorder.assertList(
+            listOf(
+                MediaViewerEvents.OnNavigateTo(0),
+                MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData),
+            )
+        )
     }

     @Test
     fun `clicking swipe on the image invokes the expected callback`() {
-        val eventsRecorder = EventsRecorder<MediaViewerEvents>(expectEvents = false)
+        val eventsRecorder = EventsRecorder<MediaViewerEvents>()
+        val state = aMediaViewerState(
+            eventSink = eventsRecorder
+        )
         ensureCalledOnce { callback ->
             rule.setMediaViewerView(
-                aMediaViewerState(
-                    downloadedMedia = AsyncData.Success(
-                        LocalMedia(Uri.EMPTY, anImageMediaInfo())
-                    ),
-                    mediaInfo = anImageMediaInfo(),
-                    eventSink = eventsRecorder
-                ),
+                state = state,
                 onBackClick = callback,
             )
             val imageContentDescription = rule.activity.getString(CommonStrings.common_image)
             rule.onNodeWithContentDescription(imageContentDescription).performTouchInput { swipeDown(startY = centerY) }
             rule.mainClock.advanceTimeBy(1_000)
         }
+        eventsRecorder.assertList(
+            listOf(
+                MediaViewerEvents.OnNavigateTo(0),
+                MediaViewerEvents.LoadMedia(state.listData.first() as MediaViewerPageData.MediaViewerData),
+            )
+        )
     }

     @Test
     fun `error case, click on retry emits the expected Event`() {
         val eventsRecorder = EventsRecorder<MediaViewerEvents>()
+        val data = aMediaViewerPageData(
+            downloadedMedia = AsyncData.Failure(IllegalStateException("error")),
+        )
         rule.setMediaViewerView(
             aMediaViewerState(
-                downloadedMedia = AsyncData.Failure(IllegalStateException("error")),
-                mediaInfo = anImageMediaInfo(),
+                listData = listOf(data),
                 eventSink = eventsRecorder
             ),
         )
         rule.clickOn(CommonStrings.action_retry)
-        eventsRecorder.assertSingle(MediaViewerEvents.RetryLoading)
+        eventsRecorder.assertList(
+            listOf(
+                MediaViewerEvents.OnNavigateTo(0),
+                MediaViewerEvents.LoadMedia(data),
+                MediaViewerEvents.LoadMedia(data),
+            )
+        )
     }

     @Test
     fun `error case, click on cancel emits the expected Event`() {
         val eventsRecorder = EventsRecorder<MediaViewerEvents>()
+        val data = aMediaViewerPageData(
+            downloadedMedia = AsyncData.Failure(IllegalStateException("error")),
+        )
         rule.setMediaViewerView(
             aMediaViewerState(
-                downloadedMedia = AsyncData.Failure(IllegalStateException("error")),
-                mediaInfo = anImageMediaInfo(),
+                listData = listOf(data),
                 eventSink = eventsRecorder
             ),
         )
         rule.clickOn(CommonStrings.action_cancel)
-        eventsRecorder.assertSingle(MediaViewerEvents.ClearLoadingError)
+        eventsRecorder.assertList(
+            listOf(
+                MediaViewerEvents.OnNavigateTo(0),
+                MediaViewerEvents.LoadMedia(data),
+                MediaViewerEvents.ClearLoadingError(data)
+            )
+        )
     }
 }

diff --git a/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt
index 1b93856bde3..f1ebbd04e94 100644
--- a/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt
+++ b/libraries/mediaviewer/test/src/main/kotlin/io/element/android/libraries/mediaviewer/test/FakeLocalMediaFactory.kt
@@ -43,6 +43,7 @@ class FakeLocalMediaFactory(
             dateSent = null,
             dateSentFull = null,
             waveform = null,
+            duration = null,
         )
         return aLocalMedia(uri, mediaInfo)
     }
diff --git a/services/toolbox/test/src/main/kotlin/io/element/android/services/toolbox/test/systemclock/FakeSystemClock.kt b/services/toolbox/test/src/main/kotlin/io/element/android/services/toolbox/test/systemclock/FakeSystemClock.kt
index 3835b163ac4..444502aea83 100644
--- a/services/toolbox/test/src/main/kotlin/io/element/android/services/toolbox/test/systemclock/FakeSystemClock.kt
+++ b/services/toolbox/test/src/main/kotlin/io/element/android/services/toolbox/test/systemclock/FakeSystemClock.kt
@@ -11,8 +11,8 @@ import io.element.android.services.toolbox.api.systemclock.SystemClock

 const val A_FAKE_TIMESTAMP = 123L

-class FakeSystemClock : SystemClock {
-    override fun epochMillis(): Long {
-        return A_FAKE_TIMESTAMP
-    }
+class FakeSystemClock(
+    var epochMillisResult: Long = A_FAKE_TIMESTAMP
+) : SystemClock {
+    override fun epochMillis() = epochMillisResult
 }
diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt
index 0e8a347ef28..d140d3fea48 100644
--- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt
+++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt
@@ -122,6 +122,7 @@ class KonsistClassNameTest {
             .withoutName(
                 "Factory",
                 "TimelineController",
+                "TimelineMediaGalleryDataSource",
             )
             .withoutNameStartingWith(
                 "Accompanist",
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.audio_MediaAudioView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.audio_MediaAudioView_Day_0_en.png
index 70d447adcdb..10f75384f6f 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.audio_MediaAudioView_Day_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.audio_MediaAudioView_Day_0_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:7c7d4201ed9aa37995f4ab8ac982404f59e77374f316a057685886f14e698c35
-size 24680
+oid sha256:8d8842663702441ce586c7e2141c0cdf47032a26b6015e592abb5682e3cd2c60
+size 25152
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.audio_MediaAudioView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.audio_MediaAudioView_Day_1_en.png
index 41b0cc2f9a7..3eb086fab4f 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.audio_MediaAudioView_Day_1_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.audio_MediaAudioView_Day_1_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:b94fd31b7ed71eacfe8f136bfd59405b85d31a0fe557800311794f4ba7006271
-size 22749
+oid sha256:e57fc21cd01917630a08324f49a3d76d82823ee4a2f90f23624c120126426689
+size 23190
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Day_0_en.png
index 93d2782dbbe..c497a8cb269 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Day_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Day_0_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:333a21a41a7d5f8d47946648a7381dadeccf213a1176696b3512c08ae929d4d6
-size 7819
+oid sha256:ddb495eaf8113f0be1ba572697083bd5b8ccd4c308780478b6c4691dd0f8d922
+size 8019
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Day_1_en.png
index a40e83dc489..257beda2948 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Day_1_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Day_1_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:cba8f49caf65856569266a561206437840a43da2d41ed5f08321ecda99204329
-size 8236
+oid sha256:22e4744300d62e550a9c545420621fe6aa8db1674a876731be31a12fbc426cbc
+size 7320
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Day_2_en.png
index b5b75d1b639..a9f023e618e 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Day_2_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Day_2_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:a6d04e0ee068682ebb0a3842ba73407855f2b83b7389d26fa0f3e2ec20d42dc8
-size 7389
+oid sha256:5076cbf15e1d0ec2c70432c88a5f48eb074490bdf8431cfb51109f91ad4e9576
+size 7495
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.video_MediaVideoView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.video_MediaVideoView_Day_0_en.png
index 6ab81fd4de1..4bd792bedf9 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.video_MediaVideoView_Day_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.local.video_MediaVideoView_Day_0_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:267d4be8b727a0ecb5af1e5f1e69adfd68f50f32f1de78f4f9fde60f635244b5
-size 13045
+oid sha256:b3599a3a6fb43f98d928ce71c3ef7a8b8102d558c31ea1f735a948e337738f95
+size 13533
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_0_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_0_en.png
index fd5c2af6e60..0ab3d99837b 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_0_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_0_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:66437179fb0b851d4d4d647d00cab94cc7422d625f559839c675b378dbf1af38
-size 389408
+oid sha256:1e4ef6ed6fe4c858ac4c67bf2bc5d428f4d4734cf8b01244a3bc963a815a540a
+size 389328
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_10_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_10_en.png
index 6a81f3f1294..81afb4ea1cd 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_10_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_10_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:2b286342ff4d46637beac1f980294f77b3e2eb6824d56448cdbdce7b41c911ab
-size 388612
+oid sha256:67ca752577251e9e57e7680ae1bbdef3324c84ec6a1037aa9f7c228bb8206f4c
+size 388634
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_11_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_11_en.png
index 4a43ce31dae..b66dd0d5064 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_11_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_11_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:8165bcb4b0d52a227aad4e1f3951fc3628ef647947ef2584ed50d8ede8a6a344
-size 38248
+oid sha256:96941aab9596583187e4a089bd448252be551e3ef6b2fb7550c3bad5c7ba60fb
+size 37905
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_12_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_12_en.png
index f3d0c19a9fc..2b16d56f044 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_12_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_12_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:c9efb4ed1ee82bba30351bf213f0873637e8194140a3bbea669321bf76bc6483
-size 31449
+oid sha256:add442c1cabc79cde42e65775b22413c92918bba93d96bb72a9ff214b9ae7fac
+size 31126
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_13_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_13_en.png
index 6d8afe11407..a227321f6b1 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_13_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_13_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:5d2882d79b9f66726c6d16c8c6fc84cb9f65a5674222fe85794229dc5ba12a6b
-size 24679
+oid sha256:0c288f75fcccb93e074afd8178219887ce8a541d8e444df18ca041647114d340
+size 24491
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_14_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_14_en.png
new file mode 100644
index 00000000000..c96f6f4bfbf
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_14_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9a60bd02d969c7c6ccc0eb2e8f4f2c5551b8d910e5bb2f96710589040541691d
+size 7973
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_15_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_15_en.png
new file mode 100644
index 00000000000..205d657d464
--- /dev/null
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_15_en.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:049993637421009db857dbd8a647d268241cd0f88a12a376804444647f94e885
+size 5069
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_1_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_1_en.png
index 12d5df3fa13..73e4bde51ea 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_1_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_1_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:da172fdf40dc8702bc6dcb89bcc75e93bd279f6bbb9454f5283febe4da25d399
-size 389440
+oid sha256:248ad0bbfc8c8a56975c2fc6ddfa5275ff4f3ad39b9a76126c8d4bdd0c566e88
+size 389354
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_2_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_2_en.png
index bf9ae5ebac5..ef7f299a264 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_2_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_2_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:ee478f10b781385a5bd472a9fb1047e869ca2e46e1bab28758315722ff911bbc
-size 94992
+oid sha256:ecdd1219635a61be5473773464d3796ea6a8f17ac2c384e131265e9557dadf42
+size 95129
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_3_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_3_en.png
index 40ff36cd942..adf626696dc 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_3_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_3_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:7ad6e45382dec9bb27593b6e2ed92ed633204479040ccebaf4d362bb2f41fec7
-size 396403
+oid sha256:c89d437ccf40ac25227d19d1b775ae0992cad7970784e29586438a60f6bf950b
+size 396206
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_4_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_4_en.png
index d94989c7571..cf56e5a13bc 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_4_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_4_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:d10cb9be5b5139f0fdfdfb11cc3d3eca1955297180e5db8142bfea6250f20d73
-size 25811
+oid sha256:9ac33be436135d5022024108ec403ea3e53995d8e42dcc52bbd3d8041e5e2975
+size 25053
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_5_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_5_en.png
index 3603786361f..75b5e2302f3 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_5_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_5_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:69b5d7572ae6e4ff084867fac1bae41a55c75c9a5236cb6ccb4c31b89ef77898
-size 5442
+oid sha256:4d0288375c9e746d4cbb9b270c1c7f5e97633d76027186bd9568555edeaf8700
+size 5411
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_6_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_6_en.png
index 6b6e6a655da..2bfbd92c3b7 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_6_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_6_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:f8d3e3f8733424870b254be90599ed1ff6ba784089600bcb200fbef62c81537c
-size 14562
+oid sha256:c756b50710b1ed10eea01d8f97d8cca79a3b3440c559e1f593c790b55e5f6556
+size 14194
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_7_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_7_en.png
index 32e7fcef893..be7ed165cf0 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_7_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_7_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:d1785d90957316791969f047e66bd779da62d675004914099f2af2b69bebe405
-size 14700
+oid sha256:2c5a6e30127fe88d1d55da6daebff64981e88bbf7a37c712c99f831849e56172
+size 14374
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_8_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_8_en.png
index dc33c0aef45..654e1d891ed 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_8_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_8_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:1ddcd8e9e20de4171a3d9f8175806a268723e47a14dca431849c2c29edaf5d0b
-size 26267
+oid sha256:1ddd7d087dac24b5f2861b59c946eb89046b9bcb1a709dca423b8893f55a81f9
+size 26217
diff --git a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_9_en.png b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_9_en.png
index d115aaa7edf..f230ae125e2 100644
--- a/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_9_en.png
+++ b/tests/uitests/src/test/snapshots/images/libraries.mediaviewer.impl.viewer_MediaViewerView_9_en.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:035ef0079af6e9825a52b86e2eab50667404a66d70dd2756596b60cc1cea376a
-size 26404
+oid sha256:8a0e84ee0e17fa30222ffb5fe58f353ca847b251a8da7088a58faa467f1f6742
+size 26258