Merge pull request #3967 from element-hq/feature/bma/mediaModule
Rework on media module
This commit is contained in:
commit
6592e3e939
92 changed files with 1241 additions and 684 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d18f7e2c169f7e4b9e435584d868454ae5f765a32d29bbbfe48d65b45f4161d6
|
||||
size 18845
|
||||
oid sha256:3a92cb782d97553f364fab3df3fe159557a26aad8b7e5c176b40616c2f05abdd
|
||||
size 51410
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3250fa9767aec264308719c6fdf6cea94d5ccea21bfa8a735d7df5d79b572555
|
||||
size 21214
|
||||
oid sha256:361ff23fd2ff99aecfdffd10b45e9235d86183d4856cb2a3e99f85b9e04c2d59
|
||||
size 51376
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4ba70221a149ce0f8abaa4e4f870011bea82630edf99ecacea560a737f56346f
|
||||
size 22215
|
||||
oid sha256:efbfed755b29293009f45fca33f58863b612772de9a1d55593c979dbb04ff6f2
|
||||
size 88981
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3a92cb782d97553f364fab3df3fe159557a26aad8b7e5c176b40616c2f05abdd
|
||||
size 51410
|
||||
oid sha256:507c7dffae1aabfa687174f1f964e2c40b004183b6bc3a70b56d764e0d308b47
|
||||
size 392923
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:361ff23fd2ff99aecfdffd10b45e9235d86183d4856cb2a3e99f85b9e04c2d59
|
||||
size 51376
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:efbfed755b29293009f45fca33f58863b612772de9a1d55593c979dbb04ff6f2
|
||||
size 88981
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:507c7dffae1aabfa687174f1f964e2c40b004183b6bc3a70b56d764e0d308b47
|
||||
size 392923
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:be67078d37af27ed532a6ae3a6194c154ca72b7c87319eed3f0e982cbc677acc
|
||||
size 10908
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2907ba5f75a40e30a37ad4d302797cea4e3d2b77cb1c66a9432319242b8f1995
|
||||
size 12188
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5307e90428957819812269b9b3e0c6e9d59238141d54cd959aa5506290797a35
|
||||
size 11587
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:59e981e51595a368745c92f355793b2c0301a9e4929430733876a02f3ac75e2e
|
||||
size 11881
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:52354bcf471b14e38e582cc29f73407c8ca65026b2b7c6db3d3b28ec94950679
|
||||
size 11413
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2a3e650bde2df79bebfc110eb46998ab21b1e5dba471fd5a755371e5027a27a5
|
||||
size 388634
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:193190902275db6ffd03dcc41b190e8fc3bba60dda9678274481fbbefe89567d
|
||||
size 387830
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3b7160dc01dd4704bb07a8c91858bef0d4e99c0f55ca1d7dbdc3e22546a25125
|
||||
size 12210
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2591b742c64d3d058d4638e5014080f8b3eb13eba3a73951d71dfdc60e7676d9
|
||||
size 12455
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:05794effa4c47e3d2b68892e613f18a55c96789a4e65a182dd0bf2ca0c812d44
|
||||
size 14474
|
||||
Loading…
Add table
Add a link
Reference in a new issue