Merge pull request #3967 from element-hq/feature/bma/mediaModule
Rework on media module
This commit is contained in:
@@ -51,6 +51,7 @@ import io.element.android.libraries.architecture.BackstackWithOverlayBox
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.architecture.overlay.Overlay
|
||||
import io.element.android.libraries.architecture.overlay.operation.hide
|
||||
import io.element.android.libraries.architecture.overlay.operation.show
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
@@ -66,8 +67,8 @@ import io.element.android.libraries.matrix.api.room.joinedRoomMembers
|
||||
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache
|
||||
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
|
||||
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
|
||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
|
||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
@@ -86,6 +87,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||
private val showLocationEntryPoint: ShowLocationEntryPoint,
|
||||
private val createPollEntryPoint: CreatePollEntryPoint,
|
||||
private val elementCallEntryPoint: ElementCallEntryPoint,
|
||||
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val room: MatrixRoom,
|
||||
private val roomMemberProfilesCache: RoomMemberProfilesCache,
|
||||
@@ -228,14 +230,22 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||
createNode<MessagesNode>(buildContext, listOf(callback, inputs))
|
||||
}
|
||||
is NavTarget.MediaViewer -> {
|
||||
val inputs = MediaViewerNode.Inputs(
|
||||
val params = MediaViewerEntryPoint.Params(
|
||||
mediaInfo = navTarget.mediaInfo,
|
||||
mediaSource = navTarget.mediaSource,
|
||||
thumbnailSource = navTarget.thumbnailSource,
|
||||
canDownload = true,
|
||||
canShare = true,
|
||||
)
|
||||
createNode<MediaViewerNode>(buildContext, listOf(inputs))
|
||||
val callback = object : MediaViewerEntryPoint.Callback {
|
||||
override fun onDone() {
|
||||
overlay.hide()
|
||||
}
|
||||
}
|
||||
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
|
||||
.params(params)
|
||||
.callback(callback)
|
||||
.build()
|
||||
}
|
||||
is NavTarget.AttachmentPreview -> {
|
||||
val inputs = AttachmentsPreviewNode.Inputs(navTarget.attachment)
|
||||
|
||||
@@ -20,12 +20,14 @@ import io.element.android.features.messages.impl.attachments.Attachment
|
||||
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.mediaviewer.api.local.LocalMediaRenderer
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
class AttachmentsPreviewNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
presenterFactory: AttachmentsPreviewPresenter.Factory,
|
||||
private val localMediaRenderer: LocalMediaRenderer,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
data class Inputs(val attachment: Attachment) : NodeInputs
|
||||
|
||||
@@ -46,6 +48,7 @@ class AttachmentsPreviewNode @AssistedInject constructor(
|
||||
val state = presenter.present()
|
||||
AttachmentsPreviewView(
|
||||
state = state,
|
||||
localMediaRenderer = localMediaRenderer,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,12 +10,9 @@ package io.element.android.features.messages.impl.attachments.preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import androidx.core.net.toUri
|
||||
import io.element.android.features.messages.impl.attachments.Attachment
|
||||
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.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
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
|
||||
|
||||
@@ -23,9 +20,6 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
|
||||
override val values: Sequence<AttachmentsPreviewState>
|
||||
get() = sequenceOf(
|
||||
anAttachmentsPreviewState(),
|
||||
anAttachmentsPreviewState(mediaInfo = aVideoMediaInfo()),
|
||||
anAttachmentsPreviewState(mediaInfo = anAudioMediaInfo()),
|
||||
anAttachmentsPreviewState(mediaInfo = anApkMediaInfo()),
|
||||
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Processing),
|
||||
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(0.5f)),
|
||||
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"))),
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
package io.element.android.features.messages.impl.attachments.preview
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
@@ -20,6 +21,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
@@ -34,20 +36,20 @@ import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaView
|
||||
import io.element.android.libraries.mediaviewer.api.local.rememberLocalMediaViewState
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
|
||||
import io.element.android.libraries.textcomposer.TextComposer
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.wysiwyg.display.TextDisplay
|
||||
import me.saket.telephoto.zoomable.ZoomSpec
|
||||
import me.saket.telephoto.zoomable.rememberZoomableState
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AttachmentsPreviewView(
|
||||
state: AttachmentsPreviewState,
|
||||
localMediaRenderer: LocalMediaRenderer,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun postSendAttachment() {
|
||||
@@ -82,6 +84,7 @@ fun AttachmentsPreviewView(
|
||||
) {
|
||||
AttachmentPreviewContent(
|
||||
state = state,
|
||||
localMediaRenderer = localMediaRenderer,
|
||||
onSendClick = ::postSendAttachment,
|
||||
)
|
||||
}
|
||||
@@ -129,6 +132,7 @@ private fun AttachmentSendStateView(
|
||||
@Composable
|
||||
private fun AttachmentPreviewContent(
|
||||
state: AttachmentsPreviewState,
|
||||
localMediaRenderer: LocalMediaRenderer,
|
||||
onSendClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
@@ -142,17 +146,7 @@ private fun AttachmentPreviewContent(
|
||||
) {
|
||||
when (val attachment = state.attachment) {
|
||||
is Attachment.Media -> {
|
||||
val localMediaViewState = rememberLocalMediaViewState(
|
||||
zoomableState = rememberZoomableState(
|
||||
zoomSpec = ZoomSpec(maxZoomFactor = 4f, preventOverOrUnderZoom = false)
|
||||
)
|
||||
)
|
||||
LocalMediaView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
localMedia = attachment.localMedia,
|
||||
localMediaViewState = localMediaViewState,
|
||||
onClick = {}
|
||||
)
|
||||
localMediaRenderer.Render(attachment.localMedia)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,5 +199,15 @@ private fun AttachmentsPreviewBottomActions(
|
||||
internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark {
|
||||
AttachmentsPreviewView(
|
||||
state = state,
|
||||
localMediaRenderer = object : LocalMediaRenderer {
|
||||
@Composable
|
||||
override fun Render(localMedia: LocalMedia) {
|
||||
Image(
|
||||
painter = painterResource(id = CommonDrawables.sample_background),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation
|
||||
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ import io.element.android.libraries.matrix.test.media.aMediaSource
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||
import io.element.android.libraries.matrix.test.timeline.aStickerContent
|
||||
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
|
||||
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation
|
||||
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.operation.pop
|
||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
@@ -32,20 +33,16 @@ import io.element.android.features.roomdetails.impl.members.details.RoomMemberDe
|
||||
import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsNode
|
||||
import io.element.android.features.roomdetails.impl.rolesandpermissions.RolesAndPermissionsFlowNode
|
||||
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
|
||||
import io.element.android.features.userprofile.shared.avatar.AvatarPreviewNode
|
||||
import io.element.android.libraries.architecture.BackstackWithOverlayBox
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.architecture.overlay.operation.show
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
||||
import kotlinx.parcelize.Parcelize
|
||||
@@ -59,6 +56,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
||||
private val room: MatrixRoom,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val messagesEntryPoint: MessagesEntryPoint,
|
||||
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
|
||||
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
|
||||
@@ -202,22 +200,18 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
||||
createNode<RoomMemberDetailsNode>(buildContext, plugins)
|
||||
}
|
||||
is NavTarget.AvatarPreview -> {
|
||||
// We need to fake the MimeType here for the viewer to work.
|
||||
val mimeType = MimeTypes.Images
|
||||
val input = MediaViewerNode.Inputs(
|
||||
mediaInfo = MediaInfo(
|
||||
filename = navTarget.name,
|
||||
caption = null,
|
||||
mimeType = mimeType,
|
||||
formattedFileSize = "",
|
||||
fileExtension = ""
|
||||
),
|
||||
mediaSource = MediaSource(url = navTarget.avatarUrl),
|
||||
thumbnailSource = null,
|
||||
canDownload = false,
|
||||
canShare = false,
|
||||
)
|
||||
createNode<AvatarPreviewNode>(buildContext, listOf(input))
|
||||
val callback = object : MediaViewerEntryPoint.Callback {
|
||||
override fun onDone() {
|
||||
backstack.pop()
|
||||
}
|
||||
}
|
||||
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
|
||||
.avatar(
|
||||
navTarget.name,
|
||||
navTarget.avatarUrl,
|
||||
)
|
||||
.callback(callback)
|
||||
.build()
|
||||
}
|
||||
|
||||
is NavTarget.PollHistory -> {
|
||||
|
||||
@@ -15,6 +15,7 @@ import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.operation.pop
|
||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
@@ -24,18 +25,14 @@ import io.element.android.features.call.api.ElementCallEntryPoint
|
||||
import io.element.android.features.userprofile.api.UserProfileEntryPoint
|
||||
import io.element.android.features.userprofile.impl.root.UserProfileNode
|
||||
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
|
||||
import io.element.android.features.userprofile.shared.avatar.AvatarPreviewNode
|
||||
import io.element.android.libraries.architecture.BackstackView
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
|
||||
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
|
||||
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
|
||||
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
@@ -44,6 +41,7 @@ class UserProfileFlowNode @AssistedInject constructor(
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val elementCallEntryPoint: ElementCallEntryPoint,
|
||||
private val sessionIdHolder: CurrentSessionIdHolder,
|
||||
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
|
||||
) : BaseFlowNode<UserProfileFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Root,
|
||||
@@ -80,22 +78,18 @@ class UserProfileFlowNode @AssistedInject constructor(
|
||||
createNode<UserProfileNode>(buildContext, listOf(callback, params))
|
||||
}
|
||||
is NavTarget.AvatarPreview -> {
|
||||
// We need to fake the MimeType here for the viewer to work.
|
||||
val mimeType = MimeTypes.Images
|
||||
val input = MediaViewerNode.Inputs(
|
||||
mediaInfo = MediaInfo(
|
||||
val callback = object : MediaViewerEntryPoint.Callback {
|
||||
override fun onDone() {
|
||||
backstack.pop()
|
||||
}
|
||||
}
|
||||
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
|
||||
.avatar(
|
||||
filename = navTarget.name,
|
||||
caption = null,
|
||||
mimeType = mimeType,
|
||||
formattedFileSize = "",
|
||||
fileExtension = "",
|
||||
),
|
||||
mediaSource = MediaSource(url = navTarget.avatarUrl),
|
||||
thumbnailSource = null,
|
||||
canDownload = false,
|
||||
canShare = false,
|
||||
)
|
||||
createNode<AvatarPreviewNode>(buildContext, listOf(input))
|
||||
avatarUrl = navTarget.avatarUrl
|
||||
)
|
||||
.callback(callback)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
/*
|
||||
* 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.features.userprofile.shared.avatar
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
|
||||
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerPresenter
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
class AvatarPreviewNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
presenterFactory: MediaViewerPresenter.Factory,
|
||||
) : MediaViewerNode(buildContext, plugins, presenterFactory)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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('.', "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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?) {}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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('.', "")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -169,26 +169,20 @@ fun Project.setupKover() {
|
||||
filters {
|
||||
excludes.classes(
|
||||
"*State$*", // Exclude inner classes
|
||||
"io.element.android.appnav.root.RootNavState*",
|
||||
"io.element.android.libraries.matrix.api.timeline.item.event.OtherState$*",
|
||||
"io.element.android.libraries.matrix.api.timeline.item.event.EventSendState$*",
|
||||
"io.element.android.libraries.matrix.api.room.RoomMembershipState*",
|
||||
"io.element.android.libraries.matrix.api.room.MatrixRoomMembersState*",
|
||||
"io.element.android.libraries.push.impl.notifications.NotificationState*",
|
||||
"io.element.android.features.messages.impl.media.local.pdf.PdfViewerState",
|
||||
"io.element.android.features.messages.impl.media.local.LocalMediaViewState",
|
||||
"io.element.android.features.location.impl.map.MapState*",
|
||||
"io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState*",
|
||||
"io.element.android.libraries.designsystem.swipe.SwipeableActionsState*",
|
||||
"io.element.android.features.messages.impl.timeline.components.ExpandableState*",
|
||||
"io.element.android.features.messages.impl.timeline.model.bubble.BubbleState*",
|
||||
"io.element.android.libraries.maplibre.compose.CameraPositionState*",
|
||||
"io.element.android.libraries.maplibre.compose.SaveableCameraPositionState",
|
||||
"io.element.android.libraries.maplibre.compose.SymbolState*",
|
||||
"io.element.android.appnav.root.RootNavState",
|
||||
"io.element.android.features.ftue.api.state.*",
|
||||
"io.element.android.features.ftue.impl.welcome.state.*",
|
||||
"io.element.android.features.messages.impl.timeline.model.bubble.BubbleState",
|
||||
"io.element.android.libraries.designsystem.swipe.SwipeableActionsState",
|
||||
"io.element.android.libraries.designsystem.theme.components.bottomsheet.CustomSheetState",
|
||||
"io.element.android.libraries.mediaviewer.api.local.pdf.PdfViewerState",
|
||||
"io.element.android.libraries.maplibre.compose.CameraPositionState",
|
||||
"io.element.android.libraries.maplibre.compose.SaveableCameraPositionState",
|
||||
"io.element.android.libraries.maplibre.compose.SymbolState",
|
||||
"io.element.android.libraries.matrix.api.room.RoomMembershipState",
|
||||
"io.element.android.libraries.matrix.api.room.MatrixRoomMembersState",
|
||||
"io.element.android.libraries.matrix.api.timeline.item.event.OtherState$*",
|
||||
"io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState*",
|
||||
"io.element.android.libraries.mediaviewer.impl.local.pdf.PdfViewerState",
|
||||
"io.element.android.libraries.textcomposer.model.TextEditorState",
|
||||
)
|
||||
includes.classes("*State")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user