From ce09aac59dfe368ef36d84bd1fe52007a0f08efc Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 12 Dec 2024 17:01:23 +0100 Subject: [PATCH] Add audio file support in the gallery and in the media viewer. Make MediaPlayerControllerView compatible to control audio playback --- .../messages/impl/MessagesFlowNode.kt | 2 + .../components/media/WaveformPlaybackView.kt | 44 +- .../libraries/mediaviewer/api/MediaInfo.kt | 13 +- .../impl/DefaultMediaViewerEntryPoint.kt | 1 + .../impl/gallery/EventItemFactory.kt | 14 +- .../impl/gallery/MediaGalleryPresenter.kt | 1 + .../impl/gallery/MediaGalleryStateProvider.kt | 8 +- .../impl/gallery/MediaGalleryView.kt | 11 + .../mediaviewer/impl/gallery/MediaItem.kt | 15 + .../impl/gallery/MediaItemsPostProcessor.kt | 1 + .../impl/gallery/ui/AudioItemView.kt | 216 ++++++++++ .../impl/gallery/ui/MediaItemAudioProvider.kt | 54 +++ .../impl/local/AndroidLocalMediaFactory.kt | 4 + .../mediaviewer/impl/local/LocalMediaView.kt | 10 +- .../impl/local/audio/MediaAudioView.kt | 380 ++++++++++++++++++ .../local/audio/MediaInfoAudioProvider.kt | 23 ++ .../impl/local/audio/MediaMetadata.kt | 35 ++ .../impl/local/file/MediaInfoFileProvider.kt | 2 - .../local/video/MediaPlayerControllerState.kt | 8 +- .../MediaPlayerControllerStateProvider.kt | 5 + .../local/video/MediaPlayerControllerView.kt | 32 +- .../impl/local/video/MediaVideoView.kt | 1 + .../impl/viewer/MediaViewerStateProvider.kt | 11 + 23 files changed, 866 insertions(+), 25 deletions(-) create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/AudioItemView.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemAudioProvider.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaInfoAudioProvider.kt create mode 100644 libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaMetadata.kt diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 6754f5c683..2d0be01de1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -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, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt index e7fc1c5ce4..4078399830 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/media/WaveformPlaybackView.kt @@ -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.normalisedData(maxSamplesCount: Int): Immutable return result.toPersistentList() } + +fun aWaveForm(): List { + 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, + ) +} diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt index 7daa5ab7ef..8d72d049ed 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/MediaInfo.kt @@ -24,6 +24,7 @@ data class MediaInfo( val senderAvatar: String?, val dateSent: String?, val dateSentFull: String?, + val waveform: List?, ) : 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? = 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, ) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt index d85bf08b8e..59a7f423e6 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/DefaultMediaViewerEntryPoint.kt @@ -54,6 +54,7 @@ class DefaultMediaViewerEntryPoint @Inject constructor() : MediaViewerEntryPoint senderAvatar = null, dateSent = null, dateSentFull = null, + waveform = null, ), mediaSource = MediaSource(url = avatarUrl), thumbnailSource = null, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt index 8fcea07f52..4baa1d0ee1 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/EventItemFactory.kt @@ -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, ) } } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt index c122e95447..242d23c10d 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt @@ -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 }, ) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt index 58d566dddd..d148121dbd 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryStateProvider.kt @@ -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 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 } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItem.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItem.kt index f43387fdb6..0925b4937b 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItem.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItem.kt @@ -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?, + ) : 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 } } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt index 6706dd08c8..1ed9f0e42b 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaItemsPostProcessor.kt @@ -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) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/AudioItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/AudioItemView.kt new file mode 100644 index 0000000000..a15a12ba58 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/AudioItemView.kt @@ -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 = {}, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemAudioProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemAudioProvider.kt new file mode 100644 index 0000000000..4bcdf60d3a --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/MediaItemAudioProvider.kt @@ -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 { + override val values: Sequence + 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? = null, +): MediaItem.Audio { + return MediaItem.Audio( + id = id, + eventId = null, + mediaInfo = anAudioMediaInfo( + filename = filename, + caption = caption, + ), + mediaSource = MediaSource(""), + duration = duration, + waveform = waveform?.toImmutableList(), + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt index c17d613e55..ceed35121a 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/AndroidLocalMediaFactory.kt @@ -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?, ): 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, ) ) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt index 5d0a2993df..1c56c291b4 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt @@ -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, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt new file mode 100644 index 0000000000..667aa7156b --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaAudioView.kt @@ -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, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaInfoAudioProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaInfoAudioProvider.kt new file mode 100644 index 0000000000..87f9bc3735 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaInfoAudioProvider.kt @@ -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 { + override val values: Sequence + get() = sequenceOf( + anAudioMediaInfo(), + anAudioMediaInfo( + waveForm = aWaveForm(), + ), + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaMetadata.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaMetadata.kt new file mode 100644 index 0000000000..d49559e1df --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/audio/MediaMetadata.kt @@ -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) + } + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaInfoFileProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaInfoFileProvider.kt index 980f9eba89..08d906dd98 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaInfoFileProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaInfoFileProvider.kt @@ -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 { override val values: Sequence get() = sequenceOf( aPdfMediaInfo(), - anAudioMediaInfo(), ) } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaPlayerControllerState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaPlayerControllerState.kt index c4e4b913a7..0316e5088e 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaPlayerControllerState.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaPlayerControllerState.kt @@ -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) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaPlayerControllerStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaPlayerControllerStateProvider.kt index 78059bd4eb..c26c16513f 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaPlayerControllerStateProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaPlayerControllerStateProvider.kt @@ -18,6 +18,9 @@ open class MediaPlayerControllerStateProvider : PreviewParameterProvider aMediaViewerState( mediaBottomSheetState = aMediaDeleteConfirmationState(), ), + anAudioMediaInfo( + waveForm = aWaveForm(), + ).let { + aMediaViewerState( + downloadedMedia = AsyncData.Success( + LocalMedia(Uri.EMPTY, it) + ), + mediaInfo = it, + ) + }, ) }