Add inline voice player to the files gallery
This commit is contained in:
parent
df72ab6a86
commit
0ed4d34329
12 changed files with 449 additions and 48 deletions
|
|
@ -43,6 +43,7 @@ dependencies {
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
implementation(projects.libraries.matrixui)
|
implementation(projects.libraries.matrixui)
|
||||||
implementation(projects.libraries.uiStrings)
|
implementation(projects.libraries.uiStrings)
|
||||||
|
implementation(projects.libraries.voiceplayer.api)
|
||||||
implementation(projects.services.toolbox.api)
|
implementation(projects.services.toolbox.api)
|
||||||
|
|
||||||
api(projects.libraries.mediaviewer.api)
|
api(projects.libraries.mediaviewer.api)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
package io.element.android.libraries.mediaviewer.impl.gallery
|
package io.element.android.libraries.mediaviewer.impl.gallery
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
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
|
||||||
|
|
@ -18,12 +19,15 @@ import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.gallery.di.LocalMediaItemPresenterFactories
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.gallery.di.MediaItemPresenterFactories
|
||||||
|
|
||||||
@ContributesNode(RoomScope::class)
|
@ContributesNode(RoomScope::class)
|
||||||
class MediaGalleryNode @AssistedInject constructor(
|
class MediaGalleryNode @AssistedInject constructor(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
presenterFactory: MediaGalleryPresenter.Factory,
|
presenterFactory: MediaGalleryPresenter.Factory,
|
||||||
|
private val mediaItemPresenterFactories: MediaItemPresenterFactories,
|
||||||
) : Node(buildContext, plugins = plugins),
|
) : Node(buildContext, plugins = plugins),
|
||||||
MediaGalleryNavigator {
|
MediaGalleryNavigator {
|
||||||
private val presenter = presenterFactory.create(
|
private val presenter = presenterFactory.create(
|
||||||
|
|
@ -56,12 +60,16 @@ class MediaGalleryNode @AssistedInject constructor(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
CompositionLocalProvider(
|
||||||
MediaGalleryView(
|
LocalMediaItemPresenterFactories provides mediaItemPresenterFactories,
|
||||||
state = state,
|
) {
|
||||||
onBackClick = ::onBackClick,
|
val state = presenter.present()
|
||||||
onItemClick = ::onItemClick,
|
MediaGalleryView(
|
||||||
modifier = modifier,
|
state = state,
|
||||||
)
|
onBackClick = ::onBackClick,
|
||||||
|
onItemClick = ::onItemClick,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,6 @@ open class MediaGalleryStateProvider : PreviewParameterProvider<MediaGalleryStat
|
||||||
id = UniqueId("2"),
|
id = UniqueId("2"),
|
||||||
formattedDate = "September 2004",
|
formattedDate = "September 2004",
|
||||||
),
|
),
|
||||||
aMediaItemFile(id = UniqueId("3")),
|
|
||||||
aMediaItemAudio(id = UniqueId("4")),
|
aMediaItemAudio(id = UniqueId("4")),
|
||||||
aMediaItemVoice(
|
aMediaItemVoice(
|
||||||
id = UniqueId("5"),
|
id = UniqueId("5"),
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import androidx.compose.foundation.pager.rememberPagerState
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
|
@ -40,6 +41,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.designsystem.components.BigIcon
|
import io.element.android.libraries.designsystem.components.BigIcon
|
||||||
import io.element.android.libraries.designsystem.components.PageTitle
|
import io.element.android.libraries.designsystem.components.PageTitle
|
||||||
import io.element.android.libraries.designsystem.components.async.AsyncFailure
|
import io.element.android.libraries.designsystem.components.async.AsyncFailure
|
||||||
|
|
@ -60,12 +62,16 @@ import io.element.android.libraries.mediaviewer.impl.R
|
||||||
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
|
import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState
|
||||||
import io.element.android.libraries.mediaviewer.impl.details.MediaDeleteConfirmationBottomSheet
|
import io.element.android.libraries.mediaviewer.impl.details.MediaDeleteConfirmationBottomSheet
|
||||||
import io.element.android.libraries.mediaviewer.impl.details.MediaDetailsBottomSheet
|
import io.element.android.libraries.mediaviewer.impl.details.MediaDetailsBottomSheet
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.gallery.di.LocalMediaItemPresenterFactories
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.gallery.di.aFakeMediaItemPresenterFactories
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.gallery.di.rememberPresenter
|
||||||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.AudioItemView
|
import io.element.android.libraries.mediaviewer.impl.gallery.ui.AudioItemView
|
||||||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.DateItemView
|
import io.element.android.libraries.mediaviewer.impl.gallery.ui.DateItemView
|
||||||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.FileItemView
|
import io.element.android.libraries.mediaviewer.impl.gallery.ui.FileItemView
|
||||||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.ImageItemView
|
import io.element.android.libraries.mediaviewer.impl.gallery.ui.ImageItemView
|
||||||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.VideoItemView
|
import io.element.android.libraries.mediaviewer.impl.gallery.ui.VideoItemView
|
||||||
import io.element.android.libraries.mediaviewer.impl.gallery.ui.VoiceItemView
|
import io.element.android.libraries.mediaviewer.impl.gallery.ui.VoiceItemView
|
||||||
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
|
|
||||||
|
|
@ -256,6 +262,7 @@ private fun MediaGalleryFilesList(
|
||||||
eventSink: (MediaGalleryEvents) -> Unit,
|
eventSink: (MediaGalleryEvents) -> Unit,
|
||||||
onItemClick: (MediaItem.Event) -> Unit,
|
onItemClick: (MediaItem.Event) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
val presenterFactories = LocalMediaItemPresenterFactories.current
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
|
|
@ -275,12 +282,16 @@ private fun MediaGalleryFilesList(
|
||||||
onDownloadClick = { eventSink(MediaGalleryEvents.SaveOnDisk(item)) },
|
onDownloadClick = { eventSink(MediaGalleryEvents.SaveOnDisk(item)) },
|
||||||
onInfoClick = { eventSink(MediaGalleryEvents.OpenInfo(item)) },
|
onInfoClick = { eventSink(MediaGalleryEvents.OpenInfo(item)) },
|
||||||
)
|
)
|
||||||
is MediaItem.Voice -> VoiceItemView(
|
is MediaItem.Voice -> {
|
||||||
item,
|
val presenter: Presenter<VoiceMessageState> = presenterFactories.rememberPresenter(item)
|
||||||
onShareClick = { eventSink(MediaGalleryEvents.Share(item)) },
|
VoiceItemView(
|
||||||
onDownloadClick = { eventSink(MediaGalleryEvents.SaveOnDisk(item)) },
|
presenter.present(),
|
||||||
onInfoClick = { eventSink(MediaGalleryEvents.OpenInfo(item)) },
|
item,
|
||||||
)
|
onShareClick = { eventSink(MediaGalleryEvents.Share(item)) },
|
||||||
|
onDownloadClick = { eventSink(MediaGalleryEvents.SaveOnDisk(item)) },
|
||||||
|
onInfoClick = { eventSink(MediaGalleryEvents.OpenInfo(item)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
is MediaItem.DateSeparator -> DateItemView(item)
|
is MediaItem.DateSeparator -> DateItemView(item)
|
||||||
is MediaItem.Image,
|
is MediaItem.Image,
|
||||||
is MediaItem.Video -> {
|
is MediaItem.Video -> {
|
||||||
|
|
@ -462,9 +473,13 @@ private fun LoadingContent(
|
||||||
internal fun MediaGalleryViewPreview(
|
internal fun MediaGalleryViewPreview(
|
||||||
@PreviewParameter(MediaGalleryStateProvider::class) state: MediaGalleryState
|
@PreviewParameter(MediaGalleryStateProvider::class) state: MediaGalleryState
|
||||||
) = ElementPreview {
|
) = ElementPreview {
|
||||||
MediaGalleryView(
|
CompositionLocalProvider(
|
||||||
state = state,
|
LocalMediaItemPresenterFactories provides aFakeMediaItemPresenterFactories(),
|
||||||
onBackClick = {},
|
) {
|
||||||
onItemClick = {},
|
MediaGalleryView(
|
||||||
)
|
state = state,
|
||||||
|
onBackClick = {},
|
||||||
|
onItemClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.mediaviewer.impl.gallery.di
|
||||||
|
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
|
||||||
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||||
|
import io.element.android.libraries.voiceplayer.api.aVoiceMessageState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fake [MediaItemPresenterFactories] for screenshot tests.
|
||||||
|
*/
|
||||||
|
fun aFakeMediaItemPresenterFactories() = MediaItemPresenterFactories(
|
||||||
|
mapOf(
|
||||||
|
Pair(
|
||||||
|
MediaItem.Voice::class.java,
|
||||||
|
MediaItemPresenterFactory<MediaItem.Voice, VoiceMessageState> { Presenter { aVoiceMessageState() } },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.mediaviewer.impl.gallery.di
|
||||||
|
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides a [MediaItemPresenterFactories] to the composition.
|
||||||
|
*/
|
||||||
|
val LocalMediaItemPresenterFactories = staticCompositionLocalOf {
|
||||||
|
MediaItemPresenterFactories(emptyMap())
|
||||||
|
}
|
||||||
|
|
@ -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.impl.gallery.di
|
||||||
|
|
||||||
|
import dagger.MapKey
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Annotation to add a factory of type [MediaItemPresenterFactory] to a
|
||||||
|
* Dagger map multi binding keyed with a subclass of [MediaItem.Event].
|
||||||
|
*/
|
||||||
|
@Retention(AnnotationRetention.RUNTIME)
|
||||||
|
@MapKey
|
||||||
|
annotation class MediaItemEventContentKey(val value: KClass<out MediaItem.Event>)
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.mediaviewer.impl.gallery.di
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import com.squareup.anvil.annotations.ContributesTo
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.multibindings.Multibinds
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dagger module that declares the [MediaItemPresenterFactory] map multi binding.
|
||||||
|
*
|
||||||
|
* Its sole purpose is to support the case of an empty map multibinding.
|
||||||
|
*/
|
||||||
|
@Module
|
||||||
|
@ContributesTo(RoomScope::class)
|
||||||
|
interface MediaItemPresenterFactoriesModule {
|
||||||
|
@Multibinds
|
||||||
|
fun multiBindMediaItemPresenterFactories(): @JvmSuppressWildcards Map<Class<out MediaItem.Event>, MediaItemPresenterFactory<*, *>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Room level caching layer for the [MediaItemPresenterFactory] instances.
|
||||||
|
*
|
||||||
|
* It will cache the presenter instances in the room scope, so that they can be
|
||||||
|
* reused across recompositions of the gallery items that happen whenever an item
|
||||||
|
* goes out of the [LazyColumn] viewport.
|
||||||
|
*/
|
||||||
|
@SingleIn(RoomScope::class)
|
||||||
|
class MediaItemPresenterFactories @Inject constructor(
|
||||||
|
private val factories: @JvmSuppressWildcards Map<Class<out MediaItem.Event>, MediaItemPresenterFactory<*, *>>,
|
||||||
|
) {
|
||||||
|
private val presenters: MutableMap<MediaItem.Event, Presenter<*>> = mutableMapOf()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and caches a presenter for the given content.
|
||||||
|
*
|
||||||
|
* Will throw if the presenter is not found in the [MediaItemPresenterFactory] map multi binding.
|
||||||
|
*
|
||||||
|
* @param C The [MediaItem.Event] subtype handled by this TimelineItem presenter.
|
||||||
|
* @param S The state type produced by this timeline item presenter.
|
||||||
|
* @param content The [MediaItem.Event] instance to create a presenter for.
|
||||||
|
* @param contentClass The class of [content].
|
||||||
|
* @return An instance of a TimelineItem presenter that will be cached in the room scope.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun <C : MediaItem.Event, S : Any> rememberPresenter(
|
||||||
|
content: C,
|
||||||
|
contentClass: Class<C>,
|
||||||
|
): Presenter<S> = remember(content) {
|
||||||
|
presenters[content]?.let {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
it as Presenter<S>
|
||||||
|
} ?: factories.getValue(contentClass).let {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
(it as MediaItemPresenterFactory<C, S>).create(content).apply {
|
||||||
|
presenters[content] = this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and caches a presenter for the given content.
|
||||||
|
*
|
||||||
|
* Will throw if the presenter is not found in the [MediaItemPresenterFactory] map multi binding.
|
||||||
|
*
|
||||||
|
* @param C The [MediaItem.Event] subtype handled by this TimelineItem presenter.
|
||||||
|
* @param S The state type produced by this timeline item presenter.
|
||||||
|
* @param content The [MediaItem.Event] instance to create a presenter for.
|
||||||
|
* @return An instance of a TimelineItem presenter that will be cached in the room scope.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
inline fun <reified C : MediaItem.Event, S : Any> MediaItemPresenterFactories.rememberPresenter(
|
||||||
|
content: C
|
||||||
|
): Presenter<S> = rememberPresenter(
|
||||||
|
content = content,
|
||||||
|
contentClass = C::class.java
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.mediaviewer.impl.gallery.di
|
||||||
|
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A factory for a [Presenter] associated with a timeline item.
|
||||||
|
*
|
||||||
|
* Implementations should be annotated with [AssistedFactory] to be created by Dagger.
|
||||||
|
*
|
||||||
|
* @param C The timeline item's [MediaItem.Event] subtype.
|
||||||
|
* @param S The [Presenter]'s state class.
|
||||||
|
* @return A [Presenter] that produces a state of type [S] for the given content of type [C].
|
||||||
|
*/
|
||||||
|
fun interface MediaItemPresenterFactory<C : MediaItem.Event, S : Any> {
|
||||||
|
fun create(content: C): Presenter<S>
|
||||||
|
}
|
||||||
|
|
@ -20,10 +20,18 @@ import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
@ -32,15 +40,23 @@ import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
|
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
|
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
|
||||||
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||||
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider
|
||||||
|
import io.element.android.libraries.voiceplayer.api.aVoiceMessageState
|
||||||
import kotlinx.collections.immutable.toPersistentList
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VoiceItemView(
|
fun VoiceItemView(
|
||||||
|
state: VoiceMessageState,
|
||||||
voice: MediaItem.Voice,
|
voice: MediaItem.Voice,
|
||||||
onShareClick: () -> Unit,
|
onShareClick: () -> Unit,
|
||||||
onDownloadClick: () -> Unit,
|
onDownloadClick: () -> Unit,
|
||||||
|
|
@ -53,6 +69,7 @@ fun VoiceItemView(
|
||||||
.padding(top = 20.dp, start = 16.dp, end = 16.dp),
|
.padding(top = 20.dp, start = 16.dp, end = 16.dp),
|
||||||
) {
|
) {
|
||||||
VoiceInfoRow(
|
VoiceInfoRow(
|
||||||
|
state = state,
|
||||||
voice = voice,
|
voice = voice,
|
||||||
)
|
)
|
||||||
val caption = voice.mediaInfo.caption
|
val caption = voice.mediaInfo.caption
|
||||||
|
|
@ -72,8 +89,13 @@ fun VoiceItemView(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun VoiceInfoRow(
|
private fun VoiceInfoRow(
|
||||||
|
state: VoiceMessageState,
|
||||||
voice: MediaItem.Voice,
|
voice: MediaItem.Voice,
|
||||||
) {
|
) {
|
||||||
|
fun playPause() {
|
||||||
|
state.eventSink(VoiceMessageEvents.PlayPause)
|
||||||
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.clip(RoundedCornerShape(12.dp))
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
|
@ -85,49 +107,155 @@ private fun VoiceInfoRow(
|
||||||
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(
|
when (state.button) {
|
||||||
modifier = Modifier
|
VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause)
|
||||||
.background(
|
VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause)
|
||||||
color = ElementTheme.colors.bgCanvasDefault,
|
VoiceMessageState.Button.Downloading -> ProgressButton()
|
||||||
shape = CircleShape,
|
VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause)
|
||||||
)
|
VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false)
|
||||||
.border(
|
|
||||||
width = 1.dp,
|
|
||||||
color = ElementTheme.colors.borderInteractiveSecondary,
|
|
||||||
shape = CircleShape,
|
|
||||||
)
|
|
||||||
.size(36.dp)
|
|
||||||
.padding(6.dp),
|
|
||||||
imageVector = CompoundIcons.PlaySolid(),
|
|
||||||
tint = ElementTheme.colors.iconSecondary,
|
|
||||||
contentDescription = null,
|
|
||||||
)
|
|
||||||
voice.duration?.let {
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = voice.duration,
|
|
||||||
style = ElementTheme.typography.fontBodyMdMedium,
|
|
||||||
color = ElementTheme.colors.textSecondary,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = state.time,
|
||||||
|
color = ElementTheme.colors.textSecondary,
|
||||||
|
style = ElementTheme.typography.fontBodyMdMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
WaveformPlaybackView(
|
WaveformPlaybackView(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.height(34.dp),
|
.height(34.dp),
|
||||||
playbackProgress = 0f,
|
showCursor = state.showCursor,
|
||||||
showCursor = false,
|
playbackProgress = state.progress,
|
||||||
waveform = voice.waveform.toPersistentList(),
|
waveform = voice.waveform.toPersistentList(),
|
||||||
onSeek = {
|
onSeek = {
|
||||||
|
state.eventSink(VoiceMessageEvents.Seek(it))
|
||||||
},
|
},
|
||||||
seekEnabled = true,
|
seekEnabled = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress button is shown when the voice message is being downloaded.
|
||||||
|
*
|
||||||
|
* The progress indicator is optimistic and displays a pause button (which
|
||||||
|
* indicates the audio is playing) for 2 seconds before revealing the
|
||||||
|
* actual progress indicator.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ProgressButton(
|
||||||
|
displayImmediately: Boolean = false,
|
||||||
|
) {
|
||||||
|
var canDisplay by remember { mutableStateOf(displayImmediately) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
delay(2000L)
|
||||||
|
canDisplay = true
|
||||||
|
}
|
||||||
|
CustomIconButton(
|
||||||
|
onClick = {},
|
||||||
|
enabled = false,
|
||||||
|
) {
|
||||||
|
if (canDisplay) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(2.dp)
|
||||||
|
.size(16.dp),
|
||||||
|
color = ElementTheme.colors.iconSecondary,
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ControlIcon(
|
||||||
|
imageVector = CompoundIcons.PauseSolid(),
|
||||||
|
contentDescription = stringResource(id = CommonStrings.a11y_pause),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PlayButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
) {
|
||||||
|
CustomIconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
) {
|
||||||
|
ControlIcon(
|
||||||
|
imageVector = CompoundIcons.PlaySolid(),
|
||||||
|
contentDescription = stringResource(id = CommonStrings.a11y_play),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PauseButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
CustomIconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
) {
|
||||||
|
ControlIcon(
|
||||||
|
imageVector = CompoundIcons.PauseSolid(),
|
||||||
|
contentDescription = stringResource(id = CommonStrings.a11y_pause),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RetryButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
CustomIconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
) {
|
||||||
|
ControlIcon(
|
||||||
|
imageVector = CompoundIcons.Restart(),
|
||||||
|
contentDescription = stringResource(id = CommonStrings.action_retry),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ControlIcon(
|
||||||
|
imageVector: ImageVector,
|
||||||
|
contentDescription: String?,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
modifier = Modifier.padding(vertical = 10.dp),
|
||||||
|
imageVector = imageVector,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CustomIconButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier
|
||||||
|
.background(color = ElementTheme.colors.bgCanvasDefault, shape = CircleShape)
|
||||||
|
.border(
|
||||||
|
width = 1.dp,
|
||||||
|
color = ElementTheme.colors.borderInteractiveSecondary,
|
||||||
|
shape = CircleShape,
|
||||||
|
)
|
||||||
|
.size(36.dp),
|
||||||
|
enabled = enabled,
|
||||||
|
colors = IconButtonDefaults.iconButtonColors(
|
||||||
|
contentColor = ElementTheme.colors.iconSecondary,
|
||||||
|
disabledContentColor = ElementTheme.colors.iconDisabled,
|
||||||
|
),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Caption(caption: String) {
|
private fun Caption(caption: String) {
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -183,9 +311,24 @@ internal fun VoiceItemViewPreview(
|
||||||
@PreviewParameter(MediaItemVoiceProvider::class) voice: MediaItem.Voice,
|
@PreviewParameter(MediaItemVoiceProvider::class) voice: MediaItem.Voice,
|
||||||
) = ElementPreview {
|
) = ElementPreview {
|
||||||
VoiceItemView(
|
VoiceItemView(
|
||||||
|
state = aVoiceMessageState(),
|
||||||
voice = voice,
|
voice = voice,
|
||||||
onShareClick = {},
|
onShareClick = {},
|
||||||
onDownloadClick = {},
|
onDownloadClick = {},
|
||||||
onInfoClick = {},
|
onInfoClick = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun VoiceItemViewPlayPreview(
|
||||||
|
@PreviewParameter(VoiceMessageStateProvider::class) state: VoiceMessageState,
|
||||||
|
) = ElementPreview {
|
||||||
|
VoiceItemView(
|
||||||
|
state = state,
|
||||||
|
voice = aMediaItemVoice(),
|
||||||
|
onShareClick = {},
|
||||||
|
onDownloadClick = {},
|
||||||
|
onInfoClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.mediaviewer.impl.gallery.voice
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import com.squareup.anvil.annotations.ContributesTo
|
||||||
|
import dagger.Binds
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedFactory
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import dagger.multibindings.IntoMap
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.gallery.MediaItem
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.gallery.di.MediaItemEventContentKey
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.gallery.di.MediaItemPresenterFactory
|
||||||
|
import io.element.android.libraries.voiceplayer.api.VoiceMessagePresenterFactory
|
||||||
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@ContributesTo(RoomScope::class)
|
||||||
|
interface VoiceMessagePresenterModule {
|
||||||
|
@Binds
|
||||||
|
@IntoMap
|
||||||
|
@MediaItemEventContentKey(MediaItem.Voice::class)
|
||||||
|
fun bindVoiceMessagePresenterFactory(factory: VoiceMessagePresenter.Factory): MediaItemPresenterFactory<*, *>
|
||||||
|
}
|
||||||
|
|
||||||
|
class VoiceMessagePresenter @AssistedInject constructor(
|
||||||
|
voiceMessagePresenterFactory: VoiceMessagePresenterFactory,
|
||||||
|
@Assisted private val item: MediaItem.Voice,
|
||||||
|
) : Presenter<VoiceMessageState> {
|
||||||
|
@AssistedFactory
|
||||||
|
fun interface Factory : MediaItemPresenterFactory<MediaItem.Voice, VoiceMessageState> {
|
||||||
|
override fun create(content: MediaItem.Voice): VoiceMessagePresenter
|
||||||
|
}
|
||||||
|
|
||||||
|
private val presenter = voiceMessagePresenterFactory.createVoiceMessagePresenter(
|
||||||
|
eventId = item.eventId,
|
||||||
|
mediaSource = item.mediaSource,
|
||||||
|
mimeType = item.mediaInfo.mimeType,
|
||||||
|
filename = item.mediaInfo.filename,
|
||||||
|
// TODO Get the duration for the fallback?
|
||||||
|
duration = Duration.ZERO,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): VoiceMessageState {
|
||||||
|
return presenter.present()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -128,6 +128,7 @@ class KonsistPreviewTest {
|
||||||
"TimelineVideoWithCaptionRowPreview",
|
"TimelineVideoWithCaptionRowPreview",
|
||||||
"TimelineViewMessageShieldPreview",
|
"TimelineViewMessageShieldPreview",
|
||||||
"UserAvatarColorsPreview",
|
"UserAvatarColorsPreview",
|
||||||
|
"VoiceItemViewPlayPreview",
|
||||||
)
|
)
|
||||||
.assertTrue(
|
.assertTrue(
|
||||||
additionalMessage = "Functions for Preview should be named like this: <ViewUnderPreview>Preview. " +
|
additionalMessage = "Functions for Preview should be named like this: <ViewUnderPreview>Preview. " +
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue