Merge pull request #4031 from element-hq/feature/bma/fileListAudioPlayer

Render audio file in the files list and improve media viewer for audio/voice files
This commit is contained in:
Benoit Marty 2024-12-13 17:43:09 +01:00 committed by GitHub
commit 2a3fd99d35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 1034 additions and 135 deletions

View file

@ -47,6 +47,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.features.poll.api.create.CreatePollMode
import io.element.android.libraries.architecture.BackstackWithOverlayBox
@ -447,6 +448,7 @@ class MessagesFlowNode @AssistedInject constructor(
timestamp = event.sentTimeMillis,
mode = DateFormatterMode.Full,
),
waveform = (content as? TimelineItemVoiceContent)?.waveform,
),
mediaSource = mediaSource,
thumbnailSource = thumbnailSource,

View file

@ -189,7 +189,7 @@ internal fun WaveformPlaybackViewPreview() = ElementPreview {
showCursor = false,
playbackProgress = 0.5f,
onSeek = {},
waveform = persistentListOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f),
waveform = aWaveForm().toPersistentList(),
)
WaveformPlaybackView(
modifier = Modifier.height(34.dp),
@ -219,3 +219,45 @@ private fun ImmutableList<Float>.normalisedData(maxSamplesCount: Int): Immutable
return result.toPersistentList()
}
fun aWaveForm(): List<Float> {
return listOf(
0.000f,
0.000f,
0.000f,
0.003f,
0.354f,
0.353f,
0.365f,
0.790f,
0.787f,
0.167f,
0.333f,
0.975f,
0.000f,
0.102f,
0.003f,
0.531f,
0.584f,
0.317f,
0.140f,
0.475f,
0.496f,
0.561f,
0.042f,
0.263f,
0.169f,
0.829f,
0.349f,
0.010f,
0.000f,
0.000f,
1.000f,
0.334f,
0.321f,
0.011f,
0.000f,
0.000f,
0.003f,
)
}

View file

@ -24,6 +24,7 @@ data class MediaInfo(
val senderAvatar: String?,
val dateSent: String?,
val dateSentFull: String?,
val waveform: List<Float>?,
) : Parcelable
fun anImageMediaInfo(
@ -43,6 +44,7 @@ fun anImageMediaInfo(
senderAvatar = null,
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
)
fun aVideoMediaInfo(
@ -61,6 +63,7 @@ fun aVideoMediaInfo(
senderAvatar = null,
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
)
fun aPdfMediaInfo(
@ -80,6 +83,7 @@ fun aPdfMediaInfo(
senderAvatar = null,
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
)
fun anApkMediaInfo(
@ -98,15 +102,19 @@ fun anApkMediaInfo(
senderAvatar = null,
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
)
fun anAudioMediaInfo(
filename: String = "an audio file.mp3",
caption: String? = null,
senderName: String? = null,
dateSent: String? = null,
dateSentFull: String? = null,
waveForm: List<Float>? = null,
): MediaInfo = MediaInfo(
filename = "an audio file.mp3",
caption = null,
filename = filename,
caption = caption,
mimeType = MimeTypes.Mp3,
formattedFileSize = "7MB",
fileExtension = "mp3",
@ -115,4 +123,5 @@ fun anAudioMediaInfo(
senderAvatar = null,
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = waveForm,
)

View file

@ -54,6 +54,7 @@ class DefaultMediaViewerEntryPoint @Inject constructor() : MediaViewerEntryPoint
senderAvatar = null,
dateSent = null,
dateSentFull = null,
waveform = null,
),
mediaSource = MediaSource(url = avatarUrl),
thumbnailSource = null,

View file

@ -86,7 +86,7 @@ class EventItemFactory @Inject constructor(
Timber.w("Should not happen: ${content.type}")
null
}
is AudioMessageType -> MediaItem.File(
is AudioMessageType -> MediaItem.Audio(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo(
@ -100,8 +100,11 @@ class EventItemFactory @Inject constructor(
senderAvatar = event.senderProfile.getAvatarUrl(),
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
),
mediaSource = type.source,
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
waveform = null,
)
is FileMessageType -> MediaItem.File(
id = currentTimelineItem.uniqueId,
@ -117,6 +120,7 @@ class EventItemFactory @Inject constructor(
senderAvatar = event.senderProfile.getAvatarUrl(),
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
),
mediaSource = type.source,
)
@ -134,6 +138,7 @@ class EventItemFactory @Inject constructor(
senderAvatar = event.senderProfile.getAvatarUrl(),
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
),
mediaSource = type.source,
thumbnailSource = null,
@ -152,6 +157,7 @@ class EventItemFactory @Inject constructor(
senderAvatar = event.senderProfile.getAvatarUrl(),
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
),
mediaSource = type.source,
thumbnailSource = null,
@ -170,12 +176,13 @@ class EventItemFactory @Inject constructor(
senderAvatar = event.senderProfile.getAvatarUrl(),
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = null,
),
mediaSource = type.source,
thumbnailSource = type.info?.thumbnailSource,
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
)
is VoiceMessageType -> MediaItem.File(
is VoiceMessageType -> MediaItem.Audio(
id = currentTimelineItem.uniqueId,
eventId = currentTimelineItem.eventId,
mediaInfo = MediaInfo(
@ -189,8 +196,11 @@ class EventItemFactory @Inject constructor(
senderAvatar = event.senderProfile.getAvatarUrl(),
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = type.details?.waveform.orEmpty(),
),
mediaSource = type.source,
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
waveform = type.details?.waveform,
)
}
}

View file

@ -135,6 +135,7 @@ class MediaGalleryPresenter @AssistedInject constructor(
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
},
)

View file

@ -9,9 +9,11 @@ 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.aWaveForm
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
import io.element.android.libraries.mediaviewer.impl.details.aMediaDetailsBottomSheetState
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemAudio
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemDateSeparator
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage
@ -62,7 +64,11 @@ open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryStat
formattedDate = "September 2004",
),
aMediaItemFile(id = UniqueId("3")),
aMediaItemFile(id = UniqueId("4")),
aMediaItemAudio(id = UniqueId("4")),
aMediaItemAudio(
id = UniqueId("5"),
waveform = aWaveForm(),
),
aMediaItemLoadingIndicator(),
).toImmutableList()
)

View file

@ -59,6 +59,7 @@ 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.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
@ -257,6 +258,13 @@ private fun MediaGalleryFilesList(
onDownloadClick = { eventSink(MediaGalleryEvents.SaveOnDisk(item)) },
onInfoClick = { eventSink(MediaGalleryEvents.OpenInfo(item)) },
)
is MediaItem.Audio -> AudioItemView(
item,
onClick = { onItemClick(item) },
onShareClick = { eventSink(MediaGalleryEvents.Share(item)) },
onDownloadClick = { eventSink(MediaGalleryEvents.SaveOnDisk(item)) },
onInfoClick = { eventSink(MediaGalleryEvents.OpenInfo(item)) },
)
is MediaItem.DateSeparator -> DateItemView(item)
is MediaItem.Image,
is MediaItem.Video -> {
@ -312,6 +320,9 @@ private fun MediaGalleryImageGrid(
is MediaItem.DateSeparator -> {
DateItemView(item)
}
is MediaItem.Audio -> {
// Should not happen
}
is MediaItem.File -> {
// Should not happen
}

View file

@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.mediaviewer.api.MediaInfo
import kotlinx.collections.immutable.ImmutableList
sealed interface MediaItem {
data class DateSeparator(
@ -51,6 +52,15 @@ sealed interface MediaItem {
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
}
data class Audio(
val id: UniqueId,
val eventId: EventId?,
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val duration: String?,
val waveform: ImmutableList<Float>?,
) : Event
data class File(
val id: UniqueId,
val eventId: EventId?,
@ -66,6 +76,7 @@ fun MediaItem.id(): UniqueId {
is MediaItem.Image -> id
is MediaItem.Video -> id
is MediaItem.File -> id
is MediaItem.Audio -> id
}
}
@ -74,6 +85,7 @@ fun MediaItem.Event.eventId(): EventId? {
is MediaItem.Image -> eventId
is MediaItem.Video -> eventId
is MediaItem.File -> eventId
is MediaItem.Audio -> eventId
}
}
@ -82,6 +94,7 @@ fun MediaItem.Event.mediaInfo(): MediaInfo {
is MediaItem.Image -> mediaInfo
is MediaItem.Video -> mediaInfo
is MediaItem.File -> mediaInfo
is MediaItem.Audio -> mediaInfo
}
}
@ -90,6 +103,7 @@ fun MediaItem.Event.mediaSource(): MediaSource {
is MediaItem.Image -> mediaSource
is MediaItem.Video -> mediaSource
is MediaItem.File -> mediaSource
is MediaItem.Audio -> mediaSource
}
}
@ -98,5 +112,6 @@ fun MediaItem.Event.thumbnailSource(): MediaSource? {
is MediaItem.Image -> thumbnailSource
is MediaItem.Video -> thumbnailSource
is MediaItem.File -> null
is MediaItem.Audio -> null
}
}

View file

@ -56,6 +56,7 @@ class MediaItemsPostProcessor @Inject constructor() {
is MediaItem.Video -> {
imageAndVideoItemsSubList.add(0, item)
}
is MediaItem.Audio,
is MediaItem.File -> {
fileItemsSublist.add(0, item)
}

View file

@ -0,0 +1,216 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE 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.clickable
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.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.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.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.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.gallery.MediaItem
import kotlinx.collections.immutable.toPersistentList
@Composable
fun AudioItemView(
audio: MediaItem.Audio,
onClick: () -> Unit,
onShareClick: () -> Unit,
onDownloadClick: () -> Unit,
onInfoClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(top = 20.dp, start = 16.dp, end = 16.dp),
) {
FilenameRow(
audio = audio,
onClick = onClick,
)
val caption = audio.mediaInfo.caption
if (caption != null) {
Spacer(modifier = Modifier.height(16.dp))
Caption(caption)
}
Spacer(modifier = Modifier.height(16.dp))
ActionIconsRow(
onShareClick = onShareClick,
onDownloadClick = onDownloadClick,
onInfoClick = onInfoClick,
)
HorizontalDivider()
}
}
@Composable
private fun FilenameRow(
audio: MediaItem.Audio,
onClick: () -> Unit,
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(
color = ElementTheme.colors.bgSubtleSecondary,
shape = RoundedCornerShape(12.dp),
)
.clickable { onClick() }
.fillMaxWidth()
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
modifier = Modifier
.background(
color = ElementTheme.colors.bgCanvasDefault,
shape = CircleShape,
)
.border(
width = 1.dp,
color = ElementTheme.colors.borderInteractiveSecondary,
shape = CircleShape,
)
.size(36.dp)
.padding(6.dp),
imageVector = CompoundIcons.PlaySolid(),
tint = ElementTheme.colors.iconSecondary,
contentDescription = null,
)
audio.duration?.let {
Spacer(modifier = Modifier.width(8.dp))
Text(
text = audio.duration,
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.width(8.dp))
val waveform = audio.waveform
if (waveform == null) {
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,
)
}
} else {
WaveformPlaybackView(
modifier = Modifier
.weight(1f)
.height(34.dp),
playbackProgress = 0f,
showCursor = false,
waveform = waveform.toPersistentList(),
onSeek = {},
seekEnabled = false,
)
}
}
}
@Composable
private fun Caption(caption: String) {
Text(
modifier = Modifier.fillMaxWidth(),
text = caption,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
}
@Composable
private fun ActionIconsRow(
onShareClick: () -> Unit,
onDownloadClick: () -> Unit,
onInfoClick: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
IconButton(
onClick = onShareClick,
) {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
contentDescription = null,
)
}
IconButton(
onClick = onDownloadClick,
) {
Icon(
imageVector = CompoundIcons.Download(),
contentDescription = null,
)
}
IconButton(
onClick = onInfoClick,
) {
Icon(
imageVector = CompoundIcons.Info(),
contentDescription = null,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun AudioItemViewPreview(
@PreviewParameter(MediaItemAudioProvider::class) audio: MediaItem.Audio,
) = ElementPreview {
AudioItemView(
audio = audio,
onClick = {},
onShareClick = {},
onDownloadClick = {},
onInfoClick = {},
)
}

View file

@ -0,0 +1,54 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE 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.designsystem.components.media.aWaveForm
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
import kotlinx.collections.immutable.toImmutableList
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,
),
aMediaItemAudio(
waveform = aWaveForm(),
),
)
}
fun aMediaItemAudio(
id: UniqueId = UniqueId("fileId"),
filename: String = "filename",
caption: String? = null,
duration: String? = "1:23",
waveform: List<Float>? = null,
): MediaItem.Audio {
return MediaItem.Audio(
id = id,
eventId = null,
mediaInfo = anAudioMediaInfo(
filename = filename,
caption = caption,
),
mediaSource = MediaSource(""),
duration = duration,
waveform = waveform?.toImmutableList(),
)
}

View file

@ -47,6 +47,7 @@ class AndroidLocalMediaFactory @Inject constructor(
senderAvatar = mediaInfo.senderAvatar,
dateSent = mediaInfo.dateSent,
dateSentFull = mediaInfo.dateSentFull,
waveform = mediaInfo.waveform,
)
override fun createFromUri(
@ -65,6 +66,7 @@ class AndroidLocalMediaFactory @Inject constructor(
senderAvatar = null,
dateSent = null,
dateSentFull = null,
waveform = null,
)
private fun createFromUri(
@ -78,6 +80,7 @@ class AndroidLocalMediaFactory @Inject constructor(
senderAvatar: String?,
dateSent: String?,
dateSentFull: String?,
waveform: List<Float>?,
): LocalMedia {
val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream
val fileName = name ?: context.getFileName(uri) ?: ""
@ -96,6 +99,7 @@ class AndroidLocalMediaFactory @Inject constructor(
senderAvatar = senderAvatar,
dateSent = dateSent,
dateSentFull = dateSentFull,
waveform = waveform,
)
)
}

View file

@ -10,10 +10,12 @@ package io.element.android.libraries.mediaviewer.impl.local
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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
@ -48,7 +50,13 @@ fun LocalMediaView(
modifier = modifier,
onClick = onClick,
)
// TODO handle audio with exoplayer
mimeType.isMimeTypeAudio() -> MediaAudioView(
localMediaViewState = localMediaViewState,
bottomPaddingInPixels = bottomPaddingInPixels,
localMedia = localMedia,
info = mediaInfo,
modifier = modifier,
)
else -> MediaFileView(
localMediaViewState = localMediaViewState,
uri = localMedia?.uri,

View file

@ -0,0 +1,366 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE 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.material.icons.Icons
import androidx.compose.material.icons.outlined.GraphicEq
import androidx.compose.material3.MaterialTheme
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.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.toPersistentList
import kotlinx.coroutines.delay
@SuppressLint("UnsafeOptInUsageError")
@Composable
fun MediaAudioView(
localMediaViewState: LocalMediaViewState,
bottomPaddingInPixels: Int,
localMedia: LocalMedia?,
info: MediaInfo?,
modifier: Modifier = Modifier,
) {
val exoPlayer = rememberExoPlayer()
ExoPlayerMediaAudioView(
localMediaViewState = localMediaViewState,
bottomPaddingInPixels = bottomPaddingInPixels,
exoPlayer = exoPlayer,
localMedia = localMedia,
info = info,
modifier = modifier,
)
}
@SuppressLint("UnsafeOptInUsageError")
@Composable
private fun ExoPlayerMediaAudioView(
localMediaViewState: LocalMediaViewState,
bottomPaddingInPixels: Int,
exoPlayer: ExoPlayer,
localMedia: LocalMedia?,
info: MediaInfo?,
modifier: Modifier = Modifier,
) {
var mediaPlayerControllerState: MediaPlayerControllerState by remember {
mutableStateOf(
MediaPlayerControllerState(
isVisible = true,
isPlaying = 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,
)
}
}
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.toPersistentList(),
onSeek = {
exoPlayer.seekToEnsurePlaying((it * exoPlayer.duration).toLong())
},
seekEnabled = true,
)
} else {
if (!metadata.hasArtwork()) {
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.onBackground),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Outlined.GraphicEq,
contentDescription = null,
tint = MaterialTheme.colorScheme.background,
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
},
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 = MaterialTheme.colorScheme.primary
)
}
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 = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formatFileExtensionAndSize(info.fileExtension, info.formattedFileSize),
style = ElementTheme.typography.fontBodyMdRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary
)
}
}
}
@PreviewsDayNight
@Composable
internal fun MediaAudioViewPreview(
@PreviewParameter(MediaInfoAudioProvider::class) info: MediaInfo
) = ElementPreview {
MediaAudioView(
modifier = Modifier.fillMaxSize(),
bottomPaddingInPixels = 0,
localMediaViewState = rememberLocalMediaViewState(),
info = info,
localMedia = null,
)
}

