Merge pull request #3967 from element-hq/feature/bma/mediaModule
Rework on media module
This commit is contained in:
commit
4e688021cc
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.BaseFlowNode
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
import io.element.android.libraries.architecture.overlay.Overlay
|
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.architecture.overlay.operation.show
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
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.api.timeline.item.TimelineItemDebugInfo
|
||||||
import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache
|
import io.element.android.libraries.matrix.ui.messages.LocalRoomMemberProfilesCache
|
||||||
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
|
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.MediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
|
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||||
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
|
import io.element.android.libraries.textcomposer.mentions.LocalMentionSpanTheme
|
||||||
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
|
import io.element.android.libraries.textcomposer.mentions.MentionSpanTheme
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
|
@ -86,6 +87,7 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
private val showLocationEntryPoint: ShowLocationEntryPoint,
|
private val showLocationEntryPoint: ShowLocationEntryPoint,
|
||||||
private val createPollEntryPoint: CreatePollEntryPoint,
|
private val createPollEntryPoint: CreatePollEntryPoint,
|
||||||
private val elementCallEntryPoint: ElementCallEntryPoint,
|
private val elementCallEntryPoint: ElementCallEntryPoint,
|
||||||
|
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
private val room: MatrixRoom,
|
private val room: MatrixRoom,
|
||||||
private val roomMemberProfilesCache: RoomMemberProfilesCache,
|
private val roomMemberProfilesCache: RoomMemberProfilesCache,
|
||||||
|
|
@ -228,14 +230,22 @@ class MessagesFlowNode @AssistedInject constructor(
|
||||||
createNode<MessagesNode>(buildContext, listOf(callback, inputs))
|
createNode<MessagesNode>(buildContext, listOf(callback, inputs))
|
||||||
}
|
}
|
||||||
is NavTarget.MediaViewer -> {
|
is NavTarget.MediaViewer -> {
|
||||||
val inputs = MediaViewerNode.Inputs(
|
val params = MediaViewerEntryPoint.Params(
|
||||||
mediaInfo = navTarget.mediaInfo,
|
mediaInfo = navTarget.mediaInfo,
|
||||||
mediaSource = navTarget.mediaSource,
|
mediaSource = navTarget.mediaSource,
|
||||||
thumbnailSource = navTarget.thumbnailSource,
|
thumbnailSource = navTarget.thumbnailSource,
|
||||||
canDownload = true,
|
canDownload = true,
|
||||||
canShare = 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 -> {
|
is NavTarget.AttachmentPreview -> {
|
||||||
val inputs = AttachmentsPreviewNode.Inputs(navTarget.attachment)
|
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.NodeInputs
|
||||||
import io.element.android.libraries.architecture.inputs
|
import io.element.android.libraries.architecture.inputs
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
|
||||||
|
|
||||||
@ContributesNode(RoomScope::class)
|
@ContributesNode(RoomScope::class)
|
||||||
class AttachmentsPreviewNode @AssistedInject constructor(
|
class AttachmentsPreviewNode @AssistedInject constructor(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
presenterFactory: AttachmentsPreviewPresenter.Factory,
|
presenterFactory: AttachmentsPreviewPresenter.Factory,
|
||||||
|
private val localMediaRenderer: LocalMediaRenderer,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
data class Inputs(val attachment: Attachment) : NodeInputs
|
data class Inputs(val attachment: Attachment) : NodeInputs
|
||||||
|
|
||||||
|
|
@ -46,6 +48,7 @@ class AttachmentsPreviewNode @AssistedInject constructor(
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
AttachmentsPreviewView(
|
AttachmentsPreviewView(
|
||||||
state = state,
|
state = state,
|
||||||
|
localMediaRenderer = localMediaRenderer,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,9 @@ package io.element.android.features.messages.impl.attachments.preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import io.element.android.features.messages.impl.attachments.Attachment
|
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.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.TextEditorState
|
||||||
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
|
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
|
||||||
|
|
||||||
|
|
@ -23,9 +20,6 @@ open class AttachmentsPreviewStateProvider : PreviewParameterProvider<Attachment
|
||||||
override val values: Sequence<AttachmentsPreviewState>
|
override val values: Sequence<AttachmentsPreviewState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
anAttachmentsPreviewState(),
|
anAttachmentsPreviewState(),
|
||||||
anAttachmentsPreviewState(mediaInfo = aVideoMediaInfo()),
|
|
||||||
anAttachmentsPreviewState(mediaInfo = anAudioMediaInfo()),
|
|
||||||
anAttachmentsPreviewState(mediaInfo = anApkMediaInfo()),
|
|
||||||
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Processing),
|
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Processing),
|
||||||
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(0.5f)),
|
anAttachmentsPreviewState(sendActionState = SendActionState.Sending.Uploading(0.5f)),
|
||||||
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"))),
|
anAttachmentsPreviewState(sendActionState = SendActionState.Failure(RuntimeException("error"))),
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
package io.element.android.features.messages.impl.attachments.preview
|
package io.element.android.features.messages.impl.attachments.preview
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.IntrinsicSize
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
|
|
@ -20,6 +21,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
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.preview.ElementPreviewDark
|
||||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaView
|
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||||
import io.element.android.libraries.mediaviewer.api.local.rememberLocalMediaViewState
|
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.TextComposer
|
||||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.wysiwyg.display.TextDisplay
|
import io.element.android.wysiwyg.display.TextDisplay
|
||||||
import me.saket.telephoto.zoomable.ZoomSpec
|
|
||||||
import me.saket.telephoto.zoomable.rememberZoomableState
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AttachmentsPreviewView(
|
fun AttachmentsPreviewView(
|
||||||
state: AttachmentsPreviewState,
|
state: AttachmentsPreviewState,
|
||||||
|
localMediaRenderer: LocalMediaRenderer,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
fun postSendAttachment() {
|
fun postSendAttachment() {
|
||||||
|
|
@ -82,6 +84,7 @@ fun AttachmentsPreviewView(
|
||||||
) {
|
) {
|
||||||
AttachmentPreviewContent(
|
AttachmentPreviewContent(
|
||||||
state = state,
|
state = state,
|
||||||
|
localMediaRenderer = localMediaRenderer,
|
||||||
onSendClick = ::postSendAttachment,
|
onSendClick = ::postSendAttachment,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -129,6 +132,7 @@ private fun AttachmentSendStateView(
|
||||||
@Composable
|
@Composable
|
||||||
private fun AttachmentPreviewContent(
|
private fun AttachmentPreviewContent(
|
||||||
state: AttachmentsPreviewState,
|
state: AttachmentsPreviewState,
|
||||||
|
localMediaRenderer: LocalMediaRenderer,
|
||||||
onSendClick: () -> Unit,
|
onSendClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
|
|
@ -142,17 +146,7 @@ private fun AttachmentPreviewContent(
|
||||||
) {
|
) {
|
||||||
when (val attachment = state.attachment) {
|
when (val attachment = state.attachment) {
|
||||||
is Attachment.Media -> {
|
is Attachment.Media -> {
|
||||||
val localMediaViewState = rememberLocalMediaViewState(
|
localMediaRenderer.Render(attachment.localMedia)
|
||||||
zoomableState = rememberZoomableState(
|
|
||||||
zoomSpec = ZoomSpec(maxZoomFactor = 4f, preventOverOrUnderZoom = false)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
LocalMediaView(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
localMedia = attachment.localMedia,
|
|
||||||
localMediaViewState = localMediaViewState,
|
|
||||||
onClick = {}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -205,5 +199,15 @@ private fun AttachmentsPreviewBottomActions(
|
||||||
internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark {
|
internal fun AttachmentsPreviewViewPreview(@PreviewParameter(AttachmentsPreviewStateProvider::class) state: AttachmentsPreviewState) = ElementPreviewDark {
|
||||||
AttachmentsPreviewView(
|
AttachmentsPreviewView(
|
||||||
state = state,
|
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.api.timeline.item.event.EventTimelineItem
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
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 io.element.android.tests.testutils.testCoroutineDispatchers
|
||||||
import kotlinx.coroutines.test.TestScope
|
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.permalink.FakePermalinkParser
|
||||||
import io.element.android.libraries.matrix.test.timeline.aStickerContent
|
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.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.persistentListOf
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
import kotlinx.coroutines.test.runTest
|
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.Plugin
|
||||||
import com.bumble.appyx.core.plugin.plugins
|
import com.bumble.appyx.core.plugin.plugins
|
||||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||||
|
import com.bumble.appyx.navmodel.backstack.operation.pop
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
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.notificationsettings.RoomNotificationSettingsNode
|
||||||
import io.element.android.features.roomdetails.impl.rolesandpermissions.RolesAndPermissionsFlowNode
|
import io.element.android.features.roomdetails.impl.rolesandpermissions.RolesAndPermissionsFlowNode
|
||||||
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
|
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.BackstackWithOverlayBox
|
||||||
import io.element.android.libraries.architecture.BaseFlowNode
|
import io.element.android.libraries.architecture.BaseFlowNode
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
import io.element.android.libraries.architecture.overlay.operation.show
|
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.di.RoomScope
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
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.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.permalink.PermalinkData
|
||||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
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.MediaViewerEntryPoint
|
||||||
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
|
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
@ -59,6 +56,7 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
||||||
private val room: MatrixRoom,
|
private val room: MatrixRoom,
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
private val messagesEntryPoint: MessagesEntryPoint,
|
private val messagesEntryPoint: MessagesEntryPoint,
|
||||||
|
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
|
||||||
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
|
) : BaseFlowNode<RoomDetailsFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
|
initialElement = plugins.filterIsInstance<RoomDetailsEntryPoint.Params>().first().initialElement.toNavTarget(),
|
||||||
|
|
@ -202,22 +200,18 @@ class RoomDetailsFlowNode @AssistedInject constructor(
|
||||||
createNode<RoomMemberDetailsNode>(buildContext, plugins)
|
createNode<RoomMemberDetailsNode>(buildContext, plugins)
|
||||||
}
|
}
|
||||||
is NavTarget.AvatarPreview -> {
|
is NavTarget.AvatarPreview -> {
|
||||||
// We need to fake the MimeType here for the viewer to work.
|
val callback = object : MediaViewerEntryPoint.Callback {
|
||||||
val mimeType = MimeTypes.Images
|
override fun onDone() {
|
||||||
val input = MediaViewerNode.Inputs(
|
backstack.pop()
|
||||||
mediaInfo = MediaInfo(
|
}
|
||||||
filename = navTarget.name,
|
}
|
||||||
caption = null,
|
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
|
||||||
mimeType = mimeType,
|
.avatar(
|
||||||
formattedFileSize = "",
|
navTarget.name,
|
||||||
fileExtension = ""
|
navTarget.avatarUrl,
|
||||||
),
|
|
||||||
mediaSource = MediaSource(url = navTarget.avatarUrl),
|
|
||||||
thumbnailSource = null,
|
|
||||||
canDownload = false,
|
|
||||||
canShare = false,
|
|
||||||
)
|
)
|
||||||
createNode<AvatarPreviewNode>(buildContext, listOf(input))
|
.callback(callback)
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
is NavTarget.PollHistory -> {
|
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.Plugin
|
||||||
import com.bumble.appyx.core.plugin.plugins
|
import com.bumble.appyx.core.plugin.plugins
|
||||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||||
|
import com.bumble.appyx.navmodel.backstack.operation.pop
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
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.api.UserProfileEntryPoint
|
||||||
import io.element.android.features.userprofile.impl.root.UserProfileNode
|
import io.element.android.features.userprofile.impl.root.UserProfileNode
|
||||||
import io.element.android.features.userprofile.shared.UserProfileNodeHelper
|
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.BackstackView
|
||||||
import io.element.android.libraries.architecture.BaseFlowNode
|
import io.element.android.libraries.architecture.BaseFlowNode
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
import io.element.android.libraries.architecture.inputs
|
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.di.SessionScope
|
||||||
import io.element.android.libraries.matrix.api.core.RoomId
|
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.matrix.api.user.CurrentSessionIdHolder
|
||||||
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
|
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||||
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
|
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
@ContributesNode(SessionScope::class)
|
@ContributesNode(SessionScope::class)
|
||||||
|
|
@ -44,6 +41,7 @@ class UserProfileFlowNode @AssistedInject constructor(
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
private val elementCallEntryPoint: ElementCallEntryPoint,
|
private val elementCallEntryPoint: ElementCallEntryPoint,
|
||||||
private val sessionIdHolder: CurrentSessionIdHolder,
|
private val sessionIdHolder: CurrentSessionIdHolder,
|
||||||
|
private val mediaViewerEntryPoint: MediaViewerEntryPoint,
|
||||||
) : BaseFlowNode<UserProfileFlowNode.NavTarget>(
|
) : BaseFlowNode<UserProfileFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = NavTarget.Root,
|
initialElement = NavTarget.Root,
|
||||||
|
|
@ -80,22 +78,18 @@ class UserProfileFlowNode @AssistedInject constructor(
|
||||||
createNode<UserProfileNode>(buildContext, listOf(callback, params))
|
createNode<UserProfileNode>(buildContext, listOf(callback, params))
|
||||||
}
|
}
|
||||||
is NavTarget.AvatarPreview -> {
|
is NavTarget.AvatarPreview -> {
|
||||||
// We need to fake the MimeType here for the viewer to work.
|
val callback = object : MediaViewerEntryPoint.Callback {
|
||||||
val mimeType = MimeTypes.Images
|
override fun onDone() {
|
||||||
val input = MediaViewerNode.Inputs(
|
backstack.pop()
|
||||||
mediaInfo = MediaInfo(
|
}
|
||||||
|
}
|
||||||
|
mediaViewerEntryPoint.nodeBuilder(this, buildContext)
|
||||||
|
.avatar(
|
||||||
filename = navTarget.name,
|
filename = navTarget.name,
|
||||||
caption = null,
|
avatarUrl = navTarget.avatarUrl
|
||||||
mimeType = mimeType,
|
|
||||||
formattedFileSize = "",
|
|
||||||
fileExtension = "",
|
|
||||||
),
|
|
||||||
mediaSource = MediaSource(url = navTarget.avatarUrl),
|
|
||||||
thumbnailSource = null,
|
|
||||||
canDownload = false,
|
|
||||||
canShare = false,
|
|
||||||
)
|
)
|
||||||
createNode<AvatarPreviewNode>(buildContext, listOf(input))
|
.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 {
|
android {
|
||||||
namespace = "io.element.android.libraries.mediaviewer.api"
|
namespace = "io.element.android.libraries.mediaviewer.api"
|
||||||
testOptions {
|
|
||||||
unitTests {
|
|
||||||
isIncludeAndroidResources = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setupAnvil()
|
setupAnvil()
|
||||||
|
|
||||||
dependencies {
|
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.core)
|
||||||
implementation(projects.libraries.dateformatter.api)
|
implementation(projects.libraries.architecture)
|
||||||
implementation(projects.libraries.di)
|
|
||||||
implementation(projects.libraries.designsystem)
|
|
||||||
implementation(projects.libraries.matrix.api)
|
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
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
* Please see LICENSE in the repository root for full details.
|
* Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.mediaviewer.api.local
|
package io.element.android.libraries.mediaviewer.api
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
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.net.Uri
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
|
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ package io.element.android.libraries.mediaviewer.api.local
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import io.element.android.libraries.matrix.api.media.MediaFile
|
import io.element.android.libraries.matrix.api.media.MediaFile
|
||||||
|
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||||
|
|
||||||
interface LocalMediaFactory {
|
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
|
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 {
|
interface FileExtensionExtractor {
|
||||||
fun extractFromName(name: String): String
|
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 {
|
android {
|
||||||
namespace = "io.element.android.libraries.mediaviewer.impl"
|
namespace = "io.element.android.libraries.mediaviewer.impl"
|
||||||
|
testOptions {
|
||||||
|
unitTests {
|
||||||
|
isIncludeAndroidResources = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupAnvil()
|
setupAnvil()
|
||||||
|
|
@ -21,6 +26,23 @@ dependencies {
|
||||||
implementation(libs.coroutines.core)
|
implementation(libs.coroutines.core)
|
||||||
implementation(libs.dagger)
|
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)
|
api(projects.libraries.mediaviewer.api)
|
||||||
implementation(projects.libraries.androidutils)
|
implementation(projects.libraries.androidutils)
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
|
|
@ -39,4 +61,6 @@ dependencies {
|
||||||
testImplementation(libs.test.turbine)
|
testImplementation(libs.test.turbine)
|
||||||
testImplementation(libs.coroutines.core)
|
testImplementation(libs.coroutines.core)
|
||||||
testImplementation(libs.coroutines.test)
|
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.AppScope
|
||||||
import io.element.android.libraries.di.ApplicationContext
|
import io.element.android.libraries.di.ApplicationContext
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
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.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
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.di.ApplicationContext
|
||||||
import io.element.android.libraries.matrix.api.media.MediaFile
|
import io.element.android.libraries.matrix.api.media.MediaFile
|
||||||
import io.element.android.libraries.matrix.api.media.toFile
|
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.LocalMedia
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
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.FileExtensionExtractor
|
||||||
import javax.inject.Inject
|
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.
|
* 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.Composable
|
||||||
|
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||||
|
|
||||||
interface LocalMediaActions {
|
interface LocalMediaActions {
|
||||||
@Composable
|
@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.
|
* 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.Composable
|
||||||
import androidx.compose.runtime.Immutable
|
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.
|
* 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.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Please see LICENSE in the repository root for full details.
|
* Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.mediaviewer.api.local.pdf
|
package io.element.android.libraries.mediaviewer.impl.local.pdf
|
||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Please see LICENSE in the repository root for full details.
|
* Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.mediaviewer.api.local.pdf
|
package io.element.android.libraries.mediaviewer.impl.local.pdf
|
||||||
|
|
||||||
import android.graphics.pdf.PdfRenderer
|
import android.graphics.pdf.PdfRenderer
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Please see LICENSE in the repository root for full details.
|
* Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.mediaviewer.api.local.pdf
|
package io.element.android.libraries.mediaviewer.impl.local.pdf
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Please see LICENSE in the repository root for full details.
|
* Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.mediaviewer.api.local.pdf
|
package io.element.android.libraries.mediaviewer.impl.local.pdf
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
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.
|
* 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 android.content.Context
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Please see LICENSE in the repository root for full details.
|
* Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.mediaviewer.api.player
|
package io.element.android.libraries.mediaviewer.impl.local.video
|
||||||
|
|
||||||
data class MediaPlayerControllerState(
|
data class MediaPlayerControllerState(
|
||||||
val isVisible: Boolean,
|
val isVisible: Boolean,
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Please see LICENSE in the repository root for full details.
|
* Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.mediaviewer.api.player
|
package io.element.android.libraries.mediaviewer.impl.local.video
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Please see LICENSE in the repository root for full details.
|
* Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.mediaviewer.api.player
|
package io.element.android.libraries.mediaviewer.impl.local.video
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
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.
|
* 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 {
|
sealed interface MediaViewerEvents {
|
||||||
data object SaveOnDisk : MediaViewerEvents
|
data object SaveOnDisk : MediaViewerEvents
|
||||||
|
|
@ -5,22 +5,21 @@
|
||||||
* Please see LICENSE in the repository root for full details.
|
* Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.mediaviewer.api.viewer
|
package io.element.android.libraries.mediaviewer.impl.viewer
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import com.bumble.appyx.core.plugin.plugins
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.compound.theme.ForcedDarkElementTheme
|
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.architecture.inputs
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint
|
||||||
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
|
|
||||||
|
|
||||||
@ContributesNode(RoomScope::class)
|
@ContributesNode(RoomScope::class)
|
||||||
open class MediaViewerNode @AssistedInject constructor(
|
open class MediaViewerNode @AssistedInject constructor(
|
||||||
|
|
@ -28,15 +27,13 @@ open class MediaViewerNode @AssistedInject constructor(
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
presenterFactory: MediaViewerPresenter.Factory,
|
presenterFactory: MediaViewerPresenter.Factory,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
data class Inputs(
|
private val inputs = inputs<MediaViewerEntryPoint.Params>()
|
||||||
val mediaInfo: MediaInfo,
|
|
||||||
val mediaSource: MediaSource,
|
|
||||||
val thumbnailSource: MediaSource?,
|
|
||||||
val canDownload: Boolean,
|
|
||||||
val canShare: Boolean,
|
|
||||||
) : NodeInputs
|
|
||||||
|
|
||||||
private val inputs: Inputs = inputs()
|
private fun onDone() {
|
||||||
|
plugins<MediaViewerEntryPoint.Callback>().forEach {
|
||||||
|
it.onDone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private val presenter = presenterFactory.create(inputs)
|
private val presenter = presenterFactory.create(inputs)
|
||||||
|
|
||||||
|
|
@ -47,7 +44,7 @@ open class MediaViewerNode @AssistedInject constructor(
|
||||||
MediaViewerView(
|
MediaViewerView(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onBackClick = this::navigateUp
|
onBackClick = ::onDone
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Please see LICENSE in the repository root for full details.
|
* Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.mediaviewer.api.viewer
|
package io.element.android.libraries.mediaviewer.impl.viewer
|
||||||
|
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
import androidx.compose.runtime.Composable
|
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.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
||||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||||
import io.element.android.libraries.matrix.api.media.MediaFile
|
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.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.api.local.LocalMediaFactory
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import io.element.android.libraries.androidutils.R as UtilsR
|
import io.element.android.libraries.androidutils.R as UtilsR
|
||||||
|
|
||||||
class MediaViewerPresenter @AssistedInject constructor(
|
class MediaViewerPresenter @AssistedInject constructor(
|
||||||
@Assisted private val inputs: MediaViewerNode.Inputs,
|
@Assisted private val inputs: MediaViewerEntryPoint.Params,
|
||||||
private val localMediaFactory: LocalMediaFactory,
|
private val localMediaFactory: LocalMediaFactory,
|
||||||
private val mediaLoader: MatrixMediaLoader,
|
private val mediaLoader: MatrixMediaLoader,
|
||||||
private val localMediaActions: LocalMediaActions,
|
private val localMediaActions: LocalMediaActions,
|
||||||
|
|
@ -44,7 +45,7 @@ class MediaViewerPresenter @AssistedInject constructor(
|
||||||
) : Presenter<MediaViewerState> {
|
) : Presenter<MediaViewerState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
interface Factory {
|
interface Factory {
|
||||||
fun create(inputs: MediaViewerNode.Inputs): MediaViewerPresenter
|
fun create(inputs: MediaViewerEntryPoint.Params): MediaViewerPresenter
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -5,13 +5,13 @@
|
||||||
* Please see LICENSE in the repository root for full details.
|
* Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.mediaviewer.api.viewer
|
package io.element.android.libraries.mediaviewer.impl.viewer
|
||||||
|
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
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.LocalMedia
|
||||||
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
|
|
||||||
|
|
||||||
data class MediaViewerState(
|
data class MediaViewerState(
|
||||||
val mediaInfo: MediaInfo,
|
val mediaInfo: MediaInfo,
|
||||||
|
|
@ -5,18 +5,18 @@
|
||||||
* Please see LICENSE in the repository root for full details.
|
* Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.mediaviewer.api.viewer
|
package io.element.android.libraries.mediaviewer.impl.viewer
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.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.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> {
|
open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState> {
|
||||||
override val values: Sequence<MediaViewerState>
|
override val values: Sequence<MediaViewerState>
|
||||||
|
|
@ -7,8 +7,9 @@
|
||||||
|
|
||||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
@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.AnimatedVisibility
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.fadeIn
|
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.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
import io.element.android.libraries.matrix.ui.media.MediaRequestData
|
||||||
import io.element.android.libraries.mediaviewer.api.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.LocalMedia
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaView
|
import io.element.android.libraries.mediaviewer.impl.R
|
||||||
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
|
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaView
|
||||||
import io.element.android.libraries.mediaviewer.api.local.PlayableState
|
import io.element.android.libraries.mediaviewer.impl.local.PlayableState
|
||||||
import io.element.android.libraries.mediaviewer.api.local.rememberLocalMediaViewState
|
import io.element.android.libraries.mediaviewer.impl.local.rememberLocalMediaViewState
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import me.saket.telephoto.flick.FlickToDismiss
|
import me.saket.telephoto.flick.FlickToDismiss
|
||||||
|
|
@ -79,6 +80,7 @@ fun MediaViewerView(
|
||||||
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
|
||||||
var showOverlay by remember { mutableStateOf(true) }
|
var showOverlay by remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
BackHandler { onBackClick() }
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier,
|
modifier,
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
|
|
@ -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.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.matrix.api.media.MediaFile
|
import io.element.android.libraries.matrix.api.media.MediaFile
|
||||||
import io.element.android.libraries.matrix.test.media.FakeMediaFile
|
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.MediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.local.anImageMediaInfo
|
import io.element.android.libraries.mediaviewer.api.anImageMediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractorWithoutValidation
|
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Please see LICENSE in the repository root for full details.
|
* Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.mediaviewer.api.util
|
package io.element.android.libraries.mediaviewer.impl.util
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -13,7 +13,7 @@ import org.junit.runner.RunWith
|
||||||
import org.robolectric.RobolectricTestRunner
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
@RunWith(RobolectricTestRunner::class)
|
||||||
class FileExtensionExtractorTest {
|
class FileExtensionExtractorWithValidationTest {
|
||||||
@Test
|
@Test
|
||||||
fun `test FileExtensionExtractor with validation OK`() {
|
fun `test FileExtensionExtractor with validation OK`() {
|
||||||
val sut = FileExtensionExtractorWithValidation()
|
val sut = FileExtensionExtractorWithValidation()
|
||||||
|
|
@ -27,11 +27,4 @@ class FileExtensionExtractorTest {
|
||||||
val sut = FileExtensionExtractorWithValidation()
|
val sut = FileExtensionExtractorWithValidation()
|
||||||
assertThat(sut.extractFromName("test.bla")).isEqualTo("bin")
|
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)
|
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
|
||||||
package io.element.android.libraries.mediaviewer
|
package io.element.android.libraries.mediaviewer.impl.viewer
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import app.cash.molecule.RecompositionMode
|
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.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
|
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
|
||||||
import io.element.android.libraries.matrix.test.media.aMediaSource
|
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.MediaViewerEntryPoint
|
||||||
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerEvents
|
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerNode
|
|
||||||
import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerPresenter
|
|
||||||
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions
|
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions
|
||||||
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
|
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaFactory
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
|
@ -144,7 +142,7 @@ class MediaViewerPresenterTest {
|
||||||
canDownload: Boolean = true,
|
canDownload: Boolean = true,
|
||||||
): MediaViewerPresenter {
|
): MediaViewerPresenter {
|
||||||
return MediaViewerPresenter(
|
return MediaViewerPresenter(
|
||||||
inputs = MediaViewerNode.Inputs(
|
inputs = MediaViewerEntryPoint.Params(
|
||||||
mediaInfo = TESTED_MEDIA_INFO,
|
mediaInfo = TESTED_MEDIA_INFO,
|
||||||
mediaSource = aMediaSource(),
|
mediaSource = aMediaSource(),
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
* Please see LICENSE in the repository root for full details.
|
* Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.libraries.mediaviewer.api.viewer
|
package io.element.android.libraries.mediaviewer.impl.viewer
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
|
@ -18,8 +18,8 @@ import androidx.compose.ui.test.performTouchInput
|
||||||
import androidx.compose.ui.test.swipeDown
|
import androidx.compose.ui.test.swipeDown
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
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.LocalMedia
|
||||||
import io.element.android.libraries.mediaviewer.api.local.anImageMediaInfo
|
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||||
import io.element.android.tests.testutils.EventsRecorder
|
import io.element.android.tests.testutils.EventsRecorder
|
||||||
|
|
@ -18,4 +18,7 @@ dependencies {
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
implementation(projects.tests.testutils)
|
implementation(projects.tests.testutils)
|
||||||
implementation(projects.libraries.matrix.api)
|
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 androidx.compose.runtime.Composable
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
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
|
import io.element.android.tests.testutils.simulateLongTask
|
||||||
|
|
||||||
class FakeLocalMediaActions : LocalMediaActions {
|
class FakeLocalMediaActions : LocalMediaActions {
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,11 @@ package io.element.android.libraries.mediaviewer.test
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.matrix.api.media.MediaFile
|
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.LocalMedia
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory
|
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.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
|
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
|
||||||
|
|
||||||
class FakeLocalMediaFactory(
|
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
|
package io.element.android.libraries.mediaviewer.test.viewer
|
||||||
|
|
||||||
import android.net.Uri
|
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.LocalMedia
|
||||||
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
|
|
||||||
import io.element.android.libraries.mediaviewer.api.local.anImageMediaInfo
|
|
||||||
|
|
||||||
fun aLocalMedia(
|
fun aLocalMedia(
|
||||||
uri: Uri,
|
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 {
|
filters {
|
||||||
excludes.classes(
|
excludes.classes(
|
||||||
"*State$*", // Exclude inner classes
|
"*State$*", // Exclude inner classes
|
||||||
"io.element.android.appnav.root.RootNavState*",
|
"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.features.ftue.api.state.*",
|
"io.element.android.features.ftue.api.state.*",
|
||||||
"io.element.android.features.ftue.impl.welcome.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.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",
|
"io.element.android.libraries.textcomposer.model.TextEditorState",
|
||||||
)
|
)
|
||||||
includes.classes("*State")
|
includes.classes("*State")
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:d18f7e2c169f7e4b9e435584d868454ae5f765a32d29bbbfe48d65b45f4161d6
|
oid sha256:3a92cb782d97553f364fab3df3fe159557a26aad8b7e5c176b40616c2f05abdd
|
||||||
size 18845
|
size 51410
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:3250fa9767aec264308719c6fdf6cea94d5ccea21bfa8a735d7df5d79b572555
|
oid sha256:361ff23fd2ff99aecfdffd10b45e9235d86183d4856cb2a3e99f85b9e04c2d59
|
||||||
size 21214
|
size 51376
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:4ba70221a149ce0f8abaa4e4f870011bea82630edf99ecacea560a737f56346f
|
oid sha256:efbfed755b29293009f45fca33f58863b612772de9a1d55593c979dbb04ff6f2
|
||||||
size 22215
|
size 88981
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:3a92cb782d97553f364fab3df3fe159557a26aad8b7e5c176b40616c2f05abdd
|
oid sha256:507c7dffae1aabfa687174f1f964e2c40b004183b6bc3a70b56d764e0d308b47
|
||||||
size 51410
|
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