Only load full media on media viewer when it's the visible item (#6794)

* Only load full media on media viewer when it's the visible item

* Allow cancelling ongoing media loading if scrolling fast
This commit is contained in:
Jorge Martin Espinosa 2026-05-18 10:29:14 +02:00 committed by GitHub
parent 57022926c6
commit 2a8ba7fa95
5 changed files with 49 additions and 7 deletions

View file

@ -19,6 +19,7 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.extensions.mapCatchingExceptions import io.element.android.libraries.core.extensions.mapCatchingExceptions
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader 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.media.MediaFile
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.Timeline 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.MediaViewerEntryPoint.MediaViewerMode
import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMedia
@ -40,6 +41,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
class MediaViewerDataSource( class MediaViewerDataSource(
mode: MediaViewerMode, mode: MediaViewerMode,
@ -51,7 +53,7 @@ class MediaViewerDataSource(
private val pagerKeysHandler: PagerKeysHandler, private val pagerKeysHandler: PagerKeysHandler,
) { ) {
// List of media files that are currently being loaded // List of media files that are currently being loaded
private val mediaFiles: MutableList<MediaFile> = mutableListOf() private val mediaFiles: ConcurrentHashMap<MediaSource, MediaFile> = ConcurrentHashMap()
private val galleryMode = when (mode) { private val galleryMode = when (mode) {
MediaViewerMode.SingleMedia, MediaViewerMode.SingleMedia,
@ -69,7 +71,7 @@ class MediaViewerDataSource(
fun dispose() { fun dispose() {
Timber.d("Disposing MediaViewerDataSource, closing ${mediaFiles.size} media files") Timber.d("Disposing MediaViewerDataSource, closing ${mediaFiles.size} media files")
mediaFiles.forEach { it.close() } mediaFiles.values.forEach { it.close() }
mediaFiles.clear() mediaFiles.clear()
localMediaStates.clear() localMediaStates.clear()
} }
@ -163,6 +165,12 @@ class MediaViewerDataSource(
} }
suspend fun loadMedia(data: MediaViewerPageData.MediaViewerData) { suspend fun loadMedia(data: MediaViewerPageData.MediaViewerData) {
val currentState = localMediaStates[data.mediaSource.safeUrl]?.value
// If the media is already loading or has been loaded successfully, do nothing
if (currentState?.isLoading() == true || currentState?.isSuccess() == true) {
return
}
Timber.d("loadMedia for ${data.eventId}") Timber.d("loadMedia for ${data.eventId}")
val localMediaState = localMediaStates.getOrPut(data.mediaSource.safeUrl) { val localMediaState = localMediaStates.getOrPut(data.mediaSource.safeUrl) {
mutableStateOf(AsyncData.Uninitialized) mutableStateOf(AsyncData.Uninitialized)
@ -175,7 +183,7 @@ class MediaViewerDataSource(
filename = data.mediaInfo.filename filename = data.mediaInfo.filename
) )
.onSuccess { mediaFile -> .onSuccess { mediaFile ->
mediaFiles.add(mediaFile) mediaFiles[data.mediaSource] = mediaFile
} }
.mapCatchingExceptions { mediaFile -> .mapCatchingExceptions { mediaFile ->
localMediaFactory.createFromMediaFile( localMediaFactory.createFromMediaFile(
@ -190,4 +198,12 @@ class MediaViewerDataSource(
localMediaState.value = AsyncData.Failure(it) localMediaState.value = AsyncData.Failure(it)
} }
} }
fun cancelLoadingMedia(data: MediaViewerPageData.MediaViewerData) {
if (localMediaStates[data.mediaSource.safeUrl]?.value?.isLoading() == true) {
Timber.d("cancelLoadingMedia for ${data.eventId}")
mediaFiles.remove(data.mediaSource)?.close()
localMediaStates[data.mediaSource.safeUrl]?.value = AsyncData.Uninitialized
}
}
} }

View file

@ -29,4 +29,5 @@ sealed interface MediaViewerEvent {
data class Delete(val eventId: EventId) : MediaViewerEvent data class Delete(val eventId: EventId) : MediaViewerEvent
data class OnNavigateTo(val index: Int) : MediaViewerEvent data class OnNavigateTo(val index: Int) : MediaViewerEvent
data class LoadMore(val direction: Timeline.PaginationDirection) : MediaViewerEvent data class LoadMore(val direction: Timeline.PaginationDirection) : MediaViewerEvent
data class CancelLoadingMedia(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvent
} }

View file

@ -100,6 +100,9 @@ class MediaViewerPresenter(
is MediaViewerEvent.LoadMedia -> { is MediaViewerEvent.LoadMedia -> {
coroutineScope.downloadMedia(data = event.data) coroutineScope.downloadMedia(data = event.data)
} }
is MediaViewerEvent.CancelLoadingMedia -> {
dataSource.cancelLoadingMedia(event.data)
}
is MediaViewerEvent.ClearLoadingError -> { is MediaViewerEvent.ClearLoadingError -> {
dataSource.clearLoadingError(event.data) dataSource.clearLoadingError(event.data)
} }

View file

@ -52,6 +52,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.onVisibilityChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -208,11 +209,16 @@ fun MediaViewerView(
} }
is MediaViewerPageData.MediaViewerData -> { is MediaViewerPageData.MediaViewerData -> {
var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) } var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) }
LaunchedEffect(Unit) {
state.eventSink(MediaViewerEvent.LoadMedia(dataForPage))
}
Box( Box(
modifier = Modifier.fillMaxSize() modifier = Modifier
.onVisibilityChanged(minDurationMs = 200L) { isVisible ->
if (isVisible) {
state.eventSink(MediaViewerEvent.LoadMedia(dataForPage))
} else {
state.eventSink(MediaViewerEvent.CancelLoadingMedia(dataForPage))
}
}
.fillMaxSize()
) { ) {
val isDisplayed = remember(pagerState.settledPage) { val isDisplayed = remember(pagerState.settledPage) {
// This 'item provider' lambda will be called when the data source changes with an outdated `settlePage` value // This 'item provider' lambda will be called when the data source changes with an outdated `settlePage` value

View file

@ -52,6 +52,10 @@ class MediaViewerViewTest {
state = state, state = state,
onBackClick = callback, onBackClick = callback,
) )
// Wait for enough time for the onVisibilityChanged modifier to trigger
mainClock.advanceTimeBy(200)
pressBack() pressBack()
} }
eventsRecorder.assertList( eventsRecorder.assertList(
@ -110,6 +114,10 @@ class MediaViewerViewTest {
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
// Wait for enough time for the onVisibilityChanged modifier to trigger
mainClock.advanceTimeBy(200)
val contentDescription = activity!!.getString(contentDescriptionRes) val contentDescription = activity!!.getString(contentDescriptionRes)
onNodeWithContentDescription(contentDescription).performClick() onNodeWithContentDescription(contentDescription).performClick()
eventsRecorder.assertList( eventsRecorder.assertList(
@ -241,6 +249,10 @@ class MediaViewerViewTest {
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
// Wait for enough time for the onVisibilityChanged modifier to trigger
mainClock.advanceTimeBy(200)
clickOn(CommonStrings.action_retry) clickOn(CommonStrings.action_retry)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
@ -263,6 +275,10 @@ class MediaViewerViewTest {
eventSink = eventsRecorder eventSink = eventsRecorder
), ),
) )
// Wait for enough time for the onVisibilityChanged modifier to trigger
mainClock.advanceTimeBy(200)
clickOn(CommonStrings.action_cancel) clickOn(CommonStrings.action_cancel)
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(