View file

@ -0,0 +1,23 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE 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.aWaveForm
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 = aWaveForm(),
),
)
}

View file

@ -0,0 +1,35 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE 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)
}
}
}

View file

@ -10,12 +10,10 @@ 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
import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo
open class MediaInfoFileProvider : PreviewParameterProvider<MediaInfo> {
override val values: Sequence<MediaInfo>
get() = sequenceOf(
aPdfMediaInfo(),
anAudioMediaInfo(),
)
}

View file

@ -0,0 +1,30 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE 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)
}

View file

@ -0,0 +1,28 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE 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()
}
}
}

View file

@ -11,7 +11,7 @@
"DEPRECATION",
)
package io.element.android.libraries.mediaviewer.impl.local.video
package io.element.android.libraries.mediaviewer.impl.local.player
import android.annotation.SuppressLint
import android.media.AudioDeviceInfo

View file

@ -5,12 +5,18 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.local.video
package io.element.android.libraries.mediaviewer.impl.local.player
import androidx.annotation.FloatRange
data class MediaPlayerControllerState(
val isVisible: Boolean,
val isPlaying: 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)
}

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.local.video
package io.element.android.libraries.mediaviewer.impl.local.player
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
@ -18,6 +18,9 @@ open class MediaPlayerControllerStateProvider : PreviewParameterProvider<MediaPl
durationInMillis = 83_000,
isMuted = true,
),
aMediaPlayerControllerState(
canMute = false,
),
)
}
@ -27,11 +30,13 @@ private fun aMediaPlayerControllerState(
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,
progressInMillis = progressInMillis,
durationInMillis = durationInMillis,
canMute = canMute,
isMuted = isMuted,
)

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.local.video
package io.element.android.libraries.mediaviewer.impl.local.player
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
@ -133,21 +133,23 @@ fun MediaPlayerControllerView(
color = ElementTheme.colors.textPrimary,
style = ElementTheme.typography.fontBodyXsMedium,
)
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)
)
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)
)
}
}
}
}

View file

@ -1,39 +0,0 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.local.video
import android.content.Context
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
/**
* Wrapper around ExoPlayer to disable some commands.
* Necessary to hide the settings wheels from the player.
*/
@UnstableApi
class ExoPlayerWrapper(private val exoPlayer: ExoPlayer) : ExoPlayer by exoPlayer {
override fun isCommandAvailable(command: Int): Boolean {
return availableCommands.contains(command)
}
override fun getAvailableCommands(): Player.Commands {
return exoPlayer.availableCommands
.buildUpon()
.remove(Player.COMMAND_SET_TRACK_SELECTION_PARAMETERS)
.build()
}
companion object {
fun create(context: Context): ExoPlayer {
return ExoPlayerWrapper(
ExoPlayer.Builder(context).build()
)
}
}
}

View file

@ -45,6 +45,11 @@ 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 kotlin.time.Duration.Companion.seconds
@ -57,16 +62,7 @@ fun MediaVideoView(
localMedia: LocalMedia?,
modifier: Modifier = Modifier,
) {
val exoPlayer = if (LocalInspectionMode.current) {
remember {
ExoPlayerForPreview()
}
} else {
val context = LocalContext.current
remember {
ExoPlayerWrapper.create(context)
}
}
val exoPlayer = rememberExoPlayer()
ExoPlayerMediaVideoView(
localMediaViewState = localMediaViewState,
bottomPaddingInPixels = bottomPaddingInPixels,
@ -92,6 +88,7 @@ private fun ExoPlayerMediaVideoView(
isPlaying = false,
progressInMillis = 0,
durationInMillis = 0,
canMute = true,
isMuted = false,
)
)
@ -107,31 +104,33 @@ private fun ExoPlayerMediaVideoView(
localMediaViewState.playableState = playableState
val playerListener = object : Player.Listener {
override fun onRenderedFirstFrame() {
localMediaViewState.isReady = true
}
val playerListener = remember {
object : Player.Listener {
override fun onRenderedFirstFrame() {
localMediaViewState.isReady = true
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
isPlaying = isPlaying,
)
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
isPlaying = isPlaying,
)
}
override fun onVolumeChanged(volume: Float) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
isMuted = volume == 0f,
)
}
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 onTimelineChanged(timeline: Timeline, reason: Int) {
if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) {
exoPlayer.duration.takeIf { it >= 0 }
?.let {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
durationInMillis = it,
)
}
}
}
}
}
@ -211,22 +210,11 @@ private fun ExoPlayerMediaVideoView(
state = mediaPlayerControllerState,
onTogglePlay = {
autoHideController++
if (exoPlayer.isPlaying) {
exoPlayer.pause()
} else {
if (exoPlayer.playbackState == Player.STATE_ENDED) {
exoPlayer.seekTo(0)
} else {
exoPlayer.play()
}
}
exoPlayer.togglePlay()
},
onSeekChange = {
autoHideController++
if (exoPlayer.isPlaying.not()) {
exoPlayer.play()
}
exoPlayer.seekTo(it.toLong())
exoPlayer.seekToEnsurePlaying(it.toLong())
},
onToggleMute = {
autoHideController++

View file

@ -10,6 +10,7 @@ package io.element.android.libraries.mediaviewer.impl.viewer
import android.net.Uri
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.media.aWaveForm
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
@ -100,6 +101,16 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
aMediaViewerState(
mediaBottomSheetState = aMediaDeleteConfirmationState(),
),
anAudioMediaInfo(
waveForm = aWaveForm(),
).let {
aMediaViewerState(
downloadedMedia = AsyncData.Success(
LocalMedia(Uri.EMPTY, it)
),
mediaInfo = it,
)
},
)
}

View file

@ -50,6 +50,7 @@ import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import org.junit.Test
import kotlin.time.Duration.Companion.seconds
@ -163,6 +164,7 @@ class DefaultEventItemFactoryTest {
senderAvatar = null,
dateSent = "0 Day false",
dateSentFull = "0 Full false",
waveform = null,
),
mediaSource = MediaSource(""),
)
@ -211,6 +213,7 @@ class DefaultEventItemFactoryTest {
senderAvatar = null,
dateSent = "0 Day false",
dateSentFull = "0 Full false",
waveform = null,
),
mediaSource = MediaSource(""),
thumbnailSource = null,
@ -242,7 +245,7 @@ class DefaultEventItemFactoryTest {
)
)
assertThat(result).isEqualTo(
MediaItem.File(
MediaItem.Audio(
id = A_UNIQUE_ID,
eventId = AN_EVENT_ID,
mediaInfo = MediaInfo(
@ -256,8 +259,11 @@ class DefaultEventItemFactoryTest {
senderAvatar = null,
dateSent = "0 Day false",
dateSentFull = "0 Full false",
waveform = null,
),
mediaSource = MediaSource(""),
duration = "7:36",
waveform = null,
)
)
}
@ -305,6 +311,7 @@ class DefaultEventItemFactoryTest {
senderAvatar = null,
dateSent = "0 Day false",
dateSentFull = "0 Full false",
waveform = null,
),
mediaSource = MediaSource(""),
thumbnailSource = null,
@ -333,7 +340,7 @@ class DefaultEventItemFactoryTest {
),
details = AudioDetails(
duration = 456.seconds,
waveform = persistentListOf(),
waveform = persistentListOf(1f, 2f),
)
)
)
@ -341,7 +348,7 @@ class DefaultEventItemFactoryTest {
)
)
assertThat(result).isEqualTo(
MediaItem.File(
MediaItem.Audio(
id = A_UNIQUE_ID,
eventId = AN_EVENT_ID,
mediaInfo = MediaInfo(
@ -355,8 +362,11 @@ class DefaultEventItemFactoryTest {
senderAvatar = null,
dateSent = "0 Day false",
dateSentFull = "0 Full false",
waveform = listOf(1f, 2f).toImmutableList(),
),
mediaSource = MediaSource(""),
duration = "7:36",
waveform = listOf(1f, 2f).toImmutableList(),
)
)
}
@ -403,6 +413,7 @@ class DefaultEventItemFactoryTest {
senderAvatar = null,
dateSent = "0 Day false",
dateSentFull = "0 Full false",
waveform = null,
),
mediaSource = MediaSource(""),
thumbnailSource = null,

View file

@ -11,6 +11,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemAudio
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemDateSeparator
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage
@ -23,6 +24,9 @@ class MediaItemsPostProcessorTest {
private val file1 = aMediaItemFile(id = UniqueId("1"))
private val file2 = aMediaItemFile(id = UniqueId("2"))
private val file3 = aMediaItemFile(id = UniqueId("3"))
private val audio1 = aMediaItemAudio(id = UniqueId("1"))
private val audio2 = aMediaItemAudio(id = UniqueId("2"))
private val audio3 = aMediaItemAudio(id = UniqueId("3"))
private val image1 = aMediaItemImage(id = UniqueId("1"))
private val image2 = aMediaItemImage(id = UniqueId("2"))
private val image3 = aMediaItemImage(id = UniqueId("3"))
@ -68,6 +72,7 @@ class MediaItemsPostProcessorTest {
fun `process will reorder files`() {
test(
mediaItems = listOf(
audio1,
file3,
file2,
file1,
@ -79,6 +84,7 @@ class MediaItemsPostProcessorTest {
file1,
file2,
file3,
audio1,
),
)
}
@ -106,6 +112,7 @@ class MediaItemsPostProcessorTest {
fun `process will split images, videos and files`() {
test(
mediaItems = listOf(
audio1,
file1,
image1,
video1,
@ -119,6 +126,7 @@ class MediaItemsPostProcessorTest {
expectedFileItems = listOf(
date1,
file1,
audio1,
),
)
}
@ -155,6 +163,9 @@ class MediaItemsPostProcessorTest {
fun `process will handle complex case`() {
test(
mediaItems = listOf(
audio3,
audio2,
audio1,
file1,
image1,
video1,
@ -178,6 +189,9 @@ class MediaItemsPostProcessorTest {
expectedFileItems = listOf(
date1,
file1,
audio1,
audio2,
audio3,
date3,
file3,
loading1,

View file

@ -48,7 +48,8 @@ class AndroidLocalMediaFactoryTest {
senderName = A_USER_NAME,
senderAvatar = null,
dateSent = "12:34",
dateSentFull = "full"
dateSentFull = "full",
waveform = null,
)
)
}

View file

@ -42,6 +42,7 @@ class FakeLocalMediaFactory(
senderAvatar = null,
dateSent = null,
dateSentFull = null,
waveform = null,
)
return aLocalMedia(uri, mediaInfo)
}

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bbe0bf5ff3c5128405fe0af2af344140a655d93826bf7591393dbd4732a7b729
size 8383
oid sha256:470ea6854c3786db7935c55a852637c907665326a61a5dcf33c66f0710406c09
size 9650

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8ec9068b2f5b7bdf0bcf6ede6c2ea02040c1a77b1054c1fbf45fe5feb1ab78e5
size 8147
oid sha256:1b1ef50d42e57a1465de82d538a573c41a73a133890ecf84bed0317d38e33440
size 9385

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1d07440581dc64c48c047b8895520c7f46213e4806053b6a5fa4b63c15386c8c
size 12410

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9beef21701df1566b27d7b71808de32a87ae439b5213f7e081d821e60add5b36
size 16302

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0adeab82d5197daf0eb408f66ee605e0ae3862ac08de0c6a5093a957f724718c
size 39939

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:835cdc9b99cdd8eedfe2f59032344d07681cd8bbab39a0f15e340abd80c035ff
size 10979

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1813f6e96713fc8c11eca36475ac51b3162f88c71bbe4e9fe7cb0c9a58314ab4
size 11719

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1df8cd0cbef387b05959a423c20cf92605e1c671404aef4ccbb79048d4fc4a6f
size 15518

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:de63930be672d83e9bc508c6a84911f6a309c2dbac7b608c3bfef5e8a37fc49f
size 38349

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:836b153cee837cb5c0ba375b587f6bfa6e34eb9f88e4198483f956ebff91048a
size 10234

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ce8fec32ec1a902edb12d9580ca73d0cc5a9838f91d31dbbf8329326b9fa1a68
size 39676
oid sha256:237067f8a59de7f88c313d78716116c5dfe35d9112ad3d42a61f172de2637415
size 40888

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:946183ccbaf14471579f3b48861715d04ed45d6352d36126a419de7e6f362bfd
size 37632
oid sha256:9304dc29a88d7829aebfb6de1bab40fde20fcaef0fb92efdf9135d4df92c4442
size 38694

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7c7d4201ed9aa37995f4ab8ac982404f59e77374f316a057685886f14e698c35
size 24680

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b94fd31b7ed71eacfe8f136bfd59405b85d31a0fe557800311794f4ba7006271
size 22749

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6520b0faf9c22ee1d9b1a088b29fc07e3d8004db5a342cd3bff189d844aace6e
size 24649

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5dc15cba85c8cc376b780df648b4fa265c054de07a4bab426569ac26be03fec1
size 22784

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5307e90428957819812269b9b3e0c6e9d59238141d54cd959aa5506290797a35
size 11587

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:52354bcf471b14e38e582cc29f73407c8ca65026b2b7c6db3d3b28ec94950679
size 11413

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a6d04e0ee068682ebb0a3842ba73407855f2b83b7389d26fa0f3e2ec20d42dc8
size 7389

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1211fac03e492532b1e90608fd931b8fcc15dac695ef5610069bf512c2a5fef1
size 7548

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8a6a599e68f0955d84ea737603f0db83be412433691f7b7b3729a01999808830
size 25827

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c786dc4f2ec41fc334cd67febc105670c999da57cec1ea263daff2358ff5c766
size 14406
oid sha256:c7642e9e20a551e59f4529c52fa7fbe5b3f4dfcb8c26caeb716ccb2bdfc63dde
size 27176

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3f74e1b75f3e5a03df1b6b0e4509a4bf5da033c4f68cf88705ed136b471ae38c
size 14691
oid sha256:1fd53c24dd38a12b4ceefa54e1d6d096d8f443d1cdd1d1f1cc71f92c1d603a51
size 27419