Enable seeking a recorded voice message (#1758)

This commit is contained in:
jonnyandrew 2023-11-10 09:18:01 +00:00 committed by GitHub
parent 941c196dcf
commit 7a4adf3e28
8 changed files with 261 additions and 95 deletions

View file

@ -17,96 +17,233 @@
package io.element.android.features.messages.impl.voicemessages.composer package io.element.android.features.messages.impl.voicemessages.composer
import io.element.android.libraries.mediaplayer.api.MediaPlayer 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.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.scan
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
/** /**
* A media player for the voice message composer. * A media player for the voice message composer.
* *
* @param mediaPlayer The [MediaPlayer] to use. * @param mediaPlayer The [MediaPlayer] to use.
* @param coroutineScope
*/ */
class VoiceMessageComposerPlayer @Inject constructor( class VoiceMessageComposerPlayer @Inject constructor(
private val mediaPlayer: MediaPlayer, private val mediaPlayer: MediaPlayer,
private val coroutineScope: CoroutineScope,
) { ) {
private var lastPlayedMediaPath: String? = null companion object {
private val curPlayingMediaId const val MIME_TYPE = "audio/ogg"
get() = mediaPlayer.state.value.mediaId
val state: Flow<State> = mediaPlayer.state.map { state ->
if (lastPlayedMediaPath == null || lastPlayedMediaPath != state.mediaId) {
return@map State.NotLoaded
} }
State( private var mediaPath: String? = null
isPlaying = state.isPlaying,
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
}
InternalState(
playState = calcPlayState(prevState.playState, seekingTo, state),
currentPosition = state.currentPosition, currentPosition = state.currentPosition,
duration = state.duration ?: 0L, duration = state.duration,
seekingTo = seekingTo,
)
}.map {
State(
playState = it.playState,
currentPosition = it.currentPosition,
progress = calcProgress(it),
) )
}.distinctUntilChanged() }.distinctUntilChanged()
/** /**
* Start playing from the current position. * Set the voice message to be played.
*
* @param mediaPath The path to the media to be played.
* @param mimeType The mime type of the media file.
*/ */
suspend fun play(mediaPath: String, mimeType: String) { suspend fun setMedia(mediaPath: String) {
if (mediaPath == curPlayingMediaId) { this.mediaPath = mediaPath
mediaPlayer.play()
} else {
lastPlayedMediaPath = mediaPath
mediaPlayer.setMedia( mediaPlayer.setMedia(
uri = mediaPath, uri = mediaPath,
mediaId = mediaPath, mediaId = mediaPath,
mimeType = mimeType, mimeType = MIME_TYPE,
) )
mediaPlayer.play()
} }
/**
* Start playing from the current position.
*
* Call [setMedia] before calling this method.
*/
suspend fun play() {
val mediaPath = this.mediaPath
if (mediaPath == null) {
Timber.e("Set media before playing")
return
}
mediaPlayer.ensureMediaReady(mediaPath)
mediaPlayer.play()
} }
/** /**
* Pause playback. * Pause playback.
*/ */
fun pause() { fun pause() {
if (lastPlayedMediaPath == curPlayingMediaId) { if (mediaPath == mediaPlayer.state.value.mediaId) {
mediaPlayer.pause() mediaPlayer.pause()
} }
} }
data class State(
/** /**
* Whether this player is currently playing. * 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.
*/ */
val isPlaying: Boolean, suspend fun seek(position: Float) {
/** val mediaPath = this.mediaPath
* The elapsed time of this player in milliseconds. if (mediaPath == null) {
*/ Timber.e("Set media before seeking")
val currentPosition: Long, return
/** }
* The duration of this player in milliseconds.
*/ seekJob?.cancelAndJoin()
val duration: Long, seekingTo.value = position
) { seekJob = coroutineScope.launch {
companion object { val mediaState = mediaPlayer.ensureMediaReady(mediaPath)
val NotLoaded = State( val duration = mediaState.duration ?: return@launch
isPlaying = false, val positionMs = (duration * position).toLong()
currentPosition = 0L, mediaPlayer.seekTo(positionMs)
duration = 0L, }.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,
) )
} }
val isLoaded get() = this != NotLoaded 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.
* The progress of this player between 0 and 1. if (prevPlayState == PlayState.Stopped && !state.isPlaying && seekingTo == null) {
*/ return PlayState.Stopped
val progress: Float = }
if (duration == 0L)
0f return if (state.isPlaying) {
else PlayState.Playing
(currentPosition.toFloat() / duration.toFloat()) } 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 .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 get() = this.playState == PlayState.Playing
/**
* 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,
val duration: Long?,
val seekingTo: Float?,
) {
companion object {
val NotLoaded = InternalState(
playState = PlayState.Stopped,
currentPosition = 0L,
duration = null,
seekingTo = null,
)
}
}
} }

View file

@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.voicemessages.composer
import android.Manifest import android.Manifest
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -69,13 +70,17 @@ class VoiceMessageComposerPresenter @Inject constructor(
override fun present(): VoiceMessageComposerState { override fun present(): VoiceMessageComposerState {
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val recorderState by voiceRecorder.state.collectAsState(initial = VoiceRecorderState.Idle) 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 keepScreenOn by remember { derivedStateOf { recorderState is VoiceRecorderState.Recording } }
val permissionState = permissionsPresenter.present() val permissionState = permissionsPresenter.present()
var isSending by remember { mutableStateOf(false) } 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) } } LaunchedEffect(recorderState) {
val waveform by remember(recorderState) { derivedStateOf { recorderState.finishedWaveform() } } val recording = recorderState as? VoiceRecorderState.Finished
?: return@LaunchedEffect
player.setMedia(recording.file.path)
}
val onLifecycleEvent = { event: Lifecycle.Event -> val onLifecycleEvent = { event: Lifecycle.Event ->
when (event) { when (event) {
@ -115,27 +120,15 @@ class VoiceMessageComposerPresenter @Inject constructor(
} }
} }
} }
val onPlayerEvent = { event: VoiceMessagePlayerEvent -> val onPlayerEvent = { event: VoiceMessagePlayerEvent -> localCoroutineScope.launch {
when (event) {
VoiceMessagePlayerEvent.Play ->
when (val recording = recorderState) {
is VoiceRecorderState.Finished ->
localCoroutineScope.launch { localCoroutineScope.launch {
player.play( when (event) {
mediaPath = recording.file.path, VoiceMessagePlayerEvent.Play -> player.play()
mimeType = recording.mimeType, VoiceMessagePlayerEvent.Pause -> player.pause()
) is VoiceMessagePlayerEvent.Seek -> player.seek(event.position)
}
else -> Timber.e("Voice message player event received but no file to play")
}
VoiceMessagePlayerEvent.Pause -> {
player.pause()
}
is VoiceMessagePlayerEvent.Seek -> {
// TODO implement seeking
}
} }
} }
} }
val onAcceptPermissionsRationale = { val onAcceptPermissionsRationale = {
permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog) permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog)
@ -189,15 +182,13 @@ class VoiceMessageComposerPresenter @Inject constructor(
voiceMessageState = when (val state = recorderState) { voiceMessageState = when (val state = recorderState) {
is VoiceRecorderState.Recording -> VoiceMessageState.Recording( is VoiceRecorderState.Recording -> VoiceMessageState.Recording(
duration = state.elapsedTime, duration = state.elapsedTime,
levels = state.levels.toPersistentList() levels = state.levels.toPersistentList(),
) )
is VoiceRecorderState.Finished -> VoiceMessageState.Preview( is VoiceRecorderState.Finished ->
isSending = isSending, previewState(
isPlaying = playerState.isPlaying, playerState = playerState,
showCursor = playerState.isLoaded && !isSending, recorderState = recorderState,
playbackProgress = playerState.progress, isSending = isSending
time = playerTime,
waveform = waveform,
) )
else -> VoiceMessageState.Idle else -> VoiceMessageState.Idle
}, },
@ -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 { private fun CoroutineScope.startRecording() = launch {
try { try {
voiceRecorder.startRecord() voiceRecorder.startRecord()
@ -248,7 +259,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
} }
private fun AnalyticsService.captureComposerEvent() = private fun AnalyticsService.captureComposerEvent() =
analyticsService.capture( capture(
Composer( Composer(
inThread = messageComposerContext.composerMode.inThread, inThread = messageComposerContext.composerMode.inThread,
isEditing = messageComposerContext.composerMode.isEditing, isEditing = messageComposerContext.composerMode.isEditing,
@ -273,7 +284,7 @@ private fun displayTime(
playerState: VoiceMessageComposerPlayer.State, playerState: VoiceMessageComposerPlayer.State,
recording: VoiceRecorderState recording: VoiceRecorderState
): Duration = when { ): Duration = when {
playerState.isLoaded -> !playerState.isStopped ->
playerState.currentPosition.milliseconds playerState.currentPosition.milliseconds
recording is VoiceRecorderState.Finished -> recording is VoiceRecorderState.Finished ->
recording.duration recording.duration

View file

@ -154,7 +154,7 @@ class DefaultVoiceMessagePlayer(
mediaPlayer.setMedia( mediaPlayer.setMedia(
uri = mediaFile.path, uri = mediaFile.path,
mediaId = eventId.value, 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() mediaPlayer.play()
} }

View file

@ -641,7 +641,7 @@ class MessagesPresenterTest {
FakeVoiceRecorder(), FakeVoiceRecorder(),
analyticsService, analyticsService,
mediaSender, mediaSender,
player = VoiceMessageComposerPlayer(FakeMediaPlayer()), player = VoiceMessageComposerPlayer(FakeMediaPlayer(), this),
messageComposerContext = FakeMessageComposerContext(), messageComposerContext = FakeMessageComposerContext(),
permissionsPresenterFactory, permissionsPresenterFactory,
) )

View file

@ -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.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_USER_NAME 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.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.api.MediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsPresenter 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.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play)) awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
awaitItem().apply { assertThat(voiceMessageState).isEqualTo(aLoadedState()) }
val finalState = awaitItem().also { val finalState = awaitItem().also {
assertThat(it.voiceMessageState).isEqualTo(aPlayingState()) assertThat(it.voiceMessageState).isEqualTo(aPlayingState())
} }
@ -214,7 +213,6 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play)) awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
skipItems(1) // Loaded state
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Pause)) awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Pause))
val finalState = awaitItem().also { val finalState = awaitItem().also {
assertThat(it.voiceMessageState).isEqualTo(aPausedState()) 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 @Test
fun `present - delete recording`() = runTest { fun `present - delete recording`() = runTest {
val presenter = createVoiceMessageComposerPresenter() val presenter = createVoiceMessageComposerPresenter()
@ -252,7 +277,6 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play)) awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
skipItems(1) // Loaded state
awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage) awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
awaitItem().apply { awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPausedState()) assertThat(voiceMessageState).isEqualTo(aPausedState())
@ -324,9 +348,9 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play)) awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
skipItems(1) // Loaded state
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage) awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
assertThat(awaitItem().voiceMessageState).isEqualTo(aPlayingState().toSendingState()) assertThat(awaitItem().voiceMessageState).isEqualTo(aPlayingState().toSendingState())
skipItems(1) // Duplicate sending state
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
@ -603,7 +627,7 @@ class VoiceMessageComposerPresenterTest {
voiceRecorder, voiceRecorder,
analyticsService, analyticsService,
mediaSender, mediaSender,
player = VoiceMessageComposerPlayer(FakeMediaPlayer()), player = VoiceMessageComposerPlayer(FakeMediaPlayer(), this),
messageComposerContext = messageComposerContext, messageComposerContext = messageComposerContext,
FakePermissionsPresenterFactory(permissionsPresenter), FakePermissionsPresenterFactory(permissionsPresenter),
) )
@ -639,14 +663,6 @@ class VoiceMessageComposerPresenterTest {
waveform = waveform.toImmutableList(), waveform = waveform.toImmutableList(),
) )
private fun aLoadedState() =
aPreviewState(
isPlaying = false,
playbackProgress = 0.0f,
showCursor = true,
time = 0.seconds,
)
private fun aPlayingState() = private fun aPlayingState() =
aPreviewState( aPreviewState(
isPlaying = true, isPlaying = true,

View file

@ -108,7 +108,7 @@ internal fun VoiceMessagePreview(
playbackProgress = playbackProgress, playbackProgress = playbackProgress,
showCursor = showCursor, showCursor = showCursor,
waveform = waveform, waveform = waveform,
seekEnabled = false, // TODO enable seeking seekEnabled = true,
onSeek = onSeek, onSeek = onSeek,
) )
} }

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
plugins { plugins {
id("io.element.android-library") id("io.element.android-compose-library")
alias(libs.plugins.anvil) alias(libs.plugins.anvil)
} }

View file

@ -16,9 +16,11 @@
package io.element.android.libraries.voicerecorder.api package io.element.android.libraries.voicerecorder.api
import androidx.compose.runtime.Immutable
import java.io.File import java.io.File
import kotlin.time.Duration import kotlin.time.Duration
@Immutable
sealed interface VoiceRecorderState { sealed interface VoiceRecorderState {
/** /**
* The recorder is idle and not recording. * The recorder is idle and not recording.