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 -> private var mediaPath: String? = null
if (lastPlayedMediaPath == null || lastPlayedMediaPath != state.mediaId) {
return@map State.NotLoaded 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( InternalState(
isPlaying = state.isPlaying, 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()
/**
* 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. * Start playing from the current position.
* *
* @param mediaPath The path to the media to be played. * Call [setMedia] before calling this method.
* @param mimeType The mime type of the media file.
*/ */
suspend fun play(mediaPath: String, mimeType: String) { suspend fun play() {
if (mediaPath == curPlayingMediaId) { val mediaPath = this.mediaPath
mediaPlayer.play() if (mediaPath == null) {
} else { Timber.e("Set media before playing")
lastPlayedMediaPath = mediaPath return
mediaPlayer.setMedia(
uri = mediaPath,
mediaId = mediaPath,
mimeType = mimeType,
)
mediaPlayer.play()
} }
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()
} }
} }
/**
* 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( 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. * 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, val currentPosition: Long,
/** val duration: Long?,
* The duration of this player in milliseconds. val seekingTo: Float?,
*/
val duration: Long,
) { ) {
companion object { companion object {
val NotLoaded = State( val NotLoaded = InternalState(
isPlaying = false, playState = PlayState.Stopped,
currentPosition = 0L, 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
} }
} }

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) { localCoroutineScope.launch {
VoiceMessagePlayerEvent.Play -> when (event) {
when (val recording = recorderState) { VoiceMessagePlayerEvent.Play -> player.play()
is VoiceRecorderState.Finished -> VoiceMessagePlayerEvent.Pause -> player.pause()
localCoroutineScope.launch { is VoiceMessagePlayerEvent.Seek -> player.seek(event.position)
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 onAcceptPermissionsRationale = { val onAcceptPermissionsRationale = {
permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog) permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog)
@ -189,16 +182,14 @@ 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(
isSending = isSending,
isPlaying = playerState.isPlaying,
showCursor = playerState.isLoaded && !isSending,
playbackProgress = playerState.progress,
time = playerTime,
waveform = waveform,
) )
is VoiceRecorderState.Finished ->
previewState(
playerState = playerState,
recorderState = recorderState,
isSending = isSending
)
else -> VoiceMessageState.Idle else -> VoiceMessageState.Idle
}, },
showPermissionRationaleDialog = permissionState.showDialog, 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 { 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.