Merge pull request #4031 from element-hq/feature/bma/fileListAudioPlayer
Render audio file in the files list and improve media viewer for audio/voice files
This commit is contained in:
commit
ddcfac1c9c
58 changed files with 1034 additions and 135 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.TimelineItemLocationContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStickerContent
|
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.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.CreatePollEntryPoint
|
||||||
import io.element.android.features.poll.api.create.CreatePollMode
|
import io.element.android.features.poll.api.create.CreatePollMode
|
||||||
import io.element.android.libraries.architecture.BackstackWithOverlayBox
|
import io.element.android.libraries.architecture.BackstackWithOverlayBox
|
||||||
|
|
@ -447,6 +448,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
timestamp = event.sentTimeMillis,
|
timestamp = event.sentTimeMillis,
|
||||||
mode = DateFormatterMode.Full,
|
mode = DateFormatterMode.Full,
|
||||||
),
|
),
|
||||||
|
waveform = (content as? TimelineItemVoiceContent)?.waveform,
|
||||||
),
|
),
|
||||||
mediaSource = mediaSource,
|
mediaSource = mediaSource,
|
||||||
thumbnailSource = thumbnailSource,
|
thumbnailSource = thumbnailSource,
|
||||||
|
|
|
||||||
|
|
@ -189,7 +189,7 @@ internal fun WaveformPlaybackViewPreview() = ElementPreview {
|
||||||
showCursor = false,
|
showCursor = false,
|
||||||
playbackProgress = 0.5f,
|
playbackProgress = 0.5f,
|
||||||
onSeek = {},
|
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(
|
WaveformPlaybackView(
|
||||||
modifier = Modifier.height(34.dp),
|
modifier = Modifier.height(34.dp),
|
||||||
|
|
@ -219,3 +219,45 @@ private fun ImmutableList<Float>.normalisedData(maxSamplesCount: Int): Immutable
|
||||||
|
|
||||||
return result.toPersistentList()
|
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 senderAvatar: String?,
|
||||||
val dateSent: String?,
|
val dateSent: String?,
|
||||||
val dateSentFull: String?,
|
val dateSentFull: String?,
|
||||||
|
val waveform: List<Float>?,
|
||||||
) : Parcelable
|
) : Parcelable
|
||||||
|
|
||||||
fun anImageMediaInfo(
|
fun anImageMediaInfo(
|
||||||
|
|
@ -43,6 +44,7 @@ fun anImageMediaInfo(
|
||||||
senderAvatar = null,
|
senderAvatar = null,
|
||||||
dateSent = dateSent,
|
dateSent = dateSent,
|
||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
|
waveform = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun aVideoMediaInfo(
|
fun aVideoMediaInfo(
|
||||||
|
|
@ -61,6 +63,7 @@ fun aVideoMediaInfo(
|
||||||
senderAvatar = null,
|
senderAvatar = null,
|
||||||
dateSent = dateSent,
|
dateSent = dateSent,
|
||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
|
waveform = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun aPdfMediaInfo(
|
fun aPdfMediaInfo(
|
||||||
|
|
@ -80,6 +83,7 @@ fun aPdfMediaInfo(
|
||||||
senderAvatar = null,
|
senderAvatar = null,
|
||||||
dateSent = dateSent,
|
dateSent = dateSent,
|
||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
|
waveform = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun anApkMediaInfo(
|
fun anApkMediaInfo(
|
||||||
|
|
@ -98,15 +102,19 @@ fun anApkMediaInfo(
|
||||||
senderAvatar = null,
|
senderAvatar = null,
|
||||||
dateSent = dateSent,
|
dateSent = dateSent,
|
||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
|
waveform = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun anAudioMediaInfo(
|
fun anAudioMediaInfo(
|
||||||
|
filename: String = "an audio file.mp3",
|
||||||
|
caption: String? = null,
|
||||||
senderName: String? = null,
|
senderName: String? = null,
|
||||||
dateSent: String? = null,
|
dateSent: String? = null,
|
||||||
dateSentFull: String? = null,
|
dateSentFull: String? = null,
|
||||||
|
waveForm: List<Float>? = null,
|
||||||
): MediaInfo = MediaInfo(
|
): MediaInfo = MediaInfo(
|
||||||
filename = "an audio file.mp3",
|
filename = filename,
|
||||||
caption = null,
|
caption = caption,
|
||||||
mimeType = MimeTypes.Mp3,
|
mimeType = MimeTypes.Mp3,
|
||||||
formattedFileSize = "7MB",
|
formattedFileSize = "7MB",
|
||||||
fileExtension = "mp3",
|
fileExtension = "mp3",
|
||||||
|
|
@ -115,4 +123,5 @@ fun anAudioMediaInfo(
|
||||||
senderAvatar = null,
|
senderAvatar = null,
|
||||||
dateSent = dateSent,
|
dateSent = dateSent,
|
||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
|
waveform = waveForm,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ class DefaultMediaViewerEntryPoint @Inject constructor() : MediaViewerEntryPoint
|
||||||
senderAvatar = null,
|
senderAvatar = null,
|
||||||
dateSent = null,
|
dateSent = null,
|
||||||
dateSentFull = null,
|
dateSentFull = null,
|
||||||
|
waveform = null,
|
||||||
),
|
),
|
||||||
mediaSource = MediaSource(url = avatarUrl),
|
mediaSource = MediaSource(url = avatarUrl),
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ class EventItemFactory @Inject constructor(
|
||||||
Timber.w("Should not happen: ${content.type}")
|
Timber.w("Should not happen: ${content.type}")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
is AudioMessageType -> MediaItem.File(
|
is AudioMessageType -> MediaItem.Audio(
|
||||||
id = currentTimelineItem.uniqueId,
|
id = currentTimelineItem.uniqueId,
|
||||||
eventId = currentTimelineItem.eventId,
|
eventId = currentTimelineItem.eventId,
|
||||||
mediaInfo = MediaInfo(
|
mediaInfo = MediaInfo(
|
||||||
|
|
@ -100,8 +100,11 @@ class EventItemFactory @Inject constructor(
|
||||||
senderAvatar = event.senderProfile.getAvatarUrl(),
|
senderAvatar = event.senderProfile.getAvatarUrl(),
|
||||||
dateSent = dateSent,
|
dateSent = dateSent,
|
||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
|
waveform = null,
|
||||||
),
|
),
|
||||||
mediaSource = type.source,
|
mediaSource = type.source,
|
||||||
|
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
|
||||||
|
waveform = null,
|
||||||
)
|
)
|
||||||
is FileMessageType -> MediaItem.File(
|
is FileMessageType -> MediaItem.File(
|
||||||
id = currentTimelineItem.uniqueId,
|
id = currentTimelineItem.uniqueId,
|
||||||
|
|
@ -117,6 +120,7 @@ class EventItemFactory @Inject constructor(
|
||||||
senderAvatar = event.senderProfile.getAvatarUrl(),
|
senderAvatar = event.senderProfile.getAvatarUrl(),
|
||||||
dateSent = dateSent,
|
dateSent = dateSent,
|
||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
|
waveform = null,
|
||||||
),
|
),
|
||||||
mediaSource = type.source,
|
mediaSource = type.source,
|
||||||
)
|
)
|
||||||
|
|
@ -134,6 +138,7 @@ class EventItemFactory @Inject constructor(
|
||||||
senderAvatar = event.senderProfile.getAvatarUrl(),
|
senderAvatar = event.senderProfile.getAvatarUrl(),
|
||||||
dateSent = dateSent,
|
dateSent = dateSent,
|
||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
|
waveform = null,
|
||||||
),
|
),
|
||||||
mediaSource = type.source,
|
mediaSource = type.source,
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
|
|
@ -152,6 +157,7 @@ class EventItemFactory @Inject constructor(
|
||||||
senderAvatar = event.senderProfile.getAvatarUrl(),
|
senderAvatar = event.senderProfile.getAvatarUrl(),
|
||||||
dateSent = dateSent,
|
dateSent = dateSent,
|
||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
|
waveform = null,
|
||||||
),
|
),
|
||||||
mediaSource = type.source,
|
mediaSource = type.source,
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
|
|
@ -170,12 +176,13 @@ class EventItemFactory @Inject constructor(
|
||||||
senderAvatar = event.senderProfile.getAvatarUrl(),
|
senderAvatar = event.senderProfile.getAvatarUrl(),
|
||||||
dateSent = dateSent,
|
dateSent = dateSent,
|
||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
|
waveform = null,
|
||||||
),
|
),
|
||||||
mediaSource = type.source,
|
mediaSource = type.source,
|
||||||
thumbnailSource = type.info?.thumbnailSource,
|
thumbnailSource = type.info?.thumbnailSource,
|
||||||
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
|
duration = type.info?.duration?.inWholeMilliseconds?.toHumanReadableDuration(),
|
||||||
)
|
)
|
||||||
is VoiceMessageType -> MediaItem.File(
|
is VoiceMessageType -> MediaItem.Audio(
|
||||||
id = currentTimelineItem.uniqueId,
|
id = currentTimelineItem.uniqueId,
|
||||||
eventId = currentTimelineItem.eventId,
|
eventId = currentTimelineItem.eventId,
|
||||||
mediaInfo = MediaInfo(
|
mediaInfo = MediaInfo(
|
||||||
|
|
@ -189,8 +196,11 @@ class EventItemFactory @Inject constructor(
|
||||||
senderAvatar = event.senderProfile.getAvatarUrl(),
|
senderAvatar = event.senderProfile.getAvatarUrl(),
|
||||||
dateSent = dateSent,
|
dateSent = dateSent,
|
||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
|
waveform = type.details?.waveform.orEmpty(),
|
||||||
),
|
),
|
||||||
mediaSource = type.source,
|
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) {
|
thumbnailSource = when (event.mediaItem) {
|
||||||
is MediaItem.Image -> event.mediaItem.thumbnailSource ?: event.mediaItem.mediaSource
|
is MediaItem.Image -> event.mediaItem.thumbnailSource ?: event.mediaItem.mediaSource
|
||||||
is MediaItem.Video -> event.mediaItem.thumbnailSource ?: event.mediaItem.mediaSource
|
is MediaItem.Video -> event.mediaItem.thumbnailSource ?: event.mediaItem.mediaSource
|
||||||
|
is MediaItem.Audio -> null
|
||||||
is MediaItem.File -> null
|
is MediaItem.File -> null
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,11 @@ package io.element.android.libraries.mediaviewer.impl.gallery
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
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.matrix.api.core.UniqueId
|
||||||
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
|
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.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.aMediaItemDateSeparator
|
||||||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile
|
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile
|
||||||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage
|
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage
|
||||||
|
|
@ -62,7 +64,11 @@ open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryStat
|
||||||
formattedDate = "September 2004",
|
formattedDate = "September 2004",
|
||||||
),
|
),
|
||||||
aMediaItemFile(id = UniqueId("3")),
|
aMediaItemFile(id = UniqueId("3")),
|
||||||
aMediaItemFile(id = UniqueId("4")),
|
aMediaItemAudio(id = UniqueId("4")),
|
||||||
|
aMediaItemAudio(
|
||||||
|
id = UniqueId("5"),
|
||||||
|
waveform = aWaveForm(),
|
||||||
|
),
|
||||||
aMediaItemLoadingIndicator(),
|
aMediaItemLoadingIndicator(),
|
||||||
).toImmutableList()
|
).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.MediaBottomSheetState
|
||||||
import io.element.android.libraries.mediaviewer.impl.details.MediaDeleteConfirmationBottomSheet
|
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.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.DateItemView
|
||||||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.FileItemView
|
import io.element.android.libraries.mediaviewer.impl.gallery.ui.FileItemView
|
||||||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.ImageItemView
|
import io.element.android.libraries.mediaviewer.impl.gallery.ui.ImageItemView
|
||||||
|
|
@ -257,6 +258,13 @@ private fun MediaGalleryFilesList(
|
||||||
onDownloadClick = { eventSink(MediaGalleryEvents.SaveOnDisk(item)) },
|
onDownloadClick = { eventSink(MediaGalleryEvents.SaveOnDisk(item)) },
|
||||||
onInfoClick = { eventSink(MediaGalleryEvents.OpenInfo(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.DateSeparator -> DateItemView(item)
|
||||||
is MediaItem.Image,
|
is MediaItem.Image,
|
||||||
is MediaItem.Video -> {
|
is MediaItem.Video -> {
|
||||||
|
|
@ -312,6 +320,9 @@ private fun MediaGalleryImageGrid(
|
||||||
is MediaItem.DateSeparator -> {
|
is MediaItem.DateSeparator -> {
|
||||||
DateItemView(item)
|
DateItemView(item)
|
||||||
}
|
}
|
||||||
|
is MediaItem.Audio -> {
|
||||||
|
// Should not happen
|
||||||
|
}
|
||||||
is MediaItem.File -> {
|
is MediaItem.File -> {
|
||||||
// Should not happen
|
// 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.api.timeline.Timeline
|
||||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
sealed interface MediaItem {
|
sealed interface MediaItem {
|
||||||
data class DateSeparator(
|
data class DateSeparator(
|
||||||
|
|
@ -51,6 +52,15 @@ sealed interface MediaItem {
|
||||||
get() = MediaRequestData(thumbnailSource ?: mediaSource, MediaRequestData.Kind.Thumbnail(100))
|
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(
|
data class File(
|
||||||
val id: UniqueId,
|
val id: UniqueId,
|
||||||
val eventId: EventId?,
|
val eventId: EventId?,
|
||||||
|
|
@ -66,6 +76,7 @@ fun MediaItem.id(): UniqueId {
|
||||||
is MediaItem.Image -> id
|
is MediaItem.Image -> id
|
||||||
is MediaItem.Video -> id
|
is MediaItem.Video -> id
|
||||||
is MediaItem.File -> id
|
is MediaItem.File -> id
|
||||||
|
is MediaItem.Audio -> id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,6 +85,7 @@ fun MediaItem.Event.eventId(): EventId? {
|
||||||
is MediaItem.Image -> eventId
|
is MediaItem.Image -> eventId
|
||||||
is MediaItem.Video -> eventId
|
is MediaItem.Video -> eventId
|
||||||
is MediaItem.File -> eventId
|
is MediaItem.File -> eventId
|
||||||
|
is MediaItem.Audio -> eventId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,6 +94,7 @@ fun MediaItem.Event.mediaInfo(): MediaInfo {
|
||||||
is MediaItem.Image -> mediaInfo
|
is MediaItem.Image -> mediaInfo
|
||||||
is MediaItem.Video -> mediaInfo
|
is MediaItem.Video -> mediaInfo
|
||||||
is MediaItem.File -> mediaInfo
|
is MediaItem.File -> mediaInfo
|
||||||
|
is MediaItem.Audio -> mediaInfo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,6 +103,7 @@ fun MediaItem.Event.mediaSource(): MediaSource {
|
||||||
is MediaItem.Image -> mediaSource
|
is MediaItem.Image -> mediaSource
|
||||||
is MediaItem.Video -> mediaSource
|
is MediaItem.Video -> mediaSource
|
||||||
is MediaItem.File -> mediaSource
|
is MediaItem.File -> mediaSource
|
||||||
|
is MediaItem.Audio -> mediaSource
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,5 +112,6 @@ fun MediaItem.Event.thumbnailSource(): MediaSource? {
|
||||||
is MediaItem.Image -> thumbnailSource
|
is MediaItem.Image -> thumbnailSource
|
||||||
is MediaItem.Video -> thumbnailSource
|
is MediaItem.Video -> thumbnailSource
|
||||||
is MediaItem.File -> null
|
is MediaItem.File -> null
|
||||||
|
is MediaItem.Audio -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ class MediaItemsPostProcessor @Inject constructor() {
|
||||||
is MediaItem.Video -> {
|
is MediaItem.Video -> {
|
||||||
imageAndVideoItemsSubList.add(0, item)
|
imageAndVideoItemsSubList.add(0, item)
|
||||||
}
|
}
|
||||||
|
is MediaItem.Audio,
|
||||||
is MediaItem.File -> {
|
is MediaItem.File -> {
|
||||||
fileItemsSublist.add(0, item)
|
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,
|
senderAvatar = mediaInfo.senderAvatar,
|
||||||
dateSent = mediaInfo.dateSent,
|
dateSent = mediaInfo.dateSent,
|
||||||
dateSentFull = mediaInfo.dateSentFull,
|
dateSentFull = mediaInfo.dateSentFull,
|
||||||
|
waveform = mediaInfo.waveform,
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun createFromUri(
|
override fun createFromUri(
|
||||||
|
|
@ -65,6 +66,7 @@ class AndroidLocalMediaFactory @Inject constructor(
|
||||||
senderAvatar = null,
|
senderAvatar = null,
|
||||||
dateSent = null,
|
dateSent = null,
|
||||||
dateSentFull = null,
|
dateSentFull = null,
|
||||||
|
waveform = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun createFromUri(
|
private fun createFromUri(
|
||||||
|
|
@ -78,6 +80,7 @@ class AndroidLocalMediaFactory @Inject constructor(
|
||||||
senderAvatar: String?,
|
senderAvatar: String?,
|
||||||
dateSent: String?,
|
dateSent: String?,
|
||||||
dateSentFull: String?,
|
dateSentFull: String?,
|
||||||
|
waveform: List<Float>?,
|
||||||
): LocalMedia {
|
): LocalMedia {
|
||||||
val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream
|
val resolvedMimeType = mimeType ?: context.getMimeType(uri) ?: MimeTypes.OctetStream
|
||||||
val fileName = name ?: context.getFileName(uri) ?: ""
|
val fileName = name ?: context.getFileName(uri) ?: ""
|
||||||
|
|
@ -96,6 +99,7 @@ class AndroidLocalMediaFactory @Inject constructor(
|
||||||
senderAvatar = senderAvatar,
|
senderAvatar = senderAvatar,
|
||||||
dateSent = dateSent,
|
dateSent = dateSent,
|
||||||
dateSentFull = dateSentFull,
|
dateSentFull = dateSentFull,
|
||||||
|
waveform = waveform,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,12 @@ package io.element.android.libraries.mediaviewer.impl.local
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
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.isMimeTypeImage
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
||||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
import io.element.android.libraries.mediaviewer.api.local.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.file.MediaFileView
|
||||||
import io.element.android.libraries.mediaviewer.impl.local.image.MediaImageView
|
import io.element.android.libraries.mediaviewer.impl.local.image.MediaImageView
|
||||||
import io.element.android.libraries.mediaviewer.impl.local.pdf.MediaPdfView
|
import io.element.android.libraries.mediaviewer.impl.local.pdf.MediaPdfView
|
||||||
|
|
@ -48,7 +50,13 @@ fun LocalMediaView(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
)
|
)
|
||||||
// TODO handle audio with exoplayer
|
mimeType.isMimeTypeAudio() -> MediaAudioView(
|
||||||
|
localMediaViewState = localMediaViewState,
|
||||||
|
bottomPaddingInPixels = bottomPaddingInPixels,
|
||||||
|
localMedia = localMedia,
|
||||||
|
info = mediaInfo,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
else -> MediaFileView(
|
else -> MediaFileView(
|
||||||
localMediaViewState = localMediaViewState,
|
localMediaViewState = localMediaViewState,
|
||||||
uri = localMedia?.uri,
|
uri = localMedia?.uri,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,366 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.mediaviewer.impl.local.audio
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.GraphicEq
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.MediaMetadata
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.Timeline
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.ui.AspectRatioFrameLayout
|
||||||
|
import androidx.media3.ui.PlayerView
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.text.toDp
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
||||||
|
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||||
|
import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize
|
||||||
|
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaViewState
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.local.PlayableState
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.local.player.MediaPlayerControllerState
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.local.player.MediaPlayerControllerView
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.local.player.rememberExoPlayer
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.local.player.seekToEnsurePlaying
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.local.player.togglePlay
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
|
||||||
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
@SuppressLint("UnsafeOptInUsageError")
|
||||||
|
@Composable
|
||||||
|
fun MediaAudioView(
|
||||||
|
localMediaViewState: LocalMediaViewState,
|
||||||
|
bottomPaddingInPixels: Int,
|
||||||
|
localMedia: LocalMedia?,
|
||||||
|
info: MediaInfo?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val exoPlayer = rememberExoPlayer()
|
||||||
|
ExoPlayerMediaAudioView(
|
||||||
|
localMediaViewState = localMediaViewState,
|
||||||
|
bottomPaddingInPixels = bottomPaddingInPixels,
|
||||||
|
exoPlayer = exoPlayer,
|
||||||
|
localMedia = localMedia,
|
||||||
|
info = info,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UnsafeOptInUsageError")
|
||||||
|
@Composable
|
||||||
|
private fun ExoPlayerMediaAudioView(
|
||||||
|
localMediaViewState: LocalMediaViewState,
|
||||||
|
bottomPaddingInPixels: Int,
|
||||||
|
exoPlayer: ExoPlayer,
|
||||||
|
localMedia: LocalMedia?,
|
||||||
|
info: MediaInfo?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var mediaPlayerControllerState: MediaPlayerControllerState by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
MediaPlayerControllerState(
|
||||||
|
isVisible = true,
|
||||||
|
isPlaying = false,
|
||||||
|
progressInMillis = 0,
|
||||||
|
durationInMillis = 0,
|
||||||
|
canMute = false,
|
||||||
|
isMuted = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata: MediaMetadata? by remember {
|
||||||
|
mutableStateOf(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
val playableState: PlayableState.Playable by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
PlayableState.Playable(
|
||||||
|
isShowingControls = mediaPlayerControllerState.isVisible,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
localMediaViewState.playableState = playableState
|
||||||
|
|
||||||
|
val playerListener = remember {
|
||||||
|
object : Player.Listener {
|
||||||
|
override fun onRenderedFirstFrame() {
|
||||||
|
localMediaViewState.isReady = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
|
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
||||||
|
isPlaying = isPlaying,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||||
|
if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) {
|
||||||
|
exoPlayer.duration.takeIf { it >= 0 }
|
||||||
|
?.let {
|
||||||
|
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
||||||
|
durationInMillis = it,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
|
||||||
|
metadata = mediaMetadata
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(exoPlayer.isPlaying) {
|
||||||
|
if (exoPlayer.isPlaying) {
|
||||||
|
while (true) {
|
||||||
|
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
||||||
|
progressInMillis = exoPlayer.currentPosition,
|
||||||
|
)
|
||||||
|
delay(200)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Ensure we render the final state
|
||||||
|
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
||||||
|
progressInMillis = exoPlayer.currentPosition,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (localMedia?.uri != null) {
|
||||||
|
LaunchedEffect(localMedia.uri) {
|
||||||
|
val mediaItem = MediaItem.fromUri(localMedia.uri)
|
||||||
|
exoPlayer.setMediaItem(mediaItem)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
exoPlayer.setMediaItems(emptyList())
|
||||||
|
}
|
||||||
|
val context = LocalContext.current
|
||||||
|
val waveform = info?.waveform
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(ElementTheme.colors.bgSubtlePrimary),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.Center),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (LocalInspectionMode.current) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.width(240.dp),
|
||||||
|
text = "An audio Player may render an image here if the audio file contains some artwork.",
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = ElementTheme.colors.textPrimary,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AndroidView(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(shape = RoundedCornerShape(12.dp))
|
||||||
|
.clipToBounds()
|
||||||
|
.width(240.dp),
|
||||||
|
factory = {
|
||||||
|
PlayerView(context).apply {
|
||||||
|
player = exoPlayer
|
||||||
|
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||||
|
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
|
||||||
|
useController = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = { playerView ->
|
||||||
|
playerView.isVisible = metadata.hasArtwork()
|
||||||
|
},
|
||||||
|
onRelease = { playerView ->
|
||||||
|
playerView.player = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (waveform != null) {
|
||||||
|
WaveformPlaybackView(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(48.dp),
|
||||||
|
playbackProgress = mediaPlayerControllerState.progressAsFloat,
|
||||||
|
showCursor = true,
|
||||||
|
waveform = waveform.toPersistentList(),
|
||||||
|
onSeek = {
|
||||||
|
exoPlayer.seekToEnsurePlaying((it * exoPlayer.duration).toLong())
|
||||||
|
},
|
||||||
|
seekEnabled = true,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if (!metadata.hasArtwork()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(72.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.onBackground),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.GraphicEq,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.background,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (waveform == null) {
|
||||||
|
// Display the info below the player
|
||||||
|
AudioInfoView(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
info = info,
|
||||||
|
metadata = metadata,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MediaPlayerControllerView(
|
||||||
|
state = mediaPlayerControllerState,
|
||||||
|
onTogglePlay = {
|
||||||
|
exoPlayer.togglePlay()
|
||||||
|
},
|
||||||
|
onSeekChange = {
|
||||||
|
exoPlayer.seekToEnsurePlaying(it.toLong())
|
||||||
|
},
|
||||||
|
onToggleMute = {
|
||||||
|
// Cannot happen for audio files
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = bottomPaddingInPixels.toDp()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OnLifecycleEvent { _, event ->
|
||||||
|
when (event) {
|
||||||
|
Lifecycle.Event.ON_CREATE -> exoPlayer.addListener(playerListener)
|
||||||
|
Lifecycle.Event.ON_RESUME -> exoPlayer.prepare()
|
||||||
|
Lifecycle.Event.ON_PAUSE -> exoPlayer.pause()
|
||||||
|
Lifecycle.Event.ON_DESTROY -> {
|
||||||
|
exoPlayer.release()
|
||||||
|
exoPlayer.removeListener(playerListener)
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AudioInfoView(
|
||||||
|
info: MediaInfo?,
|
||||||
|
metadata: MediaMetadata?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
// Render the info about the file and from the metadata
|
||||||
|
val metaDataInfo = metadata.buildInfo()
|
||||||
|
if (metaDataInfo.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = metaDataInfo,
|
||||||
|
style = ElementTheme.typography.fontBodyMdRegular,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (info != null) {
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
Text(
|
||||||
|
text = info.filename,
|
||||||
|
maxLines = 2,
|
||||||
|
style = ElementTheme.typography.fontBodyLgRegular,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = formatFileExtensionAndSize(info.fileExtension, info.formattedFileSize),
|
||||||
|
style = ElementTheme.typography.fontBodyMdRegular,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun MediaAudioViewPreview(
|
||||||
|
@PreviewParameter(MediaInfoAudioProvider::class) info: MediaInfo
|
||||||
|
) = ElementPreview {
|
||||||
|
MediaAudioView(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
bottomPaddingInPixels = 0,
|
||||||
|
localMediaViewState = rememberLocalMediaViewState(),
|
||||||
|
info = info,
|
||||||
|
localMedia = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
|
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo
|
|
||||||
|
|
||||||
open class MediaInfoFileProvider : PreviewParameterProvider<MediaInfo> {
|
open class MediaInfoFileProvider : PreviewParameterProvider<MediaInfo> {
|
||||||
override val values: Sequence<MediaInfo>
|
override val values: Sequence<MediaInfo>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aPdfMediaInfo(),
|
aPdfMediaInfo(),
|
||||||
anAudioMediaInfo(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.mediaviewer.impl.local.player
|
||||||
|
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
|
||||||
|
fun ExoPlayer.togglePlay() {
|
||||||
|
if (isPlaying) {
|
||||||
|
pause()
|
||||||
|
} else {
|
||||||
|
if (playbackState == Player.STATE_ENDED) {
|
||||||
|
seekTo(0)
|
||||||
|
} else {
|
||||||
|
play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ExoPlayer.seekToEnsurePlaying(positionMs: Long) {
|
||||||
|
if (isPlaying.not()) {
|
||||||
|
play()
|
||||||
|
}
|
||||||
|
seekTo(positionMs)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.mediaviewer.impl.local.player
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalInspectionMode
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberExoPlayer(): ExoPlayer {
|
||||||
|
return if (LocalInspectionMode.current) {
|
||||||
|
remember {
|
||||||
|
ExoPlayerForPreview()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val context = LocalContext.current
|
||||||
|
remember {
|
||||||
|
ExoPlayer.Builder(context).build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
"DEPRECATION",
|
"DEPRECATION",
|
||||||
)
|
)
|
||||||
|
|
||||||
package io.element.android.libraries.mediaviewer.impl.local.video
|
package io.element.android.libraries.mediaviewer.impl.local.player
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.media.AudioDeviceInfo
|
import android.media.AudioDeviceInfo
|
||||||
|
|
@ -5,12 +5,18 @@
|
||||||
* Please see LICENSE in the repository root for full details.
|
* Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.mediaviewer.impl.local.video
|
package io.element.android.libraries.mediaviewer.impl.local.player
|
||||||
|
|
||||||
|
import androidx.annotation.FloatRange
|
||||||
|
|
||||||
data class MediaPlayerControllerState(
|
data class MediaPlayerControllerState(
|
||||||
val isVisible: Boolean,
|
val isVisible: Boolean,
|
||||||
val isPlaying: Boolean,
|
val isPlaying: Boolean,
|
||||||
val progressInMillis: Long,
|
val progressInMillis: Long,
|
||||||
val durationInMillis: Long,
|
val durationInMillis: Long,
|
||||||
|
val canMute: Boolean,
|
||||||
val isMuted: Boolean,
|
val isMuted: Boolean,
|
||||||
)
|
) {
|
||||||
|
@FloatRange(from = 0.0, to = 1.0)
|
||||||
|
val progressAsFloat = (progressInMillis.toFloat() / durationInMillis.toFloat()).coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Please see LICENSE in the repository root for full details.
|
* Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.mediaviewer.impl.local.video
|
package io.element.android.libraries.mediaviewer.impl.local.player
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
|
||||||
|
|
@ -18,6 +18,9 @@ open class MediaPlayerControllerStateProvider : PreviewParameterProvider<MediaPl
|
||||||
durationInMillis = 83_000,
|
durationInMillis = 83_000,
|
||||||
isMuted = true,
|
isMuted = true,
|
||||||
),
|
),
|
||||||
|
aMediaPlayerControllerState(
|
||||||
|
canMute = false,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,11 +30,13 @@ private fun aMediaPlayerControllerState(
|
||||||
progressInMillis: Long = 0,
|
progressInMillis: Long = 0,
|
||||||
// Default to 1 minute and 23 seconds
|
// Default to 1 minute and 23 seconds
|
||||||
durationInMillis: Long = 83_000,
|
durationInMillis: Long = 83_000,
|
||||||
|
canMute: Boolean = true,
|
||||||
isMuted: Boolean = false,
|
isMuted: Boolean = false,
|
||||||
) = MediaPlayerControllerState(
|
) = MediaPlayerControllerState(
|
||||||
isVisible = isVisible,
|
isVisible = isVisible,
|
||||||
isPlaying = isPlaying,
|
isPlaying = isPlaying,
|
||||||
progressInMillis = progressInMillis,
|
progressInMillis = progressInMillis,
|
||||||
durationInMillis = durationInMillis,
|
durationInMillis = durationInMillis,
|
||||||
|
canMute = canMute,
|
||||||
isMuted = isMuted,
|
isMuted = isMuted,
|
||||||
)
|
)
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Please see LICENSE in the repository root for full details.
|
* Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.mediaviewer.impl.local.video
|
package io.element.android.libraries.mediaviewer.impl.local.player
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
|
|
@ -133,21 +133,23 @@ fun MediaPlayerControllerView(
|
||||||
color = ElementTheme.colors.textPrimary,
|
color = ElementTheme.colors.textPrimary,
|
||||||
style = ElementTheme.typography.fontBodyXsMedium,
|
style = ElementTheme.typography.fontBodyXsMedium,
|
||||||
)
|
)
|
||||||
IconButton(
|
if (state.canMute) {
|
||||||
onClick = onToggleMute,
|
IconButton(
|
||||||
) {
|
onClick = onToggleMute,
|
||||||
if (state.isMuted) {
|
) {
|
||||||
Icon(
|
if (state.isMuted) {
|
||||||
imageVector = CompoundIcons.VolumeOffSolid(),
|
Icon(
|
||||||
tint = ElementTheme.colors.iconPrimary,
|
imageVector = CompoundIcons.VolumeOffSolid(),
|
||||||
contentDescription = stringResource(CommonStrings.common_unmute)
|
tint = ElementTheme.colors.iconPrimary,
|
||||||
)
|
contentDescription = stringResource(CommonStrings.common_unmute)
|
||||||
} else {
|
)
|
||||||
Icon(
|
} else {
|
||||||
imageVector = CompoundIcons.VolumeOnSolid(),
|
Icon(
|
||||||
tint = ElementTheme.colors.iconPrimary,
|
imageVector = CompoundIcons.VolumeOnSolid(),
|
||||||
contentDescription = stringResource(CommonStrings.common_mute)
|
tint = ElementTheme.colors.iconPrimary,
|
||||||
)
|
contentDescription = stringResource(CommonStrings.common_mute)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
/*
|
|
||||||
* Copyright 2023, 2024 New Vector Ltd.
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
* Please see LICENSE in the repository root for full details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package io.element.android.libraries.mediaviewer.impl.local.video
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.media3.common.Player
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrapper around ExoPlayer to disable some commands.
|
|
||||||
* Necessary to hide the settings wheels from the player.
|
|
||||||
*/
|
|
||||||
@UnstableApi
|
|
||||||
class ExoPlayerWrapper(private val exoPlayer: ExoPlayer) : ExoPlayer by exoPlayer {
|
|
||||||
override fun isCommandAvailable(command: Int): Boolean {
|
|
||||||
return availableCommands.contains(command)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAvailableCommands(): Player.Commands {
|
|
||||||
return exoPlayer.availableCommands
|
|
||||||
.buildUpon()
|
|
||||||
.remove(Player.COMMAND_SET_TRACK_SELECTION_PARAMETERS)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun create(context: Context): ExoPlayer {
|
|
||||||
return ExoPlayerWrapper(
|
|
||||||
ExoPlayer.Builder(context).build()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -45,6 +45,11 @@ import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||||
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaViewState
|
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.PlayableState
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.local.player.MediaPlayerControllerState
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.local.player.MediaPlayerControllerView
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.local.player.rememberExoPlayer
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.local.player.seekToEnsurePlaying
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.local.player.togglePlay
|
||||||
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
|
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
@ -57,16 +62,7 @@ fun MediaVideoView(
|
||||||
localMedia: LocalMedia?,
|
localMedia: LocalMedia?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val exoPlayer = if (LocalInspectionMode.current) {
|
val exoPlayer = rememberExoPlayer()
|
||||||
remember {
|
|
||||||
ExoPlayerForPreview()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val context = LocalContext.current
|
|
||||||
remember {
|
|
||||||
ExoPlayerWrapper.create(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ExoPlayerMediaVideoView(
|
ExoPlayerMediaVideoView(
|
||||||
localMediaViewState = localMediaViewState,
|
localMediaViewState = localMediaViewState,
|
||||||
bottomPaddingInPixels = bottomPaddingInPixels,
|
bottomPaddingInPixels = bottomPaddingInPixels,
|
||||||
|
|
@ -92,6 +88,7 @@ private fun ExoPlayerMediaVideoView(
|
||||||
isPlaying = false,
|
isPlaying = false,
|
||||||
progressInMillis = 0,
|
progressInMillis = 0,
|
||||||
durationInMillis = 0,
|
durationInMillis = 0,
|
||||||
|
canMute = true,
|
||||||
isMuted = false,
|
isMuted = false,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -107,31 +104,33 @@ private fun ExoPlayerMediaVideoView(
|
||||||
|
|
||||||
localMediaViewState.playableState = playableState
|
localMediaViewState.playableState = playableState
|
||||||
|
|
||||||
val playerListener = object : Player.Listener {
|
val playerListener = remember {
|
||||||
override fun onRenderedFirstFrame() {
|
object : Player.Listener {
|
||||||
localMediaViewState.isReady = true
|
override fun onRenderedFirstFrame() {
|
||||||
}
|
localMediaViewState.isReady = true
|
||||||
|
}
|
||||||
|
|
||||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||||
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
||||||
isPlaying = isPlaying,
|
isPlaying = isPlaying,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onVolumeChanged(volume: Float) {
|
override fun onVolumeChanged(volume: Float) {
|
||||||
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
||||||
isMuted = volume == 0f,
|
isMuted = volume == 0f,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||||
if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) {
|
if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) {
|
||||||
exoPlayer.duration.takeIf { it >= 0 }
|
exoPlayer.duration.takeIf { it >= 0 }
|
||||||
?.let {
|
?.let {
|
||||||
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
mediaPlayerControllerState = mediaPlayerControllerState.copy(
|
||||||
durationInMillis = it,
|
durationInMillis = it,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -211,22 +210,11 @@ private fun ExoPlayerMediaVideoView(
|
||||||
state = mediaPlayerControllerState,
|
state = mediaPlayerControllerState,
|
||||||
onTogglePlay = {
|
onTogglePlay = {
|
||||||
autoHideController++
|
autoHideController++
|
||||||
if (exoPlayer.isPlaying) {
|
exoPlayer.togglePlay()
|
||||||
exoPlayer.pause()
|
|
||||||
} else {
|
|
||||||
if (exoPlayer.playbackState == Player.STATE_ENDED) {
|
|
||||||
exoPlayer.seekTo(0)
|
|
||||||
} else {
|
|
||||||
exoPlayer.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onSeekChange = {
|
onSeekChange = {
|
||||||
autoHideController++
|
autoHideController++
|
||||||
if (exoPlayer.isPlaying.not()) {
|
exoPlayer.seekToEnsurePlaying(it.toLong())
|
||||||
exoPlayer.play()
|
|
||||||
}
|
|
||||||
exoPlayer.seekTo(it.toLong())
|
|
||||||
},
|
},
|
||||||
onToggleMute = {
|
onToggleMute = {
|
||||||
autoHideController++
|
autoHideController++
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ package io.element.android.libraries.mediaviewer.impl.viewer
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
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.MediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
|
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
|
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
|
||||||
|
|
@ -100,6 +101,16 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
|
||||||
aMediaViewerState(
|
aMediaViewerState(
|
||||||
mediaBottomSheetState = aMediaDeleteConfirmationState(),
|
mediaBottomSheetState = aMediaDeleteConfirmationState(),
|
||||||
),
|
),
|
||||||
|
anAudioMediaInfo(
|
||||||
|
waveForm = aWaveForm(),
|
||||||
|
).let {
|
||||||
|
aMediaViewerState(
|
||||||
|
downloadedMedia = AsyncData.Success(
|
||||||
|
LocalMedia(Uri.EMPTY, it)
|
||||||
|
),
|
||||||
|
mediaInfo = it,
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
|
||||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
|
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
|
@ -163,6 +164,7 @@ class DefaultEventItemFactoryTest {
|
||||||
senderAvatar = null,
|
senderAvatar = null,
|
||||||
dateSent = "0 Day false",
|
dateSent = "0 Day false",
|
||||||
dateSentFull = "0 Full false",
|
dateSentFull = "0 Full false",
|
||||||
|
waveform = null,
|
||||||
),
|
),
|
||||||
mediaSource = MediaSource(""),
|
mediaSource = MediaSource(""),
|
||||||
)
|
)
|
||||||
|
|
@ -211,6 +213,7 @@ class DefaultEventItemFactoryTest {
|
||||||
senderAvatar = null,
|
senderAvatar = null,
|
||||||
dateSent = "0 Day false",
|
dateSent = "0 Day false",
|
||||||
dateSentFull = "0 Full false",
|
dateSentFull = "0 Full false",
|
||||||
|
waveform = null,
|
||||||
),
|
),
|
||||||
mediaSource = MediaSource(""),
|
mediaSource = MediaSource(""),
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
|
|
@ -242,7 +245,7 @@ class DefaultEventItemFactoryTest {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assertThat(result).isEqualTo(
|
assertThat(result).isEqualTo(
|
||||||
MediaItem.File(
|
MediaItem.Audio(
|
||||||
id = A_UNIQUE_ID,
|
id = A_UNIQUE_ID,
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
mediaInfo = MediaInfo(
|
mediaInfo = MediaInfo(
|
||||||
|
|
@ -256,8 +259,11 @@ class DefaultEventItemFactoryTest {
|
||||||
senderAvatar = null,
|
senderAvatar = null,
|
||||||
dateSent = "0 Day false",
|
dateSent = "0 Day false",
|
||||||
dateSentFull = "0 Full false",
|
dateSentFull = "0 Full false",
|
||||||
|
waveform = null,
|
||||||
),
|
),
|
||||||
mediaSource = MediaSource(""),
|
mediaSource = MediaSource(""),
|
||||||
|
duration = "7:36",
|
||||||
|
waveform = null,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -305,6 +311,7 @@ class DefaultEventItemFactoryTest {
|
||||||
senderAvatar = null,
|
senderAvatar = null,
|
||||||
dateSent = "0 Day false",
|
dateSent = "0 Day false",
|
||||||
dateSentFull = "0 Full false",
|
dateSentFull = "0 Full false",
|
||||||
|
waveform = null,
|
||||||
),
|
),
|
||||||
mediaSource = MediaSource(""),
|
mediaSource = MediaSource(""),
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
|
|
@ -333,7 +340,7 @@ class DefaultEventItemFactoryTest {
|
||||||
),
|
),
|
||||||
details = AudioDetails(
|
details = AudioDetails(
|
||||||
duration = 456.seconds,
|
duration = 456.seconds,
|
||||||
waveform = persistentListOf(),
|
waveform = persistentListOf(1f, 2f),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -341,7 +348,7 @@ class DefaultEventItemFactoryTest {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assertThat(result).isEqualTo(
|
assertThat(result).isEqualTo(
|
||||||
MediaItem.File(
|
MediaItem.Audio(
|
||||||
id = A_UNIQUE_ID,
|
id = A_UNIQUE_ID,
|
||||||
eventId = AN_EVENT_ID,
|
eventId = AN_EVENT_ID,
|
||||||
mediaInfo = MediaInfo(
|
mediaInfo = MediaInfo(
|
||||||
|
|
@ -355,8 +362,11 @@ class DefaultEventItemFactoryTest {
|
||||||
senderAvatar = null,
|
senderAvatar = null,
|
||||||
dateSent = "0 Day false",
|
dateSent = "0 Day false",
|
||||||
dateSentFull = "0 Full false",
|
dateSentFull = "0 Full false",
|
||||||
|
waveform = listOf(1f, 2f).toImmutableList(),
|
||||||
),
|
),
|
||||||
mediaSource = MediaSource(""),
|
mediaSource = MediaSource(""),
|
||||||
|
duration = "7:36",
|
||||||
|
waveform = listOf(1f, 2f).toImmutableList(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -403,6 +413,7 @@ class DefaultEventItemFactoryTest {
|
||||||
senderAvatar = null,
|
senderAvatar = null,
|
||||||
dateSent = "0 Day false",
|
dateSent = "0 Day false",
|
||||||
dateSentFull = "0 Full false",
|
dateSentFull = "0 Full false",
|
||||||
|
waveform = null,
|
||||||
),
|
),
|
||||||
mediaSource = MediaSource(""),
|
mediaSource = MediaSource(""),
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.matrix.api.core.UniqueId
|
import io.element.android.libraries.matrix.api.core.UniqueId
|
||||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemAudio
|
||||||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemDateSeparator
|
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemDateSeparator
|
||||||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile
|
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemFile
|
||||||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage
|
import io.element.android.libraries.mediaviewer.impl.gallery.ui.aMediaItemImage
|
||||||
|
|
@ -23,6 +24,9 @@ class MediaItemsPostProcessorTest {
|
||||||
private val file1 = aMediaItemFile(id = UniqueId("1"))
|
private val file1 = aMediaItemFile(id = UniqueId("1"))
|
||||||
private val file2 = aMediaItemFile(id = UniqueId("2"))
|
private val file2 = aMediaItemFile(id = UniqueId("2"))
|
||||||
private val file3 = aMediaItemFile(id = UniqueId("3"))
|
private val file3 = aMediaItemFile(id = UniqueId("3"))
|
||||||
|
private val audio1 = aMediaItemAudio(id = UniqueId("1"))
|
||||||
|
private val audio2 = aMediaItemAudio(id = UniqueId("2"))
|
||||||
|
private val audio3 = aMediaItemAudio(id = UniqueId("3"))
|
||||||
private val image1 = aMediaItemImage(id = UniqueId("1"))
|
private val image1 = aMediaItemImage(id = UniqueId("1"))
|
||||||
private val image2 = aMediaItemImage(id = UniqueId("2"))
|
private val image2 = aMediaItemImage(id = UniqueId("2"))
|
||||||
private val image3 = aMediaItemImage(id = UniqueId("3"))
|
private val image3 = aMediaItemImage(id = UniqueId("3"))
|
||||||
|
|
@ -68,6 +72,7 @@ class MediaItemsPostProcessorTest {
|
||||||
fun `process will reorder files`() {
|
fun `process will reorder files`() {
|
||||||
test(
|
test(
|
||||||
mediaItems = listOf(
|
mediaItems = listOf(
|
||||||
|
audio1,
|
||||||
file3,
|
file3,
|
||||||
file2,
|
file2,
|
||||||
file1,
|
file1,
|
||||||
|
|
@ -79,6 +84,7 @@ class MediaItemsPostProcessorTest {
|
||||||
file1,
|
file1,
|
||||||
file2,
|
file2,
|
||||||
file3,
|
file3,
|
||||||
|
audio1,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -106,6 +112,7 @@ class MediaItemsPostProcessorTest {
|
||||||
fun `process will split images, videos and files`() {
|
fun `process will split images, videos and files`() {
|
||||||
test(
|
test(
|
||||||
mediaItems = listOf(
|
mediaItems = listOf(
|
||||||
|
audio1,
|
||||||
file1,
|
file1,
|
||||||
image1,
|
image1,
|
||||||
video1,
|
video1,
|
||||||
|
|
@ -119,6 +126,7 @@ class MediaItemsPostProcessorTest {
|
||||||
expectedFileItems = listOf(
|
expectedFileItems = listOf(
|
||||||
date1,
|
date1,
|
||||||
file1,
|
file1,
|
||||||
|
audio1,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -155,6 +163,9 @@ class MediaItemsPostProcessorTest {
|
||||||
fun `process will handle complex case`() {
|
fun `process will handle complex case`() {
|
||||||
test(
|
test(
|
||||||
mediaItems = listOf(
|
mediaItems = listOf(
|
||||||
|
audio3,
|
||||||
|
audio2,
|
||||||
|
audio1,
|
||||||
file1,
|
file1,
|
||||||
image1,
|
image1,
|
||||||
video1,
|
video1,
|
||||||
|
|
@ -178,6 +189,9 @@ class MediaItemsPostProcessorTest {
|
||||||
expectedFileItems = listOf(
|
expectedFileItems = listOf(
|
||||||
date1,
|
date1,
|
||||||
file1,
|
file1,
|
||||||
|
audio1,
|
||||||
|
audio2,
|
||||||
|
audio3,
|
||||||
date3,
|
date3,
|
||||||
file3,
|
file3,
|
||||||
loading1,
|
loading1,
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,8 @@ class AndroidLocalMediaFactoryTest {
|
||||||
senderName = A_USER_NAME,
|
senderName = A_USER_NAME,
|
||||||
senderAvatar = null,
|
senderAvatar = null,
|
||||||
dateSent = "12:34",
|
dateSent = "12:34",
|
||||||
dateSentFull = "full"
|
dateSentFull = "full",
|
||||||
|
waveform = null,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ class FakeLocalMediaFactory(
|
||||||
senderAvatar = null,
|
senderAvatar = null,
|
||||||
dateSent = null,
|
dateSent = null,
|
||||||
dateSentFull = null,
|
dateSentFull = null,
|
||||||
|
waveform = null,
|
||||||
)
|
)
|
||||||
return aLocalMedia(uri, mediaInfo)
|
return aLocalMedia(uri, mediaInfo)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:bbe0bf5ff3c5128405fe0af2af344140a655d93826bf7591393dbd4732a7b729
|
oid sha256:470ea6854c3786db7935c55a852637c907665326a61a5dcf33c66f0710406c09
|
||||||
size 8383
|
size 9650
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:8ec9068b2f5b7bdf0bcf6ede6c2ea02040c1a77b1054c1fbf45fe5feb1ab78e5
|
oid sha256:1b1ef50d42e57a1465de82d538a573c41a73a133890ecf84bed0317d38e33440
|
||||||
size 8147
|
size 9385
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:1d07440581dc64c48c047b8895520c7f46213e4806053b6a5fa4b63c15386c8c
|
||||||
|
size 12410
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9beef21701df1566b27d7b71808de32a87ae439b5213f7e081d821e60add5b36
|
||||||
|
size 16302
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0adeab82d5197daf0eb408f66ee605e0ae3862ac08de0c6a5093a957f724718c
|
||||||
|
size 39939
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:835cdc9b99cdd8eedfe2f59032344d07681cd8bbab39a0f15e340abd80c035ff
|
||||||
|
size 10979
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:1813f6e96713fc8c11eca36475ac51b3162f88c71bbe4e9fe7cb0c9a58314ab4
|
||||||
|
size 11719
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:1df8cd0cbef387b05959a423c20cf92605e1c671404aef4ccbb79048d4fc4a6f
|
||||||
|
size 15518
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:de63930be672d83e9bc508c6a84911f6a309c2dbac7b608c3bfef5e8a37fc49f
|
||||||
|
size 38349
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:836b153cee837cb5c0ba375b587f6bfa6e34eb9f88e4198483f956ebff91048a
|
||||||
|
size 10234
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:ce8fec32ec1a902edb12d9580ca73d0cc5a9838f91d31dbbf8329326b9fa1a68
|
oid sha256:237067f8a59de7f88c313d78716116c5dfe35d9112ad3d42a61f172de2637415
|
||||||
size 39676
|
size 40888
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:946183ccbaf14471579f3b48861715d04ed45d6352d36126a419de7e6f362bfd
|
oid sha256:9304dc29a88d7829aebfb6de1bab40fde20fcaef0fb92efdf9135d4df92c4442
|
||||||
size 37632
|
size 38694
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:7c7d4201ed9aa37995f4ab8ac982404f59e77374f316a057685886f14e698c35
|
||||||
|
size 24680
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b94fd31b7ed71eacfe8f136bfd59405b85d31a0fe557800311794f4ba7006271
|
||||||
|
size 22749
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:6520b0faf9c22ee1d9b1a088b29fc07e3d8004db5a342cd3bff189d844aace6e
|
||||||
|
size 24649
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:5dc15cba85c8cc376b780df648b4fa265c054de07a4bab426569ac26be03fec1
|
||||||
|
size 22784
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:5307e90428957819812269b9b3e0c6e9d59238141d54cd959aa5506290797a35
|
|
||||||
size 11587
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
|
||||||
oid sha256:52354bcf471b14e38e582cc29f73407c8ca65026b2b7c6db3d3b28ec94950679
|
|
||||||
size 11413
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:a6d04e0ee068682ebb0a3842ba73407855f2b83b7389d26fa0f3e2ec20d42dc8
|
||||||
|
size 7389
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:1211fac03e492532b1e90608fd931b8fcc15dac695ef5610069bf512c2a5fef1
|
||||||
|
size 7548
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:8a6a599e68f0955d84ea737603f0db83be412433691f7b7b3729a01999808830
|
||||||
|
size 25827
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:c786dc4f2ec41fc334cd67febc105670c999da57cec1ea263daff2358ff5c766
|
oid sha256:c7642e9e20a551e59f4529c52fa7fbe5b3f4dfcb8c26caeb716ccb2bdfc63dde
|
||||||
size 14406
|
size 27176
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:3f74e1b75f3e5a03df1b6b0e4509a4bf5da033c4f68cf88705ed136b471ae38c
|
oid sha256:1fd53c24dd38a12b4ceefa54e1d6d096d8f443d1cdd1d1f1cc71f92c1d603a51
|
||||||
size 14691
|
size 27419
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue