Add audio file support in the gallery and in the media viewer.
Make MediaPlayerControllerView compatible to control audio playback
This commit is contained in:
parent
b2a9ebb2d2
commit
ce09aac59d
23 changed files with 866 additions and 25 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ class DefaultMediaViewerEntryPoint @Inject constructor() : MediaViewerEntryPoint
|
|||
senderAvatar = null,
|
||||
dateSent = null,
|
||||
dateSentFull = null,
|
||||
waveform = null,
|
||||
),
|
||||
mediaSource = MediaSource(url = avatarUrl),
|
||||
thumbnailSource = null,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ private fun ExoPlayerMediaVideoView(
|
|||
isPlaying = false,
|
||||
progressInMillis = 0,
|
||||
durationInMillis = 0,
|
||||
canMute = true,
|
||||
isMuted = false,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue