First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions
@@ -0,0 +1,68 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023, 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.
*/
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.libraries.mediaviewer.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
setupDependencyInjection()
dependencies {
implementation(libs.coroutines.core)
implementation(libs.coil.compose)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
implementation(libs.telephoto.zoomableimage)
implementation(libs.vanniktech.blurhash)
implementation(libs.telephoto.flick)
implementation(projects.features.enterprise.api)
implementation(projects.features.viewfolder.api)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.audio.api)
implementation(projects.libraries.core)
implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.di)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixmedia.api)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.voiceplayer.api)
implementation(projects.services.toolbox.api)
api(projects.libraries.mediaviewer.api)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.di)
implementation(projects.libraries.matrix.api)
testCommonDependencies(libs, true)
testImplementation(projects.features.enterprise.test)
testImplementation(projects.libraries.audio.test)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.matrixui)
testImplementation(projects.libraries.mediaviewer.test)
testImplementation(projects.services.toolbox.test)
testImplementation(libs.coroutines.core)
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.mediaviewer.api.MediaGalleryEntryPoint
import io.element.android.libraries.mediaviewer.impl.gallery.root.MediaGalleryFlowNode
@ContributesBinding(AppScope::class)
class DefaultMediaGalleryEntryPoint : MediaGalleryEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
callback: MediaGalleryEntryPoint.Callback,
): Node {
return parentNode.createNode<MediaGalleryFlowNode>(
buildContext = buildContext,
plugins = listOf(callback),
)
}
}
@@ -0,0 +1,63 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.core.mimetype.MimeTypes
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.MediaInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerNode
@ContributesBinding(AppScope::class)
class DefaultMediaViewerEntryPoint : MediaViewerEntryPoint {
override fun createParamsForAvatar(filename: String, avatarUrl: String): MediaViewerEntryPoint.Params {
// We need to fake the MimeType here for the viewer to work.
val mimeType = MimeTypes.Images
return MediaViewerEntryPoint.Params(
mode = MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
eventId = null,
mediaInfo = MediaInfo(
filename = filename,
fileSize = null,
caption = null,
mimeType = mimeType,
formattedFileSize = "",
fileExtension = "",
senderId = UserId("@dummy:server.org"),
senderName = null,
senderAvatar = null,
dateSent = null,
dateSentFull = null,
waveform = null,
duration = null,
),
mediaSource = MediaSource(url = avatarUrl),
thumbnailSource = null,
canShowInfo = false,
)
}
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: MediaViewerEntryPoint.Params,
callback: MediaViewerEntryPoint.Callback,
): Node {
return parentNode.createNode<MediaViewerNode>(
buildContext = buildContext,
plugins = listOf(params, callback),
)
}
}
@@ -0,0 +1,220 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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 dev.zacsweers.metro.Inject
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.dateformatter.api.toHumanReadableDuration
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LegacyCallInviteContent
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownContent
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
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
@Inject
class EventItemFactory(
private val fileSizeFormatter: FileSizeFormatter,
private val fileExtensionExtractor: FileExtensionExtractor,
private val dateFormatter: DateFormatter,
) {
fun create(
currentTimelineItem: MatrixTimelineItem.Event,
): MediaItem.Event? {
val event = currentTimelineItem.event
val dateSent = dateFormatter.format(
currentTimelineItem.event.timestamp,
mode = DateFormatterMode.Day,
)
val dateSentFull = dateFormatter.format(
timestamp = currentTimelineItem.event.timestamp,
mode = DateFormatterMode.Full,
)
return when (val content = event.content) {
CallNotifyContent,
is FailedToParseMessageLikeContent,
is FailedToParseStateContent,
LegacyCallInviteContent,
is PollContent,
is ProfileChangeContent,
RedactedContent,
is RoomMembershipContent,
is StateContent,
is StickerContent,
is UnableToDecryptContent,
UnknownContent -> {
Timber.w("Should not happen: ${content.javaClass.simpleName}")
null
}
is MessageContent -> {
when (val type = content.type) {
is EmoteMessageType,
is NoticeMessageType,
is OtherMessageType,
is LocationMessageType,
is TextMessageType -> {
Timber.w("Should not happen: ${content.type}")
null
}
is AudioMessageType -> MediaItem.Audio(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo(
filename = type.filename,
fileSize = type.info?.size,
caption = type.caption,
mimeType = type.info?.mimetype.orEmpty(),
formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(type.filename),
senderId = event.sender,
senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender),
senderAvatar = event.senderProfile.getAvatarUrl(),
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
duration = null,
),
mediaSource = type.source,
)
is FileMessageType -> MediaItem.File(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo(
filename = type.filename,
fileSize = type.info?.size,
caption = type.caption,
mimeType = type.info?.mimetype.orEmpty(),
formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(type.filename),
senderId = event.sender,
senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender),
senderAvatar = event.senderProfile.getAvatarUrl(),
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
duration = null,
),
mediaSource = type.source,
// TODO We may want to add a thumbnailSource and set it to type.info?.thumbnailSource
)
is ImageMessageType -> MediaItem.Image(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo(
filename = type.filename,
fileSize = type.info?.size,
caption = type.caption,
mimeType = type.info?.mimetype.orEmpty(),
formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(type.filename),
senderId = event.sender,
senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender),
senderAvatar = event.senderProfile.getAvatarUrl(),
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
duration = null,
),
mediaSource = type.source,
thumbnailSource = type.info?.thumbnailSource,
)
is StickerMessageType -> MediaItem.Image(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo(
filename = type.filename,
fileSize = type.info?.size,
caption = type.caption,
mimeType = type.info?.mimetype.orEmpty(),
formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(type.filename),
senderId = event.sender,
senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender),
senderAvatar = event.senderProfile.getAvatarUrl(),
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
duration = null,
),
mediaSource = type.source,
thumbnailSource = type.info?.thumbnailSource,
)
is VideoMessageType -> MediaItem.Video(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo(
filename = type.filename,
fileSize = type.info?.size,
caption = type.caption,
mimeType = type.info?.mimetype.orEmpty(),
formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(type.filename),
senderId = event.sender,
senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender),
senderAvatar = event.senderProfile.getAvatarUrl(),
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
),
mediaSource = type.source,
thumbnailSource = type.info?.thumbnailSource,
)
is VoiceMessageType -> MediaItem.Voice(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo(
filename = type.filename,
fileSize = type.info?.size,
caption = type.caption,
mimeType = type.info?.mimetype.orEmpty(),
formattedFileSize = type.info?.size?.let { fileSizeFormatter.format(it) }.orEmpty(),
fileExtension = fileExtensionExtractor.extractFromName(type.filename),
senderId = event.sender,
senderName = event.senderProfile.getDisambiguatedDisplayName(event.sender),
senderAvatar = event.senderProfile.getAvatarUrl(),
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = type.details?.waveform.orEmpty(),
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
),
mediaSource = type.source,
)
}
}
}
}
}
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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 dev.zacsweers.metro.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.JoinedRoom
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
fun interface FocusedTimelineMediaGalleryDataSourceFactory {
fun createFor(
eventId: EventId,
mediaItem: MediaItem.Event,
onlyPinnedEvents: Boolean,
): MediaGalleryDataSource
}
@ContributesBinding(RoomScope::class)
class DefaultFocusedTimelineMediaGalleryDataSourceFactory(
private val room: JoinedRoom,
private val timelineMediaItemsFactory: TimelineMediaItemsFactory,
private val mediaItemsPostProcessor: MediaItemsPostProcessor,
) : FocusedTimelineMediaGalleryDataSourceFactory {
override fun createFor(
eventId: EventId,
mediaItem: MediaItem.Event,
onlyPinnedEvents: Boolean,
): MediaGalleryDataSource {
return TimelineMediaGalleryDataSource(
room = room,
mediaTimeline = FocusedMediaTimeline(
room = room,
eventId = eventId,
initialMediaItem = mediaItem,
onlyPinnedEvents = onlyPinnedEvents,
),
timelineMediaItemsFactory = timelineMediaItemsFactory,
mediaItemsPostProcessor = mediaItemsPostProcessor,
)
}
}
@@ -0,0 +1,114 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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 dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.BaseRoom
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
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import java.util.concurrent.atomic.AtomicBoolean
interface MediaGalleryDataSource {
fun start()
fun groupedMediaItemsFlow(): Flow<AsyncData<GroupedMediaItems>>
fun getLastData(): AsyncData<GroupedMediaItems>
suspend fun loadMore(direction: Timeline.PaginationDirection)
suspend fun deleteItem(eventId: EventId)
}
@SingleIn(RoomScope::class)
@ContributesBinding(RoomScope::class)
class TimelineMediaGalleryDataSource(
private val room: BaseRoom,
private val mediaTimeline: MediaTimeline,
private val timelineMediaItemsFactory: TimelineMediaItemsFactory,
private val mediaItemsPostProcessor: MediaItemsPostProcessor,
) : MediaGalleryDataSource {
private var timeline: Timeline? = null
private val groupedMediaItemsFlow = MutableSharedFlow<AsyncData<GroupedMediaItems>>(replay = 1)
override fun groupedMediaItemsFlow(): Flow<AsyncData<GroupedMediaItems>> = groupedMediaItemsFlow
override fun getLastData(): AsyncData<GroupedMediaItems> = groupedMediaItemsFlow.replayCache.firstOrNull()
?: mediaTimeline.cache?.let { AsyncData.Success(it) }
?: AsyncData.Uninitialized
private val isStarted = AtomicBoolean(false)
@OptIn(ExperimentalCoroutinesApi::class)
override fun start() {
if (!isStarted.compareAndSet(false, true)) {
return
}
flow {
val cache = mediaTimeline.cache
if (cache != null) {
groupedMediaItemsFlow.emit(AsyncData.Success(cache))
} else {
groupedMediaItemsFlow.emit(AsyncData.Loading())
}
mediaTimeline.getTimeline().fold(
{
timeline = it
emit(it)
},
{
groupedMediaItemsFlow.emit(AsyncData.Failure(it))
},
)
}.flatMapLatest { timeline ->
timeline.timelineItems.onEach {
timelineMediaItemsFactory.replaceWith(
timelineItems = it,
)
}
}.flatMapLatest {
timelineMediaItemsFactory.timelineItems
}
.distinctUntilChanged()
.map { timelineItems ->
val groupedItems = mediaItemsPostProcessor.process(mediaItems = timelineItems)
mediaTimeline.orCache(groupedItems)
}
.onEach { groupedMediaItems ->
groupedMediaItemsFlow.emit(AsyncData.Success(groupedMediaItems))
}
.onCompletion {
timeline?.close()
}
.launchIn(room.roomCoroutineScope)
}
override suspend fun loadMore(direction: Timeline.PaginationDirection) {
timeline?.paginate(direction)
}
override suspend fun deleteItem(eventId: EventId) {
timeline?.redactEvent(
eventOrTransactionId = eventId.toEventOrTransactionId(),
reason = null,
)
}
}
@@ -0,0 +1,76 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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 dev.zacsweers.metro.Inject
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import kotlinx.collections.immutable.toImmutableList
@Inject
class MediaItemsPostProcessor {
fun process(
mediaItems: List<MediaItem>,
): GroupedMediaItems {
val imageAndVideoItems = mutableListOf<MediaItem>()
val fileItems = mutableListOf<MediaItem>()
val imageAndVideoItemsSubList = mutableListOf<MediaItem.Event>()
val fileItemsSublist = mutableListOf<MediaItem.Event>()
mediaItems.forEach { item ->
when (item) {
is MediaItem.DateSeparator -> {
if (imageAndVideoItemsSubList.isNotEmpty()) {
// Date separator first
imageAndVideoItems.add(item)
// Then events
imageAndVideoItems.addAll(imageAndVideoItemsSubList)
imageAndVideoItemsSubList.clear()
}
if (fileItemsSublist.isNotEmpty()) {
// Date separator first
fileItems.add(item)
// Then events
fileItems.addAll(fileItemsSublist)
fileItemsSublist.clear()
}
}
is MediaItem.Event -> {
when (item) {
is MediaItem.Image,
is MediaItem.Video -> {
imageAndVideoItemsSubList.add(item)
}
is MediaItem.Audio,
is MediaItem.Voice,
is MediaItem.File -> {
fileItemsSublist.add(item)
}
}
}
is MediaItem.LoadingIndicator -> {
imageAndVideoItems.add(item)
fileItems.add(item)
}
}
}
if (imageAndVideoItemsSubList.isNotEmpty()) {
// Should not happen, since the SDK is always adding a date separator
imageAndVideoItems.addAll(imageAndVideoItemsSubList)
}
if (fileItemsSublist.isNotEmpty()) {
// Should not happen, since the SDK is always adding a date separator
fileItems.addAll(fileItemsSublist)
}
return GroupedMediaItems(
imageAndVideoItems = imageAndVideoItems.toImmutableList(),
fileItems = fileItems.toImmutableList(),
)
}
}
@@ -0,0 +1,106 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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 dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.RoomScope
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.CreateTimelineParams
import io.element.android.libraries.matrix.api.room.JoinedRoom
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
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(
private val room: JoinedRoom,
) : 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.createTimeline(CreateTimelineParams.MediaOnly)
.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.
* Optionally, the timeline will only contain the pinned events.
*/
class FocusedMediaTimeline(
private val room: JoinedRoom,
private val eventId: EventId,
private val onlyPinnedEvents: Boolean,
initialMediaItem: MediaItem.Event,
) : MediaTimeline {
override suspend fun getTimeline(): Result<Timeline> {
return room.createTimeline(
createTimelineParams = if (onlyPinnedEvents) {
CreateTimelineParams.PinnedOnly
} else {
CreateTimelineParams.MediaOnlyFocused(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
}
}
}
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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 dev.zacsweers.metro.Inject
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.toImmutableList
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@Inject
class TimelineMediaItemsFactory(
private val dispatchers: CoroutineDispatchers,
private val virtualItemFactory: VirtualItemFactory,
private val eventItemFactory: EventItemFactory,
) {
private val _timelineItems = MutableSharedFlow<ImmutableList<MediaItem>>(replay = 1)
private val lock = Mutex()
private val diffCache = MutableListDiffCache<MediaItem>()
private val diffCacheUpdater = DiffCacheUpdater<MatrixTimelineItem, MediaItem>(
diffCache = diffCache,
detectMoves = false,
cacheInvalidator = DefaultDiffCacheInvalidator()
) { old, new ->
if (old is MatrixTimelineItem.Event && new is MatrixTimelineItem.Event) {
old.uniqueId == new.uniqueId
} else {
false
}
}
val timelineItems: Flow<ImmutableList<MediaItem>> = _timelineItems.distinctUntilChanged()
suspend fun replaceWith(
timelineItems: List<MatrixTimelineItem>,
) = withContext(dispatchers.computation) {
lock.withLock {
diffCacheUpdater.updateWith(timelineItems)
buildAndEmitTimelineItemStates(timelineItems)
}
}
private suspend fun buildAndEmitTimelineItemStates(
timelineItems: List<MatrixTimelineItem>,
) {
val newTimelineItemStates = ArrayList<MediaItem>()
for (index in diffCache.indices().reversed()) {
val cacheItem = diffCache.get(index)
if (cacheItem == null) {
buildAndCacheItem(timelineItems, index)?.also { timelineItemState ->
newTimelineItemStates.add(timelineItemState)
}
} else {
newTimelineItemStates.add(cacheItem)
}
}
_timelineItems.emit(newTimelineItemStates.toImmutableList())
}
private fun buildAndCacheItem(
timelineItems: List<MatrixTimelineItem>,
index: Int,
): MediaItem? {
val timelineItem =
when (val currentTimelineItem = timelineItems[index]) {
is MatrixTimelineItem.Event -> eventItemFactory.create(currentTimelineItem)
is MatrixTimelineItem.Virtual -> virtualItemFactory.create(currentTimelineItem)
MatrixTimelineItem.Other -> null
}
diffCache[index] = timelineItem
return timelineItem
}
}
@@ -0,0 +1,43 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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 dev.zacsweers.metro.Inject
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
@Inject
class VirtualItemFactory(
private val dateFormatter: DateFormatter,
) {
fun create(timelineItem: MatrixTimelineItem.Virtual): MediaItem? {
return when (val virtual = timelineItem.virtual) {
is VirtualTimelineItem.DayDivider -> MediaItem.DateSeparator(
id = timelineItem.uniqueId,
formattedDate = dateFormatter.format(
timestamp = virtual.timestamp,
mode = DateFormatterMode.Month,
useRelative = true,
)
)
VirtualTimelineItem.LastForwardIndicator -> null
is VirtualTimelineItem.LoadingIndicator -> MediaItem.LoadingIndicator(
id = timelineItem.uniqueId,
direction = virtual.direction,
timestamp = virtual.timestamp
)
VirtualTimelineItem.ReadMarker -> null
VirtualTimelineItem.RoomBeginning -> null
VirtualTimelineItem.TypingNotification -> null
}
}
}
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.details
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.MediaInfo
sealed interface MediaBottomSheetState {
data object Hidden : MediaBottomSheetState
data class MediaDeleteConfirmationState(
val eventId: EventId,
val mediaInfo: MediaInfo,
val thumbnailSource: MediaSource?,
) : MediaBottomSheetState
data class MediaDetailsBottomSheetState(
val eventId: EventId?,
val canDelete: Boolean,
val mediaInfo: MediaInfo,
val thumbnailSource: MediaSource?,
) : MediaBottomSheetState
}
@@ -0,0 +1,164 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.details
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.mediaviewer.impl.R
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MediaDeleteConfirmationBottomSheet(
state: MediaBottomSheetState.MediaDeleteConfirmationState,
onDelete: (EventId) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
ModalBottomSheet(
modifier = modifier,
onDismissRequest = onDismiss,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
IconTitleSubtitleMolecule(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp, horizontal = 8.dp),
title = stringResource(R.string.screen_media_browser_delete_confirmation_title),
iconStyle = BigIcon.Style.Default(CompoundIcons.Delete(), useCriticalTint = true),
subTitle = stringResource(R.string.screen_media_browser_delete_confirmation_subtitle),
)
Spacer(modifier = Modifier.height(16.dp))
MediaRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
state = state,
)
Button(
modifier = Modifier
.fillMaxWidth()
.padding(top = 40.dp),
text = stringResource(CommonStrings.action_remove),
onClick = {
onDelete(state.eventId)
},
destructive = true,
)
TextButton(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
text = stringResource(CommonStrings.action_cancel),
onClick = {
onDismiss()
},
)
}
}
}
@Composable
private fun MediaRow(
state: MediaBottomSheetState.MediaDeleteConfirmationState,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(40.dp),
) {
if (state.thumbnailSource == null) {
BigIcon(
style = BigIcon.Style.Default(CompoundIcons.Attachment()),
)
} else {
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.background(Color.White),
model = MediaRequestData(state.thumbnailSource, MediaRequestData.Kind.Thumbnail(100)),
contentScale = ContentScale.Crop,
alignment = Alignment.Center,
contentDescription = null,
)
}
}
Column(
modifier = Modifier
.padding(start = 12.dp)
.weight(1f),
) {
// Name
Text(
modifier = Modifier.clipToBounds(),
text = state.mediaInfo.filename,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyLgRegular,
)
// Info
Text(
text = state.mediaInfo.mimeType + " - " + state.mediaInfo.formattedFileSize,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodySmRegular,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun MediaDeleteConfirmationBottomSheetPreview() = ElementPreview {
MediaDeleteConfirmationBottomSheet(
state = aMediaDeleteConfirmationState(),
onDelete = {},
onDismiss = {},
)
}
@@ -0,0 +1,234 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.details
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatar.AvatarType
import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview
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.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.impl.R
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MediaDetailsBottomSheet(
state: MediaBottomSheetState.MediaDetailsBottomSheetState,
onViewInTimeline: (EventId) -> Unit,
onShare: (EventId) -> Unit,
onForward: (EventId) -> Unit,
onDownload: (EventId) -> Unit,
onDelete: (EventId) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
ModalBottomSheet(
modifier = modifier,
onDismissRequest = onDismiss,
) {
Column(
modifier = Modifier
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Section(
title = stringResource(R.string.screen_media_details_uploaded_by),
) {
SenderRow(
mediaInfo = state.mediaInfo,
)
}
SectionText(
title = stringResource(R.string.screen_media_details_uploaded_on),
text = state.mediaInfo.dateSentFull.orEmpty(),
)
SectionText(
title = stringResource(R.string.screen_media_details_filename),
text = state.mediaInfo.filename,
)
SectionText(
title = stringResource(R.string.screen_media_details_file_format),
text = state.mediaInfo.mimeType + " - " + state.mediaInfo.formattedFileSize,
)
if (state.eventId != null) {
Column {
HorizontalDivider()
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.VisibilityOn())),
headlineContent = { Text(stringResource(CommonStrings.action_view_in_timeline)) },
style = ListItemStyle.Primary,
onClick = {
onViewInTimeline(state.eventId)
}
)
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ShareAndroid())),
headlineContent = { Text(stringResource(CommonStrings.action_share)) },
style = ListItemStyle.Primary,
onClick = {
onShare(state.eventId)
}
)
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Forward())),
headlineContent = { Text(stringResource(CommonStrings.action_forward)) },
style = ListItemStyle.Primary,
onClick = {
onForward(state.eventId)
}
)
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Download())),
headlineContent = { Text(stringResource(CommonStrings.action_save)) },
style = ListItemStyle.Primary,
onClick = {
onDownload(state.eventId)
}
)
if (state.canDelete) {
HorizontalDivider()
ListItem(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Delete())),
headlineContent = { Text(stringResource(CommonStrings.action_remove)) },
style = ListItemStyle.Destructive,
onClick = {
onDelete(state.eventId)
}
)
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
}
@Composable
private fun SenderRow(
mediaInfo: MediaInfo,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
val id = mediaInfo.senderId?.value ?: "@Alice:domain"
Avatar(
avatarData = AvatarData(
id = id,
name = mediaInfo.senderName,
url = mediaInfo.senderAvatar,
size = AvatarSize.MediaSender,
),
avatarType = AvatarType.User,
)
Column(
modifier = Modifier
.padding(start = 8.dp)
.weight(1f),
) {
// Name
val avatarColors = AvatarColorsProvider.provide(id)
Text(
modifier = Modifier.clipToBounds(),
text = mediaInfo.senderName.orEmpty(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = avatarColors.foreground,
style = ElementTheme.typography.fontBodyMdMedium,
)
// Id
Text(
text = mediaInfo.senderId?.value.orEmpty(),
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyMdRegular,
)
}
}
}
@Composable
private fun Section(
title: String,
content: @Composable () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = title.uppercase(),
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)
content()
}
}
@Composable
private fun SectionText(
title: String,
text: String,
) {
Section(title = title) {
Text(
text = text,
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
}
}
@PreviewsDayNight
@Composable
internal fun MediaDetailsBottomSheetPreview() = ElementPreview {
MediaDetailsBottomSheet(
state = aMediaDetailsBottomSheetState(),
onViewInTimeline = {},
onShare = {},
onForward = {},
onDownload = {},
onDelete = {},
onDismiss = {},
)
}
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.details
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
fun aMediaDetailsBottomSheetState(
dateSentFull: String = "December 6, 2024 at 12:59",
canDelete: Boolean = true,
): MediaBottomSheetState.MediaDetailsBottomSheetState {
return MediaBottomSheetState.MediaDetailsBottomSheetState(
eventId = EventId("\$eventId"),
canDelete = canDelete,
mediaInfo = anImageMediaInfo(
senderName = "Alice",
dateSentFull = dateSentFull,
),
thumbnailSource = null,
)
}
fun aMediaDeleteConfirmationState(): MediaBottomSheetState.MediaDeleteConfirmationState {
return MediaBottomSheetState.MediaDeleteConfirmationState(
eventId = EventId("\$eventId"),
mediaInfo = anImageMediaInfo(
senderName = "Alice",
),
thumbnailSource = null,
)
}
@@ -0,0 +1,34 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.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
data class LoadMore(val direction: Timeline.PaginationDirection) : MediaGalleryEvents
data class Share(val eventId: EventId) : MediaGalleryEvents
data class Forward(val eventId: EventId) : MediaGalleryEvents
data class SaveOnDisk(val eventId: EventId) : MediaGalleryEvents
data class OpenInfo(val mediaItem: MediaItem.Event) : MediaGalleryEvents
data class ViewInTimeline(val eventId: EventId) : MediaGalleryEvents
data class ConfirmDelete(
val eventId: EventId,
val mediaInfo: MediaInfo,
val thumbnailSource: MediaSource?,
) : MediaGalleryEvents
data object CloseBottomSheet : MediaGalleryEvents
data class Delete(val eventId: EventId) : MediaGalleryEvents
}
@@ -0,0 +1,16 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.matrix.api.core.EventId
interface MediaGalleryNavigator {
fun onViewInTimelineClick(eventId: EventId)
fun onForwardClick(eventId: EventId)
}
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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 androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.callback
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)
@AssistedInject
class MediaGalleryNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: MediaGalleryPresenter.Factory,
private val mediaItemPresenterFactories: MediaItemPresenterFactories,
) : Node(buildContext, plugins = plugins),
MediaGalleryNavigator {
private val presenter = presenterFactory.create(
navigator = this,
)
interface Callback : Plugin {
fun onBackClick()
fun showItem(item: MediaItem.Event)
fun viewInTimeline(eventId: EventId)
fun forward(eventId: EventId)
}
private val callback: Callback = callback()
override fun onViewInTimelineClick(eventId: EventId) {
callback.viewInTimeline(eventId)
}
override fun onForwardClick(eventId: EventId) {
callback.forward(eventId)
}
@Composable
override fun View(modifier: Modifier) {
CompositionLocalProvider(
LocalMediaItemPresenterFactories provides mediaItemPresenterFactories,
) {
val state = presenter.present()
MediaGalleryView(
state = state,
onBackClick = callback::onBackClick,
onItemClick = callback::showItem,
modifier = modifier,
)
}
}
}
@@ -0,0 +1,213 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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 android.content.ActivityNotFoundException
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.androidutils.R
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.mapCatchingExceptions
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.room.BaseRoom
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
@AssistedInject
class MediaGalleryPresenter(
@Assisted private val navigator: MediaGalleryNavigator,
private val room: BaseRoom,
private val mediaGalleryDataSource: MediaGalleryDataSource,
private val localMediaFactory: LocalMediaFactory,
private val mediaLoader: MatrixMediaLoader,
private val localMediaActions: LocalMediaActions,
private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<MediaGalleryState> {
@AssistedFactory
interface Factory {
fun create(
navigator: MediaGalleryNavigator,
): MediaGalleryPresenter
}
@Composable
override fun present(): MediaGalleryState {
val coroutineScope = rememberCoroutineScope()
var mode by remember { mutableStateOf(MediaGalleryMode.Images) }
val roomInfo by room.roomInfoFlow.collectAsState()
var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) }
val groupedMediaItems by remember {
mediaGalleryDataSource.groupedMediaItemsFlow()
}
.collectAsState(AsyncData.Uninitialized)
LaunchedEffect(Unit) {
mediaGalleryDataSource.start()
}
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
localMediaActions.Configure()
fun handleEvent(event: MediaGalleryEvents) {
when (event) {
is MediaGalleryEvents.ChangeMode -> {
mode = event.mode
}
is MediaGalleryEvents.LoadMore -> coroutineScope.launch {
mediaGalleryDataSource.loadMore(event.direction)
}
is MediaGalleryEvents.Delete -> coroutineScope.launch {
mediaGalleryDataSource.deleteItem(event.eventId)
}
is MediaGalleryEvents.SaveOnDisk -> coroutineScope.launch {
mediaBottomSheetState = MediaBottomSheetState.Hidden
groupedMediaItems.dataOrNull().find(event.eventId)?.let {
saveOnDisk(it)
}
}
is MediaGalleryEvents.Share -> coroutineScope.launch {
mediaBottomSheetState = MediaBottomSheetState.Hidden
groupedMediaItems.dataOrNull().find(event.eventId)?.let {
share(it)
}
}
is MediaGalleryEvents.Forward -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
navigator.onForwardClick(event.eventId)
}
is MediaGalleryEvents.ViewInTimeline -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
navigator.onViewInTimelineClick(event.eventId)
}
is MediaGalleryEvents.OpenInfo -> coroutineScope.launch {
mediaBottomSheetState = MediaBottomSheetState.MediaDetailsBottomSheetState(
eventId = event.mediaItem.eventId(),
canDelete = when (event.mediaItem.mediaInfo().senderId) {
null -> false
room.sessionId -> room.canRedactOwn().getOrElse { false } && event.mediaItem.eventId() != null
else -> room.canRedactOther().getOrElse { false } && event.mediaItem.eventId() != null
},
mediaInfo = event.mediaItem.mediaInfo(),
thumbnailSource = when (event.mediaItem) {
is MediaItem.Image -> event.mediaItem.thumbnailSource ?: event.mediaItem.mediaSource
is MediaItem.Video -> event.mediaItem.thumbnailSource ?: event.mediaItem.mediaSource
is MediaItem.Audio -> null
is MediaItem.File -> null
is MediaItem.Voice -> null
},
)
}
is MediaGalleryEvents.ConfirmDelete -> {
mediaBottomSheetState = MediaBottomSheetState.MediaDeleteConfirmationState(
eventId = event.eventId,
mediaInfo = event.mediaInfo,
thumbnailSource = event.thumbnailSource,
)
}
MediaGalleryEvents.CloseBottomSheet -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
}
}
}
return MediaGalleryState(
roomName = roomInfo.name.orEmpty(),
mode = mode,
groupedMediaItems = groupedMediaItems,
mediaBottomSheetState = mediaBottomSheetState,
snackbarMessage = snackbarMessage,
eventSink = ::handleEvent,
)
}
private suspend fun downloadMedia(mediaItem: MediaItem.Event): Result<LocalMedia> {
return mediaLoader.downloadMediaFile(
source = mediaItem.mediaSource(),
mimeType = mediaItem.mediaInfo().mimeType,
filename = mediaItem.mediaInfo().filename
)
.mapCatchingExceptions { mediaFile ->
localMediaFactory.createFromMediaFile(
mediaFile = mediaFile,
mediaInfo = mediaItem.mediaInfo()
)
}
}
private suspend fun saveOnDisk(mediaItem: MediaItem.Event) {
downloadMedia(mediaItem)
.mapCatchingExceptions { localMedia ->
localMediaActions.saveOnDisk(localMedia)
}
.onSuccess {
val snackbarMessage = SnackbarMessage(CommonStrings.common_file_saved_on_disk_android)
snackbarDispatcher.post(snackbarMessage)
}
.onFailure {
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
snackbarDispatcher.post(snackbarMessage)
}
}
private suspend fun share(mediaItem: MediaItem.Event) {
downloadMedia(mediaItem)
.mapCatchingExceptions { localMedia ->
localMediaActions.share(localMedia)
}
.onFailure {
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
snackbarDispatcher.post(snackbarMessage)
}
}
private fun mediaActionsError(throwable: Throwable): Int {
return if (throwable is ActivityNotFoundException) {
R.string.error_no_compatible_app_found
} else {
CommonStrings.error_unknown
}
}
}
private fun GroupedMediaItems?.find(eventId: EventId?): MediaItem.Event? {
if (this == null || eventId == null) {
return null
}
return (imageAndVideoItems + fileItems).filterIsInstance<MediaItem.Event>()
.firstOrNull { it.eventId() == eventId }
}
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.mediaviewer.impl.R
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
data class MediaGalleryState(
val roomName: String,
val mode: MediaGalleryMode,
val groupedMediaItems: AsyncData<GroupedMediaItems>,
val mediaBottomSheetState: MediaBottomSheetState,
val snackbarMessage: SnackbarMessage?,
val eventSink: (MediaGalleryEvents) -> Unit,
)
enum class MediaGalleryMode(val stringResource: Int) {
Images(R.string.screen_media_browser_list_mode_media),
Files(R.string.screen_media_browser_list_mode_files),
}
@@ -0,0 +1,134 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.media.WaveFormSamples
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.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> {
override val values: Sequence<MediaGalleryState>
get() = sequenceOf(
aMediaGalleryState(
roomName = "A long room name that will be truncated",
),
aMediaGalleryState(groupedMediaItems = AsyncData.Loading()),
aMediaGalleryState(groupedMediaItems = AsyncData.Success(aGroupedMediaItems())),
aMediaGalleryState(
groupedMediaItems = AsyncData.Success(
aGroupedMediaItems(
imageAndVideoItems = listOf(
aMediaItemDateSeparator(id = UniqueId("0")),
aMediaItemImage(id = UniqueId("1")),
aMediaItemDateSeparator(
id = UniqueId("2"),
formattedDate = "September 2004",
),
aMediaItemImage(id = UniqueId("3")),
aMediaItemVideo(id = UniqueId("4")),
aMediaItemImage(id = UniqueId("5")),
aMediaItemImage(id = UniqueId("6")),
aMediaItemImage(id = UniqueId("7")),
aMediaItemImage(id = UniqueId("8")),
aMediaItemImage(id = UniqueId("9")),
aMediaItemLoadingIndicator(),
).toImmutableList()
)
),
),
aMediaGalleryState(mode = MediaGalleryMode.Files),
aMediaGalleryState(mode = MediaGalleryMode.Files, groupedMediaItems = AsyncData.Loading()),
aMediaGalleryState(mode = MediaGalleryMode.Files, groupedMediaItems = AsyncData.Success(aGroupedMediaItems())),
aMediaGalleryState(
mode = MediaGalleryMode.Files,
groupedMediaItems = AsyncData.Success(
aGroupedMediaItems(
fileItems = listOf(
aMediaItemDateSeparator(id = UniqueId("0")),
aMediaItemFile(id = UniqueId("1")),
aMediaItemDateSeparator(
id = UniqueId("2"),
formattedDate = "September 2004",
),
aMediaItemAudio(id = UniqueId("4")),
aMediaItemVoice(
id = UniqueId("5"),
waveform = WaveFormSamples.realisticWaveForm,
),
aMediaItemLoadingIndicator(),
).toImmutableList()
)
),
),
aMediaGalleryState(mediaBottomSheetState = aMediaDetailsBottomSheetState()),
aMediaGalleryState(
groupedMediaItems = AsyncData.Failure(Exception("Failed to load media")),
),
aMediaGalleryState(
mode = MediaGalleryMode.Files,
groupedMediaItems = AsyncData.Failure(Exception("Failed to load media")),
),
// Timeline is loaded but does not have relevant content yet for images and videos
aMediaGalleryState(
groupedMediaItems = AsyncData.Success(
aGroupedMediaItems(
imageAndVideoItems = listOf(
aMediaItemLoadingIndicator(),
),
)
)
),
// Timeline is loaded but does not have relevant content yet for files
aMediaGalleryState(
mode = MediaGalleryMode.Files,
groupedMediaItems = AsyncData.Success(
aGroupedMediaItems(
fileItems = listOf(
aMediaItemLoadingIndicator(),
),
)
)
),
)
}
private fun aMediaGalleryState(
roomName: String = "Room name",
mode: MediaGalleryMode = MediaGalleryMode.Images,
groupedMediaItems: AsyncData<GroupedMediaItems> = AsyncData.Uninitialized,
mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden,
) = MediaGalleryState(
roomName = roomName,
mode = mode,
groupedMediaItems = groupedMediaItems,
mediaBottomSheetState = mediaBottomSheetState,
snackbarMessage = null,
eventSink = {}
)
fun aGroupedMediaItems(
imageAndVideoItems: List<MediaItem> = emptyList(),
fileItems: List<MediaItem> = emptyList(),
) = GroupedMediaItems(
imageAndVideoItems = imageAndVideoItems.toImmutableList(),
fileItems = fileItems.toImmutableList(),
)
@@ -0,0 +1,534 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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 androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.background.OnboardingBackground
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.async.AsyncFailure
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.SegmentedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.impl.R
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.details.MediaDeleteConfirmationBottomSheet
import io.element.android.libraries.mediaviewer.impl.details.MediaDetailsBottomSheet
import io.element.android.libraries.mediaviewer.impl.gallery.di.LocalMediaItemPresenterFactories
import io.element.android.libraries.mediaviewer.impl.gallery.di.aFakeMediaItemPresenterFactories
import io.element.android.libraries.mediaviewer.impl.gallery.di.rememberPresenter
import io.element.android.libraries.mediaviewer.impl.gallery.ui.AudioItemView
import io.element.android.libraries.mediaviewer.impl.gallery.ui.DateItemView
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
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MediaGalleryView(
state: MediaGalleryState,
onBackClick: () -> Unit,
onItemClick: (MediaItem.Event) -> Unit,
modifier: Modifier = Modifier,
) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
BackHandler { onBackClick() }
Scaffold(
modifier = modifier,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(
modifier = Modifier.semantics {
heading()
},
text = state.roomName,
style = ElementTheme.typography.aliasScreenTitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
BackButton(
onClick = onBackClick,
)
},
)
},
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
SingleChoiceSegmentedButtonRow(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
MediaGalleryMode.entries.forEach { mode ->
SegmentedButton(
index = mode.ordinal,
count = MediaGalleryMode.entries.size,
selected = state.mode == mode,
onClick = { state.eventSink(MediaGalleryEvents.ChangeMode(mode)) },
text = stringResource(mode.stringResource),
)
}
}
val pagerState = rememberPagerState(0, 0f) {
MediaGalleryMode.entries.size
}
LaunchedEffect(state.mode) {
pagerState.scrollToPage(state.mode.ordinal)
}
HorizontalPager(
state = pagerState,
userScrollEnabled = false,
) { page ->
val mode = MediaGalleryMode.entries[page]
MediaGalleryPage(
mode = mode,
state = state,
onItemClick = onItemClick,
)
}
}
}
when (val bottomSheetState = state.mediaBottomSheetState) {
MediaBottomSheetState.Hidden -> Unit
is MediaBottomSheetState.MediaDetailsBottomSheetState -> {
MediaDetailsBottomSheet(
state = bottomSheetState,
onViewInTimeline = { eventId ->
state.eventSink(MediaGalleryEvents.ViewInTimeline(eventId))
},
onShare = { eventId ->
state.eventSink(MediaGalleryEvents.Share(eventId))
},
onForward = { eventId ->
state.eventSink(MediaGalleryEvents.Forward(eventId))
},
onDownload = { eventId ->
state.eventSink(MediaGalleryEvents.SaveOnDisk(eventId))
},
onDelete = { eventId ->
state.eventSink(
MediaGalleryEvents.ConfirmDelete(
eventId = eventId,
mediaInfo = bottomSheetState.mediaInfo,
thumbnailSource = bottomSheetState.thumbnailSource,
)
)
},
onDismiss = {
state.eventSink(MediaGalleryEvents.CloseBottomSheet)
},
)
}
is MediaBottomSheetState.MediaDeleteConfirmationState -> {
MediaDeleteConfirmationBottomSheet(
state = bottomSheetState,
onDelete = {
state.eventSink(MediaGalleryEvents.Delete(it))
},
onDismiss = {
state.eventSink(MediaGalleryEvents.CloseBottomSheet)
},
)
}
}
}
@Composable
private fun MediaGalleryPage(
mode: MediaGalleryMode,
state: MediaGalleryState,
onItemClick: (MediaItem.Event) -> Unit,
) {
val groupedMediaItems = state.groupedMediaItems
if (groupedMediaItems.isLoadingItems(mode)) {
// Need to trigger a pagination now if there is only one LoadingIndicator.
val loadingItem = groupedMediaItems.dataOrNull()?.getItems(mode)?.singleOrNull() as? MediaItem.LoadingIndicator
if (loadingItem != null) {
LaunchedEffect(loadingItem.timestamp) {
state.eventSink(MediaGalleryEvents.LoadMore(loadingItem.direction))
}
}
LoadingContent(mode)
} else {
when (groupedMediaItems) {
is AsyncData.Success -> {
when (mode) {
MediaGalleryMode.Images -> MediaGalleryImages(
imagesAndVideos = groupedMediaItems.data.imageAndVideoItems,
eventSink = state.eventSink,
onItemClick = onItemClick,
)
MediaGalleryMode.Files -> MediaGalleryFiles(
files = groupedMediaItems.data.fileItems,
eventSink = state.eventSink,
onItemClick = onItemClick,
)
}
}
is AsyncData.Failure -> {
ErrorContent(
error = groupedMediaItems.error,
)
}
else -> Unit
}
}
}
/**
* Return true when the timeline is not loaded or if it contains only a single loading item.
*/
private fun AsyncData<GroupedMediaItems>.isLoadingItems(mode: MediaGalleryMode): Boolean {
return when (this) {
AsyncData.Uninitialized,
is AsyncData.Loading -> true
is AsyncData.Success -> data.getItems(mode).singleOrNull() is MediaItem.LoadingIndicator
is AsyncData.Failure -> false
}
}
@Composable
private fun MediaGalleryImages(
imagesAndVideos: ImmutableList<MediaItem>,
eventSink: (MediaGalleryEvents) -> Unit,
onItemClick: (MediaItem.Event) -> Unit,
) {
if (imagesAndVideos.isEmpty()) {
EmptyContent(
titleRes = R.string.screen_media_browser_media_empty_state_title,
subtitleRes = R.string.screen_media_browser_media_empty_state_subtitle,
icon = CompoundIcons.Image(),
)
} else {
MediaGalleryImageGrid(
imagesAndVideos = imagesAndVideos,
eventSink = eventSink,
onItemClick = onItemClick,
)
}
}
@Composable
private fun MediaGalleryFiles(
files: ImmutableList<MediaItem>,
eventSink: (MediaGalleryEvents) -> Unit,
onItemClick: (MediaItem.Event) -> Unit,
) {
if (files.isEmpty()) {
EmptyContent(
titleRes = R.string.screen_media_browser_files_empty_state_title,
subtitleRes = R.string.screen_media_browser_files_empty_state_subtitle,
icon = CompoundIcons.Files(),
)
} else {
MediaGalleryFilesList(
files = files,
eventSink = eventSink,
onItemClick = onItemClick,
)
}
}
@Composable
private fun MediaGalleryFilesList(
files: ImmutableList<MediaItem>,
eventSink: (MediaGalleryEvents) -> Unit,
onItemClick: (MediaItem.Event) -> Unit,
) {
val presenterFactories = LocalMediaItemPresenterFactories.current
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(
items = files,
key = { it.id() },
contentType = { it::class.java },
) { item ->
when (item) {
is MediaItem.File -> FileItemView(
modifier = Modifier.animateItem(),
file = item,
onClick = { onItemClick(item) },
onLongClick = {
eventSink(MediaGalleryEvents.OpenInfo(item))
},
)
is MediaItem.Audio -> AudioItemView(
modifier = Modifier.animateItem(),
audio = item,
onClick = { onItemClick(item) },
onLongClick = {
eventSink(MediaGalleryEvents.OpenInfo(item))
},
)
is MediaItem.Voice -> {
val presenter: Presenter<VoiceMessageState> = presenterFactories.rememberPresenter(item)
VoiceItemView(
modifier = Modifier.animateItem(),
state = presenter.present(),
voice = item,
onLongClick = {
eventSink(MediaGalleryEvents.OpenInfo(item))
},
)
}
is MediaItem.DateSeparator -> DateItemView(
modifier = Modifier.animateItem(),
item = item
)
is MediaItem.Image,
is MediaItem.Video -> {
// Should not happen
}
is MediaItem.LoadingIndicator -> LoadingMoreIndicator(
modifier = Modifier.animateItem(),
item = item,
eventSink = eventSink,
)
}
}
}
}
@Composable
private fun MediaGalleryImageGrid(
imagesAndVideos: ImmutableList<MediaItem>,
eventSink: (MediaGalleryEvents) -> Unit,
onItemClick: (MediaItem.Event) -> Unit,
) {
LazyVerticalGrid(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
columns = GridCells.Adaptive(80.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(
items = imagesAndVideos,
span = { item ->
when (item) {
is MediaItem.LoadingIndicator,
is MediaItem.DateSeparator -> GridItemSpan(maxLineSpan)
is MediaItem.Event -> GridItemSpan(1)
}
},
key = { it.id() },
contentType = { it::class.java },
) { item ->
when (item) {
is MediaItem.DateSeparator -> DateItemView(
modifier = Modifier.animateItem(),
item = item,
)
is MediaItem.Audio -> {
// Should not happen
}
is MediaItem.Voice -> {
// Should not happen
}
is MediaItem.File -> {
// Should not happen
}
is MediaItem.Image -> ImageItemView(
modifier = Modifier.animateItem(),
image = item,
onClick = { onItemClick(item) },
onLongClick = {
eventSink(MediaGalleryEvents.OpenInfo(item))
},
)
is MediaItem.Video -> VideoItemView(
modifier = Modifier.animateItem(),
video = item,
onClick = { onItemClick(item) },
onLongClick = {
eventSink(MediaGalleryEvents.OpenInfo(item))
},
)
is MediaItem.LoadingIndicator -> LoadingMoreIndicator(
modifier = Modifier.animateItem(),
item = item,
eventSink = eventSink,
)
}
}
}
}
@Composable
private fun LoadingMoreIndicator(
item: MediaItem.LoadingIndicator,
eventSink: (MediaGalleryEvents) -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
when (item.direction) {
Timeline.PaginationDirection.FORWARDS -> {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(top = 2.dp)
.height(1.dp)
)
}
Timeline.PaginationDirection.BACKWARDS -> {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.padding(vertical = 8.dp)
)
}
}
val latestEventSink by rememberUpdatedState(eventSink)
LaunchedEffect(item.timestamp) {
latestEventSink(MediaGalleryEvents.LoadMore(item.direction))
}
}
}
@Composable
private fun ErrorContent(error: Throwable) {
AsyncFailure(
throwable = error,
onRetry = null,
modifier = Modifier.fillMaxSize(),
)
}
@Composable
private fun EmptyContent(
titleRes: Int,
subtitleRes: Int,
icon: ImageVector,
) {
Box(
modifier = Modifier.fillMaxSize(),
) {
OnboardingBackground()
IconTitleSubtitleMolecule(
modifier = Modifier
.fillMaxWidth()
.padding(top = 44.dp)
.padding(24.dp),
title = stringResource(titleRes),
iconStyle = BigIcon.Style.Default(icon),
subTitle = stringResource(subtitleRes),
)
}
}
@Composable
private fun LoadingContent(
mode: MediaGalleryMode,
) {
Box(
modifier = Modifier.fillMaxSize(),
) {
OnboardingBackground()
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = 48.dp)
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
val res = when (mode) {
MediaGalleryMode.Images -> R.string.screen_media_browser_list_loading_media
MediaGalleryMode.Files -> R.string.screen_media_browser_list_loading_files
}
Text(
text = stringResource(res),
modifier = Modifier.align(Alignment.CenterHorizontally),
)
}
}
}
@PreviewsDayNight
@Composable
internal fun MediaGalleryViewPreview(
@PreviewParameter(MediaGalleryStateProvider::class) state: MediaGalleryState
) = ElementPreview {
CompositionLocalProvider(
LocalMediaItemPresenterFactories provides aFakeMediaItemPresenterFactories(),
) {
MediaGalleryView(
state = state,
onBackClick = {},
onItemClick = {},
)
}
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.di
import io.element.android.libraries.architecture.Presenter
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
/**
* A fake [MediaItemPresenterFactories] for screenshot tests.
*/
fun aFakeMediaItemPresenterFactories() = MediaItemPresenterFactories(
mapOf(
Pair(
MediaItem.Voice::class,
MediaItemPresenterFactory<MediaItem.Voice, VoiceMessageState> { Presenter { aVoiceMessageState() } },
),
)
)
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.di
import androidx.compose.runtime.staticCompositionLocalOf
/**
* Provides a [MediaItemPresenterFactories] to the composition.
*/
val LocalMediaItemPresenterFactories = staticCompositionLocalOf {
MediaItemPresenterFactories(emptyMap())
}
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.di
import dev.zacsweers.metro.MapKey
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import kotlin.reflect.KClass
/**
* Annotation to add a factory of type [MediaItemPresenterFactory] to a
* DI map multi binding keyed with a subclass of [MediaItem.Event].
*/
@Retention(AnnotationRetention.RUNTIME)
@MapKey
annotation class MediaItemEventContentKey(val value: KClass<out MediaItem.Event>)
@@ -0,0 +1,93 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.di
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.Multibinds
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import kotlin.reflect.KClass
/**
* Container that declares the [MediaItemPresenterFactory] map multi binding.
*
* Its sole purpose is to support the case of an empty map multibinding.
*/
@BindingContainer
@ContributesTo(RoomScope::class)
interface MediaItemPresenterFactoriesModule {
@Multibinds
fun multiBindMediaItemPresenterFactories(): @JvmSuppressWildcards Map<KClass<out MediaItem.Event>, MediaItemPresenterFactory<*, *>>
}
/**
* Room level caching layer for the [MediaItemPresenterFactory] instances.
*
* It will cache the presenter instances in the room scope, so that they can be
* reused across recompositions of the gallery items that happen whenever an item
* goes out of the [LazyColumn] viewport.
*/
@SingleIn(RoomScope::class)
@Inject
class MediaItemPresenterFactories(
private val factories: @JvmSuppressWildcards Map<KClass<out MediaItem.Event>, MediaItemPresenterFactory<*, *>>,
) {
private val presenters: MutableMap<MediaItem.Event, Presenter<*>> = mutableMapOf()
/**
* Creates and caches a presenter for the given content.
*
* Will throw if the presenter is not found in the [MediaItemPresenterFactory] map multi binding.
*
* @param C The [MediaItem.Event] subtype handled by this TimelineItem presenter.
* @param S The state type produced by this timeline item presenter.
* @param content The [MediaItem.Event] instance to create a presenter for.
* @param contentClass The class of [content].
* @return An instance of a TimelineItem presenter that will be cached in the room scope.
*/
@Composable
fun <C : MediaItem.Event, S : Any> rememberPresenter(
content: C,
contentClass: KClass<C>,
): Presenter<S> = remember(content) {
presenters[content]?.let {
@Suppress("UNCHECKED_CAST")
it as Presenter<S>
} ?: factories.getValue(contentClass).let {
@Suppress("UNCHECKED_CAST")
(it as MediaItemPresenterFactory<C, S>).create(content).apply {
presenters[content] = this
}
}
}
}
/**
* Creates and caches a presenter for the given content.
*
* Will throw if the presenter is not found in the [MediaItemPresenterFactory] map multi binding.
*
* @param C The [MediaItem.Event] subtype handled by this TimelineItem presenter.
* @param S The state type produced by this timeline item presenter.
* @param content The [MediaItem.Event] instance to create a presenter for.
* @return An instance of a TimelineItem presenter that will be cached in the room scope.
*/
@Composable
inline fun <reified C : MediaItem.Event, S : Any> MediaItemPresenterFactories.rememberPresenter(
content: C
): Presenter<S> = rememberPresenter(
content = content,
contentClass = C::class
)
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.di
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
/**
* A factory for a [Presenter] associated with a timeline item.
*
* Implementations should be annotated with [dev.zacsweers.metro.AssistedFactory] to be created.
*
* @param C The timeline item's [MediaItem.Event] subtype.
* @param S The [Presenter]'s state class.
* @return A [Presenter] that produces a state of type [S] for the given content of type [C].
*/
fun interface MediaItemPresenterFactory<C : MediaItem.Event, S : Any> {
fun create(content: C): Presenter<S>
}
@@ -0,0 +1,149 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.root
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.BackstackWithOverlayBox
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.overlay.Overlay
import io.element.android.libraries.architecture.overlay.operation.hide
import io.element.android.libraries.architecture.overlay.operation.show
import io.element.android.libraries.di.RoomScope
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.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.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)
@AssistedInject
class MediaGalleryFlowNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
) : BaseFlowNode<MediaGalleryFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
overlay = Overlay(
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins,
) {
sealed interface NavTarget : Parcelable {
@Parcelize
data object Root : NavTarget
@Parcelize
data class MediaViewer(
val mode: MediaViewerEntryPoint.MediaViewerMode,
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
) : NavTarget
}
private val callback: MediaGalleryEntryPoint.Callback = callback()
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> {
val callback = object : MediaGalleryNode.Callback {
override fun onBackClick() {
callback.onBackClick()
}
override fun viewInTimeline(eventId: EventId) {
callback.viewInTimeline(eventId)
}
override fun forward(eventId: EventId) {
callback.forward(eventId, fromPinnedEvents = false)
}
override fun showItem(item: MediaItem.Event) {
val mode = when (item) {
is MediaItem.Audio,
is MediaItem.Voice,
is MediaItem.File -> MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios(Timeline.Mode.Media)
is MediaItem.Image,
is MediaItem.Video -> MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos(Timeline.Mode.Media)
}
overlay.show(
NavTarget.MediaViewer(
mode = mode,
eventId = item.eventId(),
mediaInfo = item.mediaInfo(),
mediaSource = item.mediaSource(),
thumbnailSource = item.thumbnailSource(),
)
)
}
}
createNode<MediaGalleryNode>(buildContext = buildContext, plugins = listOf(callback))
}
is NavTarget.MediaViewer -> {
val callback = object : MediaViewerEntryPoint.Callback {
override fun onDone() {
overlay.hide()
}
override fun viewInTimeline(eventId: EventId) {
callback.viewInTimeline(eventId)
}
override fun forwardEvent(eventId: EventId, fromPinnedEvents: Boolean) {
// Need to go to the parent because of the overlay
callback.forward(eventId, fromPinnedEvents)
}
}
mediaViewerEntryPoint.createNode(
parentNode = this,
buildContext = buildContext,
params = MediaViewerEntryPoint.Params(
mode = navTarget.mode,
eventId = navTarget.eventId,
mediaInfo = navTarget.mediaInfo,
mediaSource = navTarget.mediaSource,
thumbnailSource = navTarget.thumbnailSource,
canShowInfo = true,
),
callback = callback,
)
}
}
}
@Composable
override fun View(modifier: Modifier) {
BackstackWithOverlayBox(modifier)
}
}
@@ -0,0 +1,135 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.core.extensions.withBrackets
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview
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.model.MediaItem
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun AudioItemView(
audio: MediaItem.Audio,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Spacer(modifier = Modifier.height(20.dp))
FilenameRow(
audio = audio,
onClick = onClick,
onLongClick = onLongClick,
)
val caption = audio.mediaInfo.caption
if (caption != null) {
CaptionView(caption)
} else {
Spacer(modifier = Modifier.height(20.dp))
}
HorizontalDivider()
}
}
@Composable
private fun FilenameRow(
audio: MediaItem.Audio,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(
color = ElementTheme.colors.bgSubtleSecondary,
shape = RoundedCornerShape(12.dp),
)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
)
.onKeyboardContextMenuAction(onLongClick)
.fillMaxWidth()
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier
.background(
color = ElementTheme.colors.bgActionSecondaryRest,
shape = CircleShape,
)
.size(32.dp)
.padding(6.dp),
imageVector = CompoundIcons.Audio(),
contentDescription = null,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = audio.mediaInfo.filename,
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val formattedSize = audio.mediaInfo.formattedFileSize
if (formattedSize.isNotEmpty()) {
Text(
text = formattedSize.withBrackets(),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun AudioItemViewPreview(
@PreviewParameter(MediaItemAudioProvider::class) audio: MediaItem.Audio,
) = ElementPreview {
AudioItemView(
audio = audio,
onClick = {},
onLongClick = {},
)
}
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.ui
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun CaptionView(
caption: String,
modifier: Modifier = Modifier,
) {
Text(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
text = caption,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
}
@@ -0,0 +1,51 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.ui
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
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.model.MediaItem
@Composable
fun DateItemView(
item: MediaItem.DateSeparator,
modifier: Modifier = Modifier,
) {
Text(
modifier = modifier
.fillMaxWidth()
.padding(12.dp)
.semantics {
heading()
},
text = item.formattedDate,
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
}
@PreviewsDayNight
@Composable
internal fun DateItemViewPreview(
@PreviewParameter(MediaItemDateSeparatorProvider::class) date: MediaItem.DateSeparator,
) = ElementPreview {
DateItemView(date)
}
@@ -0,0 +1,135 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.core.extensions.withBrackets
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview
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.model.MediaItem
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun FileItemView(
file: MediaItem.File,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Spacer(modifier = Modifier.height(20.dp))
FilenameRow(
file = file,
onClick = onClick,
onLongClick = onLongClick,
)
val caption = file.mediaInfo.caption
if (caption != null) {
CaptionView(caption)
} else {
Spacer(modifier = Modifier.height(20.dp))
}
HorizontalDivider()
}
}
@Composable
private fun FilenameRow(
file: MediaItem.File,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(
color = ElementTheme.colors.bgSubtleSecondary,
shape = RoundedCornerShape(12.dp),
)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
)
.onKeyboardContextMenuAction(onLongClick)
.fillMaxWidth()
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier
.background(
color = ElementTheme.colors.bgActionSecondaryRest,
shape = CircleShape,
)
.size(32.dp)
.padding(6.dp),
imageVector = CompoundIcons.Attachment(),
contentDescription = null,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = file.mediaInfo.filename,
modifier = Modifier.weight(1f),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val formattedSize = file.mediaInfo.formattedFileSize
if (formattedSize.isNotEmpty()) {
Text(
text = formattedSize.withBrackets(),
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun FileItemViewPreview(
@PreviewParameter(MediaItemFileProvider::class) file: MediaItem.File,
) = ElementPreview {
FileItemView(
file = file,
onClick = {},
onLongClick = {},
)
}
@@ -0,0 +1,74 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun ImageItemView(
image: MediaItem.Image,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.aspectRatio(1f)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
)
.onKeyboardContextMenuAction(onLongClick),
) {
var isLoaded by remember { mutableStateOf(false) }
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
model = image.thumbnailMediaRequestData,
contentScale = ContentScale.Crop,
alignment = Alignment.Center,
contentDescription = null,
onState = { isLoaded = it is AsyncImagePainter.State.Success },
)
}
}
@PreviewsDayNight
@Composable
internal fun ImageItemViewPreview() = ElementPreview {
ImageItemView(
image = aMediaItemImage(),
onClick = {},
onLongClick = {},
)
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.ui
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.core.preview.loremIpsum
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>
get() = sequenceOf(
aMediaItemAudio(),
aMediaItemAudio(
filename = "A long filename that should be truncated.mp3",
caption = "A caption",
),
aMediaItemAudio(
caption = loremIpsum,
),
)
}
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.ui
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
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>
get() = sequenceOf(
aMediaItemDateSeparator(),
aMediaItemDateSeparator(formattedDate = "A long date that should be truncated"),
)
}
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.ui
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.core.preview.loremIpsum
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>
get() = sequenceOf(
aMediaItemFile(),
aMediaItemFile(
filename = "A long filename that should be truncated.jpg",
caption = "A caption",
),
aMediaItemFile(
caption = loremIpsum,
),
)
}
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.ui
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
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>
get() = sequenceOf(
aMediaItemVideo(),
aMediaItemVideo(
duration = null,
),
)
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.ui
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.core.preview.loremIpsum
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>
get() = sequenceOf(
aMediaItemVoice(),
aMediaItemVoice(
filename = "A long filename that should be truncated.ogg",
caption = "A caption",
),
aMediaItemVoice(
caption = loremIpsum,
),
aMediaItemVoice(
waveform = emptyList(),
),
)
}
@@ -0,0 +1,125 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
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.model.MediaItem
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun VideoItemView(
video: MediaItem.Video,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.aspectRatio(1f)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
)
.onKeyboardContextMenuAction(onLongClick),
) {
var isLoaded by remember { mutableStateOf(false) }
AsyncImage(
modifier = Modifier
.fillMaxWidth()
.then(if (isLoaded) Modifier.background(Color.White) else Modifier),
model = video.thumbnailMediaRequestData,
contentScale = ContentScale.Crop,
alignment = Alignment.Center,
contentDescription = null,
onState = { isLoaded = it is AsyncImagePainter.State.Success },
)
VideoInfoRow(
video = video,
modifier = Modifier.align(Alignment.BottomStart)
)
}
}
@Composable
private fun VideoInfoRow(
video: MediaItem.Video,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(
ElementTheme.colors.bgCanvasDefault.copy(alpha = 0f),
ElementTheme.colors.bgCanvasDefault,
)
)
)
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier.size(20.dp),
imageVector = CompoundIcons.VideoCallSolid(),
contentDescription = null
)
video.mediaInfo.duration?.let { duration ->
Spacer(Modifier.weight(1f))
Text(
text = duration,
style = ElementTheme.typography.fontBodySmMedium,
color = ElementTheme.colors.textPrimary,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun VideoItemViewPreview(
@PreviewParameter(MediaItemVideoProvider::class) video: MediaItem.Video,
) = ElementPreview {
VideoItemView(
video = video,
onClick = {},
onLongClick = {},
)
}
@@ -0,0 +1,286 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
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.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
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
import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider
import io.element.android.libraries.voiceplayer.api.aVoiceMessageState
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
@Composable
fun VoiceItemView(
state: VoiceMessageState,
voice: MediaItem.Voice,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Spacer(modifier = Modifier.height(20.dp))
VoiceInfoRow(
state = state,
voice = voice,
onLongClick = onLongClick,
)
val caption = voice.mediaInfo.caption
if (caption != null) {
CaptionView(caption)
} else {
Spacer(modifier = Modifier.height(16.dp))
}
HorizontalDivider()
}
}
@Composable
private fun VoiceInfoRow(
state: VoiceMessageState,
voice: MediaItem.Voice,
onLongClick: () -> Unit,
) {
fun playPause() {
state.eventSink(VoiceMessageEvents.PlayPause)
}
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(
color = ElementTheme.colors.bgSubtleSecondary,
shape = RoundedCornerShape(12.dp),
)
.combinedClickable(
onClick = {},
onLongClick = onLongClick,
onLongClickLabel = stringResource(CommonStrings.action_open_context_menu),
)
.onKeyboardContextMenuAction(onLongClick)
.fillMaxWidth()
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
when (state.button) {
VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause)
VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause)
VoiceMessageState.Button.Downloading -> ProgressButton()
VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause)
VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false)
}
Spacer(Modifier.width(8.dp))
Text(
text = if (state.progress > 0f) state.time else voice.mediaInfo.duration ?: state.time,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodyMdMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.width(8.dp))
WaveformPlaybackView(
modifier = Modifier
.weight(1f)
.height(34.dp),
showCursor = state.showCursor,
playbackProgress = state.progress,
waveform = voice.mediaInfo.waveform.orEmpty().toImmutableList(),
onSeek = {
state.eventSink(VoiceMessageEvents.Seek(it))
},
seekEnabled = true,
)
}
}
/**
* Progress button is shown when the voice message is being downloaded.
*
* The progress indicator is optimistic and displays a pause button (which
* indicates the audio is playing) for 2 seconds before revealing the
* actual progress indicator.
*/
@Composable
private fun ProgressButton(
displayImmediately: Boolean = false,
) {
var canDisplay by remember { mutableStateOf(displayImmediately) }
LaunchedEffect(Unit) {
delay(2000L)
canDisplay = true
}
CustomIconButton(
onClick = {},
enabled = false,
) {
if (canDisplay) {
CircularProgressIndicator(
modifier = Modifier
.padding(2.dp)
.size(16.dp),
color = ElementTheme.colors.iconSecondary,
strokeWidth = 2.dp,
)
} else {
ControlIcon(
imageVector = CompoundIcons.PauseSolid(),
contentDescription = stringResource(id = CommonStrings.a11y_pause),
)
}
}
}
@Composable
private fun PlayButton(
onClick: () -> Unit,
enabled: Boolean = true,
) {
CustomIconButton(
onClick = onClick,
enabled = enabled,
) {
ControlIcon(
imageVector = CompoundIcons.PlaySolid(),
contentDescription = stringResource(id = CommonStrings.a11y_play),
)
}
}
@Composable
private fun PauseButton(
onClick: () -> Unit,
) {
CustomIconButton(
onClick = onClick,
) {
ControlIcon(
imageVector = CompoundIcons.PauseSolid(),
contentDescription = stringResource(id = CommonStrings.a11y_pause),
)
}
}
@Composable
private fun RetryButton(
onClick: () -> Unit,
) {
CustomIconButton(
onClick = onClick,
) {
ControlIcon(
imageVector = CompoundIcons.Restart(),
contentDescription = stringResource(id = CommonStrings.action_retry),
)
}
}
@Composable
private fun ControlIcon(
imageVector: ImageVector,
contentDescription: String?,
) {
Icon(
modifier = Modifier.padding(vertical = 10.dp),
imageVector = imageVector,
contentDescription = contentDescription,
)
}
@Composable
private fun CustomIconButton(
onClick: () -> Unit,
enabled: Boolean = true,
content: @Composable () -> Unit,
) {
IconButton(
onClick = onClick,
modifier = Modifier
.background(color = ElementTheme.colors.bgCanvasDefault, shape = CircleShape)
.border(
width = 1.dp,
color = ElementTheme.colors.borderInteractiveSecondary,
shape = CircleShape,
)
.size(36.dp),
enabled = enabled,
colors = IconButtonDefaults.iconButtonColors(
contentColor = ElementTheme.colors.iconSecondary,
disabledContentColor = ElementTheme.colors.iconDisabled,
),
content = content,
)
}
@PreviewsDayNight
@Composable
internal fun VoiceItemViewPreview(
@PreviewParameter(MediaItemVoiceProvider::class) voice: MediaItem.Voice,
) = ElementPreview {
VoiceItemView(
state = aVoiceMessageState(),
voice = voice,
onLongClick = {},
)
}
@PreviewsDayNight
@Composable
internal fun VoiceItemViewPlayPreview(
@PreviewParameter(VoiceMessageStateProvider::class) state: VoiceMessageState,
) = ElementPreview {
VoiceItemView(
state = state,
voice = aMediaItemVoice(),
onLongClick = {},
)
}
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.voice
import androidx.compose.runtime.Composable
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.Binds
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.IntoMap
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.RoomScope
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
@BindingContainer
@ContributesTo(RoomScope::class)
interface VoiceMessagePresenterModule {
@Binds
@IntoMap
@MediaItemEventContentKey(MediaItem.Voice::class)
fun bindVoiceMessagePresenterFactory(factory: VoiceMessagePresenter.Factory): MediaItemPresenterFactory<*, *>
}
@AssistedInject
class VoiceMessagePresenter(
voiceMessagePresenterFactory: VoiceMessagePresenterFactory,
@Assisted private val item: MediaItem.Voice,
) : Presenter<VoiceMessageState> {
@AssistedFactory
fun interface Factory : MediaItemPresenterFactory<MediaItem.Voice, VoiceMessageState> {
override fun create(content: MediaItem.Voice): VoiceMessagePresenter
}
private val presenter = voiceMessagePresenterFactory.createVoiceMessagePresenter(
eventId = item.eventId,
mediaSource = item.mediaSource,
mimeType = item.mediaInfo.mimeType,
filename = item.mediaInfo.filename,
// TODO Get the duration for the fallback?
duration = Duration.ZERO,
)
@Composable
override fun present(): VoiceMessageState {
return presenter.present()
}
}
@@ -0,0 +1,206 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.local
import android.Manifest
import android.app.Activity
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.FileProvider
import androidx.core.content.PermissionChecker
import androidx.core.net.toFile
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.androidutils.system.startInstallFromSourceIntent
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
@ContributesBinding(AppScope::class)
class AndroidLocalMediaActions(
@ApplicationContext private val context: Context,
private val coroutineDispatchers: CoroutineDispatchers,
private val buildMeta: BuildMeta,
) : LocalMediaActions {
private var activityContext: Context? = null
private var apkInstallLauncher: ManagedActivityResultLauncher<Intent, ActivityResult>? = null
private var pendingMedia: LocalMedia? = null
@Composable
override fun Configure() {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
apkInstallLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(),
) { activityResult ->
if (activityResult.resultCode == Activity.RESULT_OK) {
pendingMedia?.let {
coroutineScope.launch {
openFile(it)
}
}
} else {
// User cancelled
}
pendingMedia = null
}
return DisposableEffect(Unit) {
activityContext = context
onDispose {
activityContext = null
}
}
}
override suspend fun saveOnDisk(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) {
require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE)
runCatchingExceptions {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
saveOnDiskUsingMediaStore(localMedia)
} else {
saveOnDiskUsingExternalStorageApi(localMedia)
}
}.onSuccess {
Timber.v("Save on disk succeed")
}.onFailure {
Timber.e(it, "Save on disk failed")
}
}
override suspend fun share(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) {
require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE)
runCatchingExceptions {
val shareableUri = localMedia.toShareableUri()
val shareMediaIntent = Intent(Intent.ACTION_SEND)
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.putExtra(Intent.EXTRA_STREAM, shareableUri)
.setTypeAndNormalize(localMedia.info.mimeType)
withContext(coroutineDispatchers.main) {
val intent = Intent.createChooser(shareMediaIntent, null)
activityContext!!.startActivity(intent)
}
}.onSuccess {
Timber.v("Share media succeed")
}.onFailure {
Timber.e(it, "Share media failed")
}
}
override suspend fun open(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) {
require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE)
runCatchingExceptions {
when (localMedia.info.mimeType) {
MimeTypes.Apk -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (PermissionChecker.checkPermission(
context,
Manifest.permission.REQUEST_INSTALL_PACKAGES,
-1,
-1,
context.packageName
) == PermissionChecker.PERMISSION_GRANTED &&
activityContext?.packageManager?.canRequestPackageInstalls() == false) {
pendingMedia = localMedia
activityContext?.startInstallFromSourceIntent(apkInstallLauncher!!).let { }
} else {
openFile(localMedia)
}
} else {
openFile(localMedia)
}
}
else -> openFile(localMedia)
}
}.onSuccess {
Timber.v("Open media succeed")
}.onFailure {
Timber.e(it, "Open media failed")
}
}
private suspend fun openFile(localMedia: LocalMedia) {
val openMediaIntent = Intent(Intent.ACTION_VIEW)
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.setDataAndType(localMedia.toShareableUri(), localMedia.info.mimeType)
withContext(coroutineDispatchers.main) {
activityContext?.startActivity(openMediaIntent)
}
}
private fun LocalMedia.toShareableUri(): Uri {
val mediaAsFile = this.toFile()
val authority = "${buildMeta.applicationId}.fileprovider"
return FileProvider.getUriForFile(context, authority, mediaAsFile).normalizeScheme()
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun saveOnDiskUsingMediaStore(localMedia: LocalMedia) {
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, localMedia.info.filename)
put(MediaStore.MediaColumns.MIME_TYPE, localMedia.info.mimeType)
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
val resolver = context.contentResolver
val outputUri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
if (outputUri != null) {
localMedia.openStream()?.use { input ->
resolver.openOutputStream(outputUri).use { output ->
input.copyTo(output!!, DEFAULT_BUFFER_SIZE)
}
}
}
}
private fun saveOnDiskUsingExternalStorageApi(localMedia: LocalMedia) {
val target = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
localMedia.info.filename
)
localMedia.openStream()?.use { input ->
FileOutputStream(target).use { output ->
input.copyTo(output)
}
}
}
private fun LocalMedia.openStream(): InputStream? {
return context.contentResolver.openInputStream(uri)
}
/**
* Tries to extract a file from the uri.
*/
private fun LocalMedia.toFile(): File {
return uri.toFile()
}
}
@@ -0,0 +1,112 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.local
import android.content.Context
import android.net.Uri
import androidx.core.net.toUri
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.androidutils.file.getFileName
import io.element.android.libraries.androidutils.file.getFileSize
import io.element.android.libraries.androidutils.file.getMimeType
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.matrix.api.media.toFile
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
@ContributesBinding(AppScope::class)
class AndroidLocalMediaFactory(
@ApplicationContext private val context: Context,
private val fileSizeFormatter: FileSizeFormatter,
private val fileExtensionExtractor: FileExtensionExtractor,
) : LocalMediaFactory {
override fun createFromMediaFile(
mediaFile: MediaFile,
mediaInfo: MediaInfo,
): LocalMedia = createFromUri(
uri = mediaFile.toFile().toUri(),
mimeType = mediaInfo.mimeType,
name = mediaInfo.filename,
caption = mediaInfo.caption,
formattedFileSize = mediaInfo.formattedFileSize,
senderId = mediaInfo.senderId,
senderName = mediaInfo.senderName,
senderAvatar = mediaInfo.senderAvatar,
dateSent = mediaInfo.dateSent,
dateSentFull = mediaInfo.dateSentFull,
waveform = mediaInfo.waveform,
duration = mediaInfo.duration,
)
override fun createFromUri(
uri: Uri,
mimeType: String?,
name: String?,
formattedFileSize: String?
): LocalMedia = createFromUri(
uri = uri,
mimeType = mimeType,
name = name,
caption = null,
formattedFileSize = formattedFileSize,
senderId = null,
senderName = null,
senderAvatar = null,
dateSent = null,
dateSentFull = null,
waveform = null,
duration = null,
)
private fun createFromUri(
uri: Uri,
mimeType: String?,
name: String?,
caption: String?,
formattedFileSize: String?,
senderId: UserId?,
senderName: String?,
senderAvatar: String?,
dateSent: String?,
dateSentFull: String?,
waveform: List<Float>?,
duration: String?,
): LocalMedia {
val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream
val fileName = name ?: context.getFileName(uri) ?: ""
val fileSize = context.getFileSize(uri)
val calculatedFormattedFileSize = formattedFileSize ?: fileSizeFormatter.format(fileSize)
val fileExtension = fileExtensionExtractor.extractFromName(fileName)
return LocalMedia(
uri = uri,
info = MediaInfo(
mimeType = resolvedMimeType,
filename = fileName,
fileSize = fileSize,
caption = caption,
formattedFileSize = calculatedFormattedFileSize,
fileExtension = fileExtension,
senderId = senderId,
senderName = senderName,
senderAvatar = senderAvatar,
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = waveform,
duration = duration,
)
)
}
}
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.local
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.viewfolder.api.TextFileViewer
import io.element.android.libraries.audio.api.AudioFocus
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
import me.saket.telephoto.zoomable.OverzoomEffect
import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.rememberZoomableState
@ContributesBinding(AppScope::class)
class DefaultLocalMediaRenderer(
private val textFileViewer: TextFileViewer,
private val audioFocus: AudioFocus,
) : LocalMediaRenderer {
@Composable
override fun Render(localMedia: LocalMedia) {
val localMediaViewState = rememberLocalMediaViewState(
zoomableState = rememberZoomableState(
zoomSpec = ZoomSpec(maxZoomFactor = 4f, overzoomEffect = OverzoomEffect.NoLimits)
)
)
LocalMediaView(
modifier = Modifier.fillMaxSize(),
bottomPaddingInPixels = 0,
localMedia = localMedia,
localMediaViewState = localMediaViewState,
textFileViewer = textFileViewer,
audioFocus = audioFocus,
onClick = {},
)
}
}
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.local
import androidx.compose.runtime.Composable
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
interface LocalMediaActions {
@Composable
fun Configure()
/**
* Will save the current media to the Downloads directory.
* The [LocalMedia.uri] needs to have a file scheme.
*/
suspend fun saveOnDisk(localMedia: LocalMedia): Result<Unit>
/**
* Will try to find a suitable application to share the media with.
* The [LocalMedia.uri] needs to have a file scheme.
*/
suspend fun share(localMedia: LocalMedia): Result<Unit>
/**
* Will try to find a suitable application to open the media with.
* The [LocalMedia.uri] needs to have a file scheme.
*/
suspend fun open(localMedia: LocalMedia): Result<Unit>
}
@@ -0,0 +1,86 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.local
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.features.viewfolder.api.TextFileViewer
import io.element.android.libraries.audio.api.AudioFocus
import io.element.android.libraries.core.mimetype.MimeTypes
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.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.local.audio.MediaAudioView
import io.element.android.libraries.mediaviewer.impl.local.file.MediaFileView
import io.element.android.libraries.mediaviewer.impl.local.image.MediaImageView
import io.element.android.libraries.mediaviewer.impl.local.pdf.MediaPdfView
import io.element.android.libraries.mediaviewer.impl.local.txt.TextFileView
import io.element.android.libraries.mediaviewer.impl.local.video.MediaVideoView
@Composable
fun LocalMediaView(
localMedia: LocalMedia?,
bottomPaddingInPixels: Int,
audioFocus: AudioFocus?,
onClick: () -> Unit,
textFileViewer: TextFileViewer,
modifier: Modifier = Modifier,
isDisplayed: Boolean = true,
isUserSelected: Boolean = false,
localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(),
mediaInfo: MediaInfo? = localMedia?.info,
) {
val mimeType = mediaInfo?.mimeType
when {
mimeType.isMimeTypeImage() -> MediaImageView(
localMediaViewState = localMediaViewState,
localMedia = localMedia,
modifier = modifier,
onClick = onClick,
)
mimeType.isMimeTypeVideo() -> MediaVideoView(
isDisplayed = isDisplayed,
localMediaViewState = localMediaViewState,
bottomPaddingInPixels = bottomPaddingInPixels,
localMedia = localMedia,
autoplay = isUserSelected,
audioFocus = audioFocus,
modifier = modifier,
)
mimeType == MimeTypes.PlainText -> TextFileView(
localMedia = localMedia,
textFileViewer = textFileViewer,
modifier = modifier,
)
mimeType == MimeTypes.Pdf -> MediaPdfView(
localMediaViewState = localMediaViewState,
localMedia = localMedia,
modifier = modifier,
onClick = onClick,
)
mimeType.isMimeTypeAudio() -> MediaAudioView(
isDisplayed = isDisplayed,
localMediaViewState = localMediaViewState,
bottomPaddingInPixels = bottomPaddingInPixels,
localMedia = localMedia,
info = mediaInfo,
audioFocus = audioFocus,
modifier = modifier,
)
else -> MediaFileView(
localMediaViewState = localMediaViewState,
uri = localMedia?.uri,
info = mediaInfo,
modifier = modifier,
onClick = onClick,
)
}
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.local
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import me.saket.telephoto.zoomable.ZoomableState
import me.saket.telephoto.zoomable.rememberZoomableState
@Stable
class LocalMediaViewState internal constructor(
val zoomableState: ZoomableState,
) {
var isReady: Boolean by mutableStateOf(false)
var playableState: PlayableState by mutableStateOf(PlayableState.NotPlayable)
}
@Immutable
sealed interface PlayableState {
data object NotPlayable : PlayableState
data class Playable(
val isShowingControls: Boolean,
) : PlayableState
}
@Composable
fun rememberLocalMediaViewState(zoomableState: ZoomableState = rememberZoomableState()): LocalMediaViewState {
return remember(zoomableState) {
LocalMediaViewState(zoomableState)
}
}
@@ -0,0 +1,381 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.local.audio
import android.annotation.SuppressLint
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import android.widget.FrameLayout
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.isVisible
import androidx.lifecycle.Lifecycle
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.audio.api.AudioFocus
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaViewState
import io.element.android.libraries.mediaviewer.impl.local.PlayableState
import io.element.android.libraries.mediaviewer.impl.local.player.MediaPlayerControllerState
import io.element.android.libraries.mediaviewer.impl.local.player.MediaPlayerControllerView
import io.element.android.libraries.mediaviewer.impl.local.player.rememberExoPlayer
import io.element.android.libraries.mediaviewer.impl.local.player.seekToEnsurePlaying
import io.element.android.libraries.mediaviewer.impl.local.player.togglePlay
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
@SuppressLint("UnsafeOptInUsageError")
@Composable
fun MediaAudioView(
localMediaViewState: LocalMediaViewState,
bottomPaddingInPixels: Int,
localMedia: LocalMedia?,
info: MediaInfo?,
audioFocus: AudioFocus?,
modifier: Modifier = Modifier,
isDisplayed: Boolean = true,
) {
val exoPlayer = rememberExoPlayer()
ExoPlayerMediaAudioView(
isDisplayed = isDisplayed,
localMediaViewState = localMediaViewState,
bottomPaddingInPixels = bottomPaddingInPixels,
exoPlayer = exoPlayer,
localMedia = localMedia,
info = info,
audioFocus = audioFocus,
modifier = modifier,
)
}
@SuppressLint("UnsafeOptInUsageError")
@Composable
private fun ExoPlayerMediaAudioView(
isDisplayed: Boolean,
localMediaViewState: LocalMediaViewState,
bottomPaddingInPixels: Int,
exoPlayer: ExoPlayer,
localMedia: LocalMedia?,
info: MediaInfo?,
audioFocus: AudioFocus?,
modifier: Modifier = Modifier,
) {
var mediaPlayerControllerState: MediaPlayerControllerState by remember {
mutableStateOf(
MediaPlayerControllerState(
isVisible = true,
isPlaying = false,
isReady = false,
progressInMillis = 0,
durationInMillis = 0,
canMute = false,
isMuted = false,
)
)
}
var metadata: MediaMetadata? by remember {
mutableStateOf(null)
}
val playableState: PlayableState.Playable by remember {
derivedStateOf {
PlayableState.Playable(
isShowingControls = mediaPlayerControllerState.isVisible,
)
}
}
localMediaViewState.playableState = playableState
val playerListener = remember {
object : Player.Listener {
override fun onRenderedFirstFrame() {
localMediaViewState.isReady = true
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
isPlaying = isPlaying,
)
}
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) {
exoPlayer.duration.takeIf { it >= 0 }
?.let {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
durationInMillis = it,
)
}
}
}
override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
metadata = mediaMetadata
}
}
}
LaunchedEffect(exoPlayer.isPlaying) {
if (exoPlayer.isPlaying) {
while (true) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
progressInMillis = exoPlayer.currentPosition,
)
delay(200)
}
} else {
// Ensure we render the final state
mediaPlayerControllerState = mediaPlayerControllerState.copy(
progressInMillis = exoPlayer.currentPosition,
)
}
}
LaunchedEffect(isDisplayed) {
// If not displayed, make sure to pause the audio
if (!isDisplayed) {
exoPlayer.pause()
}
}
if (localMedia?.uri != null) {
LaunchedEffect(localMedia.uri) {
val mediaItem = MediaItem.fromUri(localMedia.uri)
exoPlayer.setMediaItem(mediaItem)
}
} else {
exoPlayer.setMediaItems(emptyList())
}
val context = LocalContext.current
val waveform = info?.waveform
Box(
modifier = modifier
.fillMaxSize()
.background(ElementTheme.colors.bgSubtlePrimary),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
if (LocalInspectionMode.current) {
Text(
modifier = Modifier
.padding(16.dp)
.width(240.dp),
text = "An audio Player may render an image here if the audio file contains some artwork.",
textAlign = TextAlign.Center,
color = ElementTheme.colors.textPrimary,
)
} else {
AndroidView(
modifier = Modifier
.clip(shape = RoundedCornerShape(12.dp))
.clipToBounds()
.width(240.dp),
factory = {
PlayerView(context).apply {
player = exoPlayer
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
useController = false
}
},
update = { playerView ->
playerView.isVisible = metadata.hasArtwork()
},
onRelease = { playerView ->
playerView.player = null
},
)
}
if (waveform != null) {
WaveformPlaybackView(
modifier = Modifier
.height(48.dp),
playbackProgress = mediaPlayerControllerState.progressAsFloat,
showCursor = true,
waveform = waveform.toImmutableList(),
onSeek = {
exoPlayer.seekToEnsurePlaying((it * exoPlayer.duration).toLong())
},
seekEnabled = true,
)
} else {
if (!metadata.hasArtwork()) {
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.background(ElementTheme.colors.iconPrimary),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = CompoundIcons.Audio(),
contentDescription = null,
tint = ElementTheme.colors.iconOnSolidPrimary,
modifier = Modifier
.size(32.dp),
)
}
}
}
}
if (waveform == null) {
// Display the info below the player
AudioInfoView(
modifier = Modifier.padding(horizontal = 16.dp),
info = info,
metadata = metadata,
)
}
}
MediaPlayerControllerView(
state = mediaPlayerControllerState,
onTogglePlay = {
exoPlayer.togglePlay()
},
onSeekChange = {
exoPlayer.seekToEnsurePlaying(it.toLong())
},
onToggleMute = {
// Cannot happen for audio files
},
audioFocus = audioFocus,
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(bottom = bottomPaddingInPixels.toDp()),
)
}
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_CREATE -> exoPlayer.addListener(playerListener)
Lifecycle.Event.ON_RESUME -> exoPlayer.prepare()
Lifecycle.Event.ON_PAUSE -> exoPlayer.pause()
Lifecycle.Event.ON_DESTROY -> {
exoPlayer.release()
exoPlayer.removeListener(playerListener)
}
else -> Unit
}
}
}
@Composable
private fun AudioInfoView(
info: MediaInfo?,
metadata: MediaMetadata?,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Render the info about the file and from the metadata
val metaDataInfo = metadata.buildInfo()
if (metaDataInfo.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = metaDataInfo,
style = ElementTheme.typography.fontBodyMdRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = ElementTheme.colors.textPrimary
)
}
if (info != null) {
Spacer(modifier = Modifier.height(24.dp))
Text(
text = info.filename,
maxLines = 2,
style = ElementTheme.typography.fontBodyLgRegular,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
color = ElementTheme.colors.textPrimary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formatFileExtensionAndSize(info.fileExtension, info.formattedFileSize),
style = ElementTheme.typography.fontBodyMdRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = ElementTheme.colors.textPrimary
)
}
}
}
@PreviewsDayNight
@Composable
internal fun MediaAudioViewPreview(
@PreviewParameter(MediaInfoAudioProvider::class) info: MediaInfo
) = ElementPreview {
MediaAudioView(
modifier = Modifier.fillMaxSize(),
bottomPaddingInPixels = 0,
localMediaViewState = rememberLocalMediaViewState(),
info = info,
audioFocus = null,
localMedia = null,
)
}
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.local.audio
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.components.media.WaveFormSamples
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo
open class MediaInfoAudioProvider : PreviewParameterProvider<MediaInfo> {
override val values: Sequence<MediaInfo>
get() = sequenceOf(
anAudioMediaInfo(),
anAudioMediaInfo(
waveForm = WaveFormSamples.realisticWaveForm,
),
)
}
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.local.audio
import androidx.media3.common.MediaMetadata
fun MediaMetadata?.hasArtwork(): Boolean {
return this?.artworkData != null || this?.artworkUri != null
}
fun MediaMetadata?.buildInfo(): String {
this ?: return ""
return buildString {
if (artist != null) {
append(artist)
}
if (title != null) {
if (isNotEmpty()) {
append(" - ")
}
append(title)
}
if (recordingYear != null) {
if (isNotEmpty()) {
append(" - ")
}
append(recordingYear)
}
}
}
@@ -0,0 +1,120 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.local.file
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
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.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaViewState
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
@Composable
fun MediaFileView(
localMediaViewState: LocalMediaViewState,
uri: Uri?,
info: MediaInfo?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val isAudio = info?.mimeType.isMimeTypeAudio().orFalse()
localMediaViewState.isReady = uri != null
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = modifier
.padding(horizontal = 8.dp)
.clickable(
onClick = onClick,
interactionSource = interactionSource,
indication = null
),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.background(ElementTheme.colors.iconPrimary),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (isAudio) CompoundIcons.Audio() else CompoundIcons.Attachment(),
contentDescription = null,
tint = ElementTheme.colors.iconOnSolidPrimary,
modifier = Modifier
.size(32.dp)
.rotate(if (isAudio) 0f else -45f),
)
}
if (info != null) {
Spacer(modifier = Modifier.height(20.dp))
Text(
text = info.filename,
maxLines = 2,
style = ElementTheme.typography.fontBodyLgRegular,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
color = ElementTheme.colors.textPrimary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formatFileExtensionAndSize(info.fileExtension, info.formattedFileSize),
style = ElementTheme.typography.fontBodyMdRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = ElementTheme.colors.textPrimary
)
}
}
}
}
@PreviewsDayNight
@Composable
internal fun MediaFileViewPreview(
@PreviewParameter(MediaInfoFileProvider::class) info: MediaInfo
) = ElementPreview {
MediaFileView(
modifier = Modifier.fillMaxSize(),
localMediaViewState = rememberLocalMediaViewState(),
uri = null,
info = info,
onClick = {},
)
}
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.local.file
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
open class MediaInfoFileProvider : PreviewParameterProvider<MediaInfo> {
override val values: Sequence<MediaInfo>
get() = sequenceOf(
aPdfMediaInfo(),
)
}
@@ -0,0 +1,65 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.local.image
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaViewState
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
import io.element.android.libraries.ui.strings.CommonStrings
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
import me.saket.telephoto.zoomable.rememberZoomableImageState
@Composable
fun MediaImageView(
localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
if (LocalInspectionMode.current) {
Image(
painter = painterResource(id = CommonDrawables.sample_background),
modifier = modifier,
contentDescription = null,
)
} else {
val zoomableImageState = rememberZoomableImageState(localMediaViewState.zoomableState)
localMediaViewState.isReady = zoomableImageState.isImageDisplayed
ZoomableAsyncImage(
modifier = modifier,
state = zoomableImageState,
model = localMedia?.uri,
contentDescription = stringResource(id = CommonStrings.common_image),
contentScale = ContentScale.Fit,
onClick = { onClick() }
)
}
}
@PreviewsDayNight
@Composable
internal fun MediaImageViewPreview() = ElementPreview {
MediaImageView(
modifier = Modifier.fillMaxSize(),
localMediaViewState = rememberLocalMediaViewState(),
localMedia = null,
onClick = {},
)
}
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.local.pdf
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaViewState
@Composable
fun MediaPdfView(
localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val pdfViewerState = rememberPdfViewerState(
model = localMedia?.uri,
zoomableState = localMediaViewState.zoomableState,
)
localMediaViewState.isReady = pdfViewerState.isLoaded
PdfViewer(
pdfViewerState = pdfViewerState,
onClick = onClick,
modifier = modifier,
)
}
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.local.pdf
import android.content.Context
import android.net.Uri
import android.os.ParcelFileDescriptor
import io.element.android.libraries.core.extensions.runCatchingExceptions
import java.io.File
class ParcelFileDescriptorFactory(private val context: Context) {
fun create(model: Any?) = runCatchingExceptions {
when (model) {
is File -> ParcelFileDescriptor.open(model, ParcelFileDescriptor.MODE_READ_ONLY)
is Uri -> context.contentResolver.openFileDescriptor(model, "r")!!
else -> error(RuntimeException("Can't handle this model"))
}
}
}
@@ -0,0 +1,101 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.local.pdf
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.pdf.PdfRenderer
import androidx.compose.runtime.Stable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
@Stable
class PdfPage(
maxWidth: Int,
val pageIndex: Int,
private val mutex: Mutex,
private val pdfRenderer: PdfRenderer,
private val coroutineScope: CoroutineScope,
) {
sealed interface State {
data class Loading(val width: Int, val height: Int) : State
data class Loaded(val bitmap: Bitmap) : State
}
private val renderWidth = maxWidth
private val renderHeight: Int
private var loadJob: Job? = null
init {
// We are just opening and closing the page to extract data so we can build the Loading state with the correct dimensions.
pdfRenderer.openPage(pageIndex).use { page ->
renderHeight = (page.height * (renderWidth.toFloat() / page.width)).toInt()
}
}
private val mutableStateFlow = MutableStateFlow<State>(
State.Loading(
width = renderWidth,
height = renderHeight
)
)
val stateFlow: StateFlow<State> = mutableStateFlow
fun load() {
loadJob = coroutineScope.launch {
val bitmap = mutex.withLock {
withContext(Dispatchers.IO) {
pdfRenderer.openPageRenderAndClose(pageIndex, renderWidth, renderHeight)
}
}
mutableStateFlow.value = State.Loaded(bitmap)
}
}
fun close() {
loadJob?.cancel()
when (val loadingState = stateFlow.value) {
is State.Loading -> return
is State.Loaded -> {
loadingState.bitmap.recycle()
mutableStateFlow.value = State.Loading(
width = renderWidth,
height = renderHeight
)
}
}
}
private fun PdfRenderer.openPageRenderAndClose(index: Int, bitmapWidth: Int, bitmapHeight: Int): Bitmap {
fun createBitmap(bitmapWidth: Int, bitmapHeight: Int): Bitmap {
val bitmap = Bitmap.createBitmap(
bitmapWidth,
bitmapHeight,
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(bitmap)
canvas.drawColor(Color.WHITE)
canvas.drawBitmap(bitmap, 0f, 0f, null)
return bitmap
}
return openPage(index).use { page ->
createBitmap(bitmapWidth, bitmapHeight).apply {
page.render(this, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
}
}
}
}
@@ -0,0 +1,79 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.local.pdf
import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.extensions.runCatchingExceptions
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class PdfRendererManager(
private val parcelFileDescriptor: ParcelFileDescriptor,
private val width: Int,
private val coroutineScope: CoroutineScope,
) {
private val mutex = Mutex()
private var pdfRenderer: PdfRenderer? = null
private val mutablePdfPages = MutableStateFlow<AsyncData<ImmutableList<PdfPage>>>(AsyncData.Uninitialized)
val pdfPages: StateFlow<AsyncData<ImmutableList<PdfPage>>> = mutablePdfPages
fun open() {
coroutineScope.launch {
mutex.withLock {
withContext(Dispatchers.IO) {
pdfRenderer = runCatchingExceptions {
PdfRenderer(parcelFileDescriptor)
}.fold(
onSuccess = { pdfRenderer ->
pdfRenderer.apply {
// Preload just 3 pages so we can render faster
val firstPages = loadPages(from = 0, to = 3)
mutablePdfPages.value = AsyncData.Success(firstPages.toImmutableList())
val nextPages = loadPages(from = 3, to = pageCount)
mutablePdfPages.value = AsyncData.Success((firstPages + nextPages).toImmutableList())
}
},
onFailure = {
mutablePdfPages.value = AsyncData.Failure(it)
null
}
)
}
}
}
}
fun close() {
coroutineScope.launch {
mutex.withLock {
mutablePdfPages.value.dataOrNull()?.forEach { pdfPage ->
pdfPage.close()
}
pdfRenderer?.close()
parcelFileDescriptor.close()
}
}
}
private fun PdfRenderer.loadPages(from: Int, to: Int): List<PdfPage> {
return (from until minOf(to, pageCount)).map { pageIndex ->
PdfPage(width, pageIndex, mutex, this, coroutineScope)
}
}
}
@@ -0,0 +1,177 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.local.pdf
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.mediaviewer.impl.viewer.topAppBarHeight
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import me.saket.telephoto.zoomable.zoomable
import java.io.IOException
@Composable
fun PdfViewer(
pdfViewerState: PdfViewerState,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
BoxWithConstraints(
modifier = modifier
.zoomable(
state = pdfViewerState.zoomableState,
onClick = { onClick() }
),
contentAlignment = Alignment.Center
) {
val maxWidthInPx = maxWidth.roundToPx()
DisposableEffect(pdfViewerState) {
pdfViewerState.openForWidth(maxWidthInPx)
onDispose {
pdfViewerState.close()
}
}
val pdfPages = pdfViewerState.getPages()
PdfPagesView(
pdfPages = pdfPages,
lazyListState = pdfViewerState.lazyListState,
)
}
}
@Composable
private fun PdfPagesView(
pdfPages: AsyncData<ImmutableList<PdfPage>>,
lazyListState: LazyListState,
modifier: Modifier = Modifier,
) {
when (pdfPages) {
is AsyncData.Uninitialized,
is AsyncData.Loading -> Unit
is AsyncData.Failure -> PdfPagesErrorView(
pdfPages.error,
modifier,
)
is AsyncData.Success -> PdfPagesContentView(
pdfPages = pdfPages.data,
lazyListState = lazyListState,
modifier = modifier
)
}
}
@Composable
private fun PdfPagesErrorView(
error: Throwable,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = buildString {
append(stringResource(id = CommonStrings.error_unknown))
append("\n\n")
append(error.localizedMessage)
},
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontBodyLgRegular,
)
}
}
@Composable
private fun PdfPagesContentView(
pdfPages: ImmutableList<PdfPage>,
lazyListState: LazyListState,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
state = lazyListState,
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically)
) {
// Add a fake item to the top so that the first item is not at the top of the screen.
item {
Spacer(modifier = Modifier.height(topAppBarHeight))
}
items(pdfPages.size) { index ->
val pdfPage = pdfPages[index]
PdfPageView(pdfPage)
}
}
}
@Composable
private fun PdfPageView(
pdfPage: PdfPage,
) {
val pdfPageState by pdfPage.stateFlow.collectAsState()
DisposableEffect(pdfPage) {
pdfPage.load()
onDispose {
pdfPage.close()
}
}
when (val state = pdfPageState) {
is PdfPage.State.Loaded -> {
Image(
bitmap = state.bitmap.asImageBitmap(),
contentDescription = stringResource(id = CommonStrings.a11y_page_n, pdfPage.pageIndex),
contentScale = ContentScale.FillWidth,
modifier = Modifier.fillMaxWidth()
)
}
is PdfPage.State.Loading -> {
Box(
modifier = Modifier
.fillMaxWidth()
.height(state.height.toDp())
.background(color = Color.White)
)
}
}
}
@PreviewsDayNight
@Composable
internal fun PdfPagesErrorViewPreview() = ElementPreview {
PdfPagesErrorView(
error = IOException("file not in PDF format or corrupted"),
)
}
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.local.pdf
import android.content.Context
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import io.element.android.libraries.architecture.AsyncData
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
import me.saket.telephoto.zoomable.ZoomableState
import me.saket.telephoto.zoomable.rememberZoomableState
@Stable
class PdfViewerState(
private val model: Any?,
private val coroutineScope: CoroutineScope,
private val context: Context,
val zoomableState: ZoomableState,
val lazyListState: LazyListState,
) {
var isLoaded by mutableStateOf(false)
private var pdfRendererManager by mutableStateOf<PdfRendererManager?>(null)
@Composable
fun getPages(): AsyncData<ImmutableList<PdfPage>> {
return pdfRendererManager?.run {
pdfPages.collectAsState().value
} ?: AsyncData.Uninitialized
}
fun openForWidth(maxWidth: Int) {
ParcelFileDescriptorFactory(context).create(model)
.onSuccess {
pdfRendererManager = PdfRendererManager(it, maxWidth, coroutineScope).apply {
open()
}
isLoaded = true
}
}
fun close() {
pdfRendererManager?.close()
isLoaded = false
}
}
@Composable
fun rememberPdfViewerState(
model: Any?,
zoomableState: ZoomableState = rememberZoomableState(),
lazyListState: LazyListState = rememberLazyListState(),
context: Context = LocalContext.current,
coroutineScope: CoroutineScope = rememberCoroutineScope(),
): PdfViewerState {
return remember(model) {
PdfViewerState(
model = model,
coroutineScope = coroutineScope,
context = context,
zoomableState = zoomableState,
lazyListState = lazyListState
)
}
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.local.player
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
fun ExoPlayer.togglePlay() {
if (isPlaying) {
pause()
} else {
if (playbackState == Player.STATE_ENDED) {
seekTo(0)
} else {
play()
}
}
}
fun ExoPlayer.seekToEnsurePlaying(positionMs: Long) {
if (isPlaying.not()) {
play()
}
seekTo(positionMs)
}
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.local.player
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.media3.exoplayer.ExoPlayer
@Composable
fun rememberExoPlayer(): ExoPlayer {
return if (LocalInspectionMode.current) {
remember {
ExoPlayerForPreview()
}
} else {
val context = LocalContext.current
remember {
ExoPlayer.Builder(context).build()
}
}
}
@@ -0,0 +1,252 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:Suppress(
"OVERRIDE_DEPRECATION",
"RedundantNullableReturnType",
"DEPRECATION",
)
package io.element.android.libraries.mediaviewer.impl.local.player
import android.annotation.SuppressLint
import android.media.AudioDeviceInfo
import android.os.Looper
import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.TextureView
import androidx.media3.common.AudioAttributes
import androidx.media3.common.AuxEffectInfo
import androidx.media3.common.DeviceInfo
import androidx.media3.common.Effect
import androidx.media3.common.Format
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.common.PriorityTaskManager
import androidx.media3.common.Timeline
import androidx.media3.common.TrackSelectionParameters
import androidx.media3.common.Tracks
import androidx.media3.common.VideoSize
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.Clock
import androidx.media3.common.util.Size
import androidx.media3.exoplayer.DecoderCounters
import androidx.media3.exoplayer.ExoPlaybackException
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.PlayerMessage
import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.ScrubbingModeParameters
import androidx.media3.exoplayer.SeekParameters
import androidx.media3.exoplayer.analytics.AnalyticsCollector
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.image.ImageOutput
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ShuffleOrder
import androidx.media3.exoplayer.source.TrackGroupArray
import androidx.media3.exoplayer.trackselection.TrackSelectionArray
import androidx.media3.exoplayer.trackselection.TrackSelector
import androidx.media3.exoplayer.video.VideoFrameMetadataListener
import androidx.media3.exoplayer.video.spherical.CameraMotionListener
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
@SuppressLint("UnsafeOptInUsageError")
@ExcludeFromCoverage
class ExoPlayerForPreview(
private val isPlaying: Boolean = false,
) : ExoPlayer {
override fun getApplicationLooper(): Looper = throw NotImplementedError()
override fun addListener(listener: Player.Listener) {}
override fun removeListener(listener: Player.Listener) {}
override fun setMediaItems(mediaItems: MutableList<MediaItem>) {}
override fun setMediaItems(mediaItems: MutableList<MediaItem>, resetPosition: Boolean) {}
override fun setMediaItems(mediaItems: MutableList<MediaItem>, startIndex: Int, startPositionMs: Long) {}
override fun setMediaItem(mediaItem: MediaItem) {}
override fun setMediaItem(mediaItem: MediaItem, startPositionMs: Long) {}
override fun setMediaItem(mediaItem: MediaItem, resetPosition: Boolean) {}
override fun addMediaItem(mediaItem: MediaItem) {}
override fun addMediaItem(index: Int, mediaItem: MediaItem) {}
override fun addMediaItems(mediaItems: MutableList<MediaItem>) {}
override fun addMediaItems(index: Int, mediaItems: MutableList<MediaItem>) {}
override fun moveMediaItem(currentIndex: Int, newIndex: Int) {}
override fun moveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int) {}
override fun replaceMediaItem(index: Int, mediaItem: MediaItem) {}
override fun replaceMediaItems(fromIndex: Int, toIndex: Int, mediaItems: MutableList<MediaItem>) {}
override fun removeMediaItem(index: Int) {}
override fun removeMediaItems(fromIndex: Int, toIndex: Int) {}
override fun clearMediaItems() {}
override fun isCommandAvailable(command: Int): Boolean = throw NotImplementedError()
override fun canAdvertiseSession(): Boolean = throw NotImplementedError()
override fun getAvailableCommands(): Player.Commands = throw NotImplementedError()
override fun prepare(mediaSource: MediaSource) {}
override fun prepare(mediaSource: MediaSource, resetPosition: Boolean, resetState: Boolean) {}
override fun prepare() {}
override fun getPlaybackState(): Int = throw NotImplementedError()
override fun getPlaybackSuppressionReason(): Int = throw NotImplementedError()
override fun isPlaying() = isPlaying
override fun getPlayerError(): ExoPlaybackException? = null
override fun play() {}
override fun pause() {}
override fun setPlayWhenReady(playWhenReady: Boolean) {}
override fun getPlayWhenReady(): Boolean = throw NotImplementedError()
override fun setRepeatMode(repeatMode: Int) {}
override fun getRepeatMode(): Int = throw NotImplementedError()
override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) {}
override fun getShuffleModeEnabled(): Boolean = throw NotImplementedError()
override fun isLoading(): Boolean = throw NotImplementedError()
override fun seekToDefaultPosition() {}
override fun seekToDefaultPosition(mediaItemIndex: Int) {}
override fun seekTo(positionMs: Long) {}
override fun seekTo(mediaItemIndex: Int, positionMs: Long) {}
override fun getSeekBackIncrement(): Long = throw NotImplementedError()
override fun seekBack() {}
override fun getSeekForwardIncrement(): Long = throw NotImplementedError()
override fun seekForward() {}
override fun hasPreviousMediaItem(): Boolean = throw NotImplementedError()
override fun seekToPreviousMediaItem() {}
override fun getMaxSeekToPreviousPosition(): Long = throw NotImplementedError()
override fun seekToPrevious() {}
override fun hasNextMediaItem(): Boolean = throw NotImplementedError()
override fun seekToNextMediaItem() {}
override fun seekToNext() {}
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {}
override fun setPlaybackSpeed(speed: Float) {}
override fun getPlaybackParameters(): PlaybackParameters = throw NotImplementedError()
override fun stop() {}
override fun release() {}
override fun getCurrentTracks(): Tracks = throw NotImplementedError()
override fun getTrackSelectionParameters(): TrackSelectionParameters = throw NotImplementedError()
override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) {}
override fun getMediaMetadata(): MediaMetadata = throw NotImplementedError()
override fun getPlaylistMetadata(): MediaMetadata = throw NotImplementedError()
override fun setPlaylistMetadata(mediaMetadata: MediaMetadata) {}
override fun getCurrentManifest(): Any? = throw NotImplementedError()
override fun getCurrentTimeline(): Timeline = throw NotImplementedError()
override fun getCurrentPeriodIndex(): Int = throw NotImplementedError()
override fun getCurrentWindowIndex(): Int = throw NotImplementedError()
override fun getCurrentMediaItemIndex(): Int = throw NotImplementedError()
override fun getNextWindowIndex(): Int = throw NotImplementedError()
override fun getNextMediaItemIndex(): Int = throw NotImplementedError()
override fun getPreviousWindowIndex(): Int = throw NotImplementedError()
override fun getPreviousMediaItemIndex(): Int = throw NotImplementedError()
override fun getCurrentMediaItem(): MediaItem? = throw NotImplementedError()
override fun getMediaItemCount(): Int = throw NotImplementedError()
override fun getMediaItemAt(index: Int): MediaItem = throw NotImplementedError()
override fun getDuration(): Long = throw NotImplementedError()
override fun getCurrentPosition(): Long = throw NotImplementedError()
override fun getBufferedPosition(): Long = throw NotImplementedError()
override fun getBufferedPercentage(): Int = throw NotImplementedError()
override fun getTotalBufferedDuration(): Long = throw NotImplementedError()
override fun isCurrentWindowDynamic(): Boolean = throw NotImplementedError()
override fun isCurrentMediaItemDynamic(): Boolean = throw NotImplementedError()
override fun isCurrentWindowLive(): Boolean = throw NotImplementedError()
override fun isCurrentMediaItemLive(): Boolean = throw NotImplementedError()
override fun getCurrentLiveOffset(): Long = throw NotImplementedError()
override fun isCurrentWindowSeekable(): Boolean = throw NotImplementedError()
override fun isCurrentMediaItemSeekable(): Boolean = throw NotImplementedError()
override fun isPlayingAd(): Boolean = throw NotImplementedError()
override fun getCurrentAdGroupIndex(): Int = throw NotImplementedError()
override fun getCurrentAdIndexInAdGroup(): Int = throw NotImplementedError()
override fun getContentDuration(): Long = throw NotImplementedError()
override fun getContentPosition(): Long = throw NotImplementedError()
override fun getContentBufferedPosition(): Long = throw NotImplementedError()
override fun getAudioAttributes(): AudioAttributes = throw NotImplementedError()
override fun setVolume(volume: Float) = throw NotImplementedError()
override fun getVolume(): Float = throw NotImplementedError()
override fun clearVideoSurface() {}
override fun clearVideoSurface(surface: Surface?) {}
override fun setVideoSurface(surface: Surface?) {}
override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) {}
override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) {}
override fun setVideoSurfaceView(surfaceView: SurfaceView?) {}
override fun clearVideoSurfaceView(surfaceView: SurfaceView?) {}
override fun setVideoTextureView(textureView: TextureView?) {}
override fun clearVideoTextureView(textureView: TextureView?) {}
override fun getVideoSize(): VideoSize = throw NotImplementedError()
override fun getSurfaceSize(): Size = throw NotImplementedError()
override fun getCurrentCues(): CueGroup = throw NotImplementedError()
override fun getDeviceInfo(): DeviceInfo = throw NotImplementedError()
override fun getDeviceVolume(): Int = throw NotImplementedError()
override fun isDeviceMuted(): Boolean = throw NotImplementedError()
override fun setDeviceVolume(volume: Int) {}
override fun setDeviceVolume(volume: Int, flags: Int) {}
override fun increaseDeviceVolume() {}
override fun increaseDeviceVolume(flags: Int) {}
override fun decreaseDeviceVolume() {}
override fun decreaseDeviceVolume(flags: Int) {}
override fun setDeviceMuted(muted: Boolean) {}
override fun setDeviceMuted(muted: Boolean, flags: Int) {}
override fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) {}
override fun addAudioOffloadListener(listener: ExoPlayer.AudioOffloadListener) {}
override fun removeAudioOffloadListener(listener: ExoPlayer.AudioOffloadListener) {}
override fun getAnalyticsCollector(): AnalyticsCollector = throw NotImplementedError()
override fun addAnalyticsListener(listener: AnalyticsListener) {}
override fun removeAnalyticsListener(listener: AnalyticsListener) {}
override fun getRendererCount(): Int = throw NotImplementedError()
override fun getRendererType(index: Int): Int = throw NotImplementedError()
override fun getRenderer(index: Int): Renderer = throw NotImplementedError()
override fun getTrackSelector(): TrackSelector? = throw NotImplementedError()
override fun getCurrentTrackGroups(): TrackGroupArray = throw NotImplementedError()
override fun getCurrentTrackSelections(): TrackSelectionArray = throw NotImplementedError()
override fun getPlaybackLooper(): Looper = throw NotImplementedError()
override fun getClock(): Clock = throw NotImplementedError()
override fun setMediaSources(mediaSources: MutableList<MediaSource>) {}
override fun setMediaSources(mediaSources: MutableList<MediaSource>, resetPosition: Boolean) {}
override fun setMediaSources(mediaSources: MutableList<MediaSource>, startMediaItemIndex: Int, startPositionMs: Long) {}
override fun setMediaSource(mediaSource: MediaSource) {}
override fun setMediaSource(mediaSource: MediaSource, startPositionMs: Long) {}
override fun setMediaSource(mediaSource: MediaSource, resetPosition: Boolean) {}
override fun addMediaSource(mediaSource: MediaSource) {}
override fun addMediaSource(index: Int, mediaSource: MediaSource) {}
override fun addMediaSources(mediaSources: MutableList<MediaSource>) {}
override fun addMediaSources(index: Int, mediaSources: MutableList<MediaSource>) {}
override fun setShuffleOrder(shuffleOrder: ShuffleOrder) {}
override fun getShuffleOrder(): ShuffleOrder = ShuffleOrder.DefaultShuffleOrder(0)
override fun setPreloadConfiguration(preloadConfiguration: ExoPlayer.PreloadConfiguration) {}
override fun getPreloadConfiguration(): ExoPlayer.PreloadConfiguration = throw NotImplementedError()
override fun setAudioSessionId(audioSessionId: Int) {}
override fun getAudioSessionId(): Int = throw NotImplementedError()
override fun setAuxEffectInfo(auxEffectInfo: AuxEffectInfo) {}
override fun clearAuxEffectInfo() {}
override fun setPreferredAudioDevice(audioDeviceInfo: AudioDeviceInfo?) {}
override fun setSkipSilenceEnabled(skipSilenceEnabled: Boolean) {}
override fun getSkipSilenceEnabled(): Boolean = throw NotImplementedError()
override fun setScrubbingModeEnabled(scrubbingModeEnabled: Boolean) {}
override fun isScrubbingModeEnabled(): Boolean = false
override fun setScrubbingModeParameters(scrubbingModeParameters: ScrubbingModeParameters) {}
override fun getScrubbingModeParameters(): ScrubbingModeParameters = ScrubbingModeParameters.DEFAULT
override fun setVideoEffects(videoEffects: MutableList<Effect>) {}
override fun setVideoScalingMode(videoScalingMode: Int) {}
override fun getVideoScalingMode(): Int = throw NotImplementedError()
override fun setVideoChangeFrameRateStrategy(videoChangeFrameRateStrategy: Int) {}
override fun getVideoChangeFrameRateStrategy(): Int = throw NotImplementedError()
override fun setVideoFrameMetadataListener(listener: VideoFrameMetadataListener) {}
override fun clearVideoFrameMetadataListener(listener: VideoFrameMetadataListener) {}
override fun setCameraMotionListener(listener: CameraMotionListener) {}
override fun clearCameraMotionListener(listener: CameraMotionListener) {}
override fun createMessage(target: PlayerMessage.Target): PlayerMessage = throw NotImplementedError()
override fun setSeekParameters(seekParameters: SeekParameters?) {}
override fun getSeekParameters(): SeekParameters = throw NotImplementedError()
override fun setForegroundMode(foregroundMode: Boolean) {}
override fun setPauseAtEndOfMediaItems(pauseAtEndOfMediaItems: Boolean) {}
override fun getPauseAtEndOfMediaItems(): Boolean = throw NotImplementedError()
override fun getAudioFormat(): Format? = throw NotImplementedError()
override fun getVideoFormat(): Format? = throw NotImplementedError()
override fun getAudioDecoderCounters(): DecoderCounters? = throw NotImplementedError()
override fun getVideoDecoderCounters(): DecoderCounters? = throw NotImplementedError()
override fun setHandleAudioBecomingNoisy(handleAudioBecomingNoisy: Boolean) {}
override fun setWakeMode(wakeMode: Int) {}
override fun setPriority(priority: Int) {}
override fun setPriorityTaskManager(priorityTaskManager: PriorityTaskManager?) {}
override fun isSleepingForOffload(): Boolean = throw NotImplementedError()
override fun isTunnelingEnabled(): Boolean = throw NotImplementedError()
override fun isReleased(): Boolean = throw NotImplementedError()
override fun setImageOutput(imageOutput: ImageOutput?) {}
}
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.local.player
import androidx.annotation.FloatRange
data class MediaPlayerControllerState(
val isVisible: Boolean,
val isPlaying: Boolean,
val isReady: Boolean,
val progressInMillis: Long,
val durationInMillis: Long,
val canMute: Boolean,
val isMuted: Boolean,
) {
@FloatRange(from = 0.0, to = 1.0)
val progressAsFloat = (progressInMillis.toFloat() / durationInMillis.toFloat()).coerceIn(0f, 1f)
}
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.local.player
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class MediaPlayerControllerStateProvider : PreviewParameterProvider<MediaPlayerControllerState> {
override val values: Sequence<MediaPlayerControllerState> = sequenceOf(
aMediaPlayerControllerState(),
aMediaPlayerControllerState(
isPlaying = true,
progressInMillis = 59_000,
durationInMillis = 83_000,
isMuted = true,
),
aMediaPlayerControllerState(
canMute = false,
),
)
}
private fun aMediaPlayerControllerState(
isVisible: Boolean = true,
isPlaying: Boolean = false,
isReady: Boolean = false,
progressInMillis: Long = 0,
// Default to 1 minute and 23 seconds
durationInMillis: Long = 83_000,
canMute: Boolean = true,
isMuted: Boolean = false,
) = MediaPlayerControllerState(
isVisible = isVisible,
isPlaying = isPlaying,
isReady = isReady,
progressInMillis = progressInMillis,
durationInMillis = durationInMillis,
canMute = canMute,
isMuted = isMuted,
)
@@ -0,0 +1,196 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.local.player
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.audio.api.AudioFocus
import io.element.android.libraries.audio.api.AudioFocusRequester
import io.element.android.libraries.dateformatter.api.toHumanReadableDuration
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.IconButton
import io.element.android.libraries.designsystem.theme.components.Slider
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.mediaviewer.impl.util.bgCanvasWithTransparency
import io.element.android.libraries.ui.strings.CommonStrings
import timber.log.Timber
@Composable
fun MediaPlayerControllerView(
state: MediaPlayerControllerState,
onTogglePlay: () -> Unit,
onSeekChange: (Float) -> Unit,
onToggleMute: () -> Unit,
audioFocus: AudioFocus?,
modifier: Modifier = Modifier,
) {
if (audioFocus != null) {
val latestOnTogglePlay by rememberUpdatedState(onTogglePlay)
LaunchedEffect(state.isPlaying) {
if (state.isPlaying) {
audioFocus.requestAudioFocus(
requester = AudioFocusRequester.MediaViewer,
onFocusLost = {
Timber.w("Audio focus lost")
latestOnTogglePlay()
},
)
} else {
audioFocus.releaseAudioFocus()
}
}
}
AnimatedVisibility(
visible = state.isVisible,
modifier = modifier,
enter = fadeIn(),
exit = fadeOut(),
) {
Box(
modifier = Modifier
.background(color = bgCanvasWithTransparency)
.padding(horizontal = 8.dp, vertical = 4.dp),
contentAlignment = Alignment.Center,
) {
Row(
modifier = Modifier
.widthIn(max = 480.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val bgColor = if (state.isPlaying) {
ElementTheme.colors.bgCanvasDefault
} else {
ElementTheme.colors.textPrimary
}
Box(
modifier = Modifier
.size(36.dp)
.background(
color = bgColor,
shape = CircleShape,
)
.clip(CircleShape)
.clickable { onTogglePlay() }
.padding(8.dp),
contentAlignment = Alignment.Center,
) {
if (state.isPlaying) {
Icon(
imageVector = CompoundIcons.PauseSolid(),
tint = ElementTheme.colors.iconPrimary,
contentDescription = stringResource(CommonStrings.a11y_pause)
)
} else {
Icon(
imageVector = CompoundIcons.PlaySolid(),
tint = ElementTheme.colors.iconOnSolidPrimary,
contentDescription = stringResource(CommonStrings.a11y_play)
)
}
}
Text(
modifier = Modifier
.widthIn(min = 48.dp)
.padding(horizontal = 8.dp),
text = state.progressInMillis.toHumanReadableDuration(),
textAlign = TextAlign.Center,
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyXsMedium,
)
var lastSelectedValue by remember { mutableFloatStateOf(-1f) }
Slider(
modifier = Modifier.weight(1f),
valueRange = 0f..state.durationInMillis.toFloat(),
value = lastSelectedValue.takeIf { it >= 0 } ?: state.progressInMillis.toFloat(),
onValueChange = {
lastSelectedValue = it
},
onValueChangeFinish = {
onSeekChange(lastSelectedValue)
lastSelectedValue = -1f
},
useCustomLayout = true,
)
val formattedDuration = remember(state.durationInMillis) {
state.durationInMillis.toHumanReadableDuration()
}
Text(
modifier = Modifier
.widthIn(min = 48.dp)
.padding(horizontal = 8.dp),
text = formattedDuration,
textAlign = TextAlign.Center,
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyXsMedium,
)
if (state.canMute) {
IconButton(
onClick = onToggleMute,
) {
if (state.isMuted) {
Icon(
imageVector = CompoundIcons.VolumeOffSolid(),
tint = ElementTheme.colors.iconPrimary,
contentDescription = stringResource(CommonStrings.common_unmute)
)
} else {
Icon(
imageVector = CompoundIcons.VolumeOnSolid(),
tint = ElementTheme.colors.iconPrimary,
contentDescription = stringResource(CommonStrings.common_mute)
)
}
}
}
}
}
}
}
@PreviewsDayNight
@Composable
internal fun MediaPlayerControllerViewPreview(
@PreviewParameter(MediaPlayerControllerStateProvider::class) state: MediaPlayerControllerState
) = ElementPreview {
MediaPlayerControllerView(
state = state,
onTogglePlay = {},
onSeekChange = {},
onToggleMute = {},
audioFocus = null,
)
}
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.local.txt
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
open class TextFileContentProvider : PreviewParameterProvider<AsyncData<ImmutableList<String>>> {
override val values: Sequence<AsyncData<ImmutableList<String>>>
get() = sequenceOf(
AsyncData.Uninitialized,
AsyncData.Loading(),
AsyncData.Success(persistentListOf("Hello, World!")),
AsyncData.Failure(Exception("Failed to load text")),
)
}
@@ -0,0 +1,112 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.local.txt
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.viewfolder.api.TextFileViewer
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.viewer.topAppBarHeight
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@Composable
fun TextFileView(
localMedia: LocalMedia?,
textFileViewer: TextFileViewer,
modifier: Modifier = Modifier,
) {
val data = remember { mutableStateOf<AsyncData<ImmutableList<String>>>(AsyncData.Uninitialized) }
val context = LocalContext.current
LaunchedEffect(localMedia?.uri) {
data.value = AsyncData.Loading()
if (localMedia?.uri != null) {
// Load the file content
val result = runCatchingExceptions {
context.contentResolver.openInputStream(localMedia.uri).use {
it?.bufferedReader()?.readLines()?.toList().orEmpty()
}
}
data.value = if (result.isSuccess) {
AsyncData.Success(result.getOrNull().orEmpty().toImmutableList())
} else {
AsyncData.Failure(result.exceptionOrNull() ?: Exception("An error occurred"))
}
}
}
TextFileContentView(
data = data.value,
textFileViewer = textFileViewer,
modifier = modifier,
)
}
@Composable
private fun TextFileContentView(
data: AsyncData<ImmutableList<String>>,
textFileViewer: TextFileViewer,
modifier: Modifier = Modifier,
) {
when (data) {
AsyncData.Uninitialized,
is AsyncData.Loading -> Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
is AsyncData.Failure -> Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(text = data.error.message ?: stringResource(id = CommonStrings.error_unknown))
}
is AsyncData.Success -> {
textFileViewer.Render(
lines = data.data,
modifier = modifier
.fillMaxSize()
.padding(top = topAppBarHeight),
)
}
}
}
@PreviewsDayNight
@Composable
internal fun TextFileContentViewPreview(
@PreviewParameter(TextFileContentProvider::class) text: AsyncData<ImmutableList<String>>,
) = ElementPreview {
TextFileContentView(
data = text,
textFileViewer = { lines, modifier ->
Text(
modifier = modifier,
text = lines.firstOrNull() ?: "File content"
)
}
)
}
@@ -0,0 +1,324 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.local.video
import android.annotation.SuppressLint
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout
import androidx.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.Player.STATE_READY
import androidx.media3.common.Timeline
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.audio.api.AudioFocus
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.KeepScreenOn
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaViewState
import io.element.android.libraries.mediaviewer.impl.local.PlayableState
import io.element.android.libraries.mediaviewer.impl.local.player.MediaPlayerControllerState
import io.element.android.libraries.mediaviewer.impl.local.player.MediaPlayerControllerView
import io.element.android.libraries.mediaviewer.impl.local.player.rememberExoPlayer
import io.element.android.libraries.mediaviewer.impl.local.player.seekToEnsurePlaying
import io.element.android.libraries.mediaviewer.impl.local.player.togglePlay
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
import kotlinx.coroutines.delay
import me.saket.telephoto.zoomable.zoomable
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
@SuppressLint("UnsafeOptInUsageError")
@Composable
fun MediaVideoView(
isDisplayed: Boolean,
localMediaViewState: LocalMediaViewState,
bottomPaddingInPixels: Int,
localMedia: LocalMedia?,
autoplay: Boolean,
audioFocus: AudioFocus?,
modifier: Modifier = Modifier,
) {
val exoPlayer = rememberExoPlayer()
ExoPlayerMediaVideoView(
isDisplayed = isDisplayed,
localMediaViewState = localMediaViewState,
bottomPaddingInPixels = bottomPaddingInPixels,
exoPlayer = exoPlayer,
localMedia = localMedia,
autoplay = autoplay,
audioFocus = audioFocus,
modifier = modifier,
)
}
@SuppressLint("UnsafeOptInUsageError")
@Composable
private fun ExoPlayerMediaVideoView(
isDisplayed: Boolean,
localMediaViewState: LocalMediaViewState,
bottomPaddingInPixels: Int,
exoPlayer: ExoPlayer,
localMedia: LocalMedia?,
autoplay: Boolean,
audioFocus: AudioFocus?,
modifier: Modifier = Modifier,
) {
var mediaPlayerControllerState: MediaPlayerControllerState by remember {
mutableStateOf(
MediaPlayerControllerState(
isVisible = true,
isPlaying = false,
isReady = false,
progressInMillis = 0,
durationInMillis = 0,
canMute = true,
isMuted = false,
)
)
}
val playableState: PlayableState.Playable by remember {
derivedStateOf {
PlayableState.Playable(
isShowingControls = mediaPlayerControllerState.isVisible,
)
}
}
localMediaViewState.playableState = playableState
val playerListener = remember {
object : Player.Listener {
override fun onRenderedFirstFrame() {
localMediaViewState.isReady = true
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
isPlaying = isPlaying,
)
}
override fun onVolumeChanged(volume: Float) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
isMuted = volume == 0f,
)
}
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) {
exoPlayer.duration.takeIf { it >= 0 }
?.let {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
durationInMillis = it,
)
}
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
isReady = playbackState == STATE_READY,
)
}
}
}
var autoHideController by remember { mutableIntStateOf(0) }
LaunchedEffect(autoHideController) {
delay(5.seconds)
if (exoPlayer.isPlaying) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
isVisible = false,
)
}
}
if (localMedia?.uri != null) {
LaunchedEffect(localMedia.uri) {
val mediaItem = MediaItem.fromUri(localMedia.uri)
exoPlayer.setMediaItem(mediaItem)
}
} else {
exoPlayer.setMediaItems(emptyList())
}
KeepScreenOn(mediaPlayerControllerState.isPlaying)
Box(
modifier = modifier
.background(ElementTheme.colors.bgSubtlePrimary),
) {
val context = LocalContext.current
if (LocalInspectionMode.current) {
Text(
modifier = Modifier
.background(ElementTheme.colors.bgSubtlePrimary)
.align(Alignment.Center),
text = "A Video Player will render here",
)
} else {
AndroidView(
modifier = Modifier
.fillMaxSize()
.zoomable(
state = localMediaViewState.zoomableState,
onClick = {
autoHideController++
mediaPlayerControllerState = mediaPlayerControllerState.copy(
isVisible = !mediaPlayerControllerState.isVisible,
)
}
),
factory = {
PlayerView(context).apply {
player = exoPlayer
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
useController = false
}
},
onRelease = { playerView ->
playerView.player = null
},
)
}
MediaPlayerControllerView(
state = mediaPlayerControllerState,
onTogglePlay = {
autoHideController++
exoPlayer.togglePlay()
},
onSeekChange = {
autoHideController++
exoPlayer.seekToEnsurePlaying(it.toLong())
},
onToggleMute = {
autoHideController++
exoPlayer.volume = if (exoPlayer.volume == 1f) 0f else 1f
},
audioFocus = audioFocus,
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(bottom = bottomPaddingInPixels.toDp()),
)
}
LaunchedEffect(exoPlayer.isPlaying) {
if (exoPlayer.isPlaying) {
while (true) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
progressInMillis = exoPlayer.currentPosition,
)
delay(200)
}
} else {
// Ensure we render the final state
mediaPlayerControllerState = mediaPlayerControllerState.copy(
progressInMillis = exoPlayer.currentPosition,
)
}
}
ExoPlayerLifecycleHelper(
exoPlayer = exoPlayer,
autoplay = autoplay,
isDisplayed = isDisplayed,
playerListener = playerListener,
mediaPlayerControllerState = mediaPlayerControllerState,
)
}
@OptIn(UnstableApi::class)
@Composable
private fun ExoPlayerLifecycleHelper(
exoPlayer: ExoPlayer,
autoplay: Boolean,
isDisplayed: Boolean,
playerListener: Player.Listener,
mediaPlayerControllerState: MediaPlayerControllerState,
) {
// Prepare and release the exoPlayer with the composable lifecycle
DisposableEffect(Unit) {
Timber.d("ExoPlayerMediaVideoView DisposableEffect: initializing exoPlayer")
exoPlayer.addListener(playerListener)
exoPlayer.prepare()
onDispose {
Timber.d("Disposing exoplayer")
if (!exoPlayer.isReleased) {
exoPlayer.removeListener(playerListener)
exoPlayer.release()
}
}
}
var needsAutoPlay by remember { mutableStateOf(autoplay) }
LaunchedEffect(needsAutoPlay, isDisplayed, mediaPlayerControllerState.isReady) {
val isReadyAndNotPlaying = mediaPlayerControllerState.isReady && !mediaPlayerControllerState.isPlaying
if (needsAutoPlay && isDisplayed && isReadyAndNotPlaying) {
// When displayed, start autoplaying
exoPlayer.play()
needsAutoPlay = false
} else if (!isDisplayed && mediaPlayerControllerState.isPlaying) {
// If not displayed, make sure to pause the video
exoPlayer.pause()
}
}
// Pause playback when lifecycle is paused
OnLifecycleEvent { _, event ->
if (event == Lifecycle.Event.ON_PAUSE && exoPlayer.isPlaying) {
exoPlayer.pause()
}
}
}
@PreviewsDayNight
@Composable
internal fun MediaVideoViewPreview() = ElementPreview {
MediaVideoView(
isDisplayed = true,
modifier = Modifier.fillMaxSize(),
bottomPaddingInPixels = 0,
localMediaViewState = rememberLocalMediaViewState(),
localMedia = null,
audioFocus = null,
autoplay = false,
)
}
@@ -0,0 +1,31 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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 }
}
@@ -0,0 +1,126 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.mediaviewer.api.MediaInfo
sealed interface MediaItem {
data class DateSeparator(
val id: UniqueId,
val formattedDate: String,
) : MediaItem
data class LoadingIndicator(
val id: UniqueId,
val direction: Timeline.PaginationDirection,
val timestamp: Long,
) : MediaItem
sealed interface Event : MediaItem
data class Image(
val id: UniqueId,
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
) : Event {
val thumbnailMediaRequestData: MediaRequestData
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
}
data class Video(
val id: UniqueId,
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
) : Event {
val thumbnailMediaRequestData: MediaRequestData
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
}
data class Audio(
val id: UniqueId,
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
) : Event
data class Voice(
val id: UniqueId,
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
) : Event
data class File(
val id: UniqueId,
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
) : Event
}
fun MediaItem.id(): UniqueId {
return when (this) {
is MediaItem.DateSeparator -> id
is MediaItem.LoadingIndicator -> id
is MediaItem.Image -> id
is MediaItem.Video -> id
is MediaItem.File -> id
is MediaItem.Audio -> id
is MediaItem.Voice -> id
}
}
fun MediaItem.Event.eventId(): EventId? {
return when (this) {
is MediaItem.Image -> eventId
is MediaItem.Video -> eventId
is MediaItem.File -> eventId
is MediaItem.Audio -> eventId
is MediaItem.Voice -> eventId
}
}
fun MediaItem.Event.mediaInfo(): MediaInfo {
return when (this) {
is MediaItem.Image -> mediaInfo
is MediaItem.Video -> mediaInfo
is MediaItem.File -> mediaInfo
is MediaItem.Audio -> mediaInfo
is MediaItem.Voice -> mediaInfo
}
}
fun MediaItem.Event.mediaSource(): MediaSource {
return when (this) {
is MediaItem.Image -> mediaSource
is MediaItem.Video -> mediaSource
is MediaItem.File -> mediaSource
is MediaItem.Audio -> mediaSource
is MediaItem.Voice -> mediaSource
}
}
fun MediaItem.Event.thumbnailSource(): MediaSource? {
return when (this) {
is MediaItem.Image -> thumbnailSource
is MediaItem.Video -> thumbnailSource
is MediaItem.File -> null
is MediaItem.Audio -> null
is MediaItem.Voice -> null
}
}
@@ -0,0 +1,129 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.WaveFormSamples
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> = WaveFormSamples.realisticWaveForm,
): 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,
)
}
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.util
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import io.element.android.compound.theme.ElementTheme
val bgCanvasWithTransparency: Color
@Composable
get() = ElementTheme.colors.bgCanvasDefault.copy(alpha = 0.6f)
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.util
import android.webkit.MimeTypeMap
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
@ContributesBinding(AppScope::class)
class FileExtensionExtractorWithValidation : FileExtensionExtractor {
override fun extractFromName(name: String): String {
val fileExtension = name.substringAfterLast('.', "")
// Makes sure the extension is known by the system, otherwise default to binary extension.
return if (MimeTypeMap.getSingleton().hasExtension(fileExtension)) {
fileExtension
} else {
"bin"
}
}
}
@@ -0,0 +1,191 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.viewer
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.extensions.mapCatchingExceptions
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.datasource.MediaGalleryDataSource
import io.element.android.libraries.mediaviewer.impl.gallery.MediaGalleryMode
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.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import timber.log.Timber
class MediaViewerDataSource(
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,
is MediaViewerMode.TimelineImagesAndVideos -> MediaGalleryMode.Images
is MediaViewerMode.TimelineFilesAndAudios -> MediaGalleryMode.Files
}
// Map of sourceUrl to local media state
private val localMediaStates: MutableMap<String, MutableState<AsyncData<LocalMedia>>> =
mutableMapOf()
fun setup() {
galleryDataSource.start()
}
fun dispose() {
mediaFiles.forEach { it.close() }
mediaFiles.clear()
localMediaStates.clear()
}
@Composable
fun collectAsState(): State<ImmutableList<MediaViewerPageData>> {
return remember { dataFlow() }.collectAsState(initialData())
}
@VisibleForTesting
internal fun dataFlow(): Flow<ImmutableList<MediaViewerPageData>> {
return galleryDataSource.groupedMediaItemsFlow()
.map { groupedItems ->
when (groupedItems) {
AsyncData.Uninitialized,
is AsyncData.Loading -> {
persistentListOf(
MediaViewerPageData.Loading(
direction = Timeline.PaginationDirection.BACKWARDS,
timestamp = systemClock.epochMillis(),
pagerKey = Long.MIN_VALUE,
)
)
}
is AsyncData.Failure -> {
persistentListOf(
MediaViewerPageData.Failure(groupedItems.error),
)
}
is AsyncData.Success -> {
withContext(dispatcher) {
val mediaItems = groupedItems.data.getItems(galleryMode)
buildMediaViewerPageList(mediaItems)
}
}
}
}
}
private fun initialData(): ImmutableList<MediaViewerPageData> {
val initialMediaItems =
galleryDataSource.getLastData().dataOrNull()?.getItems(galleryMode).orEmpty()
return buildMediaViewerPageList(initialMediaItems)
}
/**
* Build a list of [MediaViewerPageData] from a list of [MediaItem].
* In particular, create a mutable state of AsyncData<LocalMedia> for each media item, which
* will be used to render the downloaded media (see [loadMedia] which will update this value).
*/
private fun buildMediaViewerPageList(groupedItems: List<MediaItem>) = buildList {
// 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 -> {
val sourceUrl = mediaItem.mediaSource().url
val localMedia = localMediaStates.getOrPut(sourceUrl) {
mutableStateOf(AsyncData.Uninitialized)
}
add(
MediaViewerPageData.MediaViewerData(
eventId = mediaItem.eventId(),
mediaInfo = mediaItem.mediaInfo(),
mediaSource = mediaItem.mediaSource(),
thumbnailSource = mediaItem.thumbnailSource(),
downloadedMedia = localMedia,
pagerKey = pagerKeysHandler.getKey(mediaItem),
)
)
}
is MediaItem.LoadingIndicator -> add(
MediaViewerPageData.Loading(
direction = mediaItem.direction,
timestamp = systemClock.epochMillis(),
pagerKey = pagerKeysHandler.getKey(mediaItem),
)
)
}
}
}.toImmutableList()
fun clearLoadingError(data: MediaViewerPageData.MediaViewerData) {
localMediaStates[data.mediaSource.url]?.value = AsyncData.Uninitialized
}
suspend fun loadMore(direction: Timeline.PaginationDirection) {
galleryDataSource.loadMore(direction)
}
suspend fun loadMedia(data: MediaViewerPageData.MediaViewerData) {
Timber.d("loadMedia for ${data.eventId}")
val localMediaState = localMediaStates.getOrPut(data.mediaSource.url) {
mutableStateOf(AsyncData.Uninitialized)
}
localMediaState.value = AsyncData.Loading()
mediaLoader
.downloadMediaFile(
source = data.mediaSource,
mimeType = data.mediaInfo.mimeType,
filename = data.mediaInfo.filename
)
.onSuccess { mediaFile ->
mediaFiles.add(mediaFile)
}
.mapCatchingExceptions { mediaFile ->
localMediaFactory.createFromMediaFile(
mediaFile = mediaFile,
mediaInfo = data.mediaInfo
)
}
.onSuccess {
localMediaState.value = AsyncData.Success(it)
}
.onFailure {
localMediaState.value = AsyncData.Failure(it)
}
}
}
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-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.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.Timeline
sealed interface MediaViewerEvents {
data class LoadMedia(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class SaveOnDisk(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class Share(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class OpenWith(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class ClearLoadingError(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class ViewInTimeline(val eventId: EventId) : MediaViewerEvents
data class Forward(val eventId: EventId) : MediaViewerEvents
data class OpenInfo(val data: MediaViewerPageData.MediaViewerData) : MediaViewerEvents
data class ConfirmDelete(
val eventId: EventId,
val data: MediaViewerPageData.MediaViewerData,
) : MediaViewerEvents
data object CloseBottomSheet : MediaViewerEvents
data class Delete(val eventId: EventId) : MediaViewerEvents
data class OnNavigateTo(val index: Int) : MediaViewerEvents
data class LoadMore(val direction: Timeline.PaginationDirection) : MediaViewerEvents
}
@@ -0,0 +1,104 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.viewer
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.androidutils.system.areAnimationsEnabled
import kotlinx.coroutines.delay
import me.saket.telephoto.ExperimentalTelephotoApi
import me.saket.telephoto.flick.FlickToDismiss
import me.saket.telephoto.flick.FlickToDismissState
import me.saket.telephoto.flick.rememberFlickToDismissState
import kotlin.time.Duration
@OptIn(ExperimentalTelephotoApi::class)
@Composable
fun MediaViewerFlickToDismiss(
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
onDragging: () -> Unit = {},
onResetting: () -> Unit = {},
content: @Composable BoxScope.() -> Unit,
) {
val flickState = rememberFlickToDismissState(dismissThresholdRatio = 0.1f, rotateOnDrag = false)
val context = LocalContext.current
DismissFlickEffects(
flickState = flickState,
onDismissing = { animationDuration ->
// Only add the delay if an animation should be played, otherwise `onDismiss` will never be called
if (context.areAnimationsEnabled()) {
delay(animationDuration / 3)
}
onDismiss()
},
onDragging = onDragging,
onResetting = onResetting,
)
FlickToDismiss(
state = flickState,
modifier = modifier.background(backgroundColorFor(flickState)),
content = content,
)
}
@Composable
private fun DismissFlickEffects(
flickState: FlickToDismissState,
onDismissing: suspend (Duration) -> Unit,
onDragging: suspend () -> Unit,
onResetting: suspend () -> Unit,
) {
val currentOnDismissing by rememberUpdatedState(onDismissing)
val currentOnDragging by rememberUpdatedState(onDragging)
val currentOnResetting by rememberUpdatedState(onResetting)
when (val gestureState = flickState.gestureState) {
is FlickToDismissState.GestureState.Dismissing -> {
LaunchedEffect(Unit) {
currentOnDismissing(gestureState.animationDuration)
}
}
is FlickToDismissState.GestureState.Dragging -> {
LaunchedEffect(Unit) {
currentOnDragging()
}
}
is FlickToDismissState.GestureState.Resetting -> {
LaunchedEffect(Unit) {
currentOnResetting()
}
}
else -> Unit
}
}
@Composable
private fun backgroundColorFor(flickState: FlickToDismissState): Color {
val animatedAlpha by animateFloatAsState(
targetValue = when (flickState.gestureState) {
is FlickToDismissState.GestureState.Dismissed,
is FlickToDismissState.GestureState.Dismissing -> 0f
is FlickToDismissState.GestureState.Dragging,
is FlickToDismissState.GestureState.Idle,
is FlickToDismissState.GestureState.Resetting -> 1f - flickState.offsetFraction
},
label = "Background alpha",
)
return ElementTheme.colors.bgCanvasDefault.copy(alpha = animatedAlpha)
}
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.matrix.api.core.EventId
interface MediaViewerNavigator {
fun onViewInTimelineClick(eventId: EventId)
fun onForwardClick(eventId: EventId, fromPinnedEvents: Boolean)
fun onItemDeleted()
}
@@ -0,0 +1,156 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.viewer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.compound.theme.ForcedDarkElementTheme
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.viewfolder.api.TextFileViewer
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.audio.api.AudioFocus
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
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)
@AssistedInject
class MediaViewerNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: MediaViewerPresenter.Factory,
timelineMediaGalleryDataSource: TimelineMediaGalleryDataSource,
focusedTimelineMediaGalleryDataSourceFactory: FocusedTimelineMediaGalleryDataSourceFactory,
mediaLoader: MatrixMediaLoader,
localMediaFactory: LocalMediaFactory,
coroutineDispatchers: CoroutineDispatchers,
systemClock: SystemClock,
pagerKeysHandler: PagerKeysHandler,
private val textFileViewer: TextFileViewer,
private val audioFocus: AudioFocus,
private val sessionId: SessionId,
private val enterpriseService: EnterpriseService,
) : Node(buildContext, plugins = plugins),
MediaViewerNavigator {
private val callback: MediaViewerEntryPoint.Callback = callback()
private val inputs = inputs<MediaViewerEntryPoint.Params>()
override fun onViewInTimelineClick(eventId: EventId) {
callback.viewInTimeline(eventId)
}
override fun onForwardClick(eventId: EventId, fromPinnedEvents: Boolean) {
callback.forwardEvent(eventId, fromPinnedEvents)
}
override fun onItemDeleted() {
callback.onDone()
}
private val mediaGallerySource = if (inputs.mode == MediaViewerEntryPoint.MediaViewerMode.SingleMedia) {
SingleMediaGalleryDataSource.createFrom(inputs)
} else {
val eventId = inputs.eventId
if (eventId == null) {
// Should not happen
timelineMediaGalleryDataSource
} else {
// Can we use a specific timeline?
val timelineMode = inputs.mode.getTimelineMode()
when (timelineMode) {
null -> timelineMediaGalleryDataSource
Timeline.Mode.Live,
is Timeline.Mode.FocusedOnEvent,
is Timeline.Mode.Thread -> {
// 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(),
onlyPinnedEvents = false,
)
}
}
Timeline.Mode.PinnedEvents -> {
focusedTimelineMediaGalleryDataSourceFactory.createFor(
eventId = eventId,
mediaItem = inputs.toMediaItem(),
onlyPinnedEvents = true,
)
}
Timeline.Mode.Media -> timelineMediaGalleryDataSource
}
}
}
private val presenter = presenterFactory.create(
inputs = inputs,
navigator = this,
dataSource = MediaViewerDataSource(
mode = inputs.mode,
dispatcher = coroutineDispatchers.computation,
galleryDataSource = mediaGallerySource,
mediaLoader = mediaLoader,
localMediaFactory = localMediaFactory,
systemClock = systemClock,
pagerKeysHandler = pagerKeysHandler,
)
)
@Composable
override fun View(modifier: Modifier) {
val colors by remember {
enterpriseService.semanticColorsFlow(sessionId = sessionId)
}.collectAsState(SemanticColorsLightDark.default)
ForcedDarkElementTheme(
colors = colors,
) {
val state = presenter.present()
MediaViewerView(
state = state,
textFileViewer = textFileViewer,
modifier = modifier,
audioFocus = audioFocus,
onBackClick = callback::onDone,
)
}
}
}
internal fun MediaViewerEntryPoint.MediaViewerMode.getTimelineMode(): Timeline.Mode? {
return when (this) {
is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> timelineMode
is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> timelineMode
else -> null
}
}
@@ -0,0 +1,296 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.viewer
import android.content.ActivityNotFoundException
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.IntState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther
import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.R
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import io.element.android.libraries.androidutils.R as UtilsR
@AssistedInject
class MediaViewerPresenter(
@Assisted private val inputs: MediaViewerEntryPoint.Params,
@Assisted private val navigator: MediaViewerNavigator,
@Assisted private val dataSource: MediaViewerDataSource,
private val room: JoinedRoom,
private val localMediaActions: LocalMediaActions,
) : Presenter<MediaViewerState> {
@AssistedFactory
fun interface Factory {
fun create(
inputs: MediaViewerEntryPoint.Params,
navigator: MediaViewerNavigator,
dataSource: MediaViewerDataSource,
): MediaViewerPresenter
}
// Use a local snackbarDispatcher because this presenter is used in an Overlay Node
private val snackbarDispatcher = SnackbarDispatcher()
@Composable
override fun present(): MediaViewerState {
val coroutineScope = rememberCoroutineScope()
val data = dataSource.collectAsState()
val currentIndex = remember { mutableIntStateOf(searchIndex(data.value, inputs.eventId)) }
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
NoMoreItemsBackwardSnackBarDisplayer(currentIndex, data)
NoMoreItemsForwardSnackBarDisplayer(currentIndex, data)
var mediaBottomSheetState by remember { mutableStateOf<MediaBottomSheetState>(MediaBottomSheetState.Hidden) }
DisposableEffect(Unit) {
dataSource.setup()
onDispose {
dataSource.dispose()
}
}
localMediaActions.Configure()
fun handleEvent(event: MediaViewerEvents) {
when (event) {
is MediaViewerEvents.LoadMedia -> {
coroutineScope.downloadMedia(data = event.data)
}
is MediaViewerEvents.ClearLoadingError -> {
dataSource.clearLoadingError(event.data)
}
is MediaViewerEvents.SaveOnDisk -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
coroutineScope.saveOnDisk(event.data.downloadedMedia.value)
}
is MediaViewerEvents.Share -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
coroutineScope.share(event.data.downloadedMedia.value)
}
is MediaViewerEvents.OpenWith -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
coroutineScope.open(event.data.downloadedMedia.value)
}
is MediaViewerEvents.Delete -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
coroutineScope.delete(event.eventId)
}
is MediaViewerEvents.ViewInTimeline -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
navigator.onViewInTimelineClick(event.eventId)
}
is MediaViewerEvents.Forward -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
navigator.onForwardClick(
eventId = event.eventId,
fromPinnedEvents = inputs.mode.getTimelineMode() == Timeline.Mode.PinnedEvents,
)
}
is MediaViewerEvents.OpenInfo -> coroutineScope.launch {
mediaBottomSheetState = MediaBottomSheetState.MediaDetailsBottomSheetState(
eventId = event.data.eventId,
canDelete = when (event.data.mediaInfo.senderId) {
null -> false
room.sessionId -> room.canRedactOwn().getOrElse { false } && event.data.eventId != null
else -> room.canRedactOther().getOrElse { false } && event.data.eventId != null
},
mediaInfo = event.data.mediaInfo,
thumbnailSource = event.data.thumbnailSource,
)
}
is MediaViewerEvents.ConfirmDelete -> {
mediaBottomSheetState = MediaBottomSheetState.MediaDeleteConfirmationState(
eventId = event.eventId,
mediaInfo = event.data.mediaInfo,
thumbnailSource = event.data.thumbnailSource ?: event.data.mediaSource,
)
}
MediaViewerEvents.CloseBottomSheet -> {
mediaBottomSheetState = MediaBottomSheetState.Hidden
}
is MediaViewerEvents.OnNavigateTo -> {
currentIndex.intValue = event.index
}
is MediaViewerEvents.LoadMore -> coroutineScope.launch {
dataSource.loadMore(event.direction)
}
}
}
return MediaViewerState(
initiallySelectedEventId = inputs.eventId,
listData = data.value,
currentIndex = currentIndex.intValue,
snackbarMessage = snackbarMessage,
canShowInfo = inputs.canShowInfo,
mediaBottomSheetState = mediaBottomSheetState,
eventSink = ::handleEvent,
)
}
@Composable
private fun NoMoreItemsBackwardSnackBarDisplayer(
currentIndex: IntState,
data: State<ImmutableList<MediaViewerPageData>>,
) {
val isRenderingLoadingBackward by remember {
derivedStateOf {
currentIndex.intValue == data.value.lastIndex &&
data.value.size > 1 &&
data.value.lastOrNull() is MediaViewerPageData.Loading
}
}
if (isRenderingLoadingBackward) {
LaunchedEffect(Unit) {
// Observe the loading data vanishing
snapshotFlow { data.value.lastOrNull() is MediaViewerPageData.Loading }
.distinctUntilChanged()
.filter { !it }
.onEach { showNoMoreItemsSnackbar() }
.launchIn(this)
}
}
}
@Composable
private fun NoMoreItemsForwardSnackBarDisplayer(
currentIndex: IntState,
data: State<ImmutableList<MediaViewerPageData>>,
) {
val isRenderingLoadingForward by remember {
derivedStateOf {
currentIndex.intValue == 0 &&
data.value.size > 1 &&
data.value.firstOrNull() is MediaViewerPageData.Loading
}
}
if (isRenderingLoadingForward) {
LaunchedEffect(Unit) {
// Observe the loading data vanishing
snapshotFlow { data.value.firstOrNull() is MediaViewerPageData.Loading }
.distinctUntilChanged()
.filter { !it }
.onEach { showNoMoreItemsSnackbar() }
.launchIn(this)
}
}
}
private fun showNoMoreItemsSnackbar() {
val messageResId = when (inputs.mode) {
MediaViewerEntryPoint.MediaViewerMode.SingleMedia,
is MediaViewerEntryPoint.MediaViewerMode.TimelineImagesAndVideos -> R.string.screen_media_details_no_more_media_to_show
is MediaViewerEntryPoint.MediaViewerMode.TimelineFilesAndAudios -> R.string.screen_media_details_no_more_files_to_show
}
val message = SnackbarMessage(messageResId)
snackbarDispatcher.post(message)
}
private fun CoroutineScope.downloadMedia(
data: MediaViewerPageData.MediaViewerData,
) = launch {
dataSource.loadMedia(data)
}
private fun CoroutineScope.saveOnDisk(localMedia: AsyncData<LocalMedia>) = launch {
if (localMedia is AsyncData.Success) {
localMediaActions.saveOnDisk(localMedia.data)
.onSuccess {
val snackbarMessage = SnackbarMessage(CommonStrings.common_file_saved_on_disk_android)
snackbarDispatcher.post(snackbarMessage)
}
.onFailure {
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
snackbarDispatcher.post(snackbarMessage)
}
}
}
private fun CoroutineScope.delete(eventId: EventId) = launch {
room.liveTimeline.redactEvent(eventId.toEventOrTransactionId(), null)
.onFailure {
val snackbarMessage = SnackbarMessage(CommonStrings.error_unknown)
snackbarDispatcher.post(snackbarMessage)
}
.onSuccess {
navigator.onItemDeleted()
}
}
private fun CoroutineScope.share(localMedia: AsyncData<LocalMedia>) = launch {
if (localMedia is AsyncData.Success) {
localMediaActions.share(localMedia.data)
.onFailure {
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
snackbarDispatcher.post(snackbarMessage)
}
}
}
private fun CoroutineScope.open(localMedia: AsyncData<LocalMedia>) = launch {
if (localMedia is AsyncData.Success) {
localMediaActions.open(localMedia.data)
.onFailure {
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
snackbarDispatcher.post(snackbarMessage)
}
}
}
private fun mediaActionsError(throwable: Throwable): Int {
return if (throwable is ActivityNotFoundException) {
UtilsR.string.error_no_compatible_app_found
} else {
CommonStrings.error_unknown
}
}
private fun searchIndex(data: List<MediaViewerPageData>, eventId: EventId?): Int {
if (eventId == null) {
return 0
}
return data.indexOfFirst {
(it as? MediaViewerPageData.MediaViewerData)?.eventId == eventId
}.coerceAtLeast(0)
}
}
@@ -0,0 +1,54 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.viewer
import androidx.compose.runtime.State
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
import kotlinx.collections.immutable.ImmutableList
data class MediaViewerState(
val initiallySelectedEventId: EventId?,
val listData: ImmutableList<MediaViewerPageData>,
val currentIndex: Int,
val snackbarMessage: SnackbarMessage?,
val canShowInfo: Boolean,
val mediaBottomSheetState: MediaBottomSheetState,
val eventSink: (MediaViewerEvents) -> Unit,
)
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(
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val downloadedMedia: State<AsyncData<LocalMedia>>,
override val pagerKey: Long,
) : MediaViewerPageData
}
@@ -0,0 +1,214 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.viewer
import android.net.Uri
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.media.WaveFormSamples
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
import io.element.android.libraries.mediaviewer.api.aTxtMediaInfo
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
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.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.details.aMediaDeleteConfirmationState
import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState
import kotlinx.collections.immutable.toImmutableList
open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> {
override val values: Sequence<MediaViewerState>
get() = sequenceOf(
aMediaViewerState(),
aMediaViewerState(listOf(aMediaViewerPageData(AsyncData.Loading()))),
aMediaViewerState(listOf(aMediaViewerPageData(AsyncData.Failure(IllegalStateException("error"))))),
anImageMediaInfo(
senderName = "Sally Sanderson",
dateSent = "21 NOV, 2024",
caption = "A caption",
).let {
aMediaViewerState(
listOf(
aMediaViewerPageData(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
)
},
aVideoMediaInfo(
senderName = "A very long name so that it will be truncated and will not be displayed on multiple lines",
dateSent = "A very very long date that will be truncated and will not be displayed on multiple lines",
caption = "A caption",
).let {
aMediaViewerState(
listOf(
aMediaViewerPageData(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
)
},
aPdfMediaInfo().let {
aMediaViewerState(
listOf(
aMediaViewerPageData(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
)
},
aMediaViewerState(
listOf(
aMediaViewerPageData(
downloadedMedia = AsyncData.Loading(),
mediaInfo = anApkMediaInfo(),
)
)
),
anApkMediaInfo().let {
aMediaViewerState(
listOf(
aMediaViewerPageData(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
)
},
aMediaViewerState(
listOf(
aMediaViewerPageData(
downloadedMedia = AsyncData.Loading(),
mediaInfo = anAudioMediaInfo(),
)
)
),
anAudioMediaInfo().let {
aMediaViewerState(
listOf(
aMediaViewerPageData(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
)
},
anImageMediaInfo().let {
aMediaViewerState(
listOf(
aMediaViewerPageData(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
),
canShowInfo = false,
)
},
aMediaViewerState(
mediaBottomSheetState = aMediaDetailsBottomSheetState(),
),
aMediaViewerState(
mediaBottomSheetState = aMediaDeleteConfirmationState(),
),
anAudioMediaInfo(
waveForm = WaveFormSamples.realisticWaveForm,
).let {
aMediaViewerState(
listOf(
aMediaViewerPageData(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
)
)
},
aMediaViewerState(
listOf(
aMediaViewerPageDataLoading()
),
),
aMediaViewerState(
listOf(
MediaViewerPageData.Failure(Exception("error"))
),
),
aMediaViewerState(
listOf(
aMediaViewerPageData(
downloadedMedia = AsyncData.Loading(),
mediaInfo = aTxtMediaInfo(),
)
)
),
)
}
fun aMediaViewerPageDataLoading(
direction: Timeline.PaginationDirection = Timeline.PaginationDirection.BACKWARDS,
timestamp: Long = 0L,
): MediaViewerPageData {
return MediaViewerPageData.Loading(
direction = direction,
timestamp = timestamp,
pagerKey = 0L,
)
}
fun aMediaViewerPageData(
downloadedMedia: AsyncData<LocalMedia> = AsyncData.Uninitialized,
mediaInfo: MediaInfo = anImageMediaInfo(),
mediaSource: MediaSource = MediaSource(""),
): MediaViewerPageData.MediaViewerData = MediaViewerPageData.MediaViewerData(
eventId = null,
mediaInfo = mediaInfo,
mediaSource = mediaSource,
thumbnailSource = null,
downloadedMedia = mutableStateOf(downloadedMedia),
pagerKey = 0L,
)
fun aMediaViewerState(
listData: List<MediaViewerPageData> = listOf(aMediaViewerPageData()),
currentIndex: Int = 0,
canShowInfo: Boolean = true,
mediaBottomSheetState: MediaBottomSheetState = MediaBottomSheetState.Hidden,
eventSink: (MediaViewerEvents) -> Unit = {},
) = MediaViewerState(
initiallySelectedEventId = EventId("\$a:b"),
listData = listData.toImmutableList(),
currentIndex = currentIndex,
snackbarMessage = null,
canShowInfo = canShowInfo,
mediaBottomSheetState = mediaBottomSheetState,
eventSink = eventSink,
)
@@ -0,0 +1,606 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.libraries.mediaviewer.impl.viewer
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.viewfolder.api.TextFileViewer
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.audio.api.AudioFocus
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.designsystem.components.async.AsyncFailure
import io.element.android.libraries.designsystem.components.async.AsyncLoading
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
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.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.R
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.details.MediaDeleteConfirmationBottomSheet
import io.element.android.libraries.mediaviewer.impl.details.MediaDetailsBottomSheet
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaView
import io.element.android.libraries.mediaviewer.impl.local.PlayableState
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
import io.element.android.libraries.mediaviewer.impl.util.bgCanvasWithTransparency
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.delay
import me.saket.telephoto.zoomable.OverzoomEffect
import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.rememberZoomableState
val topAppBarHeight = 88.dp
@Composable
fun MediaViewerView(
state: MediaViewerState,
textFileViewer: TextFileViewer,
onBackClick: () -> Unit,
audioFocus: AudioFocus?,
modifier: Modifier = Modifier,
) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
var showOverlay by remember { mutableStateOf(true) }
val defaultBottomPaddingInPixels = if (LocalInspectionMode.current) 303 else 0
val currentData = state.listData.getOrNull(state.currentIndex)
BackHandler { onBackClick() }
Scaffold(
modifier,
containerColor = Color.Transparent,
snackbarHost = { SnackbarHost(snackbarHostState) },
) {
val pagerState = rememberPagerState(state.currentIndex, 0f) {
state.listData.size
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
state.eventSink(MediaViewerEvents.OnNavigateTo(page))
}
}
HorizontalPager(
state = pagerState,
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 -> {
MediaViewerErrorPage(
throwable = dataForPage.throwable,
onDismiss = onBackClick,
)
}
is MediaViewerPageData.Loading -> {
LaunchedEffect(dataForPage.timestamp) {
state.eventSink(MediaViewerEvents.LoadMore(dataForPage.direction))
}
MediaViewerLoadingPage(
onDismiss = onBackClick,
)
}
is MediaViewerPageData.MediaViewerData -> {
var bottomPaddingInPixels by remember { mutableIntStateOf(defaultBottomPaddingInPixels) }
LaunchedEffect(Unit) {
state.eventSink(MediaViewerEvents.LoadMedia(dataForPage))
}
Box(
modifier = Modifier.fillMaxSize()
) {
val isDisplayed = remember(pagerState.settledPage) {
// This 'item provider' lambda will be called when the data source changes with an outdated `settlePage` value
// So we need to update this value only when the `settledPage` value changes. It seems like a bug that needs to be fixed in Compose.
page == pagerState.settledPage
}
MediaViewerPage(
isDisplayed = isDisplayed,
showOverlay = showOverlay,
bottomPaddingInPixels = bottomPaddingInPixels,
data = dataForPage,
textFileViewer = textFileViewer,
onDismiss = onBackClick,
onRetry = {
state.eventSink(MediaViewerEvents.LoadMedia(dataForPage))
},
onDismissError = {
state.eventSink(MediaViewerEvents.ClearLoadingError(dataForPage))
},
onShowOverlayChange = {
showOverlay = it
},
audioFocus = audioFocus,
isUserSelected = (state.listData[page] as? MediaViewerPageData.MediaViewerData)?.eventId == state.initiallySelectedEventId,
)
// Bottom bar
AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) {
Box(
modifier = Modifier
.fillMaxSize()
.navigationBarsPadding()
) {
MediaViewerBottomBar(
modifier = Modifier.align(Alignment.BottomCenter),
showDivider = dataForPage.mediaInfo.mimeType.isMimeTypeVideo(),
caption = dataForPage.mediaInfo.caption,
onHeightChange = { bottomPaddingInPixels = it },
)
}
}
}
}
}
}
// Top bar
AnimatedVisibility(visible = showOverlay, enter = fadeIn(), exit = fadeOut()) {
Box(
modifier = Modifier
.fillMaxSize()
.navigationBarsPadding()
) {
when (currentData) {
is MediaViewerPageData.MediaViewerData -> {
MediaViewerTopBar(
data = currentData,
canShowInfo = state.canShowInfo,
onBackClick = onBackClick,
onInfoClick = {
state.eventSink(MediaViewerEvents.OpenInfo(currentData))
},
eventSink = state.eventSink
)
}
else -> {
TopAppBar(
title = {
if (currentData is MediaViewerPageData.Loading) {
Text(
modifier = Modifier.semantics {
heading()
},
text = stringResource(id = CommonStrings.common_loading_more),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = bgCanvasWithTransparency,
),
navigationIcon = { BackButton(onClick = onBackClick) },
)
}
}
}
}
}
when (val bottomSheetState = state.mediaBottomSheetState) {
MediaBottomSheetState.Hidden -> Unit
is MediaBottomSheetState.MediaDetailsBottomSheetState -> {
MediaDetailsBottomSheet(
state = bottomSheetState,
onViewInTimeline = {
state.eventSink(MediaViewerEvents.ViewInTimeline(it))
},
onShare = {
(currentData as? MediaViewerPageData.MediaViewerData)?.let {
state.eventSink(MediaViewerEvents.Share(currentData))
}
},
onForward = {
state.eventSink(MediaViewerEvents.Forward(it))
},
onDownload = {
(currentData as? MediaViewerPageData.MediaViewerData)?.let {
state.eventSink(MediaViewerEvents.SaveOnDisk(currentData))
}
},
onDelete = { eventId ->
(currentData as? MediaViewerPageData.MediaViewerData)?.let {
state.eventSink(
MediaViewerEvents.ConfirmDelete(
eventId,
currentData,
)
)
}
},
onDismiss = {
state.eventSink(MediaViewerEvents.CloseBottomSheet)
},
)
}
is MediaBottomSheetState.MediaDeleteConfirmationState -> {
MediaDeleteConfirmationBottomSheet(
state = bottomSheetState,
onDelete = {
state.eventSink(MediaViewerEvents.Delete(it))
},
onDismiss = {
state.eventSink(MediaViewerEvents.CloseBottomSheet)
},
)
}
}
}
@Composable
private fun MediaViewerPage(
isDisplayed: Boolean,
showOverlay: Boolean,
bottomPaddingInPixels: Int,
data: MediaViewerPageData.MediaViewerData,
textFileViewer: TextFileViewer,
isUserSelected: Boolean,
onDismiss: () -> Unit,
onRetry: () -> Unit,
onDismissError: () -> Unit,
onShowOverlayChange: (Boolean) -> Unit,
audioFocus: AudioFocus?,
modifier: Modifier = Modifier,
) {
val currentShowOverlay by rememberUpdatedState(showOverlay)
val currentOnShowOverlayChange by rememberUpdatedState(onShowOverlayChange)
MediaViewerFlickToDismiss(
onDismiss = onDismiss,
onDragging = {
currentOnShowOverlayChange(false)
},
onResetting = {
currentOnShowOverlayChange(true)
},
modifier = modifier,
) {
val downloadedMedia by data.downloadedMedia
val showProgress = rememberShowProgress(downloadedMedia)
Box(
modifier = Modifier
.fillMaxSize()
.navigationBarsPadding()
) {
Box(contentAlignment = Alignment.Center) {
val zoomableState = rememberZoomableState(
zoomSpec = ZoomSpec(maxZoomFactor = 4f, overzoomEffect = OverzoomEffect.NoLimits)
)
val localMediaViewState = rememberLocalMediaViewState(zoomableState)
val showThumbnail = !localMediaViewState.isReady
val playableState = localMediaViewState.playableState
val showError = downloadedMedia.isFailure()
LaunchedEffect(playableState) {
if (playableState is PlayableState.Playable) {
currentOnShowOverlayChange(playableState.isShowingControls)
}
}
LocalMediaView(
modifier = Modifier.fillMaxSize(),
isDisplayed = isDisplayed,
bottomPaddingInPixels = bottomPaddingInPixels,
localMediaViewState = localMediaViewState,
localMedia = downloadedMedia.dataOrNull(),
mediaInfo = data.mediaInfo,
textFileViewer = textFileViewer,
onClick = {
if (playableState is PlayableState.NotPlayable) {
currentOnShowOverlayChange(!currentShowOverlay)
}
},
isUserSelected = isUserSelected,
audioFocus = audioFocus,
)
ThumbnailView(
mediaInfo = data.mediaInfo,
thumbnailSource = data.thumbnailSource,
isVisible = showThumbnail,
)
if (showError) {
ErrorView(
errorMessage = stringResource(id = CommonStrings.error_unknown),
onRetry = onRetry,
onDismiss = onDismissError
)
}
}
if (showProgress) {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.height(2.dp)
)
}
}
}
}
@Composable
private fun MediaViewerLoadingPage(
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
MediaViewerFlickToDismiss(
onDismiss = onDismiss,
modifier = modifier,
) {
Box(
modifier = Modifier
.fillMaxSize()
.navigationBarsPadding(),
contentAlignment = Alignment.Center
) {
AsyncLoading()
}
}
}
@Composable
private fun MediaViewerErrorPage(
throwable: Throwable,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
MediaViewerFlickToDismiss(
onDismiss = onDismiss,
modifier = modifier,
) {
Box(
modifier = Modifier
.fillMaxSize()
.navigationBarsPadding(),
contentAlignment = Alignment.Center
) {
AsyncFailure(
throwable = throwable,
onRetry = null
)
}
}
}
@Composable
private fun rememberShowProgress(downloadedMedia: AsyncData<LocalMedia>): Boolean {
var showProgress by remember {
mutableStateOf(false)
}
if (LocalInspectionMode.current) {
showProgress = downloadedMedia.isLoading()
} else {
// Trick to avoid showing progress indicator if the media is already on disk.
// When sdk will expose download progress we'll be able to remove this.
LaunchedEffect(downloadedMedia) {
showProgress = false
delay(100)
if (downloadedMedia.isLoading()) {
showProgress = true
}
}
}
return showProgress
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MediaViewerTopBar(
data: MediaViewerPageData.MediaViewerData,
canShowInfo: Boolean,
onBackClick: () -> Unit,
onInfoClick: () -> Unit,
eventSink: (MediaViewerEvents) -> Unit,
) {
val downloadedMedia by data.downloadedMedia
val actionsEnabled = downloadedMedia.isSuccess()
val mimeType = data.mediaInfo.mimeType
val senderName = data.mediaInfo.senderName
val dateSent = data.mediaInfo.dateSent
TopAppBar(
title = {
if (senderName != null && dateSent != null) {
Column(
modifier = Modifier
.fillMaxWidth()
) {
Text(
modifier = Modifier.semantics {
heading()
},
text = senderName,
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = dateSent,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = bgCanvasWithTransparency,
),
navigationIcon = { BackButton(onClick = onBackClick) },
actions = {
IconButton(
enabled = actionsEnabled,
onClick = {
eventSink(MediaViewerEvents.OpenWith(data))
},
) {
when (mimeType) {
MimeTypes.Apk -> Icon(
resourceId = R.drawable.ic_apk_install,
contentDescription = stringResource(id = CommonStrings.common_install_apk_android)
)
else -> Icon(
imageVector = CompoundIcons.PopOut(),
contentDescription = stringResource(id = CommonStrings.action_open_with)
)
}
}
if (canShowInfo) {
IconButton(
onClick = onInfoClick,
enabled = actionsEnabled,
) {
Icon(
imageVector = CompoundIcons.Info(),
contentDescription = stringResource(id = CommonStrings.a11y_view_details),
)
}
}
}
)
}
@Composable
private fun MediaViewerBottomBar(
caption: String?,
showDivider: Boolean,
onHeightChange: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.background(bgCanvasWithTransparency)
.onSizeChanged {
onHeightChange(it.height)
},
) {
if (caption != null) {
if (showDivider) {
HorizontalDivider()
}
Text(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
text = caption,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyLgRegular,
)
}
}
}
@Composable
private fun ThumbnailView(
thumbnailSource: MediaSource?,
isVisible: Boolean,
mediaInfo: MediaInfo,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
if (isVisible) {
val mediaRequestData = MediaRequestData(
source = thumbnailSource,
kind = MediaRequestData.Kind.File(mediaInfo.filename, mediaInfo.mimeType)
)
val alpha = if (LocalInspectionMode.current) 0.1f else 1f
AsyncImage(
modifier = Modifier
.fillMaxSize()
.alpha(alpha),
model = mediaRequestData,
contentScale = ContentScale.Fit,
contentDescription = null,
)
}
}
}
@Composable
private fun ErrorView(
errorMessage: String,
onRetry: () -> Unit,
onDismiss: () -> Unit,
) {
RetryDialog(
content = errorMessage,
onRetry = onRetry,
onDismiss = onDismiss
)
}
// Only preview in dark, dark theme is forced on the Node.
@Preview
@Composable
internal fun MediaViewerViewPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) = ElementPreviewDark {
MediaViewerView(
state = state,
audioFocus = null,
textFileViewer = { _, _ -> },
onBackClick = {},
)
}
@@ -0,0 +1,88 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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 dev.zacsweers.metro.Inject
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.eventId
/**
* 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)
*/
@Inject
class PagerKeysHandler {
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
}
}
@@ -0,0 +1,90 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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,
)
}
}
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M160,880Q127,880 103.5,856.5Q80,833 80,800L80,160Q80,127 103.5,103.5Q127,80 160,80L480,80L720,320L720,490L640,490L640,360L440,360L440,160L160,160Q160,160 160,160Q160,160 160,160L160,800Q160,800 160,800Q160,800 160,800L600,800L600,880L160,880ZM160,800L160,490L160,490L160,360L160,160L160,160Q160,160 160,160Q160,160 160,160L160,800Q160,800 160,800Q160,800 160,800L160,800ZM200,760Q204,711 230,670Q256,629 298,605L260,537Q260,536 264,522Q269,520 273.5,520Q278,520 280,525L319,595Q339,587 359,582.5Q379,578 400,578Q421,578 441,582.5Q461,587 481,595L520,525Q520,525 535,521Q540,523 541,528Q542,533 540,537L502,605Q544,629 570,670Q596,711 600,760L200,760ZM310,700Q318,700 324,694Q330,688 330,680Q330,672 324,666Q318,660 310,660Q302,660 296,666Q290,672 290,680Q290,688 296,694Q302,700 310,700ZM490,700Q498,700 504,694Q510,688 510,680Q510,672 504,666Q498,660 490,660Q482,660 476,666Q470,672 470,680Q470,688 476,694Q482,700 490,700ZM800,880L640,720L696,663L760,726L760,560L840,560L840,726L904,663L960,720L800,880Z"/>
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_list_loading_media">"Зареждане на медийни файлове…"</string>
<string name="screen_media_browser_list_mode_files">"Файлове"</string>
<string name="screen_media_browser_list_mode_media">"Медия"</string>
<string name="screen_media_browser_title">"Медия и файлове"</string>
</resources>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_delete_confirmation_subtitle">"Tento soubor bude odstraněn z místnosti a členové k němu nebudou mít přístup."</string>
<string name="screen_media_browser_delete_confirmation_title">"Smazat soubor?"</string>
<string name="screen_media_browser_download_error_message">"Zkontrolujte připojení k internetu a zkuste to znovu."</string>
<string name="screen_media_browser_files_empty_state_subtitle">"Zde se zobrazí dokumenty, zvukové soubory a hlasové zprávy nahrané do této místnosti."</string>
<string name="screen_media_browser_files_empty_state_title">"Zatím nebyly nahrány žádné soubory"</string>
<string name="screen_media_browser_list_loading_files">"Načítání souborů…"</string>
<string name="screen_media_browser_list_loading_media">"Načítání médií…"</string>
<string name="screen_media_browser_list_mode_files">"Soubory"</string>
<string name="screen_media_browser_list_mode_media">"Média"</string>
<string name="screen_media_browser_media_empty_state_subtitle">"Obrázky a videa nahraná do této místnosti budou zobrazeny zde."</string>
<string name="screen_media_browser_media_empty_state_title">"Zatím nebyla nahrána žádná média"</string>
<string name="screen_media_browser_title">"Média a soubory"</string>
<string name="screen_media_details_file_format">"Formát souboru"</string>
<string name="screen_media_details_filename">"Název souboru"</string>
<string name="screen_media_details_no_more_files_to_show">"Žádné další soubory k zobrazení"</string>
<string name="screen_media_details_no_more_media_to_show">"Žádná další média k zobrazení"</string>
<string name="screen_media_details_uploaded_by">"Nahrál(a)"</string>
<string name="screen_media_details_uploaded_on">"Nahráno"</string>
</resources>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_delete_confirmation_subtitle">"Bydd y ffeil hon yn cael ei thynnu o\'r ystafell a bydd gan aelodau ddim mynediad iddi."</string>
<string name="screen_media_browser_delete_confirmation_title">"Dileu ffeil?"</string>
<string name="screen_media_browser_download_error_message">"Gwiriwch eich cysylltiad rhyngrwyd a rhowch gynnig arall arni."</string>
<string name="screen_media_browser_files_empty_state_subtitle">"Bydd dogfennau, ffeiliau sain, a negeseuon llais llwythwyd i\'r ystafell hon yn cael eu dangos yma."</string>
<string name="screen_media_browser_files_empty_state_title">"Dim ffeiliau wedi\'u llwytho eto"</string>
<string name="screen_media_browser_list_loading_files">"Wrthi\'n llwytho ffeiliau…"</string>
<string name="screen_media_browser_list_loading_media">"Wrthi\'n llwytho cyfryngau…"</string>
<string name="screen_media_browser_list_mode_files">"Ffeiliau"</string>
<string name="screen_media_browser_list_mode_media">"Cyfryngau"</string>
<string name="screen_media_browser_media_empty_state_subtitle">"Bydd delweddau a fideos llwythwyd i\'r ystafell hon yn cael eu dangos yma."</string>
<string name="screen_media_browser_media_empty_state_title">"Dim cyfrwng wedi\'i llwytho eto"</string>
<string name="screen_media_browser_title">"Cyfryngau a ffeiliau"</string>
<string name="screen_media_details_file_format">"Fformat ffeil"</string>
<string name="screen_media_details_filename">"Enw\'r ffeil"</string>
<string name="screen_media_details_no_more_files_to_show">"Dim mwy o ffeiliau i\'w dangos"</string>
<string name="screen_media_details_no_more_media_to_show">"Dim mwy o gyfryngau i\'w dangos"</string>
<string name="screen_media_details_uploaded_by">"Llwythwyd gan"</string>
<string name="screen_media_details_uploaded_on">"Llwythwyd i fyny ar"</string>
</resources>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_delete_confirmation_subtitle">"Denne fil vil blive fjernet fra rummet, og medlemmer vil ikke længere have adgang til den."</string>
<string name="screen_media_browser_delete_confirmation_title">"Vil du slette filen?"</string>
<string name="screen_media_browser_download_error_message">"Tjek din internetforbindelse, og prøv igen."</string>
<string name="screen_media_browser_files_empty_state_subtitle">"Dokumenter, lydfiler og stemmemeddelelser uploadet til dette rum vises her."</string>
<string name="screen_media_browser_files_empty_state_title">"Ingen filer uploadet endnu"</string>
<string name="screen_media_browser_list_loading_files">"Indlæser filer…"</string>
<string name="screen_media_browser_list_loading_media">"Indlæser medier…"</string>
<string name="screen_media_browser_list_mode_files">"Filer"</string>
<string name="screen_media_browser_list_mode_media">"Medier"</string>
<string name="screen_media_browser_media_empty_state_subtitle">"Billeder og videoer uploadet til dette rum vil blive vist her."</string>
<string name="screen_media_browser_media_empty_state_title">"Ingen medier uploadet endnu"</string>
<string name="screen_media_browser_title">"Medier og filer"</string>
<string name="screen_media_details_file_format">"Filformat"</string>
<string name="screen_media_details_filename">"Filnavn"</string>
<string name="screen_media_details_no_more_files_to_show">"Ikke flere filer at vise"</string>
<string name="screen_media_details_no_more_media_to_show">"Ikke flere medier at vise"</string>
<string name="screen_media_details_uploaded_by">"Uploadet af"</string>
<string name="screen_media_details_uploaded_on">"Uploadet på"</string>
</resources>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_delete_confirmation_subtitle">"Diese Datei wird aus dem Chat entfernt und die Mitglieder werden keinen Zugriff mehr darauf haben."</string>
<string name="screen_media_browser_delete_confirmation_title">"Datei löschen?"</string>
<string name="screen_media_browser_download_error_message">"Überprüfe deine Internetverbindung und versuche es erneut."</string>
<string name="screen_media_browser_files_empty_state_subtitle">"Dokumente, Audiodateien und Sprachnachrichten, die in diesen Chat hochgeladen wurden, werden hier angezeigt."</string>
<string name="screen_media_browser_files_empty_state_title">"Es wurden noch keine Dateien hochgeladen"</string>
<string name="screen_media_browser_list_loading_files">"Dateien werden geladen…"</string>
<string name="screen_media_browser_list_loading_media">"Medien werden geladen…"</string>
<string name="screen_media_browser_list_mode_files">"Dateien"</string>
<string name="screen_media_browser_list_mode_media">"Medien"</string>
<string name="screen_media_browser_media_empty_state_subtitle">"In diesen Chat hochgeladene Bilder und Videos werden hier angezeigt."</string>
<string name="screen_media_browser_media_empty_state_title">"Noch keine Medien hochgeladen"</string>
<string name="screen_media_browser_title">"Medien und Dateien"</string>
<string name="screen_media_details_file_format">"Dateiformat"</string>
<string name="screen_media_details_filename">"Dateiname"</string>
<string name="screen_media_details_no_more_files_to_show">"Keine weiteren Dateien zum Anzeigen"</string>
<string name="screen_media_details_no_more_media_to_show">"Keine weiteren Medien mehr zum Anzeigen"</string>
<string name="screen_media_details_uploaded_by">"Hochgeladen von"</string>
<string name="screen_media_details_uploaded_on">"Hochgeladen am"</string>
</resources>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_delete_confirmation_subtitle">"Αυτό το αρχείο θα αφαιρεθεί από την αίθουσα και τα μέλη δεν θα έχουν πρόσβαση σε αυτό."</string>
<string name="screen_media_browser_delete_confirmation_title">"Διαγραφή αρχείου;"</string>
<string name="screen_media_browser_download_error_message">"Ελέγξτε τη σύνδεσή σας στο διαδίκτυο και δοκιμάστε ξανά."</string>
<string name="screen_media_browser_files_empty_state_subtitle">"Τα έγγραφα, τα αρχεία ήχου και τα φωνητικά μηνύματα που μεταφορτώνονται σε αυτή την αίθουσα θα εμφανίζονται εδώ."</string>
<string name="screen_media_browser_files_empty_state_title">"Δεν έχουν μεταφορτωθεί ακόμα αρχεία"</string>
<string name="screen_media_browser_list_loading_files">"Φόρτωση αρχείων…"</string>
<string name="screen_media_browser_list_loading_media">"Φόρτωση πολυμέσων…"</string>
<string name="screen_media_browser_list_mode_files">"Αρχεία"</string>
<string name="screen_media_browser_list_mode_media">"Πολυμέσα"</string>
<string name="screen_media_browser_media_empty_state_subtitle">"Οι εικόνες και τα βίντεο που μεταφορτώνονται σε αυτή την αίθουσα θα εμφανίζονται εδώ."</string>
<string name="screen_media_browser_media_empty_state_title">"Δεν έχουν μεταφορτωθεί ακόμα πολυμέσα"</string>
<string name="screen_media_browser_title">"Πολυμέσα και αρχεία"</string>
<string name="screen_media_details_file_format">"Μορφή αρχείου"</string>
<string name="screen_media_details_filename">"Όνομα αρχείου"</string>
<string name="screen_media_details_no_more_files_to_show">"Δεν υπάρχουν άλλα αρχεία για εμφάνιση"</string>
<string name="screen_media_details_no_more_media_to_show">"Δεν υπάρχουν άλλα μέσα για εμφάνιση"</string>
<string name="screen_media_details_uploaded_by">"Μεταφορτώθηκε από"</string>
<string name="screen_media_details_uploaded_on">"Μεταφορτώθηκε στις"</string>
</resources>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_delete_confirmation_subtitle">"Este archivo se eliminará de la sala y los miembros no tendrán acceso a él."</string>
<string name="screen_media_browser_delete_confirmation_title">"¿Borrar archivo?"</string>
<string name="screen_media_browser_download_error_message">"Verifica tu conexión a Internet e inténtalo de nuevo."</string>
<string name="screen_media_browser_files_empty_state_subtitle">"Los documentos, archivos de audio y mensajes de voz subidos a esta sala se mostrarán aquí."</string>
<string name="screen_media_browser_files_empty_state_title">"Aún no se ha subido ningún archivo"</string>
<string name="screen_media_browser_list_loading_files">"Cargando archivos…"</string>
<string name="screen_media_browser_list_loading_media">"Cargando medios…"</string>
<string name="screen_media_browser_list_mode_files">"Archivos"</string>
<string name="screen_media_browser_list_mode_media">"Medios"</string>
<string name="screen_media_browser_media_empty_state_subtitle">"Las imágenes y vídeos subidos a esta sala se mostrarán aquí."</string>
<string name="screen_media_browser_media_empty_state_title">"Aún no se ha subido ningún medio"</string>
<string name="screen_media_browser_title">"Medios y archivos"</string>
<string name="screen_media_details_file_format">"Formato de archivo"</string>
<string name="screen_media_details_filename">"Nombre del archivo"</string>
<string name="screen_media_details_no_more_files_to_show">"No hay más archivos que mostrar"</string>
<string name="screen_media_details_no_more_media_to_show">"No hay más medios que mostrar"</string>
<string name="screen_media_details_uploaded_by">"Subido por"</string>
<string name="screen_media_details_uploaded_on">"Subido el"</string>
</resources>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_delete_confirmation_subtitle">"Järgnevaga eemaldame selle faili jututoast ka tema liikmed enam ei pääse failile ligi."</string>
<string name="screen_media_browser_delete_confirmation_title">"Kas kustutame faili?"</string>
<string name="screen_media_browser_download_error_message">"Kontrolli oma nutiseadme internetiühenduse toimimist ja proovi uuesti"</string>
<string name="screen_media_browser_files_empty_state_subtitle">"Antud jututuppa üleslaaditud dokumendid, helifailid ja häälsõnumid saavad olema nähtaval siin."</string>
<string name="screen_media_browser_files_empty_state_title">"Ühtegi faili pole veel üleslaaditud"</string>
<string name="screen_media_browser_list_loading_files">"Laadime faile…"</string>
<string name="screen_media_browser_list_loading_media">"Laadime meediat…"</string>
<string name="screen_media_browser_list_mode_files">"Failid"</string>
<string name="screen_media_browser_list_mode_media">"Meedia"</string>
<string name="screen_media_browser_media_empty_state_subtitle">"Antud jututuppa üleslaaditud pildid ja videod kuvatakse siin."</string>
<string name="screen_media_browser_media_empty_state_title">"Mitte keegi pole veel meediat üles laadinud"</string>
<string name="screen_media_browser_title">"Meedia ja failid"</string>
<string name="screen_media_details_file_format">"Failivorming"</string>
<string name="screen_media_details_filename">"Failinimi"</string>
<string name="screen_media_details_no_more_files_to_show">"Pole enam kuvatavaid faile"</string>
<string name="screen_media_details_no_more_media_to_show">"Pole enam kuvatavat meediat"</string>
<string name="screen_media_details_uploaded_by">"Üleslaadija"</string>
<string name="screen_media_details_uploaded_on">"Üleslaaditud"</string>
</resources>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_delete_confirmation_subtitle">"Fitxategia gelatik kenduko da eta kideek ezingo dute atzitu."</string>
<string name="screen_media_browser_delete_confirmation_title">"Fitxategia ezabatu?"</string>
<string name="screen_media_browser_download_error_message">"Egiaztatu Interneteko konexioa eta saiatu berriro."</string>
<string name="screen_media_browser_files_empty_state_subtitle">"Gela honetara igotako dokumentuak, audio-fitxategiak, eta ahots-mezuak hemen erakutsiko dira."</string>
<string name="screen_media_browser_files_empty_state_title">"Oraindik ez da fitxategirik igo"</string>
<string name="screen_media_browser_list_loading_files">"Fitxategiak kargatzen…"</string>
<string name="screen_media_browser_list_loading_media">"Multimedia kargatzen…"</string>
<string name="screen_media_browser_list_mode_files">"Fitxategiak"</string>
<string name="screen_media_browser_list_mode_media">"Multimedia"</string>
<string name="screen_media_browser_media_empty_state_subtitle">"Gela honetara igotako irudiak eta bideoak hemen erakutsiko dira."</string>
<string name="screen_media_browser_media_empty_state_title">"Oraindik ez da multimedia fitxategirik igo"</string>
<string name="screen_media_browser_title">"Multimedia eta fitxategiak"</string>
<string name="screen_media_details_file_format">"Fitxategiaren formatua"</string>
<string name="screen_media_details_filename">"Fitxategiaren izena"</string>
<string name="screen_media_details_uploaded_by">"Nork igota:"</string>
<string name="screen_media_details_uploaded_on">"Noiz igota:"</string>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_delete_confirmation_title">"حذف پرونده؟"</string>
<string name="screen_media_browser_download_error_message">"بررسی اتّصال اینترنتیتان و تلاش دوباره."</string>
<string name="screen_media_browser_files_empty_state_subtitle">"سندها، پرونده‌ها و پیام‌های صوتی بار گذاشته در این اتاق این‌جا نشان داده خواهند شد."</string>
<string name="screen_media_browser_files_empty_state_title">"هنوز هیچ پرونده‌ای بارگذاشته نشده"</string>
<string name="screen_media_browser_list_loading_files">"بار کردن پرونده‌ها…"</string>
<string name="screen_media_browser_list_loading_media">"بار کردن رسانه‌ها…"</string>
<string name="screen_media_browser_list_mode_files">"پرونده‌ها"</string>
<string name="screen_media_browser_list_mode_media">"رسانه"</string>
<string name="screen_media_browser_media_empty_state_subtitle">"تصویرها و ویدیوهای بار گذاشته در این اتاق این‌جا نشان داده خواهند شد."</string>
<string name="screen_media_browser_media_empty_state_title">"هنوز هیچ رسانه‌ای بارگذاشته نشده"</string>
<string name="screen_media_browser_title">"رسانه‌ها و پرونده‌ها"</string>
<string name="screen_media_details_file_format">"قالب پرونده"</string>
<string name="screen_media_details_filename">"نام پرونده"</string>
<string name="screen_media_details_uploaded_by">"بارگذاشته به دست"</string>
<string name="screen_media_details_uploaded_on">"بارگذاشته در"</string>
</resources>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_delete_confirmation_subtitle">"Tämä tiedosto poistetaan huoneesta, eikä jäsenillä ole enää pääsyä siihen."</string>
<string name="screen_media_browser_delete_confirmation_title">"Poistetaanko tiedosto?"</string>
<string name="screen_media_browser_download_error_message">"Tarkista internet-yhteytesi ja yritä uudelleen."</string>
<string name="screen_media_browser_files_empty_state_subtitle">"Tähän huoneeseen ladatut asiakirjat, äänitiedostot ja ääniviestit näkyvät täällä."</string>
<string name="screen_media_browser_files_empty_state_title">"Ei vielä ladattuja tiedostoja"</string>
<string name="screen_media_browser_list_loading_files">"Ladataan tiedostoja…"</string>
<string name="screen_media_browser_list_loading_media">"Ladataan mediaa…"</string>
<string name="screen_media_browser_list_mode_files">"Tiedostot"</string>
<string name="screen_media_browser_list_mode_media">"Media"</string>
<string name="screen_media_browser_media_empty_state_subtitle">"Tähän huoneeseen lähetetyt kuvat ja videot näytetään täällä."</string>
<string name="screen_media_browser_media_empty_state_title">"Mediaa ei ole vielä lähetetty"</string>
<string name="screen_media_browser_title">"Media ja tiedostot"</string>
<string name="screen_media_details_file_format">"Tiedostomuoto"</string>
<string name="screen_media_details_filename">"Tiedostonimi"</string>
<string name="screen_media_details_no_more_files_to_show">"Ei enää näytettäviä tiedostoja"</string>
<string name="screen_media_details_no_more_media_to_show">"Ei enää näytettävää mediaa"</string>
<string name="screen_media_details_uploaded_by">"Lähettäjä"</string>
<string name="screen_media_details_uploaded_on">"Lähetetty"</string>
</resources>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_delete_confirmation_subtitle">"Ce fichier sera supprimé du salon et les membres ny auront plus accès."</string>
<string name="screen_media_browser_delete_confirmation_title">"Supprimer le fichier ?"</string>
<string name="screen_media_browser_download_error_message">"Vérifiez votre connexion Internet et réessayez."</string>
<string name="screen_media_browser_files_empty_state_subtitle">"Les documents, les fichiers audio et les messages vocaux envoyés dans ce salon seront affichés ici."</string>
<string name="screen_media_browser_files_empty_state_title">"Aucun fichier na encore été envoyé"</string>
<string name="screen_media_browser_list_loading_files">"Chargement des fichiers…"</string>
<string name="screen_media_browser_list_loading_media">"Chargement des médias…"</string>
<string name="screen_media_browser_list_mode_files">"Fichiers"</string>
<string name="screen_media_browser_list_mode_media">"Média"</string>
<string name="screen_media_browser_media_empty_state_subtitle">"Les images et vidéos envoyées dans ce salon seront affichées ici."</string>
<string name="screen_media_browser_media_empty_state_title">"Aucun média na encore été envoyé dans ce salon"</string>
<string name="screen_media_browser_title">"Médias et fichiers"</string>
<string name="screen_media_details_file_format">"Format du fichier"</string>
<string name="screen_media_details_filename">"Nom du fichier"</string>
<string name="screen_media_details_no_more_files_to_show">"Il ny a plus de fichiers à montrer"</string>
<string name="screen_media_details_no_more_media_to_show">"Il ny a plus de médias à montrer"</string>
<string name="screen_media_details_uploaded_by">"Envoyé par"</string>
<string name="screen_media_details_uploaded_on">"Envoyé le"</string>
</resources>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_delete_confirmation_subtitle">"Ez a fájl el lesz távolítva a szobából, és a tagok nem férhetnek hozzá."</string>
<string name="screen_media_browser_delete_confirmation_title">"Törli a fájlt?"</string>
<string name="screen_media_browser_download_error_message">"Ellenőrizze az internetkapcsolatot, és próbálja újra."</string>
<string name="screen_media_browser_files_empty_state_subtitle">"A szobába feltöltött dokumentumok, hangfájlok és hangüzenetek itt jelennek meg."</string>
<string name="screen_media_browser_files_empty_state_title">"Még nincsenek fájlok feltöltve"</string>
<string name="screen_media_browser_list_loading_files">"Fájlok betöltése…"</string>
<string name="screen_media_browser_list_loading_media">"Média betöltése…"</string>
<string name="screen_media_browser_list_mode_files">"Fájlok"</string>
<string name="screen_media_browser_list_mode_media">"Média"</string>
<string name="screen_media_browser_media_empty_state_subtitle">"Az ebbe a szobába feltöltött képek és videók itt jelennek meg."</string>
<string name="screen_media_browser_media_empty_state_title">"Még nincs feltöltött média"</string>
<string name="screen_media_browser_title">"Média és fájlok"</string>
<string name="screen_media_details_file_format">"Fájlformátum"</string>
<string name="screen_media_details_filename">"Fájlnév"</string>
<string name="screen_media_details_no_more_files_to_show">"Nincs több megjeleníthető fájl"</string>
<string name="screen_media_details_no_more_media_to_show">"Nincs több megjeleníthető média"</string>
<string name="screen_media_details_uploaded_by">"Feltöltötte:"</string>
<string name="screen_media_details_uploaded_on">"Feltöltve:"</string>
</resources>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_delete_confirmation_subtitle">"Berkas ini akan dihapus dari ruangan dan anggota tidak akan memiliki akses ke sana."</string>
<string name="screen_media_browser_delete_confirmation_title">"Hapus berkas?"</string>
<string name="screen_media_browser_download_error_message">"Periksa koneksi internet Anda dan coba lagi."</string>
<string name="screen_media_browser_files_empty_state_subtitle">"Dokumen, berkas audio, dan pesan suara yang diunggah ke ruangan ini akan ditampilkan di sini."</string>
<string name="screen_media_browser_files_empty_state_title">"Belum ada berkas yang diunggah"</string>
<string name="screen_media_browser_list_loading_files">"Memuat berkas…"</string>
<string name="screen_media_browser_list_loading_media">"Memuat media…"</string>
<string name="screen_media_browser_list_mode_files">"Berkas"</string>
<string name="screen_media_browser_list_mode_media">"Media"</string>
<string name="screen_media_browser_media_empty_state_subtitle">"Gambar dan video yang diunggah ke ruangan ini akan ditampilkan di sini."</string>
<string name="screen_media_browser_media_empty_state_title">"Belum ada media yang diunggah"</string>
<string name="screen_media_browser_title">"Media dan berkas"</string>
<string name="screen_media_details_file_format">"Format berkas"</string>
<string name="screen_media_details_filename">"Nama berkas"</string>
<string name="screen_media_details_no_more_files_to_show">"Tidak ada lagi berkas untuk ditampilkan"</string>
<string name="screen_media_details_no_more_media_to_show">"Tidak ada lagi media untuk ditampilkan"</string>
<string name="screen_media_details_uploaded_by">"Diunggah oleh"</string>
<string name="screen_media_details_uploaded_on">"Diunggah pada"</string>
</resources>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_delete_confirmation_subtitle">"Questo file verrà rimosso dalla stanza e i membri non ne avranno accesso."</string>
<string name="screen_media_browser_delete_confirmation_title">"Eliminare il file?"</string>
<string name="screen_media_browser_download_error_message">"Controlla la tua connessione Internet e riprova."</string>
<string name="screen_media_browser_files_empty_state_subtitle">"I documenti, i file audio e i messaggi vocali caricati in questa stanza verranno visualizzati qui."</string>
<string name="screen_media_browser_files_empty_state_title">"Nessun file ancora caricato"</string>
<string name="screen_media_browser_list_loading_files">"Caricamento dei file…"</string>
<string name="screen_media_browser_list_loading_media">"Caricamento dei file multimediali…"</string>
<string name="screen_media_browser_list_mode_files">"File"</string>
<string name="screen_media_browser_list_mode_media">"Contenuti multimediali"</string>
<string name="screen_media_browser_media_empty_state_subtitle">"Le immagini e i video caricati in questa stanza verranno mostrati qui."</string>
<string name="screen_media_browser_media_empty_state_title">"Nessun file multimediale ancora caricato"</string>
<string name="screen_media_browser_title">"File e contenuti multimediali"</string>
<string name="screen_media_details_file_format">"Formato del file"</string>
<string name="screen_media_details_filename">"Nome del file"</string>
<string name="screen_media_details_no_more_files_to_show">"Nessun altro file da mostrare"</string>
<string name="screen_media_details_no_more_media_to_show">"Non ci sono più contenuti multimediali da mostrare"</string>
<string name="screen_media_details_uploaded_by">"Caricato da"</string>
<string name="screen_media_details_uploaded_on">"Caricato il"</string>
</resources>
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_browser_delete_confirmation_subtitle">"이 파일은 방에서 삭제되며, 회원들은 더 이상 액세스할 수 없습니다."</string>
<string name="screen_media_browser_delete_confirmation_title">"파일을 삭제하시겠습니까?"</string>
<string name="screen_media_browser_download_error_message">"인터넷 연결을 확인하고 다시 시도해 주세요."</string>
<string name="screen_media_browser_files_empty_state_subtitle">"이 방에 업로드된 문서, 오디오 파일 및 음성 메시지가 여기에 표시됩니다."</string>
<string name="screen_media_browser_files_empty_state_title">"아직 업로드된 파일이 없습니다."</string>
<string name="screen_media_browser_list_loading_files">"파일 로딩 중…"</string>
<string name="screen_media_browser_list_loading_media">"미디어 로딩 중…"</string>
<string name="screen_media_browser_list_mode_files">"파일"</string>
<string name="screen_media_browser_list_mode_media">"미디어"</string>
<string name="screen_media_browser_media_empty_state_subtitle">"이 방에 업로드된 이미지와 동영상은 여기에 표시됩니다."</string>
<string name="screen_media_browser_media_empty_state_title">"아직 미디어가 업로드되지 않았습니다."</string>
<string name="screen_media_browser_title">"미디어 및 파일"</string>
<string name="screen_media_details_file_format">"파일 형식"</string>
<string name="screen_media_details_filename">"파일 명"</string>
<string name="screen_media_details_no_more_files_to_show">"더 이상 표시할 파일이 없습니다"</string>
<string name="screen_media_details_no_more_media_to_show">"더 이상 보여줄 미디어가 없습니다"</string>
<string name="screen_media_details_uploaded_by">"에 의해 업로드됨"</string>
<string name="screen_media_details_uploaded_on">"에 업로드됨"</string>
</resources>

Some files were not shown because too many files have changed in this diff Show More