Merge pull request #3967 from element-hq/feature/bma/mediaModule

Rework on media module
This commit is contained in:
Benoit Marty 2024-12-02 14:41:36 +01:00 committed by GitHub
commit 4e688021cc
92 changed files with 1241 additions and 684 deletions

View file

@ -13,45 +13,12 @@ plugins {
android {
namespace = "io.element.android.libraries.mediaviewer.api"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
setupAnvil()
dependencies {
implementation(libs.coil.compose)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
implementation(libs.coroutines.core)
implementation(libs.dagger)
implementation(libs.telephoto.zoomableimage)
implementation(libs.vanniktech.blurhash)
implementation(libs.telephoto.flick)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.di)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.mediaviewer.test)
testImplementation(projects.tests.testutils)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
testImplementation(libs.test.mockk)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.turbine)
testImplementation(libs.coroutines.core)
testImplementation(libs.coroutines.test)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}

View file

@ -1,11 +1,11 @@
/*
* Copyright 2023, 2024 New Vector Ltd.
* 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.api.local
package io.element.android.libraries.mediaviewer.api
import android.os.Parcelable
import io.element.android.libraries.core.mimetype.MimeTypes

View file

@ -0,0 +1,38 @@
/*
* 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.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.matrix.api.media.MediaSource
interface MediaViewerEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun callback(callback: Callback): NodeBuilder
fun params(params: Params): NodeBuilder
fun avatar(filename: String, avatarUrl: String): NodeBuilder
fun build(): Node
}
interface Callback : Plugin {
fun onDone()
}
data class Params(
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val canDownload: Boolean,
val canShare: Boolean,
) : NodeInputs
}

View file

@ -10,6 +10,7 @@ package io.element.android.libraries.mediaviewer.api.local
import android.net.Uri
import android.os.Parcelable
import androidx.compose.runtime.Immutable
import io.element.android.libraries.mediaviewer.api.MediaInfo
import kotlinx.parcelize.Parcelize
@Parcelize

View file

@ -9,6 +9,7 @@ package io.element.android.libraries.mediaviewer.api.local
import android.net.Uri
import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.mediaviewer.api.MediaInfo
interface LocalMediaFactory {
/**

View file

@ -0,0 +1,15 @@
/*
* 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.api.local
import androidx.compose.runtime.Composable
interface LocalMediaRenderer {
@Composable
fun Render(localMedia: LocalMedia)
}

View file

@ -1,418 +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.api.local
import android.annotation.SuppressLint
import android.net.Uri
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.wrapContentSize
import androidx.compose.foundation.shape.CircleShape
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.mutableIntStateOf
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.rotate
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.core.bool.orFalse
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.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.designsystem.utils.KeepScreenOn
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize
import io.element.android.libraries.mediaviewer.api.local.exoplayer.ExoPlayerWrapper
import io.element.android.libraries.mediaviewer.api.local.pdf.PdfViewer
import io.element.android.libraries.mediaviewer.api.local.pdf.rememberPdfViewerState
import io.element.android.libraries.mediaviewer.api.player.MediaPlayerControllerState
import io.element.android.libraries.mediaviewer.api.player.MediaPlayerControllerView
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.delay
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
import me.saket.telephoto.zoomable.rememberZoomableImageState
import kotlin.time.Duration.Companion.seconds
@Composable
fun LocalMediaView(
localMedia: LocalMedia?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(),
mediaInfo: MediaInfo? = localMedia?.info,
) {
val mimeType = mediaInfo?.mimeType
when {
mimeType.isMimeTypeImage() -> MediaImageView(
localMediaViewState = localMediaViewState,
localMedia = localMedia,
modifier = modifier,
onClick = onClick,
)
mimeType.isMimeTypeVideo() -> MediaVideoView(
localMediaViewState = localMediaViewState,
localMedia = localMedia,
modifier = modifier,
)
mimeType == MimeTypes.Pdf -> MediaPDFView(
localMediaViewState = localMediaViewState,
localMedia = localMedia,
modifier = modifier,
onClick = onClick,
)
// TODO handle audio with exoplayer
else -> MediaFileView(
localMediaViewState = localMediaViewState,
uri = localMedia?.uri,
info = mediaInfo,
modifier = modifier,
onClick = onClick,
)
}
}
@Composable
private fun MediaImageView(
localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
if (LocalInspectionMode.current) {
Image(
painter = painterResource(id = CommonDrawables.sample_background),
modifier = modifier,
contentDescription = null,
)
} else {
val zoomableImageState = rememberZoomableImageState(localMediaViewState.zoomableState)
localMediaViewState.isReady = zoomableImageState.isImageDisplayed
ZoomableAsyncImage(
modifier = modifier,
state = zoomableImageState,
model = localMedia?.uri,
contentDescription = stringResource(id = CommonStrings.common_image),
contentScale = ContentScale.Fit,
onClick = { onClick() }
)
}
}
@Composable
private fun MediaVideoView(
localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?,
modifier: Modifier = Modifier,
) {
if (LocalInspectionMode.current) {
Text(
modifier = modifier
.background(ElementTheme.colors.bgSubtlePrimary)
.wrapContentSize(),
text = "A Video Player will render here",
)
} else {
ExoPlayerMediaVideoView(
localMediaViewState = localMediaViewState,
localMedia = localMedia,
modifier = modifier,
)
}
}
@SuppressLint("UnsafeOptInUsageError")
@Composable
private fun ExoPlayerMediaVideoView(
localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?,
modifier: Modifier = Modifier,
) {
var mediaPlayerControllerState: MediaPlayerControllerState by remember {
mutableStateOf(
MediaPlayerControllerState(
isVisible = false,
isPlaying = false,
progressInMillis = 0,
durationInMillis = 0,
isMuted = false,
)
)
}
val playableState: PlayableState.Playable by remember {
derivedStateOf {
PlayableState.Playable(
isShowingControls = mediaPlayerControllerState.isVisible,
)
}
}
localMediaViewState.playableState = playableState
val context = LocalContext.current
val exoPlayer = remember {
ExoPlayerWrapper.create(context)
}
val playerListener = object : Player.Listener {
override fun onRenderedFirstFrame() {
localMediaViewState.isReady = true
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
isPlaying = isPlaying,
)
}
override fun onVolumeChanged(volume: Float) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
isMuted = volume == 0f,
)
}
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
durationInMillis = exoPlayer.duration,
)
}
}
}
LaunchedEffect(Unit) {
exoPlayer.addListener(playerListener)
exoPlayer.prepare()
}
var autoHideController by remember { mutableIntStateOf(0) }
LaunchedEffect(autoHideController) {
delay(5.seconds)
if (exoPlayer.isPlaying) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
isVisible = false,
)
}
}
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())
}
KeepScreenOn(mediaPlayerControllerState.isPlaying)
Box(
modifier = modifier
.background(ElementTheme.colors.bgSubtlePrimary)
.wrapContentSize(),
) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = {
PlayerView(context).apply {
player = exoPlayer
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
setOnClickListener {
autoHideController++
mediaPlayerControllerState = mediaPlayerControllerState.copy(
isVisible = !mediaPlayerControllerState.isVisible,
)
}
useController = false
}
},
onRelease = { playerView ->
playerView.setOnClickListener(null)
playerView.setControllerVisibilityListener(null as PlayerView.ControllerVisibilityListener?)
playerView.player = null
},
)
MediaPlayerControllerView(
state = mediaPlayerControllerState,
onTogglePlay = {
autoHideController++
if (exoPlayer.isPlaying) {
exoPlayer.pause()
} else {
if (exoPlayer.playbackState == Player.STATE_ENDED) {
exoPlayer.seekTo(0)
} else {
exoPlayer.play()
}
}
},
onSeekChange = {
autoHideController++
if (exoPlayer.isPlaying.not()) {
exoPlayer.play()
}
exoPlayer.seekTo(it.toLong())
},
onToggleMute = {
autoHideController++
exoPlayer.volume = if (exoPlayer.volume == 1f) 0f else 1f
},
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter),
)
}
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> exoPlayer.play()
Lifecycle.Event.ON_PAUSE -> exoPlayer.pause()
Lifecycle.Event.ON_DESTROY -> {
exoPlayer.release()
exoPlayer.removeListener(playerListener)
}
else -> Unit
}
}
}
@Composable
private fun MediaPDFView(
localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val pdfViewerState = rememberPdfViewerState(
model = localMedia?.uri,
zoomableState = localMediaViewState.zoomableState,
)
localMediaViewState.isReady = pdfViewerState.isLoaded
PdfViewer(
pdfViewerState = pdfViewerState,
onClick = onClick,
modifier = modifier,
)
}
@Composable
private fun MediaFileView(
localMediaViewState: LocalMediaViewState,
uri: Uri?,
info: MediaInfo?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val isAudio = info?.mimeType.isMimeTypeAudio().orFalse()
localMediaViewState.isReady = uri != null
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = modifier
.padding(horizontal = 8.dp)
.clickable(
onClick = onClick,
interactionSource = interactionSource,
indication = null
),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.onBackground),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (isAudio) Icons.Outlined.GraphicEq else CompoundIcons.Attachment(),
contentDescription = null,
tint = MaterialTheme.colorScheme.background,
modifier = Modifier
.size(32.dp)
.rotate(if (isAudio) 0f else -45f),
)
}
if (info != null) {
Spacer(modifier = Modifier.height(20.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
)
}
}
}
}

View file

@ -7,30 +7,6 @@
package io.element.android.libraries.mediaviewer.api.util
import android.webkit.MimeTypeMap
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
interface FileExtensionExtractor {
fun extractFromName(name: String): String
}
@ContributesBinding(AppScope::class)
class FileExtensionExtractorWithValidation @Inject constructor() : FileExtensionExtractor {
override fun extractFromName(name: String): String {
val fileExtension = name.substringAfterLast('.', "")
// Makes sure the extension is known by the system, otherwise default to binary extension.
return if (MimeTypeMap.getSingleton().hasExtension(fileExtension)) {
fileExtension
} else {
"bin"
}
}
}
class FileExtensionExtractorWithoutValidation : FileExtensionExtractor {
override fun extractFromName(name: String): String {
return name.substringAfterLast('.', "")
}
}

View file

@ -13,6 +13,11 @@ plugins {
android {
namespace = "io.element.android.libraries.mediaviewer.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
setupAnvil()
@ -21,6 +26,23 @@ dependencies {
implementation(libs.coroutines.core)
implementation(libs.dagger)
implementation(libs.coil.compose)
implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.ui)
implementation(libs.telephoto.zoomableimage)
implementation(libs.vanniktech.blurhash)
implementation(libs.telephoto.flick)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.di)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings)
api(projects.libraries.mediaviewer.api)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
@ -39,4 +61,6 @@ dependencies {
testImplementation(libs.test.turbine)
testImplementation(libs.coroutines.core)
testImplementation(libs.coroutines.test)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
}

View file

@ -0,0 +1,64 @@
/*
* 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
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.impl.viewer.MediaViewerNode
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultMediaViewerEntryPoint @Inject constructor() : MediaViewerEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): MediaViewerEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : MediaViewerEntryPoint.NodeBuilder {
override fun callback(callback: MediaViewerEntryPoint.Callback): MediaViewerEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun params(params: MediaViewerEntryPoint.Params): MediaViewerEntryPoint.NodeBuilder {
plugins += params
return this
}
override fun avatar(filename: String, avatarUrl: String): MediaViewerEntryPoint.NodeBuilder {
// We need to fake the MimeType here for the viewer to work.
val mimeType = MimeTypes.Images
return params(
MediaViewerEntryPoint.Params(
mediaInfo = MediaInfo(
filename = filename,
caption = null,
mimeType = mimeType,
formattedFileSize = "",
fileExtension = ""
),
mediaSource = MediaSource(url = avatarUrl),
thumbnailSource = null,
canDownload = false,
canShare = false,
)
)
}
override fun build(): Node {
return parentNode.createNode<MediaViewerNode>(buildContext, plugins)
}
}
}
}

View file

@ -35,7 +35,6 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaActions
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber

View file

@ -20,9 +20,9 @@ import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.matrix.api.media.toFile
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.LocalMediaFactory
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
import javax.inject.Inject

View file

@ -0,0 +1,37 @@
/*
* 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
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
import me.saket.telephoto.zoomable.ZoomSpec
import me.saket.telephoto.zoomable.rememberZoomableState
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultLocalMediaRenderer @Inject constructor() : LocalMediaRenderer {
@Composable
override fun Render(localMedia: LocalMedia) {
val localMediaViewState = rememberLocalMediaViewState(
zoomableState = rememberZoomableState(
zoomSpec = ZoomSpec(maxZoomFactor = 4f, preventOverOrUnderZoom = false)
)
)
LocalMediaView(
modifier = Modifier.fillMaxSize(),
localMedia = localMedia,
localMediaViewState = localMediaViewState,
onClick = {}
)
}
}

View file

@ -5,9 +5,10 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.api.local
package io.element.android.libraries.mediaviewer.impl.local
import androidx.compose.runtime.Composable
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
interface LocalMediaActions {
@Composable

View file

@ -0,0 +1,58 @@
/*
* 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
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.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.file.MediaFileView
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.video.MediaVideoView
@Composable
fun LocalMediaView(
localMedia: LocalMedia?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(),
mediaInfo: MediaInfo? = localMedia?.info,
) {
val mimeType = mediaInfo?.mimeType
when {
mimeType.isMimeTypeImage() -> MediaImageView(
localMediaViewState = localMediaViewState,
localMedia = localMedia,
modifier = modifier,
onClick = onClick,
)
mimeType.isMimeTypeVideo() -> MediaVideoView(
localMediaViewState = localMediaViewState,
localMedia = localMedia,
modifier = modifier,
)
mimeType == MimeTypes.Pdf -> MediaPdfView(
localMediaViewState = localMediaViewState,
localMedia = localMedia,
modifier = modifier,
onClick = onClick,
)
// TODO handle audio with exoplayer
else -> MediaFileView(
localMediaViewState = localMediaViewState,
uri = localMedia?.uri,
info = mediaInfo,
modifier = modifier,
onClick = onClick,
)
}
}

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.api.local
package io.element.android.libraries.mediaviewer.impl.local
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable

View file

@ -0,0 +1,122 @@
/*
* 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.file
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
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.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
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 io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaViewState
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
@Composable
fun MediaFileView(
localMediaViewState: LocalMediaViewState,
uri: Uri?,
info: MediaInfo?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val isAudio = info?.mimeType.isMimeTypeAudio().orFalse()
localMediaViewState.isReady = uri != null
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = modifier
.padding(horizontal = 8.dp)
.clickable(
onClick = onClick,
interactionSource = interactionSource,
indication = null
),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.onBackground),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (isAudio) Icons.Outlined.GraphicEq else CompoundIcons.Attachment(),
contentDescription = null,
tint = MaterialTheme.colorScheme.background,
modifier = Modifier
.size(32.dp)
.rotate(if (isAudio) 0f else -45f),
)
}
if (info != null) {
Spacer(modifier = Modifier.height(20.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(MediaInfoFileProvider::class) info: MediaInfo
) = ElementPreview {
MediaFileView(
modifier = Modifier.fillMaxSize(),
localMediaViewState = rememberLocalMediaViewState(),
uri = null,
info = info,
onClick = {},
)
}

View file

@ -0,0 +1,21 @@
/*
* 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.file
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo
open class MediaInfoFileProvider : PreviewParameterProvider<MediaInfo> {
override val values: Sequence<MediaInfo>
get() = sequenceOf(
aPdfMediaInfo(),
anAudioMediaInfo(),
)
}

View file

@ -0,0 +1,64 @@
/*
* 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.image
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.CommonDrawables
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.rememberLocalMediaViewState
import io.element.android.libraries.ui.strings.CommonStrings
import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage
import me.saket.telephoto.zoomable.rememberZoomableImageState
@Composable
fun MediaImageView(
localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
if (LocalInspectionMode.current) {
Image(
painter = painterResource(id = CommonDrawables.sample_background),
modifier = modifier,
contentDescription = null,
)
} else {
val zoomableImageState = rememberZoomableImageState(localMediaViewState.zoomableState)
localMediaViewState.isReady = zoomableImageState.isImageDisplayed
ZoomableAsyncImage(
modifier = modifier,
state = zoomableImageState,
model = localMedia?.uri,
contentDescription = stringResource(id = CommonStrings.common_image),
contentScale = ContentScale.Fit,
onClick = { onClick() }
)
}
}
@PreviewsDayNight
@Composable
internal fun MediaImageViewPreview() = ElementPreview {
MediaImageView(
modifier = Modifier.fillMaxSize(),
localMediaViewState = rememberLocalMediaViewState(),
localMedia = null,
onClick = {},
)
}

View file

@ -0,0 +1,32 @@
/*
* 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.pdf
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaViewState
@Composable
fun MediaPdfView(
localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val pdfViewerState = rememberPdfViewerState(
model = localMedia?.uri,
zoomableState = localMediaViewState.zoomableState,
)
localMediaViewState.isReady = pdfViewerState.isLoaded
PdfViewer(
pdfViewerState = pdfViewerState,
onClick = onClick,
modifier = modifier,
)
}

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.api.local.pdf
package io.element.android.libraries.mediaviewer.impl.local.pdf
import android.content.Context
import android.net.Uri

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.api.local.pdf
package io.element.android.libraries.mediaviewer.impl.local.pdf
import android.graphics.Bitmap
import android.graphics.Canvas

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.api.local.pdf
package io.element.android.libraries.mediaviewer.impl.local.pdf
import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.api.local.pdf
package io.element.android.libraries.mediaviewer.impl.local.pdf
import androidx.compose.foundation.Image
import androidx.compose.foundation.background

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.api.local.pdf
package io.element.android.libraries.mediaviewer.impl.local.pdf
import android.content.Context
import androidx.compose.foundation.lazy.LazyListState

View file

@ -0,0 +1,254 @@
/*
* Copyright 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only
* Please see LICENSE in the repository root for full details.
*/
@file:Suppress(
"OVERRIDE_DEPRECATION",
"RedundantNullableReturnType",
"DEPRECATION",
)
package io.element.android.libraries.mediaviewer.impl.local.video
import android.annotation.SuppressLint
import android.media.AudioDeviceInfo
import android.os.Looper
import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.TextureView
import androidx.media3.common.AudioAttributes
import androidx.media3.common.AuxEffectInfo
import androidx.media3.common.DeviceInfo
import androidx.media3.common.Effect
import androidx.media3.common.Format
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player
import androidx.media3.common.PriorityTaskManager
import androidx.media3.common.Timeline
import androidx.media3.common.TrackSelectionParameters
import androidx.media3.common.Tracks
import androidx.media3.common.VideoSize
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.Clock
import androidx.media3.common.util.Size
import androidx.media3.exoplayer.DecoderCounters
import androidx.media3.exoplayer.ExoPlaybackException
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.PlayerMessage
import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.SeekParameters
import androidx.media3.exoplayer.analytics.AnalyticsCollector
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.image.ImageOutput
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ShuffleOrder
import androidx.media3.exoplayer.source.TrackGroupArray
import androidx.media3.exoplayer.trackselection.TrackSelectionArray
import androidx.media3.exoplayer.trackselection.TrackSelector
import androidx.media3.exoplayer.video.VideoFrameMetadataListener
import androidx.media3.exoplayer.video.spherical.CameraMotionListener
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
@SuppressLint("UnsafeOptInUsageError")
@ExcludeFromCoverage
class ExoPlayerForPreview(
private val isPlaying: Boolean = false,
) : ExoPlayer {
override fun getApplicationLooper(): Looper = throw NotImplementedError()
override fun addListener(listener: Player.Listener) {}
override fun removeListener(listener: Player.Listener) {}
override fun setMediaItems(mediaItems: MutableList<MediaItem>) {}
override fun setMediaItems(mediaItems: MutableList<MediaItem>, resetPosition: Boolean) {}
override fun setMediaItems(mediaItems: MutableList<MediaItem>, startIndex: Int, startPositionMs: Long) {}
override fun setMediaItem(mediaItem: MediaItem) {}
override fun setMediaItem(mediaItem: MediaItem, startPositionMs: Long) {}
override fun setMediaItem(mediaItem: MediaItem, resetPosition: Boolean) {}
override fun addMediaItem(mediaItem: MediaItem) {}
override fun addMediaItem(index: Int, mediaItem: MediaItem) {}
override fun addMediaItems(mediaItems: MutableList<MediaItem>) {}
override fun addMediaItems(index: Int, mediaItems: MutableList<MediaItem>) {}
override fun moveMediaItem(currentIndex: Int, newIndex: Int) {}
override fun moveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int) {}
override fun replaceMediaItem(index: Int, mediaItem: MediaItem) {}
override fun replaceMediaItems(fromIndex: Int, toIndex: Int, mediaItems: MutableList<MediaItem>) {}
override fun removeMediaItem(index: Int) {}
override fun removeMediaItems(fromIndex: Int, toIndex: Int) {}
override fun clearMediaItems() {}
override fun isCommandAvailable(command: Int): Boolean = throw NotImplementedError()
override fun canAdvertiseSession(): Boolean = throw NotImplementedError()
override fun getAvailableCommands(): Player.Commands = throw NotImplementedError()
override fun prepare(mediaSource: MediaSource) {}
override fun prepare(mediaSource: MediaSource, resetPosition: Boolean, resetState: Boolean) {}
override fun prepare() {}
override fun getPlaybackState(): Int = throw NotImplementedError()
override fun getPlaybackSuppressionReason(): Int = throw NotImplementedError()
override fun isPlaying() = isPlaying
override fun getPlayerError(): ExoPlaybackException? = null
override fun play() {}
override fun pause() {}
override fun setPlayWhenReady(playWhenReady: Boolean) {}
override fun getPlayWhenReady(): Boolean = throw NotImplementedError()
override fun setRepeatMode(repeatMode: Int) {}
override fun getRepeatMode(): Int = throw NotImplementedError()
override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) {}
override fun getShuffleModeEnabled(): Boolean = throw NotImplementedError()
override fun isLoading(): Boolean = throw NotImplementedError()
override fun seekToDefaultPosition() {}
override fun seekToDefaultPosition(mediaItemIndex: Int) {}
override fun seekTo(positionMs: Long) {}
override fun seekTo(mediaItemIndex: Int, positionMs: Long) {}
override fun getSeekBackIncrement(): Long = throw NotImplementedError()
override fun seekBack() {}
override fun getSeekForwardIncrement(): Long = throw NotImplementedError()
override fun seekForward() {}
override fun hasPreviousMediaItem(): Boolean = throw NotImplementedError()
override fun seekToPreviousWindow() {}
override fun seekToPreviousMediaItem() {}
override fun getMaxSeekToPreviousPosition(): Long = throw NotImplementedError()
override fun seekToPrevious() {}
override fun hasNext(): Boolean = throw NotImplementedError()
override fun hasNextWindow(): Boolean = throw NotImplementedError()
override fun hasNextMediaItem(): Boolean = throw NotImplementedError()
override fun next() {}
override fun seekToNextWindow() {}
override fun seekToNextMediaItem() {}
override fun seekToNext() {}
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {}
override fun setPlaybackSpeed(speed: Float) {}
override fun getPlaybackParameters(): PlaybackParameters = throw NotImplementedError()
override fun stop() {}
override fun release() {}
override fun getCurrentTracks(): Tracks = throw NotImplementedError()
override fun getTrackSelectionParameters(): TrackSelectionParameters = throw NotImplementedError()
override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) {}
override fun getMediaMetadata(): MediaMetadata = throw NotImplementedError()
override fun getPlaylistMetadata(): MediaMetadata = throw NotImplementedError()
override fun setPlaylistMetadata(mediaMetadata: MediaMetadata) {}
override fun getCurrentManifest(): Any? = throw NotImplementedError()
override fun getCurrentTimeline(): Timeline = throw NotImplementedError()
override fun getCurrentPeriodIndex(): Int = throw NotImplementedError()
override fun getCurrentWindowIndex(): Int = throw NotImplementedError()
override fun getCurrentMediaItemIndex(): Int = throw NotImplementedError()
override fun getNextWindowIndex(): Int = throw NotImplementedError()
override fun getNextMediaItemIndex(): Int = throw NotImplementedError()
override fun getPreviousWindowIndex(): Int = throw NotImplementedError()
override fun getPreviousMediaItemIndex(): Int = throw NotImplementedError()
override fun getCurrentMediaItem(): MediaItem? = throw NotImplementedError()
override fun getMediaItemCount(): Int = throw NotImplementedError()
override fun getMediaItemAt(index: Int): MediaItem = throw NotImplementedError()
override fun getDuration(): Long = throw NotImplementedError()
override fun getCurrentPosition(): Long = throw NotImplementedError()
override fun getBufferedPosition(): Long = throw NotImplementedError()
override fun getBufferedPercentage(): Int = throw NotImplementedError()
override fun getTotalBufferedDuration(): Long = throw NotImplementedError()
override fun isCurrentWindowDynamic(): Boolean = throw NotImplementedError()
override fun isCurrentMediaItemDynamic(): Boolean = throw NotImplementedError()
override fun isCurrentWindowLive(): Boolean = throw NotImplementedError()
override fun isCurrentMediaItemLive(): Boolean = throw NotImplementedError()
override fun getCurrentLiveOffset(): Long = throw NotImplementedError()
override fun isCurrentWindowSeekable(): Boolean = throw NotImplementedError()
override fun isCurrentMediaItemSeekable(): Boolean = throw NotImplementedError()
override fun isPlayingAd(): Boolean = throw NotImplementedError()
override fun getCurrentAdGroupIndex(): Int = throw NotImplementedError()
override fun getCurrentAdIndexInAdGroup(): Int = throw NotImplementedError()
override fun getContentDuration(): Long = throw NotImplementedError()
override fun getContentPosition(): Long = throw NotImplementedError()
override fun getContentBufferedPosition(): Long = throw NotImplementedError()
override fun getAudioAttributes(): AudioAttributes = throw NotImplementedError()
override fun setVolume(volume: Float) = throw NotImplementedError()
override fun getVolume(): Float = throw NotImplementedError()
override fun clearVideoSurface() {}
override fun clearVideoSurface(surface: Surface?) {}
override fun setVideoSurface(surface: Surface?) {}
override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) {}
override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) {}
override fun setVideoSurfaceView(surfaceView: SurfaceView?) {}
override fun clearVideoSurfaceView(surfaceView: SurfaceView?) {}
override fun setVideoTextureView(textureView: TextureView?) {}
override fun clearVideoTextureView(textureView: TextureView?) {}
override fun getVideoSize(): VideoSize = throw NotImplementedError()
override fun getSurfaceSize(): Size = throw NotImplementedError()
override fun getCurrentCues(): CueGroup = throw NotImplementedError()
override fun getDeviceInfo(): DeviceInfo = throw NotImplementedError()
override fun getDeviceVolume(): Int = throw NotImplementedError()
override fun isDeviceMuted(): Boolean = throw NotImplementedError()
override fun setDeviceVolume(volume: Int) {}
override fun setDeviceVolume(volume: Int, flags: Int) {}
override fun increaseDeviceVolume() {}
override fun increaseDeviceVolume(flags: Int) {}
override fun decreaseDeviceVolume() {}
override fun decreaseDeviceVolume(flags: Int) {}
override fun setDeviceMuted(muted: Boolean) {}
override fun setDeviceMuted(muted: Boolean, flags: Int) {}
override fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) {}
override fun getAudioComponent(): ExoPlayer.AudioComponent? = throw NotImplementedError()
override fun getVideoComponent(): ExoPlayer.VideoComponent? = throw NotImplementedError()
override fun getTextComponent(): ExoPlayer.TextComponent? = throw NotImplementedError()
override fun getDeviceComponent(): ExoPlayer.DeviceComponent? = throw NotImplementedError()
override fun addAudioOffloadListener(listener: ExoPlayer.AudioOffloadListener) {}
override fun removeAudioOffloadListener(listener: ExoPlayer.AudioOffloadListener) {}
override fun getAnalyticsCollector(): AnalyticsCollector = throw NotImplementedError()
override fun addAnalyticsListener(listener: AnalyticsListener) {}
override fun removeAnalyticsListener(listener: AnalyticsListener) {}
override fun getRendererCount(): Int = throw NotImplementedError()
override fun getRendererType(index: Int): Int = throw NotImplementedError()
override fun getRenderer(index: Int): Renderer = throw NotImplementedError()
override fun getTrackSelector(): TrackSelector? = throw NotImplementedError()
override fun getCurrentTrackGroups(): TrackGroupArray = throw NotImplementedError()
override fun getCurrentTrackSelections(): TrackSelectionArray = throw NotImplementedError()
override fun getPlaybackLooper(): Looper = throw NotImplementedError()
override fun getClock(): Clock = throw NotImplementedError()
override fun setMediaSources(mediaSources: MutableList<MediaSource>) {}
override fun setMediaSources(mediaSources: MutableList<MediaSource>, resetPosition: Boolean) {}
override fun setMediaSources(mediaSources: MutableList<MediaSource>, startMediaItemIndex: Int, startPositionMs: Long) {}
override fun setMediaSource(mediaSource: MediaSource) {}
override fun setMediaSource(mediaSource: MediaSource, startPositionMs: Long) {}
override fun setMediaSource(mediaSource: MediaSource, resetPosition: Boolean) {}
override fun addMediaSource(mediaSource: MediaSource) {}
override fun addMediaSource(index: Int, mediaSource: MediaSource) {}
override fun addMediaSources(mediaSources: MutableList<MediaSource>) {}
override fun addMediaSources(index: Int, mediaSources: MutableList<MediaSource>) {}
override fun setShuffleOrder(shuffleOrder: ShuffleOrder) {}
override fun setPreloadConfiguration(preloadConfiguration: ExoPlayer.PreloadConfiguration) {}
override fun getPreloadConfiguration(): ExoPlayer.PreloadConfiguration = throw NotImplementedError()
override fun setAudioSessionId(audioSessionId: Int) {}
override fun getAudioSessionId(): Int = throw NotImplementedError()
override fun setAuxEffectInfo(auxEffectInfo: AuxEffectInfo) {}
override fun clearAuxEffectInfo() {}
override fun setPreferredAudioDevice(audioDeviceInfo: AudioDeviceInfo?) {}
override fun setSkipSilenceEnabled(skipSilenceEnabled: Boolean) {}
override fun getSkipSilenceEnabled(): Boolean = throw NotImplementedError()
override fun setVideoEffects(videoEffects: MutableList<Effect>) {}
override fun setVideoScalingMode(videoScalingMode: Int) {}
override fun getVideoScalingMode(): Int = throw NotImplementedError()
override fun setVideoChangeFrameRateStrategy(videoChangeFrameRateStrategy: Int) {}
override fun getVideoChangeFrameRateStrategy(): Int = throw NotImplementedError()
override fun setVideoFrameMetadataListener(listener: VideoFrameMetadataListener) {}
override fun clearVideoFrameMetadataListener(listener: VideoFrameMetadataListener) {}
override fun setCameraMotionListener(listener: CameraMotionListener) {}
override fun clearCameraMotionListener(listener: CameraMotionListener) {}
override fun createMessage(target: PlayerMessage.Target): PlayerMessage = throw NotImplementedError()
override fun setSeekParameters(seekParameters: SeekParameters?) {}
override fun getSeekParameters(): SeekParameters = throw NotImplementedError()
override fun setForegroundMode(foregroundMode: Boolean) {}
override fun setPauseAtEndOfMediaItems(pauseAtEndOfMediaItems: Boolean) {}
override fun getPauseAtEndOfMediaItems(): Boolean = throw NotImplementedError()
override fun getAudioFormat(): Format? = throw NotImplementedError()
override fun getVideoFormat(): Format? = throw NotImplementedError()
override fun getAudioDecoderCounters(): DecoderCounters? = throw NotImplementedError()
override fun getVideoDecoderCounters(): DecoderCounters? = throw NotImplementedError()
override fun setHandleAudioBecomingNoisy(handleAudioBecomingNoisy: Boolean) {}
override fun setWakeMode(wakeMode: Int) {}
override fun setPriority(priority: Int) {}
override fun setPriorityTaskManager(priorityTaskManager: PriorityTaskManager?) {}
override fun isSleepingForOffload(): Boolean = throw NotImplementedError()
override fun isTunnelingEnabled(): Boolean = throw NotImplementedError()
override fun isReleased(): Boolean = throw NotImplementedError()
override fun setImageOutput(imageOutput: ImageOutput?) {}
}

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.api.local.exoplayer
package io.element.android.libraries.mediaviewer.impl.local.video
import android.content.Context
import androidx.media3.common.Player

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.api.player
package io.element.android.libraries.mediaviewer.impl.local.video
data class MediaPlayerControllerState(
val isVisible: Boolean,

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.api.player
package io.element.android.libraries.mediaviewer.impl.local.video
import androidx.compose.ui.tooling.preview.PreviewParameterProvider

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.api.player
package io.element.android.libraries.mediaviewer.impl.local.video
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn

View file

@ -0,0 +1,260 @@
/*
* 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.video
import android.annotation.SuppressLint
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.widget.FrameLayout
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.media3.common.MediaItem
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.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.KeepScreenOn
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaViewState
import io.element.android.libraries.mediaviewer.impl.local.PlayableState
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.seconds
@SuppressLint("UnsafeOptInUsageError")
@Composable
fun MediaVideoView(
localMediaViewState: LocalMediaViewState,
localMedia: LocalMedia?,
modifier: Modifier = Modifier,
) {
val exoPlayer = if (LocalInspectionMode.current) {
remember {
ExoPlayerForPreview()
}
} else {
val context = LocalContext.current
remember {
ExoPlayerWrapper.create(context)
}
}
ExoPlayerMediaVideoView(
localMediaViewState = localMediaViewState,
exoPlayer = exoPlayer,
localMedia = localMedia,
modifier = modifier,
)
}
@SuppressLint("UnsafeOptInUsageError")
@Composable
private fun ExoPlayerMediaVideoView(
localMediaViewState: LocalMediaViewState,
exoPlayer: ExoPlayer,
localMedia: LocalMedia?,
modifier: Modifier = Modifier,
) {
val isControllerVisibleByDefault = LocalInspectionMode.current
var mediaPlayerControllerState: MediaPlayerControllerState by remember {
mutableStateOf(
MediaPlayerControllerState(
isVisible = isControllerVisibleByDefault,
isPlaying = false,
progressInMillis = 0,
durationInMillis = 0,
isMuted = false,
)
)
}
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 onVolumeChanged(volume: Float) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
isMuted = volume == 0f,
)
}
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
durationInMillis = exoPlayer.duration,
)
}
}
}
LaunchedEffect(Unit) {
exoPlayer.addListener(playerListener)
exoPlayer.prepare()
}
var autoHideController by remember { mutableIntStateOf(0) }
LaunchedEffect(autoHideController) {
delay(5.seconds)
if (exoPlayer.isPlaying) {
mediaPlayerControllerState = mediaPlayerControllerState.copy(
isVisible = false,
)
}
}
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())
}
KeepScreenOn(mediaPlayerControllerState.isPlaying)
Box(
modifier = modifier
.background(ElementTheme.colors.bgSubtlePrimary),
) {
val context = LocalContext.current
if (LocalInspectionMode.current) {
Text(
modifier = Modifier
.background(ElementTheme.colors.bgSubtlePrimary)
.align(Alignment.Center),
text = "A Video Player will render here",
)
} else {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = {
PlayerView(context).apply {
player = exoPlayer
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
setOnClickListener {
autoHideController++
mediaPlayerControllerState = mediaPlayerControllerState.copy(
isVisible = !mediaPlayerControllerState.isVisible,
)
}
useController = false
}
},
onRelease = { playerView ->
playerView.setOnClickListener(null)
playerView.setControllerVisibilityListener(null as PlayerView.ControllerVisibilityListener?)
playerView.player = null
},
)
}
MediaPlayerControllerView(
state = mediaPlayerControllerState,
onTogglePlay = {
autoHideController++
if (exoPlayer.isPlaying) {
exoPlayer.pause()
} else {
if (exoPlayer.playbackState == Player.STATE_ENDED) {
exoPlayer.seekTo(0)
} else {
exoPlayer.play()
}
}
},
onSeekChange = {
autoHideController++
if (exoPlayer.isPlaying.not()) {
exoPlayer.play()
}
exoPlayer.seekTo(it.toLong())
},
onToggleMute = {
autoHideController++
exoPlayer.volume = if (exoPlayer.volume == 1f) 0f else 1f
},
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter),
)
}
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> exoPlayer.play()
Lifecycle.Event.ON_PAUSE -> exoPlayer.pause()
Lifecycle.Event.ON_DESTROY -> {
exoPlayer.release()
exoPlayer.removeListener(playerListener)
}
else -> Unit
}
}
}
@PreviewsDayNight
@Composable
internal fun MediaVideoViewPreview() = ElementPreview {
MediaVideoView(
modifier = Modifier.fillMaxSize(),
localMediaViewState = rememberLocalMediaViewState(),
localMedia = null,
)
}

View file

@ -0,0 +1,27 @@
/*
* 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.util
import android.webkit.MimeTypeMap
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class FileExtensionExtractorWithValidation @Inject constructor() : FileExtensionExtractor {
override fun extractFromName(name: String): String {
val fileExtension = name.substringAfterLast('.', "")
// Makes sure the extension is known by the system, otherwise default to binary extension.
return if (MimeTypeMap.getSingleton().hasExtension(fileExtension)) {
fileExtension
} else {
"bin"
}
}
}

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.api.viewer
package io.element.android.libraries.mediaviewer.impl.viewer
sealed interface MediaViewerEvents {
data object SaveOnDisk : MediaViewerEvents

View file

@ -5,22 +5,21 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.api.viewer
package io.element.android.libraries.mediaviewer.impl.viewer
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.compound.theme.ForcedDarkElementTheme
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
@ContributesNode(RoomScope::class)
open class MediaViewerNode @AssistedInject constructor(
@ -28,15 +27,13 @@ open class MediaViewerNode @AssistedInject constructor(
@Assisted plugins: List<Plugin>,
presenterFactory: MediaViewerPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val mediaInfo: MediaInfo,
val mediaSource: MediaSource,
val thumbnailSource: MediaSource?,
val canDownload: Boolean,
val canShare: Boolean,
) : NodeInputs
private val inputs = inputs<MediaViewerEntryPoint.Params>()
private val inputs: Inputs = inputs()
private fun onDone() {
plugins<MediaViewerEntryPoint.Callback>().forEach {
it.onDone()
}
}
private val presenter = presenterFactory.create(inputs)
@ -47,7 +44,7 @@ open class MediaViewerNode @AssistedInject constructor(
MediaViewerView(
state = state,
modifier = modifier,
onBackClick = this::navigateUp
onBackClick = ::onDone
)
}
}

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.api.viewer
package io.element.android.libraries.mediaviewer.impl.viewer
import android.content.ActivityNotFoundException
import androidx.compose.runtime.Composable
@ -27,16 +27,17 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaActions
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import io.element.android.libraries.androidutils.R as UtilsR
class MediaViewerPresenter @AssistedInject constructor(
@Assisted private val inputs: MediaViewerNode.Inputs,
@Assisted private val inputs: MediaViewerEntryPoint.Params,
private val localMediaFactory: LocalMediaFactory,
private val mediaLoader: MatrixMediaLoader,
private val localMediaActions: LocalMediaActions,
@ -44,7 +45,7 @@ class MediaViewerPresenter @AssistedInject constructor(
) : Presenter<MediaViewerState> {
@AssistedFactory
interface Factory {
fun create(inputs: MediaViewerNode.Inputs): MediaViewerPresenter
fun create(inputs: MediaViewerEntryPoint.Params): MediaViewerPresenter
}
@Composable

View file

@ -5,13 +5,13 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.api.viewer
package io.element.android.libraries.mediaviewer.impl.viewer
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.media.MediaSource
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.MediaInfo
data class MediaViewerState(
val mediaInfo: MediaInfo,

View file

@ -5,18 +5,18 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.api.viewer
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.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.local.aPdfMediaInfo
import io.element.android.libraries.mediaviewer.api.local.aVideoMediaInfo
import io.element.android.libraries.mediaviewer.api.local.anApkMediaInfo
import io.element.android.libraries.mediaviewer.api.local.anAudioMediaInfo
import io.element.android.libraries.mediaviewer.api.local.anImageMediaInfo
open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> {
override val values: Sequence<MediaViewerState>

View file

@ -7,8 +7,9 @@
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.libraries.mediaviewer.api.viewer
package io.element.android.libraries.mediaviewer.impl.viewer
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
@ -55,12 +56,12 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.mediaviewer.api.R
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.LocalMediaView
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.local.PlayableState
import io.element.android.libraries.mediaviewer.api.local.rememberLocalMediaViewState
import io.element.android.libraries.mediaviewer.impl.R
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaView
import io.element.android.libraries.mediaviewer.impl.local.PlayableState
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.delay
import me.saket.telephoto.flick.FlickToDismiss
@ -79,6 +80,7 @@ fun MediaViewerView(
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
var showOverlay by remember { mutableStateOf(true) }
BackHandler { onBackClick() }
Scaffold(
modifier,
containerColor = Color.Transparent,
@ -146,8 +148,8 @@ private fun MediaViewerPage(
Box(
modifier = Modifier
.fillMaxSize()
.navigationBarsPadding()
.fillMaxSize()
.navigationBarsPadding()
) {
Box(contentAlignment = Alignment.Center) {
val zoomableState = rememberZoomableState(
@ -191,8 +193,8 @@ private fun MediaViewerPage(
if (showProgress) {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.height(2.dp)
.fillMaxWidth()
.height(2.dp)
)
}
}

View file

@ -12,9 +12,9 @@ import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.matrix.test.media.FakeMediaFile
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.local.anImageMediaInfo
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.api.util
package io.element.android.libraries.mediaviewer.impl.util
import com.google.common.truth.Truth.assertThat
import org.junit.Test
@ -13,7 +13,7 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class FileExtensionExtractorTest {
class FileExtensionExtractorWithValidationTest {
@Test
fun `test FileExtensionExtractor with validation OK`() {
val sut = FileExtensionExtractorWithValidation()
@ -27,11 +27,4 @@ class FileExtensionExtractorTest {
val sut = FileExtensionExtractorWithValidation()
assertThat(sut.extractFromName("test.bla")).isEqualTo("bin")
}
@Test
fun `test FileExtensionExtractor no validation`() {
val sut = FileExtensionExtractorWithoutValidation()
assertThat(sut.extractFromName("test.png")).isEqualTo("png")
assertThat(sut.extractFromName("test.bla")).isEqualTo("bla")
}
}

View file

@ -7,7 +7,7 @@
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.libraries.mediaviewer
package io.element.android.libraries.mediaviewer.impl.viewer
import android.net.Uri
import app.cash.molecule.RecompositionMode
@ -18,10 +18,8 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
import io.element.android.libraries.matrix.test.media.aMediaSource
import io.element.android.libraries.mediaviewer.api.local.anApkMediaInfo
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerEvents
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerPresenter
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
import io.element.android.tests.testutils.WarmUpRule
@ -144,7 +142,7 @@ class MediaViewerPresenterTest {
canDownload: Boolean = true,
): MediaViewerPresenter {
return MediaViewerPresenter(
inputs = MediaViewerNode.Inputs(
inputs = MediaViewerEntryPoint.Params(
mediaInfo = TESTED_MEDIA_INFO,
mediaSource = aMediaSource(),
thumbnailSource = null,

View file

@ -5,7 +5,7 @@
* Please see LICENSE in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.api.viewer
package io.element.android.libraries.mediaviewer.impl.viewer
import android.net.Uri
import androidx.activity.ComponentActivity
@ -18,8 +18,8 @@ import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeDown
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.anImageMediaInfo
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder

View file

@ -18,4 +18,7 @@ dependencies {
implementation(projects.libraries.core)
implementation(projects.tests.testutils)
implementation(projects.libraries.matrix.api)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
}

View file

@ -9,7 +9,7 @@ package io.element.android.libraries.mediaviewer.test
import androidx.compose.runtime.Composable
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.LocalMediaActions
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions
import io.element.android.tests.testutils.simulateLongTask
class FakeLocalMediaActions : LocalMediaActions {

View file

@ -10,11 +10,11 @@ package io.element.android.libraries.mediaviewer.test
import android.net.Uri
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaFile
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.LocalMediaFactory
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
class FakeLocalMediaFactory(

View file

@ -0,0 +1,16 @@
/*
* 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.test.util
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
class FileExtensionExtractorWithoutValidation : FileExtensionExtractor {
override fun extractFromName(name: String): String {
return name.substringAfterLast('.', "")
}
}

View file

@ -8,9 +8,9 @@
package io.element.android.libraries.mediaviewer.test.viewer
import android.net.Uri
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.local.anImageMediaInfo
fun aLocalMedia(
uri: Uri,

View file

@ -0,0 +1,20 @@
/*
* 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.test.util
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class FileExtensionExtractorWithoutValidationTest {
@Test
fun `extension should always be extracted even is invalid`() {
val sut = FileExtensionExtractorWithoutValidation()
assertThat(sut.extractFromName("test.png")).isEqualTo("png")
assertThat(sut.extractFromName("test.bla")).isEqualTo("bla")
}
}