Repository: element-hq/element-x-android (language: kotlin)

Issue:
Add capability to swipe through images in a chat.
### Your use case

#### What would you like to do?
When viewing a image I would like to be able to swipe left/right to see the previous/next image.
#### Why would you like to do it?
To be able to quickly watch and download multiple images.
#### How would you like to achieve it?
By a swipe gesture.

### Have you considered any alternatives?

Closing the image again scrolling up in the timeline and pressing on the previous/next image.

### Additional context

See also https://github.com/vector-im/element-x-ios/issues/1520

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

(Note: this is actually #1484)


Discussion hints:

Pull request: Add ability to swipe between media when opened from the timeline.
<!-- 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 when open from the timeline. Previously it was only possible to swipe when the media was opened from the media gallery.

## Motivation and context

<!-- Provide link to the corresponding issue if applicable or explain the context -->
Closes #4174 
Closes #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/79785009-813a-4f59-bd54-96bf7576d167

## Tests

<!-- Explain how you tested your development -->

- Open a room with some media / files
- Click on a media
- Observe that you can now swipe left or right to navigate between the medias of the room.

## 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


Test command: ./gradlew :libraries:mediaviewer:impl:testDebugUnitTest --tests "io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItemsTest" --tests "io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryPresenterTest" --tests "io.element.android.libraries.mediaviewer.impl.viewer.SingleMediaGalleryDataSourceTest" --tests "io.element.android.libraries.mediaviewer.impl.viewer.PagerKeysHandlerTest" --tests "io.element.android.libraries.mediaviewer.impl.datasource.DefaultEventItemFactoryTest" --tests "io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerPresenterTest" --tests "io.element.android.libraries.mediaviewer.impl.datasource.FocusedMediaTimelineTest" --tests "io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerDataSourceTest" --tests "io.element.android.libraries.mediaviewer.impl.datasource.LiveMediaTimelineTest" --tests "io.element.android.libraries.mediaviewer.impl.datasource.MediaItemsPostProcessorTest" --tests "io.element.android.libraries.mediaviewer.impl.datasource.DefaultFocusedTimelineMediaGalleryDataSourceFactoryTest" --tests "io.element.android.libraries.mediaviewer.impl.datasource.TimelineMediaGalleryDataSourceTest" :tests:konsist:testDebugUnitTest --tests "io.element.android.tests.konsist.KonsistClassNameTest"

Fix patch:
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 f9d9bbf27f0..6b84423d44f 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
@@ -123,6 +123,7 @@ class MessagesFlowNode @AssistedInject constructor(
 
         @Parcelize
         data class MediaViewer(
+            val mode: MediaViewerEntryPoint.MediaViewerMode,
             val eventId: EventId?,
             val mediaInfo: MediaInfo,
             val mediaSource: MediaSource,
@@ -248,8 +249,7 @@ class MessagesFlowNode @AssistedInject constructor(
             }
             is NavTarget.MediaViewer -> {
                 val params = MediaViewerEntryPoint.Params(
-                    // TODO When we will be able to load a media timeline from a EventId, change mode here (and use a mixed mode?)
-                    mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
+                    mode = navTarget.mode,
                     eventId = navTarget.eventId,
                     mediaInfo = navTarget.mediaInfo,
                     mediaSource = navTarget.mediaSource,
@@ -362,6 +362,7 @@ class MessagesFlowNode @AssistedInject constructor(
         val navTarget = when (event.content) {
             is TimelineItemImageContent -> {
                 buildMediaViewerNavTarget(
+                    mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos,
                     event = event,
                     content = event.content,
                     mediaSource = event.content.mediaSource,
@@ -373,6 +374,7 @@ class MessagesFlowNode @AssistedInject constructor(
                    if encrypted on certain bridges */
                 event.content.preferredMediaSource?.let { preferredMediaSource ->
                     buildMediaViewerNavTarget(
+                        mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos,
                         event = event,
                         content = event.content,
                         mediaSource = preferredMediaSource,
@@ -382,6 +384,7 @@ class MessagesFlowNode @AssistedInject constructor(
             }
             is TimelineItemVideoContent -> {
                 buildMediaViewerNavTarget(
+                    mode = MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos,
                     event = event,
                     content = event.content,
                     mediaSource = event.content.mediaSource,
@@ -390,6 +393,7 @@ class MessagesFlowNode @AssistedInject constructor(
             }
             is TimelineItemFileContent -> {
                 buildMediaViewerNavTarget(
+                    mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios,
                     event = event,
                     content = event.content,
                     mediaSource = event.content.mediaSource,
@@ -398,6 +402,7 @@ class MessagesFlowNode @AssistedInject constructor(
             }
             is TimelineItemAudioContent -> {
                 buildMediaViewerNavTarget(
+                    mode = MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios,
                     event = event,
                     content = event.content,
                     mediaSource = event.content.mediaSource,
@@ -426,12 +431,14 @@ class MessagesFlowNode @AssistedInject constructor(
     }
 
     private fun buildMediaViewerNavTarget(
+        mode: MediaViewerEntryPoint.MediaViewerMode,
         event: TimelineItem.Event,
         content: TimelineItemEventContentWithAttachment,
         mediaSource: MediaSource,
         thumbnailSource: MediaSource?,
     ): NavTarget {
         return NavTarget.MediaViewer(
+            mode = mode,
             eventId = event.eventId,
             mediaInfo = MediaInfo(
                 filename = content.filename,
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
index bb86684be0a..4d5f8c7a193 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
+++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoom.kt
@@ -118,8 +118,9 @@ interface MatrixRoom : Closeable {
 
     /**
      * Create a new timeline for the media events of the room.
+     * @param eventId The event to focus on, if any.
      */
-    suspend fun mediaTimeline(): Result<Timeline>
+    suspend fun mediaTimeline(eventId: EventId?): Result<Timeline>
 
     fun destroy()
 
diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
index ab16ac383f5..911a62f376c 100644
--- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
+++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustMatrixRoom.kt
@@ -253,11 +253,21 @@ class RustMatrixRoom(
         }
     }
 
-    override suspend fun mediaTimeline(): Result<Timeline> = withContext(roomDispatcher) {
+    override suspend fun mediaTimeline(
+        eventId: EventId?,
+    ): Result<Timeline> = withContext(roomDispatcher) {
+        val focus = if (eventId != null) {
+            TimelineFocus.Event(
+                eventId = eventId.value,
+                numContextEvents = 50u,
+            )
+        } else {
+            TimelineFocus.Live
+        }
         runCatching {
             innerRoom.timelineWithConfiguration(
                 configuration = TimelineConfiguration(
-                    focus = TimelineFocus.Live,
+                    focus = focus,
                     allowedMessageTypes = AllowedMessageTypes.Only(
                         types = listOf(
                             RoomMessageEventMessageType.FILE,
@@ -270,7 +280,7 @@ class RustMatrixRoom(
                     dateDividerMode = DateDividerMode.MONTHLY,
                 )
             ).let { inner ->
-                createTimeline(inner, mode = Timeline.Mode.MEDIA)
+                createTimeline(inner, mode = if (eventId != null) Timeline.Mode.FOCUSED_ON_EVENT else Timeline.Mode.MEDIA)
             }
         }.onFailure {
             if (it is CancellationException) {
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/datasource/EventItemFactory.kt
similarity index 98%
rename from libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt
rename to libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/EventItemFactory.kt
index 293329c2dd6..705921db8b3 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/datasource/EventItemFactory.kt
@@ -5,7 +5,7 @@
  * Please see LICENSE files in the repository root for full details.
  */
 
-package io.element.android.libraries.mediaviewer.impl.gallery
+package io.element.android.libraries.mediaviewer.impl.datasource
 
 import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
 import io.element.android.libraries.dateformatter.api.DateFormatter
@@ -40,6 +40,7 @@ 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 io.element.android.libraries.mediaviewer.impl.model.MediaItem
 import timber.log.Timber
 import javax.inject.Inject
 
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/FocusedTimelineMediaGalleryDataSourceFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/FocusedTimelineMediaGalleryDataSourceFactory.kt
new file mode 100644
index 00000000000..f7426aa4e95
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/FocusedTimelineMediaGalleryDataSourceFactory.kt
@@ -0,0 +1,45 @@
+/*
+ * 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.datasource
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
+import javax.inject.Inject
+
+interface FocusedTimelineMediaGalleryDataSourceFactory {
+    fun createFor(
+        eventId: EventId,
+        mediaItem: MediaItem.Event,
+    ): MediaGalleryDataSource
+}
+
+@ContributesBinding(RoomScope::class)
+class DefaultFocusedTimelineMediaGalleryDataSourceFactory @Inject constructor(
+    private val room: MatrixRoom,
+    private val timelineMediaItemsFactory: TimelineMediaItemsFactory,
+    private val mediaItemsPostProcessor: MediaItemsPostProcessor,
+) : FocusedTimelineMediaGalleryDataSourceFactory {
+    override fun createFor(
+        eventId: EventId,
+        mediaItem: MediaItem.Event,
+    ): MediaGalleryDataSource {
+        return TimelineMediaGalleryDataSource(
+            room = room,
+            mediaTimeline = FocusedMediaTimeline(
+                room = room,
+                eventId = eventId,
+                initialMediaItem = mediaItem,
+            ),
+            timelineMediaItemsFactory = timelineMediaItemsFactory,
+            mediaItemsPostProcessor = mediaItemsPostProcessor,
+        )
+    }
+}
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/datasource/MediaGalleryDataSource.kt
similarity index 83%
rename from libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryDataSource.kt
rename to libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaGalleryDataSource.kt
index 0e126531086..4ad36d88277 100644
--- 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/datasource/MediaGalleryDataSource.kt
@@ -5,7 +5,7 @@
  * Please see LICENSE files in the repository root for full details.
  */
 
-package io.element.android.libraries.mediaviewer.impl.gallery
+package io.element.android.libraries.mediaviewer.impl.datasource
 
 import com.squareup.anvil.annotations.ContributesBinding
 import io.element.android.libraries.architecture.AsyncData
@@ -15,6 +15,7 @@ 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 io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
@@ -39,6 +40,7 @@ interface MediaGalleryDataSource {
 @ContributesBinding(RoomScope::class)
 class TimelineMediaGalleryDataSource @Inject constructor(
     private val room: MatrixRoom,
+    private val mediaTimeline: MediaTimeline,
     private val timelineMediaItemsFactory: TimelineMediaItemsFactory,
     private val mediaItemsPostProcessor: MediaItemsPostProcessor,
 ) : MediaGalleryDataSource {
@@ -48,7 +50,9 @@ class TimelineMediaGalleryDataSource @Inject constructor(
 
     override fun groupedMediaItemsFlow(): Flow<AsyncData<GroupedMediaItems>> = groupedMediaItemsFlow
 
-    override fun getLastData(): AsyncData<GroupedMediaItems> = groupedMediaItemsFlow.replayCache.firstOrNull() ?: AsyncData.Uninitialized
+    override fun getLastData(): AsyncData<GroupedMediaItems> = groupedMediaItemsFlow.replayCache.firstOrNull()
+        ?: mediaTimeline.cache?.let { AsyncData.Success(it) }
+        ?: AsyncData.Uninitialized
 
     private val isStarted = AtomicBoolean(false)
 
@@ -58,8 +62,13 @@ class TimelineMediaGalleryDataSource @Inject constructor(
             return
         }
         flow {
-            groupedMediaItemsFlow.emit(AsyncData.Loading())
-            room.mediaTimeline().fold(
+            val cache = mediaTimeline.cache
+            if (cache != null) {
+                groupedMediaItemsFlow.emit(AsyncData.Success(cache))
+            } else {
+                groupedMediaItemsFlow.emit(AsyncData.Loading())
+            }
+            mediaTimeline.getTimeline().fold(
                 {
                     timeline = it
                     emit(it)
@@ -78,6 +87,8 @@ class TimelineMediaGalleryDataSource @Inject constructor(
             timelineMediaItemsFactory.timelineItems
         }.map { timelineItems ->
             mediaItemsPostProcessor.process(mediaItems = timelineItems)
+        }.map {
+            mediaTimeline.orCache(it)
         }.onEach { groupedMediaItems ->
             groupedMediaItemsFlow.emit(AsyncData.Success(groupedMediaItems))
         }
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/datasource/MediaItemsPostProcessor.kt
similarity index 92%
rename from libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt
rename to libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaItemsPostProcessor.kt
index 3fb8d81b1fd..1d3fd09a6cf 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/datasource/MediaItemsPostProcessor.kt
@@ -5,8 +5,10 @@
  * Please see LICENSE files in the repository root for full details.
  */
 
-package io.element.android.libraries.mediaviewer.impl.gallery
+package io.element.android.libraries.mediaviewer.impl.datasource
 
+import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
 import kotlinx.collections.immutable.toImmutableList
 import javax.inject.Inject
 
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaTimeline.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaTimeline.kt
new file mode 100644
index 00000000000..6dc41361936
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaTimeline.kt
@@ -0,0 +1,97 @@
+/*
+ * 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.datasource
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.di.SingleIn
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.UniqueId
+import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.timeline.Timeline
+import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.hasEvent
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import javax.inject.Inject
+
+interface MediaTimeline {
+    suspend fun getTimeline(): Result<Timeline>
+    val cache: GroupedMediaItems?
+    fun orCache(data: GroupedMediaItems): GroupedMediaItems
+}
+
+/**
+ * A timeline holder that can be used by the gallery and the media viewer.
+ * When opening the Media Viewer, if the held timeline knows the Event, it will
+ * be used, else a FocusedMediaTimeline will be used.
+ */
+@SingleIn(RoomScope::class)
+@ContributesBinding(RoomScope::class)
+class LiveMediaTimeline @Inject constructor(
+    private val room: MatrixRoom,
+) : MediaTimeline {
+    private var timeline: Timeline? = null
+    private val mutex = Mutex()
+
+    override suspend fun getTimeline(): Result<Timeline> = mutex.withLock {
+        val currentTimeline = timeline
+        if (currentTimeline == null) {
+            room.mediaTimeline(null)
+                .onSuccess { timeline = it }
+        } else {
+            Result.success(currentTimeline)
+        }
+    }
+
+    // No cache for LiveMediaTimeline
+    override val cache = null
+    override fun orCache(data: GroupedMediaItems) = data
+}
+
+/**
+ * A class that will provide a media timeline that is focused on a particular event.
+ */
+class FocusedMediaTimeline(
+    private val room: MatrixRoom,
+    private val eventId: EventId,
+    initialMediaItem: MediaItem.Event,
+) : MediaTimeline {
+    override suspend fun getTimeline(): Result<Timeline> {
+        return room.mediaTimeline(eventId)
+    }
+
+    override val cache = persistentListOf(
+        MediaItem.LoadingIndicator(
+            id = UniqueId("loading_forwards"),
+            direction = Timeline.PaginationDirection.FORWARDS,
+            timestamp = 0L,
+        ),
+        initialMediaItem,
+        MediaItem.LoadingIndicator(
+            id = UniqueId("loading_backwards"),
+            direction = Timeline.PaginationDirection.BACKWARDS,
+            timestamp = 0L,
+        ),
+    ).let {
+        GroupedMediaItems(
+            fileItems = it,
+            imageAndVideoItems = it,
+        )
+    }
+
+    override fun orCache(data: GroupedMediaItems): GroupedMediaItems {
+        return if (data.hasEvent(eventId)) {
+            data
+        } else {
+            cache
+        }
+    }
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaItemsFactory.kt
similarity index 96%
rename from libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt
rename to libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaItemsFactory.kt
index 0313390ddd9..a759ba4d76b 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaItemsFactory.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaItemsFactory.kt
@@ -5,13 +5,14 @@
  * Please see LICENSE files in the repository root for full details.
  */
 
-package io.element.android.libraries.mediaviewer.impl.gallery
+package io.element.android.libraries.mediaviewer.impl.datasource
 
 import io.element.android.libraries.androidutils.diff.DefaultDiffCacheInvalidator
 import io.element.android.libraries.androidutils.diff.DiffCacheUpdater
 import io.element.android.libraries.androidutils.diff.MutableListDiffCache
 import io.element.android.libraries.core.coroutine.CoroutineDispatchers
 import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.toPersistentList
 import kotlinx.coroutines.flow.Flow
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/VirtualItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/VirtualItemFactory.kt
similarity index 92%
rename from libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/VirtualItemFactory.kt
rename to libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/VirtualItemFactory.kt
index f364b06cb69..0c45edcf37a 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/VirtualItemFactory.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/VirtualItemFactory.kt
@@ -5,12 +5,13 @@
  * Please see LICENSE files in the repository root for full details.
  */
 
-package io.element.android.libraries.mediaviewer.impl.gallery
+package io.element.android.libraries.mediaviewer.impl.datasource
 
 import io.element.android.libraries.dateformatter.api.DateFormatter
 import io.element.android.libraries.dateformatter.api.DateFormatterMode
 import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
 import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
 import javax.inject.Inject
 
 class VirtualItemFactory @Inject constructor(
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryEvents.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryEvents.kt
index 8978059a514..df7d82c7b29 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryEvents.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryEvents.kt
@@ -11,6 +11,7 @@ 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.impl.model.MediaItem
 
 sealed interface MediaGalleryEvents {
     data class ChangeMode(val mode: MediaGalleryMode) : MediaGalleryEvents
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNode.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNode.kt
index c4519fcc0f9..77520763f54 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNode.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryNode.kt
@@ -21,6 +21,7 @@ import io.element.android.libraries.di.RoomScope
 import io.element.android.libraries.matrix.api.core.EventId
 import io.element.android.libraries.mediaviewer.impl.gallery.di.LocalMediaItemPresenterFactories
 import io.element.android.libraries.mediaviewer.impl.gallery.di.MediaItemPresenterFactories
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
 
 @ContributesNode(RoomScope::class)
 class MediaGalleryNode @AssistedInject constructor(
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 e094e1b9fbd..6617a8b9cbc 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
@@ -32,8 +32,14 @@ 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.mediaviewer.api.local.LocalMedia
 import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
+import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource
 import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
 import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions
+import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.eventId
+import io.element.android.libraries.mediaviewer.impl.model.mediaInfo
+import io.element.android.libraries.mediaviewer.impl.model.mediaSource
 import io.element.android.libraries.ui.strings.CommonStrings
 import kotlinx.coroutines.launch
 
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryState.kt
index 750dbe10387..62877368d7e 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryState.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryState.kt
@@ -11,7 +11,7 @@ import io.element.android.libraries.architecture.AsyncData
 import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
 import io.element.android.libraries.mediaviewer.impl.R
 import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
-import kotlinx.collections.immutable.ImmutableList
+import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
 
 data class MediaGalleryState(
     val roomName: String,
@@ -22,18 +22,6 @@ data class MediaGalleryState(
     val eventSink: (MediaGalleryEvents) -> Unit,
 )
 
-data class GroupedMediaItems(
-    val imageAndVideoItems: ImmutableList<MediaItem>,
-    val fileItems: ImmutableList<MediaItem>,
-) {
-    fun getItems(mode: MediaGalleryMode): ImmutableList<MediaItem> {
-        return when (mode) {
-            MediaGalleryMode.Images -> imageAndVideoItems
-            MediaGalleryMode.Files -> fileItems
-        }
-    }
-}
-
 enum class MediaGalleryMode(val stringResource: Int) {
     Images(R.string.screen_media_browser_list_mode_media),
     Files(R.string.screen_media_browser_list_mode_files),
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 5a1b5fcca80..aedea1eb75b 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
@@ -13,13 +13,15 @@ import io.element.android.libraries.designsystem.components.media.aWaveForm
 import io.element.android.libraries.matrix.api.core.UniqueId
 import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
 import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState
-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
-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.impl.gallery.ui.aMediaItemVideo
-import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemVoice
+import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemAudio
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemDateSeparator
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemFile
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemLoadingIndicator
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVideo
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVoice
 import kotlinx.collections.immutable.toImmutableList
 
 open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryState> {
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 3f5e1fc1072..38eed3252f8 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
@@ -72,6 +72,9 @@ import io.element.android.libraries.mediaviewer.impl.gallery.ui.FileItemView
 import io.element.android.libraries.mediaviewer.impl.gallery.ui.ImageItemView
 import io.element.android.libraries.mediaviewer.impl.gallery.ui.VideoItemView
 import io.element.android.libraries.mediaviewer.impl.gallery.ui.VoiceItemView
+import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.id
 import io.element.android.libraries.voiceplayer.api.VoiceMessageState
 import kotlinx.collections.immutable.ImmutableList
 
@@ -108,15 +111,15 @@ fun MediaGalleryView(
     ) { paddingValues ->
         Column(
             modifier = Modifier
-                    .padding(paddingValues)
-                    .consumeWindowInsets(paddingValues)
-                    .fillMaxSize(),
+                .padding(paddingValues)
+                .consumeWindowInsets(paddingValues)
+                .fillMaxSize(),
             verticalArrangement = Arrangement.spacedBy(2.dp),
         ) {
             SingleChoiceSegmentedButtonRow(
                 modifier = Modifier
-                        .fillMaxWidth()
-                        .padding(horizontal = 16.dp),
+                    .fillMaxWidth()
+                    .padding(horizontal = 16.dp),
             ) {
                 MediaGalleryMode.entries.forEach { mode ->
                     SegmentedButton(
@@ -354,8 +357,8 @@ private fun MediaGalleryImageGrid(
 ) {
     LazyVerticalGrid(
         modifier = Modifier
-                .fillMaxSize()
-                .padding(horizontal = 16.dp),
+            .fillMaxSize()
+            .padding(horizontal = 16.dp),
         columns = GridCells.Adaptive(80.dp),
         horizontalArrangement = Arrangement.spacedBy(4.dp),
         verticalArrangement = Arrangement.spacedBy(4.dp),
@@ -426,9 +429,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 -> {
@@ -466,9 +469,9 @@ private fun EmptyContent(
         OnboardingBackground()
         PageTitle(
             modifier = Modifier
-                    .fillMaxWidth()
-                    .padding(top = 44.dp)
-                    .padding(24.dp),
+                .fillMaxWidth()
+                .padding(top = 44.dp)
+                .padding(24.dp),
             title = stringResource(titleRes),
             iconStyle = BigIcon.Style.Default(icon),
             subtitle = stringResource(subtitleRes),
@@ -486,9 +489,9 @@ private fun LoadingContent(
         OnboardingBackground()
         Column(
             modifier = Modifier
-                    .fillMaxSize()
-                    .padding(top = 48.dp)
-                    .padding(24.dp),
+                .fillMaxSize()
+                .padding(top = 48.dp)
+                .padding(24.dp),
             verticalArrangement = Arrangement.spacedBy(16.dp),
             horizontalAlignment = Alignment.CenterHorizontally,
         ) {
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
deleted file mode 100644
index 8e6b708a7f1..00000000000
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSource.kt
+++ /dev/null
@@ -1,85 +0,0 @@
-/*
- * 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/di/MediaItemEventContentKey.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemEventContentKey.kt
index d76a631689a..ad2e49f16f8 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemEventContentKey.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemEventContentKey.kt
@@ -8,7 +8,7 @@
 package io.element.android.libraries.mediaviewer.impl.gallery.di
 
 import dagger.MapKey
-import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
 import kotlin.reflect.KClass
 
 /**
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemPresenterFactories.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemPresenterFactories.kt
index a2f72961354..02c0441d18b 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemPresenterFactories.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemPresenterFactories.kt
@@ -15,7 +15,7 @@ import dagger.multibindings.Multibinds
 import io.element.android.libraries.architecture.Presenter
 import io.element.android.libraries.di.RoomScope
 import io.element.android.libraries.di.SingleIn
-import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
 import javax.inject.Inject
 
 /**
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemPresenterFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemPresenterFactory.kt
index 1216a2dea65..9730fb4c053 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemPresenterFactory.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/MediaItemPresenterFactory.kt
@@ -8,7 +8,7 @@
 package io.element.android.libraries.mediaviewer.impl.gallery.di
 
 import io.element.android.libraries.architecture.Presenter
-import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
 
 /**
  * A factory for a [Presenter] associated with a timeline item.
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 71a4057f4b1..7317920005f 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
@@ -31,11 +31,11 @@ import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint
 import io.element.android.libraries.mediaviewer.api.MediaInfo
 import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
 import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryNode
-import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
-import io.element.android.libraries.mediaviewer.impl.gallery.eventId
-import io.element.android.libraries.mediaviewer.impl.gallery.mediaInfo
-import io.element.android.libraries.mediaviewer.impl.gallery.mediaSource
-import io.element.android.libraries.mediaviewer.impl.gallery.thumbnailSource
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.eventId
+import io.element.android.libraries.mediaviewer.impl.model.mediaInfo
+import io.element.android.libraries.mediaviewer.impl.model.mediaSource
+import io.element.android.libraries.mediaviewer.impl.model.thumbnailSource
 import kotlinx.parcelize.Parcelize
 
 @ContributesNode(RoomScope::class)
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/AudioItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/AudioItemView.kt
index f25337343d5..2cbbe6826ca 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/AudioItemView.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/AudioItemView.kt
@@ -36,7 +36,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
 import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
 import io.element.android.libraries.designsystem.theme.components.Icon
 import io.element.android.libraries.designsystem.theme.components.Text
-import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
 
 @Composable
 fun AudioItemView(
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/DateItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/DateItemView.kt
index c6488b42a55..84fe175fc9b 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/DateItemView.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/DateItemView.kt
@@ -18,7 +18,7 @@ import io.element.android.compound.theme.ElementTheme
 import io.element.android.libraries.designsystem.preview.ElementPreview
 import io.element.android.libraries.designsystem.preview.PreviewsDayNight
 import io.element.android.libraries.designsystem.theme.components.Text
-import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
 
 @Composable
 fun DateItemView(
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/FileItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/FileItemView.kt
index a6af3f5ad7a..bdb6539ecc5 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/FileItemView.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/FileItemView.kt
@@ -35,7 +35,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
 import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
 import io.element.android.libraries.designsystem.theme.components.Icon
 import io.element.android.libraries.designsystem.theme.components.Text
-import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
 
 @Composable
 fun FileItemView(
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/ImageItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/ImageItemView.kt
index 51610a8bd06..8df451abd64 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/ImageItemView.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/ImageItemView.kt
@@ -28,7 +28,8 @@ import coil.compose.AsyncImagePainter
 import io.element.android.compound.theme.ElementTheme
 import io.element.android.libraries.designsystem.preview.ElementPreview
 import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage
 
 @OptIn(ExperimentalFoundationApi::class)
 @Composable
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemAudioProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemAudioProvider.kt
index 7a0f6282ce4..ccbc9079cd8 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemAudioProvider.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemAudioProvider.kt
@@ -9,10 +9,8 @@ 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.UniqueId
-import io.element.android.libraries.matrix.api.media.MediaSource
-import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo
-import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemAudio
 
 class MediaItemAudioProvider : PreviewParameterProvider<MediaItem.Audio> {
     override val values: Sequence<MediaItem.Audio>
@@ -27,19 +25,3 @@ class MediaItemAudioProvider : PreviewParameterProvider<MediaItem.Audio> {
             ),
         )
 }
-
-fun aMediaItemAudio(
-    id: UniqueId = UniqueId("fileId"),
-    filename: String = "filename",
-    caption: String? = null,
-): MediaItem.Audio {
-    return MediaItem.Audio(
-        id = id,
-        eventId = null,
-        mediaInfo = anAudioMediaInfo(
-            filename = filename,
-            caption = caption,
-        ),
-        mediaSource = MediaSource(""),
-    )
-}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemDateSeparatorProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemDateSeparatorProvider.kt
index 77f789310a5..13d49a99190 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemDateSeparatorProvider.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemDateSeparatorProvider.kt
@@ -8,8 +8,8 @@
 package io.element.android.libraries.mediaviewer.impl.gallery.ui
 
 import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import io.element.android.libraries.matrix.api.core.UniqueId
-import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemDateSeparator
 
 class MediaItemDateSeparatorProvider : PreviewParameterProvider<MediaItem.DateSeparator> {
     override val values: Sequence<MediaItem.DateSeparator>
@@ -18,13 +18,3 @@ class MediaItemDateSeparatorProvider : PreviewParameterProvider<MediaItem.DateSe
             aMediaItemDateSeparator(formattedDate = "A long date that should be truncated"),
         )
 }
-
-fun aMediaItemDateSeparator(
-    id: UniqueId = UniqueId("dateId"),
-    formattedDate: String = "October 2024",
-): MediaItem.DateSeparator {
-    return MediaItem.DateSeparator(
-        id = id,
-        formattedDate = formattedDate,
-    )
-}
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 c80e287c301..74042472f9e 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,11 +9,8 @@ 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
-import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemFile
 
 class MediaItemFileProvider : PreviewParameterProvider<MediaItem.File> {
     override val values: Sequence<MediaItem.File>
@@ -28,20 +25,3 @@ 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 = eventId,
-        mediaInfo = aPdfMediaInfo(
-            filename = filename,
-            caption = caption,
-        ),
-        mediaSource = MediaSource(""),
-    )
-}
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
deleted file mode 100644
index ceb934fbe2b..00000000000
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemImageProvider.kt
+++ /dev/null
@@ -1,32 +0,0 @@
-/*
- * Copyright 2024 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.ui
-
-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.core.UserId
-import io.element.android.libraries.matrix.api.media.MediaSource
-import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
-import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
-
-fun aMediaItemImage(
-    id: UniqueId = UniqueId("imageId"),
-    eventId: EventId? = null,
-    senderId: UserId? = null,
-    mediaSourceUrl: String = "",
-): MediaItem.Image {
-    return MediaItem.Image(
-        id = id,
-        eventId = eventId,
-        mediaInfo = anImageMediaInfo(
-            senderId = senderId,
-        ),
-        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
deleted file mode 100644
index 2c788983255..00000000000
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemLoadingIndicatorProvider.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-/*
- * Copyright 2024 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.ui
-
-import io.element.android.libraries.matrix.api.core.UniqueId
-import io.element.android.libraries.matrix.api.timeline.Timeline
-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 = 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 8e59b925b75..34eef52479b 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
@@ -8,10 +8,8 @@
 package io.element.android.libraries.mediaviewer.impl.gallery.ui
 
 import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-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.aVideoMediaInfo
-import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVideo
 
 class MediaItemVideoProvider : PreviewParameterProvider<MediaItem.Video> {
     override val values: Sequence<MediaItem.Video>
@@ -22,19 +20,3 @@ class MediaItemVideoProvider : PreviewParameterProvider<MediaItem.Video> {
             ),
         )
 }
-
-fun aMediaItemVideo(
-    id: UniqueId = UniqueId("videoId"),
-    mediaSource: MediaSource = MediaSource(""),
-    duration: String? = "1:23",
-): MediaItem.Video {
-    return MediaItem.Video(
-        id = id,
-        eventId = null,
-        mediaInfo = aVideoMediaInfo(
-            duration = duration
-        ),
-        mediaSource = mediaSource,
-        thumbnailSource = null,
-    )
-}
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 43e04491de4..8353e5bfca6 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
@@ -9,11 +9,8 @@ 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.designsystem.components.media.aWaveForm
-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 io.element.android.libraries.mediaviewer.impl.model.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVoice
 
 class MediaItemVoiceProvider : PreviewParameterProvider<MediaItem.Voice> {
     override val values: Sequence<MediaItem.Voice>
@@ -31,23 +28,3 @@ class MediaItemVoiceProvider : PreviewParameterProvider<MediaItem.Voice> {
             ),
         )
 }
-
-fun aMediaItemVoice(
-    id: UniqueId = UniqueId("fileId"),
-    filename: String = "filename.ogg",
-    caption: String? = null,
-    duration: String? = "1:23",
-    waveform: List<Float> = aWaveForm(),
-): MediaItem.Voice {
-    return MediaItem.Voice(
-        id = id,
-        eventId = null,
-        mediaInfo = aVoiceMediaInfo(
-            filename = filename,
-            caption = caption,
-            duration = duration,
-            waveForm = waveform,
-        ),
-        mediaSource = MediaSource(""),
-    )
-}
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 6b394e7c55c..f23be0f382d 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
@@ -38,7 +38,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
 import io.element.android.libraries.designsystem.preview.PreviewsDayNight
 import io.element.android.libraries.designsystem.theme.components.Icon
 import io.element.android.libraries.designsystem.theme.components.Text
-import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
 
 @OptIn(ExperimentalFoundationApi::class)
 @Composable
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 d34555e1758..89f9a30f40b 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
@@ -46,7 +46,8 @@ import io.element.android.libraries.designsystem.theme.components.HorizontalDivi
 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.Text
-import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVoice
 import io.element.android.libraries.ui.strings.CommonStrings
 import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
 import io.element.android.libraries.voiceplayer.api.VoiceMessageState
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/voice/VoiceMessagePresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/voice/VoiceMessagePresenter.kt
index 4d215bc1971..92acc754fc5 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/voice/VoiceMessagePresenter.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/voice/VoiceMessagePresenter.kt
@@ -17,9 +17,9 @@ import dagger.assisted.AssistedInject
 import dagger.multibindings.IntoMap
 import io.element.android.libraries.architecture.Presenter
 import io.element.android.libraries.di.RoomScope
-import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
 import io.element.android.libraries.mediaviewer.impl.gallery.di.MediaItemEventContentKey
 import io.element.android.libraries.mediaviewer.impl.gallery.di.MediaItemPresenterFactory
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
 import io.element.android.libraries.voiceplayer.api.VoiceMessagePresenterFactory
 import io.element.android.libraries.voiceplayer.api.VoiceMessageState
 import kotlin.time.Duration
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/GroupedMediaItems.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/GroupedMediaItems.kt
new file mode 100644
index 00000000000..448426f374e
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/GroupedMediaItems.kt
@@ -0,0 +1,30 @@
+/*
+ * 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.model
+
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode
+import kotlinx.collections.immutable.ImmutableList
+
+data class GroupedMediaItems(
+    val imageAndVideoItems: ImmutableList<MediaItem>,
+    val fileItems: ImmutableList<MediaItem>,
+) {
+    fun getItems(mode: MediaGalleryMode): ImmutableList<MediaItem> {
+        return when (mode) {
+            MediaGalleryMode.Images -> imageAndVideoItems
+            MediaGalleryMode.Files -> fileItems
+        }
+    }
+}
+
+fun GroupedMediaItems.hasEvent(eventId: EventId): Boolean {
+    return (fileItems + imageAndVideoItems)
+        .filterIsInstance<MediaItem.Event>()
+        .any { it.eventId() == eventId }
+}
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/model/MediaItem.kt
similarity index 98%
rename from libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItem.kt
rename to libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItem.kt
index e1bd4d779ac..bf894869640 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/model/MediaItem.kt
@@ -5,7 +5,7 @@
  * Please see LICENSE files in the repository root for full details.
  */
 
-package io.element.android.libraries.mediaviewer.impl.gallery
+package io.element.android.libraries.mediaviewer.impl.model
 
 import io.element.android.libraries.matrix.api.core.EventId
 import io.element.android.libraries.matrix.api.core.UniqueId
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItemFactories.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItemFactories.kt
new file mode 100644
index 00000000000..b04973322ac
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaItemFactories.kt
@@ -0,0 +1,128 @@
+/*
+ * 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.model
+
+import io.element.android.libraries.designsystem.components.media.aWaveForm
+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.core.UserId
+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.aPdfMediaInfo
+import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
+import io.element.android.libraries.mediaviewer.api.aVoiceMediaInfo
+import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo
+import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
+
+fun aMediaItemImage(
+    id: UniqueId = UniqueId("imageId"),
+    eventId: EventId? = null,
+    senderId: UserId? = null,
+    mediaSourceUrl: String = "",
+): MediaItem.Image {
+    return MediaItem.Image(
+        id = id,
+        eventId = eventId,
+        mediaInfo = anImageMediaInfo(
+            senderId = senderId,
+        ),
+        mediaSource = MediaSource(mediaSourceUrl),
+        thumbnailSource = null,
+    )
+}
+
+fun aMediaItemVideo(
+    id: UniqueId = UniqueId("videoId"),
+    mediaSource: MediaSource = MediaSource(""),
+    duration: String? = "1:23",
+): MediaItem.Video {
+    return MediaItem.Video(
+        id = id,
+        eventId = null,
+        mediaInfo = aVideoMediaInfo(
+            duration = duration
+        ),
+        mediaSource = mediaSource,
+        thumbnailSource = null,
+    )
+}
+
+fun aMediaItemFile(
+    id: UniqueId = UniqueId("fileId"),
+    eventId: EventId? = null,
+    filename: String = "filename",
+    caption: String? = null,
+): MediaItem.File {
+    return MediaItem.File(
+        id = id,
+        eventId = eventId,
+        mediaInfo = aPdfMediaInfo(
+            filename = filename,
+            caption = caption,
+        ),
+        mediaSource = MediaSource(""),
+    )
+}
+
+fun aMediaItemAudio(
+    id: UniqueId = UniqueId("fileId"),
+    eventId: EventId? = null,
+    filename: String = "filename",
+    caption: String? = null,
+): MediaItem.Audio {
+    return MediaItem.Audio(
+        id = id,
+        eventId = eventId,
+        mediaInfo = anAudioMediaInfo(
+            filename = filename,
+            caption = caption,
+        ),
+        mediaSource = MediaSource(""),
+    )
+}
+
+fun aMediaItemVoice(
+    id: UniqueId = UniqueId("fileId"),
+    filename: String = "filename.ogg",
+    caption: String? = null,
+    duration: String? = "1:23",
+    waveform: List<Float> = aWaveForm(),
+): MediaItem.Voice {
+    return MediaItem.Voice(
+        id = id,
+        eventId = null,
+        mediaInfo = aVoiceMediaInfo(
+            filename = filename,
+            caption = caption,
+            duration = duration,
+            waveForm = waveform,
+        ),
+        mediaSource = MediaSource(""),
+    )
+}
+
+fun aMediaItemDateSeparator(
+    id: UniqueId = UniqueId("dateId"),
+    formattedDate: String = "October 2024",
+): MediaItem.DateSeparator {
+    return MediaItem.DateSeparator(
+        id = id,
+        formattedDate = formattedDate,
+    )
+}
+
+fun aMediaItemLoadingIndicator(
+    id: UniqueId = UniqueId("loadingId"),
+    direction: Timeline.PaginationDirection = Timeline.PaginationDirection.BACKWARDS,
+): MediaItem.LoadingIndicator {
+    return MediaItem.LoadingIndicator(
+        id = id,
+        direction = direction,
+        timestamp = 123,
+    )
+}
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
index b185834c3e4..ebaba4cdcdf 100644
--- 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
@@ -18,15 +18,16 @@ 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.MediaViewerEntryPoint.MediaViewerMode
 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.datasource.MediaGalleryDataSource
 import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode
-import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
-import io.element.android.libraries.mediaviewer.impl.gallery.eventId
-import io.element.android.libraries.mediaviewer.impl.gallery.mediaInfo
-import io.element.android.libraries.mediaviewer.impl.gallery.mediaSource
-import io.element.android.libraries.mediaviewer.impl.gallery.thumbnailSource
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.eventId
+import io.element.android.libraries.mediaviewer.impl.model.mediaInfo
+import io.element.android.libraries.mediaviewer.impl.model.mediaSource
+import io.element.android.libraries.mediaviewer.impl.model.thumbnailSource
 import io.element.android.services.toolbox.api.systemclock.SystemClock
 import kotlinx.collections.immutable.PersistentList
 import kotlinx.collections.immutable.persistentListOf
@@ -38,16 +39,23 @@ import kotlinx.coroutines.withContext
 import timber.log.Timber
 
 class MediaViewerDataSource(
-    private val galleryMode: MediaGalleryMode,
+    mode: MediaViewerMode,
     private val dispatcher: CoroutineDispatcher,
     private val galleryDataSource: MediaGalleryDataSource,
     private val mediaLoader: MatrixMediaLoader,
     private val localMediaFactory: LocalMediaFactory,
     private val systemClock: SystemClock,
+    private val pagerKeysHandler: PagerKeysHandler,
 ) {
     // List of media files that are currently being loaded
     private val mediaFiles: MutableList<MediaFile> = mutableListOf()
 
+    private val galleryMode = when (mode) {
+        MediaViewerMode.SingleMedia,
+        MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images
+        MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files
+    }
+
     // Map of sourceUrl to local media state
     private val localMediaStates: MutableMap<String, MutableState<AsyncData<LocalMedia>>> =
         mutableMapOf()
@@ -78,6 +86,7 @@ class MediaViewerDataSource(
                             MediaViewerPageData.Loading(
                                 direction = Timeline.PaginationDirection.BACKWARDS,
                                 timestamp = systemClock.epochMillis(),
+                                pagerKey = Long.MIN_VALUE,
                             )
                         )
                     }
@@ -108,7 +117,10 @@ class MediaViewerDataSource(
      * will be used to render the downloaded media (see [loadMedia] which will update this value).
      */
     private fun buildMediaViewerPageList(groupedItems: List<MediaItem>) = buildList {
-        groupedItems.forEach { mediaItem ->
+        // Filter out DateSeparator items, we do not need them for the media viewer
+        val groupedItemsNoDateSeparator = groupedItems.filterNot { it is MediaItem.DateSeparator }
+        pagerKeysHandler.accept(groupedItemsNoDateSeparator)
+        groupedItemsNoDateSeparator.forEach { mediaItem ->
             when (mediaItem) {
                 is MediaItem.DateSeparator -> Unit
                 is MediaItem.Event -> {
@@ -123,6 +135,7 @@ class MediaViewerDataSource(
                             mediaSource = mediaItem.mediaSource(),
                             thumbnailSource = mediaItem.thumbnailSource(),
                             downloadedMedia = localMedia,
+                            pagerKey = pagerKeysHandler.getKey(mediaItem),
                         )
                     )
                 }
@@ -130,6 +143,7 @@ class MediaViewerDataSource(
                     MediaViewerPageData.Loading(
                         direction = mediaItem.direction,
                         timestamp = systemClock.epochMillis(),
+                        pagerKey = pagerKeysHandler.getKey(mediaItem),
                     )
                 )
             }
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 e06b6915202..78766410514 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
@@ -24,9 +24,9 @@ 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.libraries.mediaviewer.impl.datasource.FocusedTimelineMediaGalleryDataSourceFactory
+import io.element.android.libraries.mediaviewer.impl.datasource.TimelineMediaGalleryDataSource
+import io.element.android.libraries.mediaviewer.impl.model.hasEvent
 import io.element.android.services.toolbox.api.systemclock.SystemClock
 
 @ContributesNode(RoomScope::class)
@@ -35,10 +35,12 @@ class MediaViewerNode @AssistedInject constructor(
     @Assisted plugins: List<Plugin>,
     presenterFactory: MediaViewerPresenter.Factory,
     timelineMediaGalleryDataSource: TimelineMediaGalleryDataSource,
+    focusedTimelineMediaGalleryDataSourceFactory: FocusedTimelineMediaGalleryDataSourceFactory,
     mediaLoader: MatrixMediaLoader,
     localMediaFactory: LocalMediaFactory,
     coroutineDispatchers: CoroutineDispatchers,
     systemClock: SystemClock,
+    pagerKeysHandler: PagerKeysHandler,
 ) : Node(buildContext, plugins = plugins),
     MediaViewerNavigator {
     private val inputs = inputs<MediaViewerEntryPoint.Params>()
@@ -62,25 +64,36 @@ class MediaViewerNode @AssistedInject constructor(
     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
+        val eventId = inputs.eventId
+        if (eventId == null) {
+            // Should not happen
+            timelineMediaGalleryDataSource
+        } else {
+            // Does timelineMediaGalleryDataSource knows the eventId?
+            val lastData = timelineMediaGalleryDataSource.getLastData().dataOrNull()
+            val isEventKnown = lastData?.hasEvent(eventId) == true
+            if (isEventKnown) {
+                timelineMediaGalleryDataSource
+            } else {
+                focusedTimelineMediaGalleryDataSourceFactory.createFor(
+                    eventId = eventId,
+                    mediaItem = inputs.toMediaItem(),
+                )
+            }
+        }
     }
 
     private val presenter = presenterFactory.create(
         inputs = inputs,
         navigator = this,
         dataSource = MediaViewerDataSource(
+            mode = inputs.mode,
             dispatcher = coroutineDispatchers.computation,
-            galleryMode = galleryMode,
             galleryDataSource = mediaGallerySource,
             mediaLoader = mediaLoader,
             localMediaFactory = localMediaFactory,
             systemClock = systemClock,
+            pagerKeysHandler = pagerKeysHandler,
         )
     )
 
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 cf363a70f18..32c22b04706 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
@@ -28,13 +28,17 @@ data class MediaViewerState(
 )
 
 sealed interface MediaViewerPageData {
+    val pagerKey: Long
+
     data class Failure(
         val throwable: Throwable,
+        override val pagerKey: Long = 0,
     ) : MediaViewerPageData
 
     data class Loading(
         val direction: Timeline.PaginationDirection,
         val timestamp: Long,
+        override val pagerKey: Long,
     ) : MediaViewerPageData
 
     data class MediaViewerData(
@@ -43,5 +47,6 @@ sealed interface MediaViewerPageData {
         val mediaSource: MediaSource,
         val thumbnailSource: MediaSource?,
         val downloadedMedia: State<AsyncData<LocalMedia>>,
+        override val pagerKey: Long,
     ) : 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 2c54751fed0..95557c13d67 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
@@ -169,6 +169,7 @@ fun aMediaViewerPageDataLoading(
     return MediaViewerPageData.Loading(
         direction = direction,
         timestamp = timestamp,
+        pagerKey = 0L,
     )
 }
 
@@ -182,6 +183,7 @@ fun aMediaViewerPageData(
     mediaSource = mediaSource,
     thumbnailSource = null,
     downloadedMedia = mutableStateOf(downloadedMedia),
+    pagerKey = 0L,
 )
 
 fun aMediaViewerState(
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 51476193d05..3ba57cae337 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
@@ -114,6 +114,7 @@ fun MediaViewerView(
             modifier = Modifier,
             // Pre-load previous and next pages
             beyondViewportPageCount = 1,
+            key = { index -> state.listData[index].pagerKey },
         ) { page ->
             when (val dataForPage = state.listData[page]) {
                 is MediaViewerPageData.Failure -> {
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/PagerKeysHandler.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/PagerKeysHandler.kt
new file mode 100644
index 00000000000..2c73ab26570
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/PagerKeysHandler.kt
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.viewer
+
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.eventId
+import javax.inject.Inject
+
+/**
+ * x and y are loading items.
+ * Capital letters are media items.
+ * First list emitted
+ *   x  F  G  H  y
+ * indexes will be
+ *   0  1  2  3  4
+ * (keyOffset = 0)
+ * New items added to the end of the list
+ *   x  F  G  H  I  J  K  y
+ * indexes will be
+ *   0  1  2  3  4  5  6  7
+ *  (keyOffset = 0)
+ * New items added to the beginning of the list
+ *   x  D  E  F  G  H  I  J  K  y
+ * indexes will be
+ *  -2 -1  0  1  2  3  4  5  6  7
+ * (keyOffset = -2)
+ * loader item vanishes
+ *   D  E  F  G  H  I  J  K
+ *  indexes will be
+ *  -1  0  1  2  3  4  5  6
+ * (keyOffset = -1)
+ */
+class PagerKeysHandler @Inject constructor() {
+    private data class Data(
+        val mediaItems: List<MediaItem>,
+        val keyOffset: Long,
+    )
+
+    // Will store the list of media items and the key offset of the first item in the list
+    private var cachedData: Data = Data(emptyList(), 0)
+
+    fun accept(mediaItems: List<MediaItem>) {
+        if (cachedData.mediaItems.isEmpty()) {
+            cachedData = Data(mediaItems, 0)
+        } else {
+            // Search a common item in both lists, i.e. an item with the same eventId
+            val itemInCacheIndex = cachedData.mediaItems.indexOfFirst { mediaItem ->
+                mediaItem is MediaItem.Event && mediaItems
+                    .filterIsInstance<MediaItem.Event>()
+                    .any { mediaItem.eventId() == it.eventId() }
+            }
+            cachedData = if (itemInCacheIndex == -1) {
+                // If the item is not found, start with a new cache
+                Data(mediaItems, 0)
+            } else {
+                val cachedItem = cachedData.mediaItems[itemInCacheIndex]
+                val eventId = (cachedItem as? MediaItem.Event)?.eventId()
+                if (eventId == null) {
+                    // Should not happen, but in this case, start with a new cache
+                    Data(mediaItems, 0)
+                } else {
+                    // Search the index of the item in the new list
+                    val itemIndex = mediaItems.indexOfFirst { mediaItem ->
+                        mediaItem is MediaItem.Event && mediaItem.eventId() == eventId
+                    }
+                    if (itemIndex == -1) {
+                        // If the item is not found, start with a new cache
+                        Data(mediaItems, 0)
+                    } else {
+                        // Update the cache with the new list and the new offset
+                        Data(mediaItems, cachedData.keyOffset + itemInCacheIndex - itemIndex.toLong())
+                    }
+                }
+            }
+        }
+    }
+
+    fun getKey(mediaItem: MediaItem): Long {
+        return cachedData.mediaItems.indexOf(mediaItem) + cachedData.keyOffset
+    }
+}
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt
new file mode 100644
index 00000000000..94ac0fea216
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSource.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.viewer
+
+import io.element.android.libraries.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 io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource
+import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
+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 = GroupedMediaItems(
+                // Always use imageAndVideoItems, in Single mode, this is the data that will be used
+                imageAndVideoItems = persistentListOf(params.toMediaItem()),
+                fileItems = persistentListOf(),
+            )
+        )
+    }
+}
+
+fun MediaViewerEntryPoint.Params.toMediaItem() = when {
+    mediaInfo.mimeType.isMimeTypeImage() -> {
+        MediaItem.Image(
+            id = UniqueId("dummy"),
+            eventId = eventId,
+            mediaInfo = mediaInfo,
+            mediaSource = mediaSource,
+            thumbnailSource = thumbnailSource,
+        )
+    }
+    mediaInfo.mimeType.isMimeTypeVideo() -> {
+        MediaItem.Video(
+            id = UniqueId("dummy"),
+            eventId = eventId,
+            mediaInfo = mediaInfo,
+            mediaSource = mediaSource,
+            thumbnailSource = thumbnailSource,
+        )
+    }
+    mediaInfo.mimeType.isMimeTypeAudio() -> {
+        if (mediaInfo.waveform == null) {
+            MediaItem.Audio(
+                id = UniqueId("dummy"),
+                eventId = eventId,
+                mediaInfo = mediaInfo,
+                mediaSource = mediaSource,
+            )
+        } else {
+            MediaItem.Voice(
+                id = UniqueId("dummy"),
+                eventId = eventId,
+                mediaInfo = mediaInfo,
+                mediaSource = mediaSource,
+            )
+        }
+    }
+    else -> {
+        MediaItem.File(
+            id = UniqueId("dummy"),
+            eventId = eventId,
+            mediaInfo = mediaInfo,
+            mediaSource = mediaSource,
+        )
+    }
+}


Test patch:
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt
index 1946d2259bc..509fac833c3 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt
@@ -48,6 +48,7 @@ val A_THREAD_ID = ThreadId("\$aThreadId")
 val A_THREAD_ID_2 = ThreadId("\$aThreadId2")
 val AN_EVENT_ID = EventId("\$anEventId")
 val AN_EVENT_ID_2 = EventId("\$anEventId2")
+val AN_EVENT_ID_3 = EventId("\$anEventId3")
 val A_ROOM_ALIAS = RoomAlias("#alias1:domain")
 val A_TRANSACTION_ID = TransactionId("aTransactionId")
 val A_DEVICE_ID = DeviceId("ILAKNDNASDLK")
diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
index 62d19bd66cb..1fd95da36b7 100644
--- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
+++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt
@@ -137,7 +137,7 @@ class FakeMatrixRoom(
     private val getMembersResult: (Int) -> Result<List<RoomMember>> = { lambdaError() },
     private val timelineFocusedOnEventResult: (EventId) -> Result<Timeline> = { lambdaError() },
     private val pinnedEventsTimelineResult: () -> Result<Timeline> = { lambdaError() },
-    private val mediaTimelineResult: () -> Result<Timeline> = { lambdaError() },
+    private val mediaTimelineResult: (EventId?) -> Result<Timeline> = { lambdaError() },
     private val setSendQueueEnabledLambda: (Boolean) -> Unit = { _: Boolean -> },
     private val saveComposerDraftLambda: (ComposerDraft) -> Result<Unit> = { _: ComposerDraft -> Result.success(Unit) },
     private val loadComposerDraftLambda: () -> Result<ComposerDraft?> = { Result.success<ComposerDraft?>(null) },
@@ -215,8 +215,8 @@ class FakeMatrixRoom(
         pinnedEventsTimelineResult()
     }
 
-    override suspend fun mediaTimeline(): Result<Timeline> = simulateLongTask {
-        mediaTimelineResult()
+    override suspend fun mediaTimeline(eventId: EventId?): Result<Timeline> = simulateLongTask {
+        mediaTimelineResult(eventId)
     }
 
     override suspend fun subscribeToSync() {
diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/FakeTimelineItemPresenterFactories.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/FakeTimelineItemPresenterFactories.kt
index bde6a57ad56..7a2094d8903 100644
--- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/FakeTimelineItemPresenterFactories.kt
+++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/di/FakeTimelineItemPresenterFactories.kt
@@ -8,7 +8,7 @@
 package io.element.android.libraries.mediaviewer.impl.gallery.di
 
 import io.element.android.libraries.architecture.Presenter
-import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
 import io.element.android.libraries.voiceplayer.api.VoiceMessageState
 import io.element.android.libraries.voiceplayer.api.aVoiceMessageState
 
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/datasource/DefaultEventItemFactoryTest.kt
similarity index 99%
rename from libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/DefaultEventItemFactoryTest.kt
rename to libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt
index a0bc4c1f0fc..40e6721c10d 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/datasource/DefaultEventItemFactoryTest.kt
@@ -5,7 +5,7 @@
  * Please see LICENSE files in the repository root for full details.
  */
 
-package io.element.android.libraries.mediaviewer.impl.gallery
+package io.element.android.libraries.mediaviewer.impl.datasource
 
 import com.google.common.truth.Truth.assertThat
 import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
@@ -48,6 +48,7 @@ import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageCo
 import io.element.android.libraries.matrix.test.timeline.aStickerContent
 import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
 import io.element.android.libraries.mediaviewer.api.MediaInfo
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
 import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
 import kotlinx.collections.immutable.persistentListOf
 import kotlinx.collections.immutable.toImmutableList
diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultFocusedTimelineMediaGalleryDataSourceFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultFocusedTimelineMediaGalleryDataSourceFactoryTest.kt
new file mode 100644
index 00000000000..a2ab9b162f6
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultFocusedTimelineMediaGalleryDataSourceFactoryTest.kt
@@ -0,0 +1,31 @@
+/*
+ * 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.datasource
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.test.AN_EVENT_ID
+import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class DefaultFocusedTimelineMediaGalleryDataSourceFactoryTest {
+    @Test
+    fun `createFor should create a TimelineMediaGalleryDataSource`() = runTest {
+        val sut = DefaultFocusedTimelineMediaGalleryDataSourceFactory(
+            room = FakeMatrixRoom(),
+            timelineMediaItemsFactory = createTimelineMediaItemsFactory(),
+            mediaItemsPostProcessor = MediaItemsPostProcessor(),
+        )
+        val result = sut.createFor(
+            eventId = AN_EVENT_ID,
+            mediaItem = aMediaItemImage(),
+        )
+        assertThat(result).isInstanceOf(TimelineMediaGalleryDataSource::class.java)
+    }
+}
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/datasource/FakeMediaGalleryDataSource.kt
similarity index 91%
rename from libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/FakeMediaGalleryDataSource.kt
rename to libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/FakeMediaGalleryDataSource.kt
index 419c2c568a3..ea4bdfbad8b 100644
--- 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/datasource/FakeMediaGalleryDataSource.kt
@@ -5,11 +5,12 @@
  * Please see LICENSE files in the repository root for full details.
  */
 
-package io.element.android.libraries.mediaviewer.impl.gallery
+package io.element.android.libraries.mediaviewer.impl.datasource
 
 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.libraries.mediaviewer.impl.model.GroupedMediaItems
 import io.element.android.tests.testutils.lambda.lambdaError
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/FocusedMediaTimelineTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/FocusedMediaTimelineTest.kt
new file mode 100644
index 00000000000..1d4fd3adbef
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/FocusedMediaTimelineTest.kt
@@ -0,0 +1,102 @@
+/*
+ * 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.datasource
+
+import com.google.common.truth.Truth.assertThat
+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.room.FakeMatrixRoom
+import io.element.android.libraries.matrix.test.timeline.FakeTimeline
+import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class FocusedMediaTimelineTest {
+    @Test
+    fun `check the returned cache data`() {
+        val media = aMediaItemImage()
+        val sut = createFocusedMediaTimeline(
+            initialMediaItem = media,
+        )
+        val cache = sut.cache
+        assertThat(cache.imageAndVideoItems.size).isEqualTo(3)
+        assertThat(cache.fileItems.size).isEqualTo(3)
+        assertThat(cache.imageAndVideoItems[1]).isEqualTo(media)
+        assertThat(cache.fileItems[1]).isEqualTo(media)
+    }
+
+    @Test
+    fun `when event is not found, the cache is returned`() {
+        val media = aMediaItemImage(
+            eventId = AN_EVENT_ID,
+        )
+        val sut = createFocusedMediaTimeline(
+            initialMediaItem = media,
+        )
+        val cache = sut.orCache(
+            GroupedMediaItems(
+                imageAndVideoItems = persistentListOf(),
+                fileItems = persistentListOf(),
+            )
+        )
+        assertThat(cache.imageAndVideoItems.size).isEqualTo(3)
+        assertThat(cache.fileItems.size).isEqualTo(3)
+    }
+
+    @Test
+    fun `when event is found, the data is returned`() {
+        val media = aMediaItemImage(
+            eventId = AN_EVENT_ID,
+        )
+        val sut = createFocusedMediaTimeline(
+            initialMediaItem = media,
+        )
+        val cache = sut.orCache(
+            GroupedMediaItems(
+                imageAndVideoItems = persistentListOf(media),
+                fileItems = persistentListOf(),
+            )
+        )
+        assertThat(cache.imageAndVideoItems.size).isEqualTo(1)
+        assertThat(cache.fileItems).isEmpty()
+    }
+
+    @Test
+    fun `getTimeline returns the timeline provided by the room`() = runTest {
+        val mediaTimelineResult = lambdaRecorder<EventId?, Result<Timeline>> {
+            Result.success(FakeTimeline())
+        }
+        val room = FakeMatrixRoom(
+            mediaTimelineResult = mediaTimelineResult,
+        )
+        val sut = createFocusedMediaTimeline(
+            room = room,
+            eventId = AN_EVENT_ID,
+        )
+        val timeline = sut.getTimeline()
+        assertThat(timeline.isSuccess).isTrue()
+        mediaTimelineResult.assertions().isCalledOnce().with(value(AN_EVENT_ID))
+    }
+
+    private fun createFocusedMediaTimeline(
+        room: MatrixRoom = FakeMatrixRoom(),
+        eventId: EventId = AN_EVENT_ID,
+        initialMediaItem: MediaItem.Event = aMediaItemImage(),
+    ) = FocusedMediaTimeline(
+        room = room,
+        eventId = eventId,
+        initialMediaItem = initialMediaItem,
+    )
+}
diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/LiveMediaTimelineTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/LiveMediaTimelineTest.kt
new file mode 100644
index 00000000000..95b62a5824f
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/LiveMediaTimelineTest.kt
@@ -0,0 +1,54 @@
+/*
+ * 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.datasource
+
+import com.google.common.truth.Truth.assertThat
+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.room.FakeMatrixRoom
+import io.element.android.libraries.matrix.test.timeline.FakeTimeline
+import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
+import io.element.android.tests.testutils.lambda.lambdaRecorder
+import io.element.android.tests.testutils.lambda.value
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class LiveMediaTimelineTest {
+    @Test
+    fun `LiveMediaTimeline cache is always null`() = runTest {
+        val sut = createLiveMediaTimeline()
+        assertThat<GroupedMediaItems?>(sut.cache).isNull()
+    }
+
+    @Test
+    fun `getTimeline returns the timeline provided by the room, then from cache`() = runTest {
+        val mediaTimelineResult = lambdaRecorder<EventId?, Result<Timeline>> {
+            Result.success(FakeTimeline())
+        }
+        val room = FakeMatrixRoom(
+            mediaTimelineResult = mediaTimelineResult,
+        )
+        val sut = createLiveMediaTimeline(
+            room = room,
+        )
+        val timeline = sut.getTimeline()
+        assertThat(timeline.isSuccess).isTrue()
+        mediaTimelineResult.assertions().isCalledOnce().with(value(null))
+        val timeline2 = sut.getTimeline()
+        assertThat(timeline2.isSuccess).isTrue()
+        // No called another time
+        mediaTimelineResult.assertions().isCalledOnce()
+    }
+
+    private fun createLiveMediaTimeline(
+        room: MatrixRoom = FakeMatrixRoom(),
+    ) = LiveMediaTimeline(
+        room = room,
+    )
+}
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/datasource/MediaItemsPostProcessorTest.kt
similarity index 88%
rename from libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessorTest.kt
rename to libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/MediaItemsPostProcessorTest.kt
index 4c823350ce6..a8903394b31 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/datasource/MediaItemsPostProcessorTest.kt
@@ -5,17 +5,19 @@
  * Please see LICENSE files in the repository root for full details.
  */
 
-package io.element.android.libraries.mediaviewer.impl.gallery
+package io.element.android.libraries.mediaviewer.impl.datasource
 
 import com.google.common.truth.Truth.assertThat
 import io.element.android.libraries.matrix.api.core.UniqueId
-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
-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.impl.gallery.ui.aMediaItemVideo
-import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemVoice
+import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemAudio
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemDateSeparator
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemFile
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemLoadingIndicator
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVideo
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVoice
 import kotlinx.collections.immutable.toImmutableList
 import org.junit.Test
 
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/datasource/TimelineMediaGalleryDataSourceTest.kt
similarity index 93%
rename from libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/TimelineMediaGalleryDataSourceTest.kt
rename to libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/TimelineMediaGalleryDataSourceTest.kt
index 2f8cd634eca..736fd309f36 100644
--- 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/datasource/TimelineMediaGalleryDataSourceTest.kt
@@ -5,7 +5,7 @@
  * Please see LICENSE files in the repository root for full details.
  */
 
-package io.element.android.libraries.mediaviewer.impl.gallery
+package io.element.android.libraries.mediaviewer.impl.datasource
 
 import app.cash.turbine.test
 import com.google.common.truth.Truth.assertThat
@@ -34,6 +34,8 @@ 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.impl.model.GroupedMediaItems
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
 import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
 import io.element.android.tests.testutils.WarmUpRule
 import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -260,18 +262,21 @@ class TimelineMediaGalleryDataSourceTest {
     ): TimelineMediaGalleryDataSource {
         return TimelineMediaGalleryDataSource(
             room = room,
-            timelineMediaItemsFactory = TimelineMediaItemsFactory(
-                dispatchers = testCoroutineDispatchers(),
-                virtualItemFactory = VirtualItemFactory(
-                    dateFormatter = FakeDateFormatter(),
-                ),
-                eventItemFactory = EventItemFactory(
-                    fileSizeFormatter = FakeFileSizeFormatter(),
-                    fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
-                    dateFormatter = FakeDateFormatter(),
-                ),
-            ),
+            mediaTimeline = LiveMediaTimeline(room),
+            timelineMediaItemsFactory = createTimelineMediaItemsFactory(),
             mediaItemsPostProcessor = MediaItemsPostProcessor(),
         )
     }
 }
+
+fun TestScope.createTimelineMediaItemsFactory() = TimelineMediaItemsFactory(
+    dispatchers = testCoroutineDispatchers(),
+    virtualItemFactory = VirtualItemFactory(
+        dateFormatter = FakeDateFormatter(),
+    ),
+    eventItemFactory = EventItemFactory(
+        fileSizeFormatter = FakeFileSizeFormatter(),
+        fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
+        dateFormatter = FakeDateFormatter(),
+    ),
+)
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 0f304f209e9..1a43bbacf6d 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
@@ -21,8 +21,10 @@ import io.element.android.libraries.matrix.test.A_USER_ID_2
 import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
 import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
 import io.element.android.libraries.matrix.test.timeline.FakeTimeline
+import io.element.android.libraries.mediaviewer.impl.datasource.FakeMediaGalleryDataSource
+import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource
 import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
-import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage
 import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions
 import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
 import io.element.android.tests.testutils.WarmUpRule
diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/model/GroupedMediaItemsTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/model/GroupedMediaItemsTest.kt
new file mode 100644
index 00000000000..16f07779941
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/model/GroupedMediaItemsTest.kt
@@ -0,0 +1,32 @@
+/*
+ * 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.model
+
+import com.google.common.truth.Truth.assertThat
+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_EVENT_ID_3
+import kotlinx.collections.immutable.persistentListOf
+import org.junit.Test
+
+class GroupedMediaItemsTest {
+    @Test
+    fun `hasEvent returns the expected value`() {
+        val sut = GroupedMediaItems(
+            imageAndVideoItems = persistentListOf(
+                aMediaItemImage(eventId = AN_EVENT_ID),
+            ),
+            fileItems = persistentListOf(
+                aMediaItemAudio(eventId = AN_EVENT_ID_2),
+            ),
+        )
+        assertThat(sut.hasEvent(AN_EVENT_ID)).isTrue()
+        assertThat(sut.hasEvent(AN_EVENT_ID_2)).isTrue()
+        assertThat(sut.hasEvent(AN_EVENT_ID_3)).isFalse()
+    }
+}
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
index 5348eb2aa34..b49c174d21e 100644
--- 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
@@ -17,15 +17,15 @@ 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.MediaViewerEntryPoint.MediaViewerMode
 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.datasource.FakeMediaGalleryDataSource
+import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource
 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.impl.model.aMediaItemDateSeparator
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemFile
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage
+import io.element.android.libraries.mediaviewer.impl.model.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
@@ -122,10 +122,12 @@ class MediaViewerDataSourceTest {
                 MediaViewerPageData.Loading(
                     direction = Timeline.PaginationDirection.BACKWARDS,
                     timestamp = A_FAKE_TIMESTAMP,
+                    pagerKey = 0L,
                 ),
                 MediaViewerPageData.Loading(
                     direction = Timeline.PaginationDirection.FORWARDS,
                     timestamp = A_FAKE_TIMESTAMP,
+                    pagerKey = 1L,
                 ),
             )
         }
@@ -135,7 +137,7 @@ class MediaViewerDataSourceTest {
     fun `test dataFlow with data galleryMode image`() = runTest {
         val galleryDataSource = FakeMediaGalleryDataSource()
         val sut = createMediaViewerDataSource(
-            galleryMode = MediaGalleryMode.Images,
+            mode = MediaViewerMode.TimelineImagesAndVideos,
             galleryDataSource = galleryDataSource,
         )
         sut.dataFlow().test {
@@ -157,7 +159,7 @@ class MediaViewerDataSourceTest {
     fun `test dataFlow with data galleryMode files`() = runTest {
         val galleryDataSource = FakeMediaGalleryDataSource()
         val sut = createMediaViewerDataSource(
-            galleryMode = MediaGalleryMode.Files,
+            mode = MediaViewerMode.TimelineFilesAndAudios,
             galleryDataSource = galleryDataSource,
         )
         sut.dataFlow().test {
@@ -263,16 +265,17 @@ class MediaViewerDataSourceTest {
     }
 
     private fun TestScope.createMediaViewerDataSource(
-        galleryMode: MediaGalleryMode = MediaGalleryMode.Images,
+        mode: MediaViewerMode = MediaViewerMode.TimelineImagesAndVideos,
         galleryDataSource: MediaGalleryDataSource = FakeMediaGalleryDataSource(),
         mediaLoader: MatrixMediaLoader = FakeMatrixMediaLoader(),
         localMediaFactory: LocalMediaFactory = FakeLocalMediaFactory(mockMediaUrl),
     ) = MediaViewerDataSource(
-        galleryMode = galleryMode,
+        mode = mode,
         dispatcher = testCoroutineDispatchers().computation,
         galleryDataSource = galleryDataSource,
         mediaLoader = mediaLoader,
         localMediaFactory = localMediaFactory,
         systemClock = FakeSystemClock(),
+        pagerKeysHandler = PagerKeysHandler(),
     )
 }
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 8a2d536a78c..03d66992bf4 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
@@ -29,13 +29,12 @@ 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.R
+import io.element.android.libraries.mediaviewer.impl.datasource.FakeMediaGalleryDataSource
+import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource
 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.impl.gallery.ui.aMediaItemLoadingIndicator
+import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemLoadingIndicator
 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
@@ -782,16 +781,13 @@ class MediaViewerPresenterTest {
             ),
             navigator = mediaViewerNavigator,
             dataSource = MediaViewerDataSource(
-                galleryMode = when (mode) {
-                    MediaViewerEntryPoint.MediaViewerMode.SingleMedia -> MediaGalleryMode.Images
-                    MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images
-                    MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files
-                },
+                mode = mode,
                 dispatcher = testCoroutineDispatchers().computation,
                 galleryDataSource = mediaGalleryDataSource,
                 mediaLoader = matrixMediaLoader,
                 localMediaFactory = localMediaFactory,
                 systemClock = FakeSystemClock(),
+                pagerKeysHandler = PagerKeysHandler(),
             ),
             room = room,
             localMediaActions = localMediaActions,
diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/PagerKeysHandlerTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/PagerKeysHandlerTest.kt
new file mode 100644
index 00000000000..807c7b51cd1
--- /dev/null
+++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/PagerKeysHandlerTest.kt
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2025 New Vector Ltd.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+ * Please see LICENSE files in the repository root for full details.
+ */
+
+package io.element.android.libraries.mediaviewer.impl.viewer
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.libraries.matrix.api.timeline.Timeline
+import io.element.android.libraries.matrix.test.AN_EVENT_ID
+import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemLoadingIndicator
+import org.junit.Test
+
+class PagerKeysHandlerTest {
+    private val image1 = aMediaItemImage(
+        eventId = AN_EVENT_ID,
+    )
+    private val image2 = aMediaItemImage(
+        eventId = AN_EVENT_ID_2,
+    )
+    private val aBackwardLoadingIndicator = aMediaItemLoadingIndicator(
+        direction = Timeline.PaginationDirection.BACKWARDS
+    )
+    private val aForwardLoadingIndicator = aMediaItemLoadingIndicator(
+        direction = Timeline.PaginationDirection.FORWARDS
+    )
+
+    @Test
+    fun `when new items are inserted after existing items, keys are not shifted`() {
+        val sut = PagerKeysHandler()
+        sut.accept(listOf(aBackwardLoadingIndicator, image1, aForwardLoadingIndicator))
+        assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(0)
+        assertThat(sut.getKey(image1)).isEqualTo(1)
+        assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(2)
+        sut.accept(listOf(aBackwardLoadingIndicator, image1, image2, aForwardLoadingIndicator))
+        assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(0)
+        assertThat(sut.getKey(image1)).isEqualTo(1)
+        assertThat(sut.getKey(image2)).isEqualTo(2)
+        assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(3)
+    }
+
+    @Test
+    fun `when new items are inserted before existing items, keys are not shifted`() {
+        val sut = PagerKeysHandler()
+        sut.accept(listOf(aBackwardLoadingIndicator, image1, aForwardLoadingIndicator))
+        assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(0)
+        assertThat(sut.getKey(image1)).isEqualTo(1)
+        assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(2)
+        sut.accept(listOf(aBackwardLoadingIndicator, image2, image1, aForwardLoadingIndicator))
+        assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(-1)
+        assertThat(sut.getKey(image2)).isEqualTo(0)
+        assertThat(sut.getKey(image1)).isEqualTo(1)
+        assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(2)
+        // Accepting the same list should not change the keys
+        sut.accept(listOf(aBackwardLoadingIndicator, image2, image1, aForwardLoadingIndicator))
+        assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(-1)
+        assertThat(sut.getKey(image2)).isEqualTo(0)
+        assertThat(sut.getKey(image1)).isEqualTo(1)
+        assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(2)
+    }
+
+    @Test
+    fun `when loaders are removed, keys are not shifted`() {
+        val sut = PagerKeysHandler()
+        sut.accept(listOf(aBackwardLoadingIndicator, image1, aForwardLoadingIndicator))
+        assertThat(sut.getKey(aBackwardLoadingIndicator)).isEqualTo(0)
+        assertThat(sut.getKey(image1)).isEqualTo(1)
+        assertThat(sut.getKey(aForwardLoadingIndicator)).isEqualTo(2)
+        sut.accept(listOf(image1))
+        assertThat(sut.getKey(image1)).isEqualTo(1)
+    }
+}
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/viewer/SingleMediaGalleryDataSourceTest.kt
similarity index 94%
rename from libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/SingleMediaGalleryDataSourceTest.kt
rename to libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/SingleMediaGalleryDataSourceTest.kt
index d616322ddca..540f1076010 100644
--- 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/viewer/SingleMediaGalleryDataSourceTest.kt
@@ -5,7 +5,7 @@
  * Please see LICENSE files in the repository root for full details.
  */
 
-package io.element.android.libraries.mediaviewer.impl.gallery
+package io.element.android.libraries.mediaviewer.impl.viewer
 
 import app.cash.turbine.test
 import com.google.common.truth.Truth.assertThat
@@ -22,8 +22,10 @@ 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.libraries.mediaviewer.impl.gallery.aGroupedMediaItems
+import io.element.android.libraries.mediaviewer.impl.model.MediaItem
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemFile
+import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage
 import io.element.android.tests.testutils.WarmUpRule
 import kotlinx.coroutines.test.runTest
 import org.junit.Rule
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 d140d3fea48..b5e75b904da 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
@@ -136,6 +136,7 @@ class KonsistClassNameTest {
                 "Enterprise",
                 "Fdroid",
                 "FileExtensionExtractor",
+                "LiveMediaTimeline",
                 "KeyStore",
                 "Matrix",
                 "Noop",