Extract voice message player to its own module
This commit is contained in:
parent
c40d468e4f
commit
5e0d6a6e8a
23 changed files with 321 additions and 141 deletions
|
|
@ -47,6 +47,7 @@ dependencies {
|
||||||
implementation(projects.libraries.permissions.api)
|
implementation(projects.libraries.permissions.api)
|
||||||
implementation(projects.libraries.preferences.api)
|
implementation(projects.libraries.preferences.api)
|
||||||
implementation(projects.libraries.roomselect.api)
|
implementation(projects.libraries.roomselect.api)
|
||||||
|
implementation(projects.libraries.voiceplayer.api)
|
||||||
implementation(projects.libraries.voicerecorder.api)
|
implementation(projects.libraries.voicerecorder.api)
|
||||||
implementation(projects.libraries.mediaplayer.api)
|
implementation(projects.libraries.mediaplayer.api)
|
||||||
implementation(projects.libraries.uiUtils)
|
implementation(projects.libraries.uiUtils)
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,8 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||||
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
|
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TimelineItemEventContentView(
|
fun TimelineItemEventContentView(
|
||||||
|
|
|
||||||
|
|
@ -40,9 +40,6 @@ import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentProvider
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentProvider
|
||||||
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageEvents
|
|
||||||
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
|
|
||||||
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageStateProvider
|
|
||||||
import io.element.android.libraries.androidutils.accessibility.isScreenReaderEnabled
|
import io.element.android.libraries.androidutils.accessibility.isScreenReaderEnabled
|
||||||
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
|
||||||
|
|
@ -52,6 +49,9 @@ 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.ui.strings.CommonStrings
|
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 kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@
|
||||||
package io.element.android.features.messages.impl.timeline.di
|
package io.element.android.features.messages.impl.timeline.di
|
||||||
|
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||||
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
|
|
||||||
import io.element.android.features.messages.impl.voicemessages.timeline.aVoiceMessageState
|
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||||
|
import io.element.android.libraries.voiceplayer.api.aVoiceMessageState
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A fake [TimelineItemPresenterFactories] for screenshot tests.
|
* A fake [TimelineItemPresenterFactories] for screenshot tests.
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import im.vector.app.features.analytics.plan.Composer
|
import im.vector.app.features.analytics.plan.Composer
|
||||||
import io.element.android.features.messages.api.MessageComposerContext
|
import io.element.android.features.messages.api.MessageComposerContext
|
||||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
|
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||||
import io.element.android.libraries.permissions.api.PermissionsEvents
|
import io.element.android.libraries.permissions.api.PermissionsEvents
|
||||||
|
|
@ -29,6 +28,7 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
|
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
|
||||||
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
|
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
|
||||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||||
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageException
|
||||||
import io.element.android.libraries.voicerecorder.api.VoiceRecorder
|
import io.element.android.libraries.voicerecorder.api.VoiceRecorder
|
||||||
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
|
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,6 @@
|
||||||
package io.element.android.features.messages.impl.voicemessages.timeline
|
package io.element.android.features.messages.impl.voicemessages.timeline
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import com.squareup.anvil.annotations.ContributesTo
|
import com.squareup.anvil.annotations.ContributesTo
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
|
|
@ -23,17 +18,10 @@ import dagger.multibindings.IntoMap
|
||||||
import io.element.android.features.messages.impl.timeline.di.TimelineItemEventContentKey
|
import io.element.android.features.messages.impl.timeline.di.TimelineItemEventContentKey
|
||||||
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactory
|
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactory
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
||||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
|
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.architecture.runUpdatingState
|
|
||||||
import io.element.android.libraries.core.extensions.flatMap
|
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
import io.element.android.libraries.ui.utils.time.formatShort
|
import io.element.android.libraries.voiceplayer.api.VoiceMessagePresenterFactory
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@ContributesTo(RoomScope::class)
|
@ContributesTo(RoomScope::class)
|
||||||
|
|
@ -45,9 +33,7 @@ interface VoiceMessagePresenterModule {
|
||||||
}
|
}
|
||||||
|
|
||||||
class VoiceMessagePresenter @AssistedInject constructor(
|
class VoiceMessagePresenter @AssistedInject constructor(
|
||||||
voiceMessagePlayerFactory: VoiceMessagePlayer.Factory,
|
voiceMessagePresenterFactory: VoiceMessagePresenterFactory,
|
||||||
private val analyticsService: AnalyticsService,
|
|
||||||
private val scope: CoroutineScope,
|
|
||||||
@Assisted private val content: TimelineItemVoiceContent,
|
@Assisted private val content: TimelineItemVoiceContent,
|
||||||
) : Presenter<VoiceMessageState> {
|
) : Presenter<VoiceMessageState> {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
|
|
@ -55,97 +41,16 @@ class VoiceMessagePresenter @AssistedInject constructor(
|
||||||
override fun create(content: TimelineItemVoiceContent): VoiceMessagePresenter
|
override fun create(content: TimelineItemVoiceContent): VoiceMessagePresenter
|
||||||
}
|
}
|
||||||
|
|
||||||
private val player = voiceMessagePlayerFactory.create(
|
private val presenter = voiceMessagePresenterFactory.createVoiceMessagePresenter(
|
||||||
eventId = content.eventId,
|
eventId = content.eventId,
|
||||||
mediaSource = content.mediaSource,
|
mediaSource = content.mediaSource,
|
||||||
mimeType = content.mimeType,
|
mimeType = content.mimeType,
|
||||||
filename = content.filename,
|
filename = content.filename,
|
||||||
|
duration = content.duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val play = mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): VoiceMessageState {
|
override fun present(): VoiceMessageState {
|
||||||
val playerState by player.state.collectAsState(
|
return presenter.present()
|
||||||
VoiceMessagePlayer.State(
|
|
||||||
isReady = false,
|
|
||||||
isPlaying = false,
|
|
||||||
isEnded = false,
|
|
||||||
currentPosition = 0L,
|
|
||||||
duration = null
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val button by remember {
|
|
||||||
derivedStateOf {
|
|
||||||
when {
|
|
||||||
content.eventId == null -> VoiceMessageState.Button.Disabled
|
|
||||||
playerState.isPlaying -> VoiceMessageState.Button.Pause
|
|
||||||
play.value is AsyncData.Loading -> VoiceMessageState.Button.Downloading
|
|
||||||
play.value is AsyncData.Failure -> VoiceMessageState.Button.Retry
|
|
||||||
else -> VoiceMessageState.Button.Play
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val duration by remember {
|
|
||||||
derivedStateOf { playerState.duration ?: content.duration.inWholeMilliseconds }
|
|
||||||
}
|
|
||||||
val progress by remember {
|
|
||||||
derivedStateOf {
|
|
||||||
playerState.currentPosition / duration.toFloat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val time by remember {
|
|
||||||
derivedStateOf {
|
|
||||||
when {
|
|
||||||
playerState.isReady && !playerState.isEnded -> playerState.currentPosition
|
|
||||||
playerState.currentPosition > 0 -> playerState.currentPosition
|
|
||||||
else -> duration
|
|
||||||
}.milliseconds.formatShort()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val showCursor by remember {
|
|
||||||
derivedStateOf {
|
|
||||||
!play.value.isUninitialized() && !playerState.isEnded
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun eventSink(event: VoiceMessageEvents) {
|
|
||||||
when (event) {
|
|
||||||
is VoiceMessageEvents.PlayPause -> {
|
|
||||||
if (playerState.isPlaying) {
|
|
||||||
player.pause()
|
|
||||||
} else if (playerState.isReady) {
|
|
||||||
player.play()
|
|
||||||
} else {
|
|
||||||
scope.launch {
|
|
||||||
play.runUpdatingState(
|
|
||||||
errorTransform = {
|
|
||||||
analyticsService.trackError(
|
|
||||||
VoiceMessageException.PlayMessageError("Error while trying to play voice message", it)
|
|
||||||
)
|
|
||||||
it
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
player.prepare().flatMap {
|
|
||||||
runCatching { player.play() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is VoiceMessageEvents.Seek -> {
|
|
||||||
player.seekTo((event.percentage * duration).toLong())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return VoiceMessageState(
|
|
||||||
button = button,
|
|
||||||
progress = progress,
|
|
||||||
time = time,
|
|
||||||
showCursor = showCursor,
|
|
||||||
eventSink = { eventSink(it) },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import im.vector.app.features.analytics.plan.Composer
|
import im.vector.app.features.analytics.plan.Composer
|
||||||
import io.element.android.features.messages.impl.messagecomposer.aReplyMode
|
import io.element.android.features.messages.impl.messagecomposer.aReplyMode
|
||||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
|
|
||||||
import io.element.android.features.messages.test.FakeMessageComposerContext
|
import io.element.android.features.messages.test.FakeMessageComposerContext
|
||||||
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
import io.element.android.libraries.matrix.api.core.ProgressCallback
|
||||||
import io.element.android.libraries.matrix.api.media.AudioInfo
|
import io.element.android.libraries.matrix.api.media.AudioInfo
|
||||||
|
|
@ -36,6 +35,7 @@ import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
|
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
|
||||||
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
|
import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent
|
||||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||||
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageException
|
||||||
import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
|
import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
|
|
|
||||||
23
libraries/voiceplayer/api/build.gradle.kts
Normal file
23
libraries/voiceplayer/api/build.gradle.kts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import extension.setupAnvil
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
plugins {
|
||||||
|
id("io.element.android-compose-library")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.element.android.libraries.voiceplayer.api"
|
||||||
|
}
|
||||||
|
|
||||||
|
setupAnvil()
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.annotationjvm)
|
||||||
|
implementation(libs.coroutines.core)
|
||||||
|
implementation(projects.libraries.matrix.api)
|
||||||
|
}
|
||||||
|
|
@ -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.features.messages.impl.voicemessages.timeline
|
package io.element.android.libraries.voiceplayer.api
|
||||||
|
|
||||||
sealed interface VoiceMessageEvents {
|
sealed interface VoiceMessageEvents {
|
||||||
data object PlayPause : VoiceMessageEvents
|
data object PlayPause : VoiceMessageEvents
|
||||||
|
|
@ -5,17 +5,19 @@
|
||||||
* Please see LICENSE in the repository root for full details.
|
* Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.voicemessages
|
package io.element.android.libraries.voiceplayer.api
|
||||||
|
|
||||||
internal sealed class VoiceMessageException : Exception() {
|
sealed class VoiceMessageException : Exception() {
|
||||||
data class FileException(
|
data class FileException(
|
||||||
override val message: String?,
|
override val message: String?,
|
||||||
override val cause: Throwable? = null
|
override val cause: Throwable? = null
|
||||||
) : VoiceMessageException()
|
) : VoiceMessageException()
|
||||||
|
|
||||||
data class PermissionMissing(
|
data class PermissionMissing(
|
||||||
override val message: String?,
|
override val message: String?,
|
||||||
override val cause: Throwable?
|
override val cause: Throwable?
|
||||||
) : VoiceMessageException()
|
) : VoiceMessageException()
|
||||||
|
|
||||||
data class PlayMessageError(
|
data class PlayMessageError(
|
||||||
override val message: String?,
|
override val message: String?,
|
||||||
override val cause: Throwable?
|
override val cause: Throwable?
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
/*
|
||||||
|
* 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.voiceplayer.api
|
||||||
|
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
interface VoiceMessagePresenterFactory {
|
||||||
|
fun createVoiceMessagePresenter(
|
||||||
|
eventId: EventId?,
|
||||||
|
mediaSource: MediaSource,
|
||||||
|
mimeType: String?,
|
||||||
|
filename: String?,
|
||||||
|
duration: Duration,
|
||||||
|
): Presenter<VoiceMessageState>
|
||||||
|
}
|
||||||
|
|
@ -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.features.messages.impl.voicemessages.timeline
|
package io.element.android.libraries.voiceplayer.api
|
||||||
|
|
||||||
data class VoiceMessageState(
|
data class VoiceMessageState(
|
||||||
val button: Button,
|
val button: Button,
|
||||||
|
|
@ -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.features.messages.impl.voicemessages.timeline
|
package io.element.android.libraries.voiceplayer.api
|
||||||
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
|
||||||
43
libraries/voiceplayer/impl/build.gradle.kts
Normal file
43
libraries/voiceplayer/impl/build.gradle.kts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import extension.setupAnvil
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Copyright 2024 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
* Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
plugins {
|
||||||
|
id("io.element.android-compose-library")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "io.element.android.libraries.voiceplayer.impl"
|
||||||
|
}
|
||||||
|
|
||||||
|
setupAnvil()
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
api(projects.libraries.voiceplayer.api)
|
||||||
|
|
||||||
|
implementation(projects.libraries.core)
|
||||||
|
implementation(projects.libraries.di)
|
||||||
|
implementation(projects.libraries.matrix.api)
|
||||||
|
implementation(projects.libraries.mediaplayer.api)
|
||||||
|
implementation(projects.libraries.uiUtils)
|
||||||
|
implementation(projects.services.analytics.api)
|
||||||
|
|
||||||
|
implementation(libs.androidx.annotationjvm)
|
||||||
|
implementation(libs.coroutines.core)
|
||||||
|
|
||||||
|
testImplementation(libs.molecule.runtime)
|
||||||
|
testImplementation(libs.test.junit)
|
||||||
|
testImplementation(libs.test.truth)
|
||||||
|
testImplementation(libs.test.mockk)
|
||||||
|
testImplementation(libs.test.turbine)
|
||||||
|
testImplementation(libs.coroutines.core)
|
||||||
|
testImplementation(libs.coroutines.test)
|
||||||
|
testImplementation(projects.libraries.matrix.test)
|
||||||
|
testImplementation(projects.libraries.mediaplayer.test)
|
||||||
|
testImplementation(projects.services.analytics.test)
|
||||||
|
testImplementation(projects.tests.testutils)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
/*
|
||||||
|
* 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.voiceplayer.impl
|
||||||
|
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
import io.element.android.libraries.voiceplayer.api.VoiceMessagePresenterFactory
|
||||||
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||||
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
@ContributesBinding(RoomScope::class)
|
||||||
|
class DefaultVoiceMessagePresenterFactory @Inject constructor(
|
||||||
|
private val analyticsService: AnalyticsService,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val voiceMessagePlayerFactory: VoiceMessagePlayer.Factory,
|
||||||
|
) : VoiceMessagePresenterFactory {
|
||||||
|
override fun createVoiceMessagePresenter(
|
||||||
|
eventId: EventId?,
|
||||||
|
mediaSource: MediaSource,
|
||||||
|
mimeType: String?,
|
||||||
|
filename: String?,
|
||||||
|
duration: Duration,
|
||||||
|
): Presenter<VoiceMessageState> {
|
||||||
|
val player = voiceMessagePlayerFactory.create(
|
||||||
|
eventId = eventId,
|
||||||
|
mediaSource = mediaSource,
|
||||||
|
mimeType = mimeType,
|
||||||
|
filename = filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
return VoiceMessagePresenter(
|
||||||
|
analyticsService = analyticsService,
|
||||||
|
scope = scope,
|
||||||
|
player = player,
|
||||||
|
eventId = eventId,
|
||||||
|
duration = duration,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.features.messages.impl.voicemessages.timeline
|
package io.element.android.libraries.voiceplayer.impl
|
||||||
|
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
|
|
@ -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.features.messages.impl.voicemessages.timeline
|
package io.element.android.libraries.voiceplayer.impl
|
||||||
|
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
/*
|
||||||
|
* 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.voiceplayer.impl
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.architecture.Presenter
|
||||||
|
import io.element.android.libraries.architecture.runUpdatingState
|
||||||
|
import io.element.android.libraries.core.extensions.flatMap
|
||||||
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
|
import io.element.android.libraries.ui.utils.time.formatShort
|
||||||
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
|
||||||
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageException
|
||||||
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||||
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
class VoiceMessagePresenter(
|
||||||
|
private val analyticsService: AnalyticsService,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val player: VoiceMessagePlayer,
|
||||||
|
private val eventId: EventId?,
|
||||||
|
private val duration: Duration,
|
||||||
|
) : Presenter<VoiceMessageState> {
|
||||||
|
private val play = mutableStateOf<AsyncData<Unit>>(AsyncData.Uninitialized)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(): VoiceMessageState {
|
||||||
|
val playerState by player.state.collectAsState(
|
||||||
|
VoiceMessagePlayer.State(
|
||||||
|
isReady = false,
|
||||||
|
isPlaying = false,
|
||||||
|
isEnded = false,
|
||||||
|
currentPosition = 0L,
|
||||||
|
duration = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val button by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
when {
|
||||||
|
eventId == null -> VoiceMessageState.Button.Disabled
|
||||||
|
playerState.isPlaying -> VoiceMessageState.Button.Pause
|
||||||
|
play.value is AsyncData.Loading -> VoiceMessageState.Button.Downloading
|
||||||
|
play.value is AsyncData.Failure -> VoiceMessageState.Button.Retry
|
||||||
|
else -> VoiceMessageState.Button.Play
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val duration by remember {
|
||||||
|
derivedStateOf { playerState.duration ?: duration.inWholeMilliseconds }
|
||||||
|
}
|
||||||
|
val progress by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
playerState.currentPosition / duration.toFloat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val time by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
when {
|
||||||
|
playerState.isReady && !playerState.isEnded -> playerState.currentPosition
|
||||||
|
playerState.currentPosition > 0 -> playerState.currentPosition
|
||||||
|
else -> duration
|
||||||
|
}.milliseconds.formatShort()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val showCursor by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
!play.value.isUninitialized() && !playerState.isEnded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun eventSink(event: VoiceMessageEvents) {
|
||||||
|
when (event) {
|
||||||
|
is VoiceMessageEvents.PlayPause -> {
|
||||||
|
if (playerState.isPlaying) {
|
||||||
|
player.pause()
|
||||||
|
} else if (playerState.isReady) {
|
||||||
|
player.play()
|
||||||
|
} else {
|
||||||
|
scope.launch {
|
||||||
|
play.runUpdatingState(
|
||||||
|
errorTransform = {
|
||||||
|
analyticsService.trackError(
|
||||||
|
VoiceMessageException.PlayMessageError("Error while trying to play voice message", it)
|
||||||
|
)
|
||||||
|
it
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
player.prepare().flatMap {
|
||||||
|
runCatching { player.play() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is VoiceMessageEvents.Seek -> {
|
||||||
|
player.seekTo((event.percentage * duration).toLong())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return VoiceMessageState(
|
||||||
|
button = button,
|
||||||
|
progress = progress,
|
||||||
|
time = time,
|
||||||
|
showCursor = showCursor,
|
||||||
|
eventSink = { eventSink(it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.features.messages.impl.voicemessages.timeline
|
package io.element.android.libraries.voiceplayer.impl
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
|
|
@ -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.features.messages.impl.voicemessages.timeline
|
package io.element.android.libraries.voiceplayer.impl
|
||||||
|
|
||||||
import app.cash.turbine.TurbineTestContext
|
import app.cash.turbine.TurbineTestContext
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
|
|
@ -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.features.messages.impl.voicemessages.timeline
|
package io.element.android.libraries.voiceplayer.impl
|
||||||
|
|
||||||
import io.element.android.tests.testutils.simulateLongTask
|
import io.element.android.tests.testutils.simulateLongTask
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
@ -5,21 +5,25 @@
|
||||||
* Please see LICENSE in the repository root for full details.
|
* Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.voicemessages.timeline
|
package io.element.android.libraries.voiceplayer.impl
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionMode
|
import app.cash.molecule.RecompositionMode
|
||||||
import app.cash.molecule.moleculeFlow
|
import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
||||||
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
|
||||||
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageException
|
||||||
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class VoiceMessagePresenterTest {
|
class VoiceMessagePresenterTest {
|
||||||
|
|
@ -41,7 +45,7 @@ class VoiceMessagePresenterTest {
|
||||||
fun `pressing play downloads and plays`() = runTest {
|
fun `pressing play downloads and plays`() = runTest {
|
||||||
val presenter = createVoiceMessagePresenter(
|
val presenter = createVoiceMessagePresenter(
|
||||||
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
||||||
content = aTimelineItemVoiceContent(duration = 2_000.milliseconds),
|
duration = 2_000.milliseconds,
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -79,7 +83,7 @@ class VoiceMessagePresenterTest {
|
||||||
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
||||||
voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply { shouldFail = true },
|
voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply { shouldFail = true },
|
||||||
analyticsService = analyticsService,
|
analyticsService = analyticsService,
|
||||||
content = aTimelineItemVoiceContent(duration = 2_000.milliseconds),
|
duration = 2_000.milliseconds,
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -115,7 +119,7 @@ class VoiceMessagePresenterTest {
|
||||||
fun `pressing pause while playing pauses`() = runTest {
|
fun `pressing pause while playing pauses`() = runTest {
|
||||||
val presenter = createVoiceMessagePresenter(
|
val presenter = createVoiceMessagePresenter(
|
||||||
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
||||||
content = aTimelineItemVoiceContent(duration = 2_000.milliseconds),
|
duration = 2_000.milliseconds,
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -147,7 +151,7 @@ class VoiceMessagePresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `content with null eventId shows disabled button`() = runTest {
|
fun `content with null eventId shows disabled button`() = runTest {
|
||||||
val presenter = createVoiceMessagePresenter(
|
val presenter = createVoiceMessagePresenter(
|
||||||
content = aTimelineItemVoiceContent(eventId = null),
|
eventId = null,
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -164,7 +168,7 @@ class VoiceMessagePresenterTest {
|
||||||
fun `seeking before play`() = runTest {
|
fun `seeking before play`() = runTest {
|
||||||
val presenter = createVoiceMessagePresenter(
|
val presenter = createVoiceMessagePresenter(
|
||||||
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
||||||
content = aTimelineItemVoiceContent(duration = 10_000.milliseconds),
|
duration = 10_000.milliseconds,
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -188,7 +192,7 @@ class VoiceMessagePresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `seeking after play`() = runTest {
|
fun `seeking after play`() = runTest {
|
||||||
val presenter = createVoiceMessagePresenter(
|
val presenter = createVoiceMessagePresenter(
|
||||||
content = aTimelineItemVoiceContent(duration = 10_000.milliseconds),
|
duration = 10_000.milliseconds,
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
|
|
@ -224,19 +228,23 @@ fun TestScope.createVoiceMessagePresenter(
|
||||||
mediaPlayer: FakeMediaPlayer = FakeMediaPlayer(),
|
mediaPlayer: FakeMediaPlayer = FakeMediaPlayer(),
|
||||||
voiceMessageMediaRepo: VoiceMessageMediaRepo = FakeVoiceMessageMediaRepo(),
|
voiceMessageMediaRepo: VoiceMessageMediaRepo = FakeVoiceMessageMediaRepo(),
|
||||||
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
||||||
content: TimelineItemVoiceContent = aTimelineItemVoiceContent(),
|
eventId: EventId? = EventId("\$anEventId"),
|
||||||
|
filename: String = "filename doesn't really matter for a voice message",
|
||||||
|
duration: Duration = 61_000.milliseconds,
|
||||||
|
contentUri: String = "mxc://matrix.org/1234567890abcdefg",
|
||||||
|
mimeType: String = MimeTypes.Ogg,
|
||||||
|
mediaSource: MediaSource = MediaSource(contentUri),
|
||||||
) = VoiceMessagePresenter(
|
) = VoiceMessagePresenter(
|
||||||
voiceMessagePlayerFactory = { eventId, mediaSource, mimeType, filename ->
|
|
||||||
DefaultVoiceMessagePlayer(
|
|
||||||
mediaPlayer = mediaPlayer,
|
|
||||||
voiceMessageMediaRepoFactory = { _, _, _ -> voiceMessageMediaRepo },
|
|
||||||
eventId = eventId,
|
|
||||||
mediaSource = mediaSource,
|
|
||||||
mimeType = mimeType,
|
|
||||||
filename = filename
|
|
||||||
)
|
|
||||||
},
|
|
||||||
analyticsService = analyticsService,
|
analyticsService = analyticsService,
|
||||||
scope = this,
|
scope = this,
|
||||||
content = content,
|
player = DefaultVoiceMessagePlayer(
|
||||||
|
mediaPlayer = mediaPlayer,
|
||||||
|
voiceMessageMediaRepoFactory = { _, _, _ -> voiceMessageMediaRepo },
|
||||||
|
eventId = eventId,
|
||||||
|
mediaSource = mediaSource,
|
||||||
|
mimeType = mimeType,
|
||||||
|
filename = filename
|
||||||
|
),
|
||||||
|
eventId = eventId,
|
||||||
|
duration = duration,
|
||||||
)
|
)
|
||||||
|
|
@ -83,6 +83,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
|
||||||
implementation(project(":libraries:textcomposer:impl"))
|
implementation(project(":libraries:textcomposer:impl"))
|
||||||
implementation(project(":libraries:roomselect:impl"))
|
implementation(project(":libraries:roomselect:impl"))
|
||||||
implementation(project(":libraries:cryptography:impl"))
|
implementation(project(":libraries:cryptography:impl"))
|
||||||
|
implementation(project(":libraries:voiceplayer:impl"))
|
||||||
implementation(project(":libraries:voicerecorder:impl"))
|
implementation(project(":libraries:voicerecorder:impl"))
|
||||||
implementation(project(":libraries:mediaplayer:impl"))
|
implementation(project(":libraries:mediaplayer:impl"))
|
||||||
implementation(project(":libraries:mediaviewer:impl"))
|
implementation(project(":libraries:mediaviewer:impl"))
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue