Merge pull request #4036 from element-hq/feature/bma/extractVoiceMessagePlayer
Extract voice message player to its own module
This commit is contained in:
commit
3643ec30c4
23 changed files with 321 additions and 141 deletions
|
|
@ -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.TimelineItemVideoContent
|
||||
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.voiceplayer.api.VoiceMessageState
|
||||
|
||||
@Composable
|
||||
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.model.event.TimelineItemVoiceContent
|
||||
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.designsystem.components.media.WaveformPlaybackView
|
||||
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.Text
|
||||
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
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@
|
|||
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.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.voiceplayer.api.VoiceMessageState
|
||||
import io.element.android.libraries.voiceplayer.api.aVoiceMessageState
|
||||
|
||||
/**
|
||||
* A fake [TimelineItemPresenterFactories] for screenshot tests.
|
||||
|
|
|
|||
|
|
@ -1,23 +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.features.messages.impl.voicemessages
|
||||
|
||||
internal sealed class VoiceMessageException : Exception() {
|
||||
data class FileException(
|
||||
override val message: String?,
|
||||
override val cause: Throwable? = null
|
||||
) : VoiceMessageException()
|
||||
data class PermissionMissing(
|
||||
override val message: String?,
|
||||
override val cause: Throwable?
|
||||
) : VoiceMessageException()
|
||||
data class PlayMessageError(
|
||||
override val message: String?,
|
||||
override val cause: Throwable?
|
||||
) : VoiceMessageException()
|
||||
}
|
||||
|
|
@ -21,7 +21,6 @@ import androidx.core.net.toUri
|
|||
import androidx.lifecycle.Lifecycle
|
||||
import im.vector.app.features.analytics.plan.Composer
|
||||
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.mediaupload.api.MediaSender
|
||||
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.VoiceMessageRecorderEvent
|
||||
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.VoiceRecorderState
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
|
|
|
|||
|
|
@ -1,13 +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.features.messages.impl.voicemessages.timeline
|
||||
|
||||
sealed interface VoiceMessageEvents {
|
||||
data object PlayPause : VoiceMessageEvents
|
||||
data class Seek(val percentage: Float) : VoiceMessageEvents
|
||||
}
|
||||
|
|
@ -1,103 +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.features.messages.impl.voicemessages.timeline
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.libraries.di.CacheDirectory
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.mxc.MxcTools
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Fetches the media file for a voice message.
|
||||
*
|
||||
* Media is downloaded from the rust sdk and stored in the application's cache directory.
|
||||
* Media files are indexed by their Matrix Content (mxc://) URI and considered immutable.
|
||||
* Whenever a given mxc is found in the cache, it is returned immediately.
|
||||
*/
|
||||
interface VoiceMessageMediaRepo {
|
||||
/**
|
||||
* Factory for [VoiceMessageMediaRepo].
|
||||
*/
|
||||
fun interface Factory {
|
||||
/**
|
||||
* Creates a [VoiceMessageMediaRepo].
|
||||
*
|
||||
* @param mediaSource the media source of the voice message.
|
||||
* @param mimeType the mime type of the voice message.
|
||||
* @param filename the filename of the voice message.
|
||||
*/
|
||||
fun create(
|
||||
mediaSource: MediaSource,
|
||||
mimeType: String?,
|
||||
filename: String?,
|
||||
): VoiceMessageMediaRepo
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the voice message media file.
|
||||
*
|
||||
* In case of a cache hit the file is returned immediately.
|
||||
* In case of a cache miss the file is downloaded and then returned.
|
||||
*
|
||||
* @return A [Result] holding either the media [File] from the cache directory or an [Exception].
|
||||
*/
|
||||
suspend fun getMediaFile(): Result<File>
|
||||
}
|
||||
|
||||
class DefaultVoiceMessageMediaRepo @AssistedInject constructor(
|
||||
@CacheDirectory private val cacheDir: File,
|
||||
mxcTools: MxcTools,
|
||||
private val matrixMediaLoader: MatrixMediaLoader,
|
||||
@Assisted private val mediaSource: MediaSource,
|
||||
@Assisted("mimeType") private val mimeType: String?,
|
||||
@Assisted("filename") private val filename: String?,
|
||||
) : VoiceMessageMediaRepo {
|
||||
@ContributesBinding(RoomScope::class)
|
||||
@AssistedFactory
|
||||
fun interface Factory : VoiceMessageMediaRepo.Factory {
|
||||
override fun create(
|
||||
mediaSource: MediaSource,
|
||||
@Assisted("mimeType") mimeType: String?,
|
||||
@Assisted("filename") filename: String?,
|
||||
): DefaultVoiceMessageMediaRepo
|
||||
}
|
||||
|
||||
override suspend fun getMediaFile(): Result<File> = when {
|
||||
cachedFile == null -> Result.failure(IllegalStateException("Invalid mxcUri."))
|
||||
cachedFile.exists() -> Result.success(cachedFile)
|
||||
else -> matrixMediaLoader.downloadMediaFile(
|
||||
source = mediaSource,
|
||||
mimeType = mimeType,
|
||||
filename = filename,
|
||||
).mapCatching {
|
||||
it.use { mediaFile ->
|
||||
val dest = cachedFile.apply { parentFile?.mkdirs() }
|
||||
if (mediaFile.persist(dest.path)) {
|
||||
dest
|
||||
} else {
|
||||
error("Failed to move file to cache.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val cachedFile: File? = mxcTools.mxcUri2FilePath(mediaSource.url)?.let {
|
||||
File("${cacheDir.path}/$CACHE_VOICE_SUBDIR/$it")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subdirectory of the application's cache directory where voice messages are stored.
|
||||
*/
|
||||
private const val CACHE_VOICE_SUBDIR = "temp/voice"
|
||||
|
|
@ -1,225 +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.features.messages.impl.voicemessages.timeline
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
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.mediaplayer.api.MediaPlayer
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.update
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* A media player specialized in playing a single voice message.
|
||||
*/
|
||||
interface VoiceMessagePlayer {
|
||||
fun interface Factory {
|
||||
/**
|
||||
* Creates a [VoiceMessagePlayer].
|
||||
*
|
||||
* NB: Different voice messages can use the same content uri (e.g. in case of
|
||||
* a forward of a voice message),
|
||||
* therefore the mxc:// uri in [mediaSource] is not enough to uniquely identify
|
||||
* a voice message. This is why we must provide the eventId as well.
|
||||
*
|
||||
* @param eventId The eventId of the voice message event.
|
||||
* @param mediaSource The media source of the voice message.
|
||||
* @param mimeType The mime type of the voice message.
|
||||
* @param filename The filename of the voice message.
|
||||
*/
|
||||
fun create(
|
||||
eventId: EventId?,
|
||||
mediaSource: MediaSource,
|
||||
mimeType: String?,
|
||||
filename: String?,
|
||||
): VoiceMessagePlayer
|
||||
}
|
||||
|
||||
/**
|
||||
* The current state of this player.
|
||||
*/
|
||||
val state: Flow<State>
|
||||
|
||||
/**
|
||||
* Acquires control of the underlying [MediaPlayer] and prepares it
|
||||
* to play the media file.
|
||||
*
|
||||
* Will suspend whilst the media file is being downloaded and/or
|
||||
* the underlying [MediaPlayer] is loading the media file.
|
||||
*/
|
||||
suspend fun prepare(): Result<Unit>
|
||||
|
||||
/**
|
||||
* Play the media.
|
||||
*/
|
||||
fun play()
|
||||
|
||||
/**
|
||||
* Pause playback.
|
||||
*/
|
||||
fun pause()
|
||||
|
||||
/**
|
||||
* Seek to a specific position.
|
||||
*
|
||||
* @param positionMs The position in milliseconds.
|
||||
*/
|
||||
fun seekTo(positionMs: Long)
|
||||
|
||||
data class State(
|
||||
/**
|
||||
* Whether the player is ready to play.
|
||||
*/
|
||||
val isReady: Boolean,
|
||||
/**
|
||||
* Whether this player is currently playing.
|
||||
*/
|
||||
val isPlaying: Boolean,
|
||||
/**
|
||||
* Whether the player has reached the end of the media.
|
||||
*/
|
||||
val isEnded: Boolean,
|
||||
/**
|
||||
* The elapsed time of this player in milliseconds.
|
||||
*/
|
||||
val currentPosition: Long,
|
||||
/**
|
||||
* The duration of the current content, if available.
|
||||
*/
|
||||
val duration: Long?,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* An implementation of [VoiceMessagePlayer] which is backed by a
|
||||
* [VoiceMessageMediaRepo] to fetch and cache the media file and
|
||||
* which uses a global [MediaPlayer] instance to play the media.
|
||||
*/
|
||||
class DefaultVoiceMessagePlayer(
|
||||
private val mediaPlayer: MediaPlayer,
|
||||
voiceMessageMediaRepoFactory: VoiceMessageMediaRepo.Factory,
|
||||
private val eventId: EventId?,
|
||||
mediaSource: MediaSource,
|
||||
mimeType: String?,
|
||||
filename: String?,
|
||||
) : VoiceMessagePlayer {
|
||||
@ContributesBinding(RoomScope::class) // Scoped types can't use @AssistedInject.
|
||||
class Factory @Inject constructor(
|
||||
private val mediaPlayer: MediaPlayer,
|
||||
private val voiceMessageMediaRepoFactory: VoiceMessageMediaRepo.Factory,
|
||||
) : VoiceMessagePlayer.Factory {
|
||||
override fun create(
|
||||
eventId: EventId?,
|
||||
mediaSource: MediaSource,
|
||||
mimeType: String?,
|
||||
filename: String?,
|
||||
): DefaultVoiceMessagePlayer = DefaultVoiceMessagePlayer(
|
||||
mediaPlayer = mediaPlayer,
|
||||
voiceMessageMediaRepoFactory = voiceMessageMediaRepoFactory,
|
||||
eventId = eventId,
|
||||
mediaSource = mediaSource,
|
||||
mimeType = mimeType,
|
||||
filename = filename,
|
||||
)
|
||||
}
|
||||
|
||||
private val repo = voiceMessageMediaRepoFactory.create(
|
||||
mediaSource = mediaSource,
|
||||
mimeType = mimeType,
|
||||
filename = filename,
|
||||
)
|
||||
|
||||
private var internalState = MutableStateFlow(
|
||||
VoiceMessagePlayer.State(
|
||||
isReady = false,
|
||||
isPlaying = false,
|
||||
isEnded = false,
|
||||
currentPosition = 0L,
|
||||
duration = null
|
||||
)
|
||||
)
|
||||
|
||||
override val state: Flow<VoiceMessagePlayer.State> = combine(mediaPlayer.state, internalState) { mediaPlayerState, internalState ->
|
||||
if (mediaPlayerState.isMyTrack) {
|
||||
this.internalState.update {
|
||||
it.copy(
|
||||
isReady = mediaPlayerState.isReady,
|
||||
isPlaying = mediaPlayerState.isPlaying,
|
||||
isEnded = mediaPlayerState.isEnded,
|
||||
currentPosition = mediaPlayerState.currentPosition,
|
||||
duration = mediaPlayerState.duration,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
this.internalState.update {
|
||||
it.copy(
|
||||
isReady = false,
|
||||
isPlaying = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
VoiceMessagePlayer.State(
|
||||
isReady = internalState.isReady,
|
||||
isPlaying = internalState.isPlaying,
|
||||
isEnded = internalState.isEnded,
|
||||
currentPosition = internalState.currentPosition,
|
||||
duration = internalState.duration,
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
override suspend fun prepare(): Result<Unit> = if (eventId != null) {
|
||||
repo.getMediaFile().mapCatching<Unit, File> { mediaFile ->
|
||||
val state = internalState.value
|
||||
mediaPlayer.setMedia(
|
||||
uri = mediaFile.path,
|
||||
mediaId = eventId.value,
|
||||
// Files in the voice cache have no extension so we need to set the mime type manually.
|
||||
mimeType = MimeTypes.Ogg,
|
||||
startPositionMs = if (state.isEnded) 0L else state.currentPosition,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Result.failure(IllegalStateException("Cannot acquireControl on a voice message with no eventId"))
|
||||
}
|
||||
|
||||
override fun play() {
|
||||
if (inControl()) {
|
||||
mediaPlayer.play()
|
||||
}
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
if (inControl()) {
|
||||
mediaPlayer.pause()
|
||||
}
|
||||
}
|
||||
|
||||
override fun seekTo(positionMs: Long) {
|
||||
if (inControl()) {
|
||||
mediaPlayer.seekTo(positionMs)
|
||||
} else {
|
||||
internalState.update {
|
||||
it.copy(currentPosition = positionMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val MediaPlayer.State.isMyTrack: Boolean
|
||||
get() = if (eventId == null) false else this.mediaId == eventId.value
|
||||
|
||||
private fun inControl(): Boolean = mediaPlayer.state.value.let {
|
||||
it.isMyTrack && (it.isReady || it.isEnded)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,11 +8,6 @@
|
|||
package io.element.android.features.messages.impl.voicemessages.timeline
|
||||
|
||||
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 dagger.Binds
|
||||
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.TimelineItemPresenterFactory
|
||||
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.runUpdatingState
|
||||
import io.element.android.libraries.core.extensions.flatMap
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.ui.utils.time.formatShort
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessagePresenterFactory
|
||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||
|
||||
@Module
|
||||
@ContributesTo(RoomScope::class)
|
||||
|
|
@ -45,9 +33,7 @@ interface VoiceMessagePresenterModule {
|
|||
}
|
||||
|
||||
class VoiceMessagePresenter @AssistedInject constructor(
|
||||
voiceMessagePlayerFactory: VoiceMessagePlayer.Factory,
|
||||
private val analyticsService: AnalyticsService,
|
||||
private val scope: CoroutineScope,
|
||||
voiceMessagePresenterFactory: VoiceMessagePresenterFactory,
|
||||
@Assisted private val content: TimelineItemVoiceContent,
|
||||
) : Presenter<VoiceMessageState> {
|
||||
@AssistedFactory
|
||||
|
|
@ -55,97 +41,16 @@ class VoiceMessagePresenter @AssistedInject constructor(
|
|||
override fun create(content: TimelineItemVoiceContent): VoiceMessagePresenter
|
||||
}
|
||||
|
||||
private val player = voiceMessagePlayerFactory.create(
|
||||
private val presenter = voiceMessagePresenterFactory.createVoiceMessagePresenter(
|
||||
eventId = content.eventId,
|
||||
mediaSource = content.mediaSource,
|
||||
mimeType = content.mimeType,
|
||||
filename = content.filename,
|
||||
duration = content.duration,
|
||||
)
|
||||
|
||||
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 {
|
||||
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) },
|
||||
)
|
||||
return presenter.present()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +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.features.messages.impl.voicemessages.timeline
|
||||
|
||||
data class VoiceMessageState(
|
||||
val button: Button,
|
||||
val progress: Float,
|
||||
val time: String,
|
||||
val showCursor: Boolean,
|
||||
val eventSink: (event: VoiceMessageEvents) -> Unit,
|
||||
) {
|
||||
enum class Button {
|
||||
Play,
|
||||
Pause,
|
||||
Downloading,
|
||||
Retry,
|
||||
Disabled,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,56 +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.features.messages.impl.voicemessages.timeline
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
|
||||
open class VoiceMessageStateProvider : PreviewParameterProvider<VoiceMessageState> {
|
||||
override val values: Sequence<VoiceMessageState>
|
||||
get() = sequenceOf(
|
||||
aVoiceMessageState(
|
||||
VoiceMessageState.Button.Downloading,
|
||||
progress = 0f,
|
||||
time = "0:00",
|
||||
),
|
||||
aVoiceMessageState(
|
||||
VoiceMessageState.Button.Retry,
|
||||
progress = 0.5f,
|
||||
time = "0:01",
|
||||
),
|
||||
aVoiceMessageState(
|
||||
VoiceMessageState.Button.Play,
|
||||
progress = 1f,
|
||||
time = "1:00",
|
||||
showCursor = true,
|
||||
),
|
||||
aVoiceMessageState(
|
||||
VoiceMessageState.Button.Pause,
|
||||
progress = 0.2f,
|
||||
time = "10:00",
|
||||
showCursor = true,
|
||||
),
|
||||
aVoiceMessageState(
|
||||
VoiceMessageState.Button.Disabled,
|
||||
progress = 0.2f,
|
||||
time = "30:00",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun aVoiceMessageState(
|
||||
button: VoiceMessageState.Button = VoiceMessageState.Button.Play,
|
||||
progress: Float = 0f,
|
||||
time: String = "1:00",
|
||||
showCursor: Boolean = false,
|
||||
) = VoiceMessageState(
|
||||
button = button,
|
||||
progress = progress,
|
||||
time = time,
|
||||
showCursor = showCursor,
|
||||
eventSink = {},
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue