Add audio file support in the gallery and in the media viewer.

Make MediaPlayerControllerView compatible to control audio playback
This commit is contained in:
Benoit Marty 2024-12-12 17:01:23 +01:00
parent b2a9ebb2d2
commit ce09aac59d
23 changed files with 866 additions and 25 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,
),
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,380 @@
/*
* 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.ColumnScope
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.rememberLocalMediaViewState
import io.element.android.libraries.mediaviewer.impl.local.video.ExoPlayerForPreview
import io.element.android.libraries.mediaviewer.impl.local.video.ExoPlayerWrapper
import io.element.android.libraries.mediaviewer.impl.local.video.MediaPlayerControllerState
import io.element.android.libraries.mediaviewer.impl.local.video.MediaPlayerControllerView
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 = if (LocalInspectionMode.current) {
remember {
ExoPlayerForPreview()
}
} else {
val context = LocalContext.current
remember {
ExoPlayerWrapper.create(context)
}
}
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 = 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 = {
if (exoPlayer.isPlaying.not()) {
exoPlayer.play()
}
exoPlayer.seekTo((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(
info = info,
metadata = metadata,
)
}
}
MediaPlayerControllerView(
state = mediaPlayerControllerState,
onTogglePlay = {
if (exoPlayer.isPlaying) {
exoPlayer.pause()
} else {
if (exoPlayer.playbackState == Player.STATE_ENDED) {
exoPlayer.seekTo(0)
} else {
exoPlayer.play()
}
}
},
onSeekChange = {
if (exoPlayer.isPlaying.not()) {
exoPlayer.play()
}
exoPlayer.seekTo(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
fun ColumnScope.AudioInfoView(
info: MediaInfo?,
metadata: MediaMetadata?,
) {
// 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 MediaFileViewPreview(
@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

@ -7,10 +7,16 @@
package io.element.android.libraries.mediaviewer.impl.local.video
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

@ -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

@ -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

@ -92,6 +92,7 @@ private fun ExoPlayerMediaVideoView(
isPlaying = false,
progressInMillis = 0,
durationInMillis = 0,
canMute = true,
isMuted = false,
)
)

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,
)
},
)
}