Enable seeking a recorded voice message (#1758)
This commit is contained in:
@@ -17,96 +17,233 @@
|
||||
package io.element.android.features.messages.impl.voicemessages.composer
|
||||
|
||||
import io.element.android.libraries.mediaplayer.api.MediaPlayer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.scan
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* A media player for the voice message composer.
|
||||
*
|
||||
* @param mediaPlayer The [MediaPlayer] to use.
|
||||
* @param coroutineScope
|
||||
*/
|
||||
class VoiceMessageComposerPlayer @Inject constructor(
|
||||
private val mediaPlayer: MediaPlayer,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
) {
|
||||
private var lastPlayedMediaPath: String? = null
|
||||
private val curPlayingMediaId
|
||||
get() = mediaPlayer.state.value.mediaId
|
||||
companion object {
|
||||
const val MIME_TYPE = "audio/ogg"
|
||||
}
|
||||
|
||||
val state: Flow<State> = mediaPlayer.state.map { state ->
|
||||
if (lastPlayedMediaPath == null || lastPlayedMediaPath != state.mediaId) {
|
||||
return@map State.NotLoaded
|
||||
private var mediaPath: String? = null
|
||||
|
||||
private var seekJob: Job? = null
|
||||
private val seekingTo = MutableStateFlow<Float?>(null)
|
||||
|
||||
val state: Flow<State> = combine(mediaPlayer.state, seekingTo) { state, seekingTo ->
|
||||
state to seekingTo
|
||||
}.scan(InternalState.NotLoaded) { prevState, (state, seekingTo) ->
|
||||
if (mediaPath == null || mediaPath != state.mediaId) {
|
||||
return@scan InternalState.NotLoaded
|
||||
}
|
||||
|
||||
State(
|
||||
isPlaying = state.isPlaying,
|
||||
InternalState(
|
||||
playState = calcPlayState(prevState.playState, seekingTo, state),
|
||||
currentPosition = state.currentPosition,
|
||||
duration = state.duration ?: 0L,
|
||||
duration = state.duration,
|
||||
seekingTo = seekingTo,
|
||||
)
|
||||
}.map {
|
||||
State(
|
||||
playState = it.playState,
|
||||
currentPosition = it.currentPosition,
|
||||
progress = calcProgress(it),
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
/**
|
||||
* Set the voice message to be played.
|
||||
*/
|
||||
suspend fun setMedia(mediaPath: String) {
|
||||
this.mediaPath = mediaPath
|
||||
mediaPlayer.setMedia(
|
||||
uri = mediaPath,
|
||||
mediaId = mediaPath,
|
||||
mimeType = MIME_TYPE,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start playing from the current position.
|
||||
*
|
||||
* @param mediaPath The path to the media to be played.
|
||||
* @param mimeType The mime type of the media file.
|
||||
* Call [setMedia] before calling this method.
|
||||
*/
|
||||
suspend fun play(mediaPath: String, mimeType: String) {
|
||||
if (mediaPath == curPlayingMediaId) {
|
||||
mediaPlayer.play()
|
||||
} else {
|
||||
lastPlayedMediaPath = mediaPath
|
||||
mediaPlayer.setMedia(
|
||||
uri = mediaPath,
|
||||
mediaId = mediaPath,
|
||||
mimeType = mimeType,
|
||||
)
|
||||
mediaPlayer.play()
|
||||
suspend fun play() {
|
||||
val mediaPath = this.mediaPath
|
||||
if (mediaPath == null) {
|
||||
Timber.e("Set media before playing")
|
||||
return
|
||||
}
|
||||
|
||||
mediaPlayer.ensureMediaReady(mediaPath)
|
||||
|
||||
mediaPlayer.play()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause playback.
|
||||
*/
|
||||
fun pause() {
|
||||
if (lastPlayedMediaPath == curPlayingMediaId) {
|
||||
if (mediaPath == mediaPlayer.state.value.mediaId) {
|
||||
mediaPlayer.pause()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek to a given position in the current media.
|
||||
*
|
||||
* Call [setMedia] before calling this method.
|
||||
*
|
||||
* @param position The position to seek to between 0 and 1.
|
||||
*/
|
||||
suspend fun seek(position: Float) {
|
||||
val mediaPath = this.mediaPath
|
||||
if (mediaPath == null) {
|
||||
Timber.e("Set media before seeking")
|
||||
return
|
||||
}
|
||||
|
||||
seekJob?.cancelAndJoin()
|
||||
seekingTo.value = position
|
||||
seekJob = coroutineScope.launch {
|
||||
val mediaState = mediaPlayer.ensureMediaReady(mediaPath)
|
||||
val duration = mediaState.duration ?: return@launch
|
||||
val positionMs = (duration * position).toLong()
|
||||
mediaPlayer.seekTo(positionMs)
|
||||
}.apply {
|
||||
invokeOnCompletion {
|
||||
seekingTo.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun MediaPlayer.ensureMediaReady(mediaPath: String): MediaPlayer.State {
|
||||
val state = state.value
|
||||
if (state.mediaId == mediaPath && state.isReady) {
|
||||
return state
|
||||
}
|
||||
|
||||
return setMedia(
|
||||
uri = mediaPath,
|
||||
mediaId = mediaPath,
|
||||
mimeType = MIME_TYPE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun calcPlayState(prevPlayState: PlayState, seekingTo: Float?, state: MediaPlayer.State): PlayState {
|
||||
if (state.mediaId == null || state.mediaId != mediaPath) {
|
||||
return PlayState.Stopped
|
||||
}
|
||||
|
||||
// If we were stopped and the player didn't start playing or seeking, we are still stopped.
|
||||
if (prevPlayState == PlayState.Stopped && !state.isPlaying && seekingTo == null) {
|
||||
return PlayState.Stopped
|
||||
}
|
||||
|
||||
return if (state.isPlaying) {
|
||||
PlayState.Playing
|
||||
} else {
|
||||
PlayState.Paused
|
||||
}
|
||||
}
|
||||
|
||||
private fun calcProgress(state: InternalState): Float {
|
||||
if (state.seekingTo != null) {
|
||||
return state.seekingTo
|
||||
}
|
||||
|
||||
if (state.playState == PlayState.Stopped) {
|
||||
return 0f
|
||||
}
|
||||
|
||||
if (state.duration == null) {
|
||||
return 0f
|
||||
}
|
||||
|
||||
return (state.currentPosition.toFloat() / state.duration.toFloat())
|
||||
.coerceAtMost(1f) // Current position may exceed reported duration
|
||||
}
|
||||
|
||||
/**
|
||||
* @property playState Whether this player is currently playing. See [PlayState].
|
||||
* @property currentPosition The elapsed time of this player in milliseconds.
|
||||
* @property progress The progress of this player between 0 and 1.
|
||||
*/
|
||||
data class State(
|
||||
val playState: PlayState,
|
||||
val currentPosition: Long,
|
||||
val progress: Float,
|
||||
) {
|
||||
|
||||
companion object {
|
||||
val Initial = State(
|
||||
playState = PlayState.Stopped,
|
||||
currentPosition = 0L,
|
||||
progress = 0f,
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Whether this player is currently playing.
|
||||
*/
|
||||
val isPlaying: Boolean,
|
||||
val isPlaying get() = this.playState == PlayState.Playing
|
||||
|
||||
/**
|
||||
* The elapsed time of this player in milliseconds.
|
||||
* Whether this player is currently stopped.
|
||||
*/
|
||||
val isStopped get() = this.playState == PlayState.Stopped
|
||||
}
|
||||
|
||||
|
||||
enum class PlayState {
|
||||
/**
|
||||
* The player is stopped, i.e. it has just been initialised.
|
||||
*/
|
||||
Stopped,
|
||||
|
||||
/**
|
||||
* The player is playing.
|
||||
*/
|
||||
Playing,
|
||||
|
||||
/**
|
||||
* The player has been paused. The player can also enter the paused state after seeking to a position.
|
||||
*/
|
||||
Paused,
|
||||
}
|
||||
|
||||
private data class InternalState(
|
||||
val playState: PlayState,
|
||||
val currentPosition: Long,
|
||||
/**
|
||||
* The duration of this player in milliseconds.
|
||||
*/
|
||||
val duration: Long,
|
||||
val duration: Long?,
|
||||
val seekingTo: Float?,
|
||||
) {
|
||||
companion object {
|
||||
val NotLoaded = State(
|
||||
isPlaying = false,
|
||||
val NotLoaded = InternalState(
|
||||
playState = PlayState.Stopped,
|
||||
currentPosition = 0L,
|
||||
duration = 0L,
|
||||
duration = null,
|
||||
seekingTo = null,
|
||||
)
|
||||
}
|
||||
|
||||
val isLoaded get() = this != NotLoaded
|
||||
|
||||
/**
|
||||
* The progress of this player between 0 and 1.
|
||||
*/
|
||||
val progress: Float =
|
||||
if (duration == 0L)
|
||||
0f
|
||||
else
|
||||
(currentPosition.toFloat() / duration.toFloat())
|
||||
.coerceAtMost(1f) // Current position may exceed reported duration
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.voicemessages.composer
|
||||
|
||||
import android.Manifest
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -69,13 +70,17 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||
override fun present(): VoiceMessageComposerState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val recorderState by voiceRecorder.state.collectAsState(initial = VoiceRecorderState.Idle)
|
||||
val playerState by player.state.collectAsState(initial = VoiceMessageComposerPlayer.State.Initial)
|
||||
val keepScreenOn by remember { derivedStateOf { recorderState is VoiceRecorderState.Recording } }
|
||||
|
||||
val permissionState = permissionsPresenter.present()
|
||||
var isSending by remember { mutableStateOf(false) }
|
||||
val playerState by player.state.collectAsState(initial = VoiceMessageComposerPlayer.State.NotLoaded)
|
||||
val playerTime by remember(playerState, recorderState) { derivedStateOf { displayTime(playerState, recorderState) } }
|
||||
val waveform by remember(recorderState) { derivedStateOf { recorderState.finishedWaveform() } }
|
||||
|
||||
LaunchedEffect(recorderState) {
|
||||
val recording = recorderState as? VoiceRecorderState.Finished
|
||||
?: return@LaunchedEffect
|
||||
player.setMedia(recording.file.path)
|
||||
}
|
||||
|
||||
val onLifecycleEvent = { event: Lifecycle.Event ->
|
||||
when (event) {
|
||||
@@ -115,27 +120,15 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
val onPlayerEvent = { event: VoiceMessagePlayerEvent ->
|
||||
when (event) {
|
||||
VoiceMessagePlayerEvent.Play ->
|
||||
when (val recording = recorderState) {
|
||||
is VoiceRecorderState.Finished ->
|
||||
localCoroutineScope.launch {
|
||||
player.play(
|
||||
mediaPath = recording.file.path,
|
||||
mimeType = recording.mimeType,
|
||||
)
|
||||
}
|
||||
else -> Timber.e("Voice message player event received but no file to play")
|
||||
}
|
||||
VoiceMessagePlayerEvent.Pause -> {
|
||||
player.pause()
|
||||
}
|
||||
is VoiceMessagePlayerEvent.Seek -> {
|
||||
// TODO implement seeking
|
||||
val onPlayerEvent = { event: VoiceMessagePlayerEvent -> localCoroutineScope.launch {
|
||||
localCoroutineScope.launch {
|
||||
when (event) {
|
||||
VoiceMessagePlayerEvent.Play -> player.play()
|
||||
VoiceMessagePlayerEvent.Pause -> player.pause()
|
||||
is VoiceMessagePlayerEvent.Seek -> player.seek(event.position)
|
||||
}
|
||||
}
|
||||
}
|
||||
} }
|
||||
|
||||
val onAcceptPermissionsRationale = {
|
||||
permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog)
|
||||
@@ -189,16 +182,14 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||
voiceMessageState = when (val state = recorderState) {
|
||||
is VoiceRecorderState.Recording -> VoiceMessageState.Recording(
|
||||
duration = state.elapsedTime,
|
||||
levels = state.levels.toPersistentList()
|
||||
)
|
||||
is VoiceRecorderState.Finished -> VoiceMessageState.Preview(
|
||||
isSending = isSending,
|
||||
isPlaying = playerState.isPlaying,
|
||||
showCursor = playerState.isLoaded && !isSending,
|
||||
playbackProgress = playerState.progress,
|
||||
time = playerTime,
|
||||
waveform = waveform,
|
||||
levels = state.levels.toPersistentList(),
|
||||
)
|
||||
is VoiceRecorderState.Finished ->
|
||||
previewState(
|
||||
playerState = playerState,
|
||||
recorderState = recorderState,
|
||||
isSending = isSending
|
||||
)
|
||||
else -> VoiceMessageState.Idle
|
||||
},
|
||||
showPermissionRationaleDialog = permissionState.showDialog,
|
||||
@@ -207,6 +198,26 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun previewState(
|
||||
playerState: VoiceMessageComposerPlayer.State,
|
||||
recorderState: VoiceRecorderState,
|
||||
isSending: Boolean,
|
||||
): VoiceMessageState {
|
||||
val showCursor by remember(playerState.isStopped, isSending) { derivedStateOf { !playerState.isStopped && !isSending }}
|
||||
val playerTime by remember(playerState, recorderState) { derivedStateOf { displayTime(playerState, recorderState) } }
|
||||
val waveform by remember(recorderState) { derivedStateOf { recorderState.finishedWaveform() } }
|
||||
|
||||
return VoiceMessageState.Preview(
|
||||
isSending = isSending,
|
||||
isPlaying = playerState.isPlaying,
|
||||
showCursor = showCursor,
|
||||
playbackProgress = playerState.progress,
|
||||
time = playerTime,
|
||||
waveform = waveform,
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.startRecording() = launch {
|
||||
try {
|
||||
voiceRecorder.startRecord()
|
||||
@@ -248,7 +259,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||
}
|
||||
|
||||
private fun AnalyticsService.captureComposerEvent() =
|
||||
analyticsService.capture(
|
||||
capture(
|
||||
Composer(
|
||||
inThread = messageComposerContext.composerMode.inThread,
|
||||
isEditing = messageComposerContext.composerMode.isEditing,
|
||||
@@ -273,7 +284,7 @@ private fun displayTime(
|
||||
playerState: VoiceMessageComposerPlayer.State,
|
||||
recording: VoiceRecorderState
|
||||
): Duration = when {
|
||||
playerState.isLoaded ->
|
||||
!playerState.isStopped ->
|
||||
playerState.currentPosition.milliseconds
|
||||
recording is VoiceRecorderState.Finished ->
|
||||
recording.duration
|
||||
|
||||
@@ -154,7 +154,7 @@ class DefaultVoiceMessagePlayer(
|
||||
mediaPlayer.setMedia(
|
||||
uri = mediaFile.path,
|
||||
mediaId = eventId.value,
|
||||
mimeType = "audio/ogg" // Files in the voice cache have no extension so we need to set the mime type manually.
|
||||
mimeType = "audio/ogg", // Files in the voice cache have no extension so we need to set the mime type manually.
|
||||
)
|
||||
mediaPlayer.play()
|
||||
}
|
||||
|
||||
@@ -641,7 +641,7 @@ class MessagesPresenterTest {
|
||||
FakeVoiceRecorder(),
|
||||
analyticsService,
|
||||
mediaSender,
|
||||
player = VoiceMessageComposerPlayer(FakeMediaPlayer()),
|
||||
player = VoiceMessageComposerPlayer(FakeMediaPlayer(), this),
|
||||
messageComposerContext = FakeMessageComposerContext(),
|
||||
permissionsPresenterFactory,
|
||||
)
|
||||
|
||||
@@ -35,8 +35,8 @@ import io.element.android.features.messages.test.FakeMessageComposerContext
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
|
||||
import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||
@@ -195,7 +195,6 @@ class VoiceMessageComposerPresenterTest {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
||||
awaitItem().apply { assertThat(voiceMessageState).isEqualTo(aLoadedState()) }
|
||||
val finalState = awaitItem().also {
|
||||
assertThat(it.voiceMessageState).isEqualTo(aPlayingState())
|
||||
}
|
||||
@@ -214,7 +213,6 @@ class VoiceMessageComposerPresenterTest {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
||||
skipItems(1) // Loaded state
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Pause))
|
||||
val finalState = awaitItem().also {
|
||||
assertThat(it.voiceMessageState).isEqualTo(aPausedState())
|
||||
@@ -225,6 +223,33 @@ class VoiceMessageComposerPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - seek recording`() = runTest {
|
||||
val presenter = createVoiceMessageComposerPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.5f)))
|
||||
awaitItem().apply {
|
||||
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 0.seconds, showCursor = true))
|
||||
}
|
||||
awaitItem().apply {
|
||||
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.5f, time = 5.seconds, showCursor = true))
|
||||
eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Seek(0.2f)))
|
||||
}
|
||||
awaitItem().apply {
|
||||
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.2f, time = 5.seconds, showCursor = true))
|
||||
}
|
||||
val finalState = awaitItem().apply {
|
||||
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.2f, time = 2.seconds, showCursor = true))
|
||||
}
|
||||
|
||||
testPauseAndDestroy(finalState)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - delete recording`() = runTest {
|
||||
val presenter = createVoiceMessageComposerPresenter()
|
||||
@@ -252,7 +277,6 @@ class VoiceMessageComposerPresenterTest {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
||||
skipItems(1) // Loaded state
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
|
||||
awaitItem().apply {
|
||||
assertThat(voiceMessageState).isEqualTo(aPausedState())
|
||||
@@ -324,9 +348,9 @@ class VoiceMessageComposerPresenterTest {
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
||||
skipItems(1) // Loaded state
|
||||
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||
assertThat(awaitItem().voiceMessageState).isEqualTo(aPlayingState().toSendingState())
|
||||
skipItems(1) // Duplicate sending state
|
||||
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||
@@ -603,7 +627,7 @@ class VoiceMessageComposerPresenterTest {
|
||||
voiceRecorder,
|
||||
analyticsService,
|
||||
mediaSender,
|
||||
player = VoiceMessageComposerPlayer(FakeMediaPlayer()),
|
||||
player = VoiceMessageComposerPlayer(FakeMediaPlayer(), this),
|
||||
messageComposerContext = messageComposerContext,
|
||||
FakePermissionsPresenterFactory(permissionsPresenter),
|
||||
)
|
||||
@@ -639,14 +663,6 @@ class VoiceMessageComposerPresenterTest {
|
||||
waveform = waveform.toImmutableList(),
|
||||
)
|
||||
|
||||
private fun aLoadedState() =
|
||||
aPreviewState(
|
||||
isPlaying = false,
|
||||
playbackProgress = 0.0f,
|
||||
showCursor = true,
|
||||
time = 0.seconds,
|
||||
)
|
||||
|
||||
private fun aPlayingState() =
|
||||
aPreviewState(
|
||||
isPlaying = true,
|
||||
|
||||
@@ -108,7 +108,7 @@ internal fun VoiceMessagePreview(
|
||||
playbackProgress = playbackProgress,
|
||||
showCursor = showCursor,
|
||||
waveform = waveform,
|
||||
seekEnabled = false, // TODO enable seeking
|
||||
seekEnabled = true,
|
||||
onSeek = onSeek,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
id("io.element.android-compose-library")
|
||||
alias(libs.plugins.anvil)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
|
||||
package io.element.android.libraries.voicerecorder.api
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import java.io.File
|
||||
import kotlin.time.Duration
|
||||
|
||||
@Immutable
|
||||
sealed interface VoiceRecorderState {
|
||||
/**
|
||||
* The recorder is idle and not recording.
|
||||
|
||||
Reference in New Issue
Block a user