First Commit
This commit is contained in:
@@ -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)
|
||||
}
|
||||
+31
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+63
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+220
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+114
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+76
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+106
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+89
@@ -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
|
||||
}
|
||||
}
|
||||
+43
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -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
|
||||
}
|
||||
+164
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+234
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+37
@@ -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,
|
||||
)
|
||||
}
|
||||
+34
@@ -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
|
||||
}
|
||||
+16
@@ -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)
|
||||
}
|
||||
+71
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+213
@@ -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 }
|
||||
}
|
||||
+29
@@ -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),
|
||||
}
|
||||
+134
@@ -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(),
|
||||
)
|
||||
+534
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
+26
@@ -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() } },
|
||||
),
|
||||
)
|
||||
)
|
||||
+18
@@ -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())
|
||||
}
|
||||
+21
@@ -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>)
|
||||
+93
@@ -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
|
||||
)
|
||||
+25
@@ -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>
|
||||
}
|
||||
+149
@@ -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)
|
||||
}
|
||||
}
|
||||
+135
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+35
@@ -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,
|
||||
)
|
||||
}
|
||||
+51
@@ -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)
|
||||
}
|
||||
+135
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+74
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+28
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
+21
@@ -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"),
|
||||
)
|
||||
}
|
||||
+28
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
+23
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
+31
@@ -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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
+125
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+286
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+60
@@ -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()
|
||||
}
|
||||
}
|
||||
+206
@@ -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()
|
||||
}
|
||||
}
|
||||
+112
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
+46
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
+35
@@ -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>
|
||||
}
|
||||
+86
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+42
@@ -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)
|
||||
}
|
||||
}
|
||||
+381
@@ -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,
|
||||
)
|
||||
}
|
||||
+24
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
+36
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+120
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+20
@@ -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(),
|
||||
)
|
||||
}
|
||||
+65
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+33
@@ -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,
|
||||
)
|
||||
}
|
||||
+25
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+177
@@ -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"),
|
||||
)
|
||||
}
|
||||
+80
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+31
@@ -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)
|
||||
}
|
||||
+29
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+252
@@ -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?) {}
|
||||
}
|
||||
+24
@@ -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)
|
||||
}
|
||||
+45
@@ -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,
|
||||
)
|
||||
+196
@@ -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,
|
||||
)
|
||||
}
|
||||
+24
@@ -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")),
|
||||
)
|
||||
}
|
||||
+112
@@ -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"
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
+324
@@ -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,
|
||||
)
|
||||
}
|
||||
+31
@@ -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 }
|
||||
}
|
||||
+126
@@ -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
|
||||
}
|
||||
}
|
||||
+129
@@ -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,
|
||||
)
|
||||
}
|
||||
+17
@@ -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)
|
||||
+27
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+191
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -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
|
||||
}
|
||||
+104
@@ -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)
|
||||
}
|
||||
+17
@@ -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()
|
||||
}
|
||||
+156
@@ -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
|
||||
}
|
||||
}
|
||||
+296
@@ -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)
|
||||
}
|
||||
}
|
||||
+54
@@ -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
|
||||
}
|
||||
+214
@@ -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,
|
||||
)
|
||||
+606
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+88
@@ -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
|
||||
}
|
||||
}
|
||||
+90
@@ -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 n’y 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 n’a 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 n’a 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 n’y a plus de fichiers à montrer"</string>
|
||||
<string name="screen_media_details_no_more_media_to_show">"Il n’y 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
Reference in New Issue
Block a user