Browse Source
## Type of change - [x] Feature - [ ] Bugfix - [ ] Technical - [ ] Other : ## Content This PR consists of several macro-blocks separated by path/package: - `messages.impl.mediaplayer` : Global (room-wide) media player, now used only for voice messages but could be used for all media within EX in the future. It is backed by media3's exoplayer. Currently not unit-tested because mocking exoplayer is not trivial. - `messages.impl.voicemessages.play` : Business logic of a timeline voice message. This is all the logic that manages the voice message bubble. - `messages.impl.timeline.model` & `messages.impl.timeline.factories`: Timeline code that takes care of creating the `content` object for voice messages. - `messages.impl.timeline.components` : The actual View composable that shows the UI inside a voice message bubble. All the rest is just small related changes that must be done here and there in existing code. From a high level perspective this is how it works: - Voice messages are unlike other message bubbles because they carry state (i.e. playing, downloading...) so they have a Presenter managing this state. - Media content (i.e. the ogg file) of a voice message is downloaded from the rust SDK on first play then stored in a voice messages cache (see the `VoiceMessageCache` class, it is just a subdirectory in the app's cacheDir which is indexed by the matrix content uri). All further play attempts are done from the cache without hitting the rust SDK anymore. - Playback of the ogg file is handled with the `VoiceMessagePlayer` class which is basically a "view" of the global `MediaPlayer` that allow the voice message to only see the media player state belonging to its media content. - Drawing of the waveform is done with an OSS library wrapped in the `WaveformProgressIndicator` composable. Known issues: - The waveform has no position slider. - The waveform (and together with it the whole message bubble) is taller than the actual Figma design. - Swipe to reply for voice messages is disabled to avoid conflict with the audio scrubbing gesture (to reply to a voice message you have to use the long press menu). - The loading indicator is always shown (there is no delay). - Voice messages don't stop playing when redacted. ## Motivation and context https://github.com/vector-im/element-meta/issues/2083 ## Screenshots / GIFs Provided by Screenshot tests in the PR itself.pull/1637/head
Marco Romano
11 months ago
committed by
GitHub
107 changed files with 2115 additions and 12 deletions
@ -0,0 +1 @@
@@ -0,0 +1 @@
|
||||
Receive and play a voice message |
@ -0,0 +1,192 @@
@@ -0,0 +1,192 @@
|
||||
/* |
||||
* Copyright (c) 2023 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.messages.impl.mediaplayer |
||||
|
||||
import androidx.media3.common.MediaItem |
||||
import androidx.media3.common.Player |
||||
import com.squareup.anvil.annotations.ContributesBinding |
||||
import io.element.android.libraries.di.RoomScope |
||||
import io.element.android.libraries.di.SingleIn |
||||
import io.element.android.libraries.matrix.api.core.EventId |
||||
import kotlinx.coroutines.CoroutineScope |
||||
import kotlinx.coroutines.Dispatchers |
||||
import kotlinx.coroutines.Job |
||||
import kotlinx.coroutines.delay |
||||
import kotlinx.coroutines.flow.MutableStateFlow |
||||
import kotlinx.coroutines.flow.StateFlow |
||||
import kotlinx.coroutines.flow.asStateFlow |
||||
import kotlinx.coroutines.flow.update |
||||
import kotlinx.coroutines.launch |
||||
import javax.inject.Inject |
||||
|
||||
/** |
||||
* A media player for Element X. |
||||
*/ |
||||
interface MediaPlayer : AutoCloseable { |
||||
|
||||
/** |
||||
* The current state of the player. |
||||
*/ |
||||
val state: StateFlow<State> |
||||
|
||||
/** |
||||
* Acquires control of the player and starts playing the given media. |
||||
*/ |
||||
fun acquireControlAndPlay( |
||||
uri: String, |
||||
mediaId: String, |
||||
mimeType: String, |
||||
) |
||||
|
||||
/** |
||||
* Plays the current media. |
||||
*/ |
||||
fun play() |
||||
|
||||
/** |
||||
* Pauses the current media. |
||||
*/ |
||||
fun pause() |
||||
|
||||
/** |
||||
* Seeks the current media to the given position. |
||||
*/ |
||||
fun seekTo(positionMs: Long) |
||||
|
||||
/** |
||||
* Releases any resources associated with this player. |
||||
*/ |
||||
override fun close() |
||||
|
||||
data class State( |
||||
/** |
||||
* Whether the player is currently playing. |
||||
*/ |
||||
val isPlaying: Boolean, |
||||
/** |
||||
* The id of the media which is currently playing. |
||||
* |
||||
* NB: This is usually the string representation of the [EventId] of the event |
||||
* which contains the media. |
||||
*/ |
||||
val mediaId: String?, |
||||
/** |
||||
* The current position of the player. |
||||
*/ |
||||
val currentPosition: Long, |
||||
) |
||||
} |
||||
|
||||
/** |
||||
* Default implementation of [MediaPlayer] backed by a [SimplePlayer]. |
||||
*/ |
||||
@ContributesBinding(RoomScope::class) |
||||
@SingleIn(RoomScope::class) |
||||
class MediaPlayerImpl @Inject constructor( |
||||
private val player: SimplePlayer, |
||||
) : MediaPlayer { |
||||
|
||||
private val listener = object : SimplePlayer.Listener { |
||||
override fun onIsPlayingChanged(isPlaying: Boolean) { |
||||
_state.update { |
||||
it.copy( |
||||
currentPosition = player.currentPosition, |
||||
isPlaying = isPlaying, |
||||
) |
||||
} |
||||
if (isPlaying) { |
||||
job = scope.launch { updateCurrentPosition() } |
||||
} else { |
||||
job?.cancel() |
||||
} |
||||
} |
||||
|
||||
override fun onMediaItemTransition(mediaItem: MediaItem?) { |
||||
_state.update { |
||||
it.copy( |
||||
currentPosition = player.currentPosition, |
||||
mediaId = mediaItem?.mediaId, |
||||
) |
||||
} |
||||
} |
||||
} |
||||
|
||||
init { |
||||
player.addListener(listener) |
||||
} |
||||
|
||||
private val scope = CoroutineScope(Job() + Dispatchers.Main) |
||||
private var job: Job? = null |
||||
|
||||
private val _state = MutableStateFlow(MediaPlayer.State(false, null, 0L)) |
||||
|
||||
override val state: StateFlow<MediaPlayer.State> = _state.asStateFlow() |
||||
|
||||
override fun acquireControlAndPlay(uri: String, mediaId: String, mimeType: String) { |
||||
player.clearMediaItems() |
||||
player.setMediaItem( |
||||
MediaItem.Builder() |
||||
.setUri(uri) |
||||
.setMediaId(mediaId) |
||||
.setMimeType(mimeType) |
||||
.build() |
||||
) |
||||
player.prepare() |
||||
player.play() |
||||
} |
||||
|
||||
override fun play() { |
||||
if (player.playbackState == Player.STATE_ENDED) { |
||||
// There's a bug with some ogg files that somehow report to |
||||
// have no duration. |
||||
// With such files, once playback has ended once, calling |
||||
// player.seekTo(0) and then player.play() results in the |
||||
// player starting and stopping playing immediately effectively |
||||
// playing no sound. |
||||
// This is a workaround which will reload the media file. |
||||
player.getCurrentMediaItem()?.let { |
||||
player.setMediaItem(it) |
||||
player.prepare() |
||||
player.play() |
||||
} |
||||
} else { |
||||
player.play() |
||||
} |
||||
} |
||||
|
||||
override fun pause() { |
||||
player.pause() |
||||
} |
||||
|
||||
override fun seekTo(positionMs: Long) { |
||||
player.seekTo(positionMs) |
||||
} |
||||
|
||||
override fun close() { |
||||
player.release() |
||||
} |
||||
|
||||
private suspend fun updateCurrentPosition() { |
||||
while (true) { |
||||
if (!_state.value.isPlaying) return |
||||
delay(100) |
||||
_state.update { |
||||
it.copy(currentPosition = player.currentPosition) |
||||
} |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
|
||||
/* |
||||
* Copyright (c) 2023 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.messages.impl.mediaplayer |
||||
|
||||
import android.content.Context |
||||
import androidx.media3.common.MediaItem |
||||
import androidx.media3.common.Player |
||||
import androidx.media3.exoplayer.ExoPlayer |
||||
import com.squareup.anvil.annotations.ContributesTo |
||||
import dagger.Module |
||||
import dagger.Provides |
||||
import io.element.android.libraries.di.ApplicationContext |
||||
import io.element.android.libraries.di.RoomScope |
||||
|
||||
/** |
||||
* A subset of media3 [Player] that only exposes the few methods we need making it easier to mock. |
||||
*/ |
||||
interface SimplePlayer { |
||||
fun addListener(listener: Listener) |
||||
val currentPosition: Long |
||||
val playbackState: Int |
||||
fun clearMediaItems() |
||||
fun setMediaItem(mediaItem: MediaItem) |
||||
fun getCurrentMediaItem(): MediaItem? |
||||
fun prepare() |
||||
fun play() |
||||
fun pause() |
||||
fun seekTo(positionMs: Long) |
||||
fun release() |
||||
interface Listener { |
||||
fun onIsPlayingChanged(isPlaying: Boolean) |
||||
fun onMediaItemTransition(mediaItem: MediaItem?) |
||||
} |
||||
} |
||||
|
||||
@ContributesTo(RoomScope::class) |
||||
@Module |
||||
object SimplePlayerModule { |
||||
@Provides |
||||
fun simplePlayerProvider( |
||||
@ApplicationContext context: Context, |
||||
): SimplePlayer = SimplePlayerImpl(ExoPlayer.Builder(context).build()) |
||||
} |
||||
|
||||
/** |
||||
* Default implementation of [SimplePlayer] backed by a media3 [Player]. |
||||
*/ |
||||
class SimplePlayerImpl( |
||||
private val p: Player |
||||
) : SimplePlayer { |
||||
override fun addListener(listener: SimplePlayer.Listener) { |
||||
p.addListener(object : Player.Listener { |
||||
override fun onIsPlayingChanged(isPlaying: Boolean) = listener.onIsPlayingChanged(isPlaying) |
||||
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) = listener.onMediaItemTransition(mediaItem) |
||||
}) |
||||
} |
||||
|
||||
override val currentPosition: Long |
||||
get() = p.currentPosition |
||||
override val playbackState: Int |
||||
get() = p.playbackState |
||||
|
||||
override fun clearMediaItems() = p.clearMediaItems() |
||||
|
||||
override fun setMediaItem(mediaItem: MediaItem) = p.setMediaItem(mediaItem) |
||||
|
||||
override fun getCurrentMediaItem(): MediaItem? = p.currentMediaItem |
||||
|
||||
override fun prepare() = p.prepare() |
||||
|
||||
override fun play() = p.play() |
||||
|
||||
override fun pause() = p.pause() |
||||
|
||||
override fun seekTo(positionMs: Long) = p.seekTo(positionMs) |
||||
|
||||
override fun release() = p.release() |
||||
} |
@ -0,0 +1,238 @@
@@ -0,0 +1,238 @@
|
||||
/* |
||||
* Copyright (c) 2023 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.event |
||||
|
||||
import androidx.annotation.DrawableRes |
||||
import androidx.compose.foundation.background |
||||
import androidx.compose.foundation.clickable |
||||
import androidx.compose.foundation.layout.Box |
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.foundation.layout.Row |
||||
import androidx.compose.foundation.layout.Spacer |
||||
import androidx.compose.foundation.layout.height |
||||
import androidx.compose.foundation.layout.padding |
||||
import androidx.compose.foundation.layout.size |
||||
import androidx.compose.foundation.layout.width |
||||
import androidx.compose.foundation.shape.CircleShape |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.draw.clip |
||||
import androidx.compose.ui.res.painterResource |
||||
import androidx.compose.ui.res.stringResource |
||||
import androidx.compose.ui.text.style.TextOverflow |
||||
import androidx.compose.ui.tooling.preview.PreviewParameter |
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider |
||||
import androidx.compose.ui.unit.dp |
||||
import io.element.android.features.messages.impl.R |
||||
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.features.messages.impl.voicemessages.timeline.WaveformProgressIndicator |
||||
import io.element.android.libraries.designsystem.preview.ElementPreview |
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight |
||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator |
||||
import io.element.android.libraries.designsystem.theme.components.Icon |
||||
import io.element.android.libraries.designsystem.theme.components.Text |
||||
import io.element.android.libraries.theme.ElementTheme |
||||
import io.element.android.libraries.ui.strings.CommonStrings |
||||
|
||||
@Composable |
||||
fun TimelineItemVoiceView( |
||||
state: VoiceMessageState, |
||||
content: TimelineItemVoiceContent, |
||||
extraPadding: ExtraPadding, |
||||
modifier: Modifier = Modifier, |
||||
) { |
||||
fun playPause() { |
||||
state.eventSink(VoiceMessageEvents.PlayPause) |
||||
} |
||||
|
||||
Row( |
||||
modifier = modifier, |
||||
verticalAlignment = Alignment.CenterVertically, |
||||
) { |
||||
Box( |
||||
modifier = Modifier |
||||
.size(32.dp) |
||||
.clip(CircleShape) |
||||
.background(ElementTheme.materialColors.background), |
||||
contentAlignment = Alignment.Center, |
||||
) { |
||||
when (state.button) { |
||||
VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause) |
||||
VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause) |
||||
VoiceMessageState.Button.Downloading -> ProgressButton() |
||||
VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause) |
||||
VoiceMessageState.Button.Disabled -> DisabledPlayButton() |
||||
} |
||||
} |
||||
Spacer(Modifier.width(8.dp)) |
||||
Text( |
||||
text = state.time, |
||||
color = ElementTheme.materialColors.secondary, |
||||
style = ElementTheme.typography.fontBodySmRegular, |
||||
maxLines = 1, |
||||
overflow = TextOverflow.Ellipsis, |
||||
) |
||||
Spacer(Modifier.width(8.dp)) |
||||
WaveformProgressIndicator( |
||||
modifier = Modifier |
||||
.height(34.dp) |
||||
.weight(1f), |
||||
progress = state.progress, |
||||
amplitudes = content.waveform, |
||||
onSeek = { state.eventSink(VoiceMessageEvents.Seek(it)) } |
||||
) |
||||
Spacer(Modifier.width(extraPadding.getDpSize())) |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
private fun PlayButton( |
||||
onClick: (() -> Unit) |
||||
) { |
||||
IconButton( |
||||
drawableRes = R.drawable.play, |
||||
contentDescription = stringResource(id = CommonStrings.a11y_play), |
||||
onClick = onClick |
||||
) |
||||
} |
||||
|
||||
@Composable |
||||
private fun PauseButton( |
||||
onClick: (() -> Unit) |
||||
) { |
||||
IconButton( |
||||
drawableRes = R.drawable.pause, |
||||
contentDescription = stringResource(id = CommonStrings.a11y_play), |
||||
onClick = onClick |
||||
) |
||||
} |
||||
|
||||
@Composable |
||||
private fun RetryButton( |
||||
onClick: (() -> Unit) |
||||
) { |
||||
IconButton( |
||||
drawableRes = R.drawable.retry, |
||||
contentDescription = stringResource(id = CommonStrings.action_retry), |
||||
onClick = onClick |
||||
) |
||||
} |
||||
|
||||
@Composable |
||||
private fun ProgressButton() { |
||||
Button { |
||||
CircularProgressIndicator( |
||||
modifier = Modifier |
||||
.padding(2.dp) |
||||
.size(12.dp), |
||||
color = ElementTheme.materialColors.primary, |
||||
strokeWidth = 1.6.dp, |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
private fun DisabledPlayButton() { |
||||
IconButton( |
||||
drawableRes = R.drawable.play, |
||||
contentDescription = null, |
||||
onClick = null, |
||||
) |
||||
} |
||||
|
||||
@Composable |
||||
private fun IconButton( |
||||
@DrawableRes drawableRes: Int, |
||||
contentDescription: String?, |
||||
onClick: (() -> Unit)?, |
||||
) { |
||||
Button( |
||||
onClick = onClick, |
||||
) { |
||||
Icon( |
||||
painter = painterResource(id = drawableRes), |
||||
contentDescription = contentDescription, |
||||
tint = ElementTheme.materialColors.primary, |
||||
modifier = Modifier |
||||
.size(16.dp), |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
private fun Button( |
||||
onClick: (() -> Unit)? = null, |
||||
content: @Composable () -> Unit, |
||||
) { |
||||
Box( |
||||
modifier = Modifier |
||||
.size(32.dp) |
||||
.clip(CircleShape) |
||||
.background(ElementTheme.materialColors.background) |
||||
.let { |
||||
if (onClick != null) it.clickable(onClick = onClick) else it |
||||
}, |
||||
contentAlignment = Alignment.Center, |
||||
) { |
||||
content() |
||||
} |
||||
} |
||||
|
||||
open class TimelineItemVoiceViewParametersProvider : PreviewParameterProvider<TimelineItemVoiceViewParameters> { |
||||
private val voiceMessageStateProvider = VoiceMessageStateProvider() |
||||
private val timelineItemVoiceContentProvider = TimelineItemVoiceContentProvider() |
||||
override val values: Sequence<TimelineItemVoiceViewParameters> |
||||
get() = voiceMessageStateProvider.values.zip(timelineItemVoiceContentProvider.values) |
||||
.map { TimelineItemVoiceViewParameters(it.first, it.second) } |
||||
} |
||||
|
||||
data class TimelineItemVoiceViewParameters( |
||||
val state: VoiceMessageState, |
||||
val content: TimelineItemVoiceContent, |
||||
) |
||||
|
||||
@PreviewsDayNight |
||||
@Composable |
||||
internal fun TimelineItemVoiceViewPreview( |
||||
@PreviewParameter(TimelineItemVoiceViewParametersProvider::class) timelineItemVoiceViewParameters: TimelineItemVoiceViewParameters, |
||||
) = ElementPreview { |
||||
TimelineItemVoiceView( |
||||
state = timelineItemVoiceViewParameters.state, |
||||
content = timelineItemVoiceViewParameters.content, |
||||
extraPadding = noExtraPadding, |
||||
) |
||||
} |
||||
|
||||
@PreviewsDayNight |
||||
@Composable |
||||
internal fun TimelineItemVoiceViewUnifiedPreview() = ElementPreview { |
||||
val timelineItemVoiceViewParametersProvider = TimelineItemVoiceViewParametersProvider() |
||||
Column { |
||||
timelineItemVoiceViewParametersProvider.values.forEach { |
||||
TimelineItemVoiceView( |
||||
state = it.state, |
||||
content = it.content, |
||||
extraPadding = noExtraPadding, |
||||
) |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
|
||||
/* |
||||
* Copyright (c) 2022 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.messages.impl.timeline.model.event |
||||
|
||||
import io.element.android.libraries.matrix.api.core.EventId |
||||
import io.element.android.libraries.matrix.api.media.MediaSource |
||||
import kotlinx.collections.immutable.ImmutableList |
||||
import java.time.Duration |
||||
|
||||
data class TimelineItemVoiceContent( |
||||
val eventId: EventId?, |
||||
val body: String, |
||||
val duration: Duration, |
||||
val mediaSource: MediaSource, |
||||
val mimeType: String, |
||||
val waveform: ImmutableList<Int>, |
||||
) : TimelineItemEventContent { |
||||
override val type: String = "TimelineItemAudioContent" |
||||
} |
@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
|
||||
/* |
||||
* Copyright (c) 2023 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.messages.impl.timeline.model.event |
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider |
||||
import io.element.android.libraries.core.mimetype.MimeTypes |
||||
import io.element.android.libraries.matrix.api.core.EventId |
||||
import io.element.android.libraries.matrix.api.media.MediaSource |
||||
import kotlinx.collections.immutable.toPersistentList |
||||
import java.time.Duration |
||||
|
||||
open class TimelineItemVoiceContentProvider : PreviewParameterProvider<TimelineItemVoiceContent> { |
||||
override val values: Sequence<TimelineItemVoiceContent> |
||||
get() = sequenceOf( |
||||
aTimelineItemVoiceContent( |
||||
durationMs = 1, |
||||
waveform = listOf(), |
||||
), |
||||
aTimelineItemVoiceContent( |
||||
durationMs = 10_000, |
||||
waveform = listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0), |
||||
), |
||||
aTimelineItemVoiceContent( |
||||
durationMs = 1_800_000, // 30 minutes |
||||
waveform = List(1024) { it }, |
||||
), |
||||
) |
||||
} |
||||
|
||||
fun aTimelineItemVoiceContent( |
||||
eventId: String? = "\$anEventId", |
||||
body: String = "body doesn't really matter for a voice message", |
||||
durationMs: Long = 61_000, |
||||
contentUri: String = "mxc://matrix.org/1234567890abcdefg", |
||||
mimeType: String = MimeTypes.Ogg, |
||||
waveform: List<Int> = listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0), |
||||
) = TimelineItemVoiceContent( |
||||
eventId = eventId?.let { EventId(it) }, |
||||
body = body, |
||||
duration = Duration.ofMillis(durationMs), |
||||
mediaSource = MediaSource(contentUri), |
||||
mimeType = mimeType, |
||||
waveform = waveform.toPersistentList(), |
||||
) |
@ -0,0 +1,123 @@
@@ -0,0 +1,123 @@
|
||||
/* |
||||
* Copyright (c) 2023 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
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.AppScope |
||||
import io.element.android.libraries.di.CacheDirectory |
||||
import java.io.File |
||||
|
||||
/** |
||||
* Manages the local disk cache for a voice message. |
||||
*/ |
||||
interface VoiceMessageCache { |
||||
|
||||
/** |
||||
* Factory for [VoiceMessageCache]. |
||||
*/ |
||||
fun interface Factory { |
||||
/** |
||||
* Creates a [VoiceMessageCache] for the given Matrix Content (mxc://) URI. |
||||
* |
||||
* @param mxcUri the Matrix Content (mxc://) URI of the voice message. |
||||
*/ |
||||
fun create(mxcUri: String): VoiceMessageCache |
||||
} |
||||
|
||||
/** |
||||
* The file path of the voice message in the cache directory. |
||||
* NB: This doesn't necessarily mean that the file exists. |
||||
* |
||||
* @return the file path of the voice message in the cache directory. |
||||
*/ |
||||
val cachePath: String |
||||
|
||||
/** |
||||
* Checks if the voice message is in the cache directory. |
||||
* |
||||
* @return true if the voice message is in the cache directory. |
||||
*/ |
||||
fun isInCache(): Boolean |
||||
|
||||
/** |
||||
* Moves the file to the voice cache directory. |
||||
* |
||||
* @return true if the file was successfully moved. |
||||
*/ |
||||
fun moveToCache(file: File): Boolean |
||||
} |
||||
|
||||
/** |
||||
* Default implementation of [VoiceMessageCache]. |
||||
* |
||||
* NB: All methods will throw an [IllegalStateException] if the mxcUri is invalid. |
||||
* |
||||
* @param cacheDir the application's cache directory. |
||||
* @param mxcUri the Matrix Content (mxc://) URI of the voice message. |
||||
*/ |
||||
class VoiceMessageCacheImpl @AssistedInject constructor( |
||||
@CacheDirectory private val cacheDir: File, |
||||
@Assisted private val mxcUri: String, |
||||
) : VoiceMessageCache { |
||||
|
||||
@ContributesBinding(AppScope::class) |
||||
@AssistedFactory |
||||
fun interface Factory : VoiceMessageCache.Factory { |
||||
override fun create(mxcUri: String): VoiceMessageCacheImpl |
||||
} |
||||
|
||||
override val cachePath: String = "${cacheDir.path}/$CACHE_VOICE_SUBDIR/${mxcUri2FilePath(mxcUri)}" |
||||
|
||||
override fun isInCache(): Boolean = File(cachePath).exists() |
||||
|
||||
override fun moveToCache(file: File): Boolean { |
||||
val dest = File(cachePath).apply { parentFile?.mkdirs() } |
||||
return file.renameTo(dest) |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Subdirectory of the application's cache directory where voice messages are stored. |
||||
*/ |
||||
private const val CACHE_VOICE_SUBDIR = "temp/voice" |
||||
|
||||
/** |
||||
* Regex to match a Matrix Content (mxc://) URI. |
||||
* |
||||
* See: https://spec.matrix.org/v1.8/client-server-api/#matrix-content-mxc-uris |
||||
*/ |
||||
private val mxcRegex = Regex("""^mxc:\/\/([^\/]+)\/([^\/]+)$""") |
||||
|
||||
/** |
||||
* Sanitizes an mxcUri to be used as a relative file path. |
||||
* |
||||
* @param mxcUri the Matrix Content (mxc://) URI of the voice message. |
||||
* @return the relative file path as "<server-name>/<media-id>". |
||||
* @throws IllegalStateException if the mxcUri is invalid. |
||||
*/ |
||||
private fun mxcUri2FilePath(mxcUri: String): String = checkNotNull(mxcRegex.matchEntire(mxcUri)) { |
||||
"mxcUri2FilePath: Invalid mxcUri: $mxcUri" |
||||
}.let { match -> |
||||
buildString { |
||||
append(match.groupValues[1]) |
||||
append("/") |
||||
append(match.groupValues[2]) |
||||
} |
||||
} |
@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
|
||||
/* |
||||
* Copyright (c) 2023 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.messages.impl.voicemessages.timeline |
||||
|
||||
sealed interface VoiceMessageEvents { |
||||
data object PlayPause : VoiceMessageEvents |
||||
data class Seek(val percentage: Float) : VoiceMessageEvents |
||||
} |
@ -0,0 +1,162 @@
@@ -0,0 +1,162 @@
|
||||
/* |
||||
* Copyright (c) 2023 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.messages.impl.voicemessages.timeline |
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding |
||||
import io.element.android.features.messages.impl.mediaplayer.MediaPlayer |
||||
import io.element.android.libraries.di.RoomScope |
||||
import io.element.android.libraries.matrix.api.core.EventId |
||||
import kotlinx.coroutines.flow.Flow |
||||
import kotlinx.coroutines.flow.distinctUntilChanged |
||||
import kotlinx.coroutines.flow.map |
||||
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 media uri is not enough to uniquely identify a voice message. |
||||
* This is why we must provide the eventId as well. |
||||
* |
||||
* @param eventId The id of the voice message event. If null, a dummy |
||||
* player is returned. |
||||
* @param mediaPath The path to the voice message's media file. |
||||
*/ |
||||
fun create(eventId: EventId?, mediaPath: String): VoiceMessagePlayer |
||||
} |
||||
|
||||
/** |
||||
* The current state of this player. |
||||
*/ |
||||
val state: Flow<State> |
||||
|
||||
/** |
||||
* Start playing from the beginning acquiring control of the |
||||
* underlying [MediaPlayer]. |
||||
*/ |
||||
fun acquireControlAndPlay() |
||||
|
||||
/** |
||||
* Start playing from the current position. |
||||
*/ |
||||
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 this player is currently playing. |
||||
*/ |
||||
val isPlaying: Boolean, |
||||
/** |
||||
* Whether this player has control of the underlying [MediaPlayer]. |
||||
*/ |
||||
val isMyMedia: Boolean, |
||||
/** |
||||
* The elapsed time of this player in milliseconds. |
||||
*/ |
||||
val currentPosition: Long, |
||||
) |
||||
} |
||||
|
||||
/** |
||||
* An implementation of [VoiceMessagePlayer] which is backed by a [MediaPlayer] |
||||
* usually shared among different [VoiceMessagePlayer] instances. |
||||
* |
||||
* @param mediaPlayer The [MediaPlayer] to use. |
||||
* @param eventId The id of the voice message event. If null, the player will behave as no-op. |
||||
* @param mediaPath The path to the voice message's media file. |
||||
*/ |
||||
class VoiceMessagePlayerImpl( |
||||
private val mediaPlayer: MediaPlayer, |
||||
private val eventId: EventId?, |
||||
private val mediaPath: String, |
||||
) : VoiceMessagePlayer { |
||||
|
||||
@ContributesBinding(RoomScope::class) // Scoped types can't use @AssistedInject. |
||||
class Factory @Inject constructor( |
||||
private val mediaPlayer: MediaPlayer, |
||||
) : VoiceMessagePlayer.Factory { |
||||
override fun create(eventId: EventId?, mediaPath: String): VoiceMessagePlayerImpl { |
||||
return VoiceMessagePlayerImpl( |
||||
mediaPlayer = mediaPlayer, |
||||
eventId = eventId, |
||||
mediaPath = mediaPath, |
||||
) |
||||
} |
||||
} |
||||
|
||||
override val state: Flow<VoiceMessagePlayer.State> = mediaPlayer.state.map { state -> |
||||
VoiceMessagePlayer.State( |
||||
isPlaying = state.mediaId.isMyTrack() && state.isPlaying, |
||||
isMyMedia = state.mediaId.isMyTrack(), |
||||
currentPosition = if (state.mediaId.isMyTrack()) state.currentPosition else 0L |
||||
) |
||||
}.distinctUntilChanged() |
||||
|
||||
override fun acquireControlAndPlay() { |
||||
eventId?.let { eventId -> |
||||
mediaPlayer.acquireControlAndPlay( |
||||
uri = mediaPath, |
||||
mediaId = eventId.value, |
||||
mimeType = "audio/ogg" // Files in the voice cache have no extension so we need to set the mime type manually. |
||||
) |
||||
} |
||||
} |
||||
|
||||
override fun play() { |
||||
ifInControl { |
||||
mediaPlayer.play() |
||||
} |
||||
} |
||||
|
||||
override fun pause() { |
||||
ifInControl { |
||||
mediaPlayer.pause() |
||||
} |
||||
} |
||||
|
||||
override fun seekTo(positionMs: Long) { |
||||
ifInControl { |
||||
mediaPlayer.seekTo(positionMs) |
||||
} |
||||
} |
||||
|
||||
private fun String?.isMyTrack(): Boolean = if (eventId == null) false else this == eventId.value |
||||
|
||||
private inline fun ifInControl(block: () -> Unit) { |
||||
if (mediaPlayer.state.value.mediaId.isMyTrack()) block() |
||||
} |
||||
} |
@ -0,0 +1,151 @@
@@ -0,0 +1,151 @@
|
||||
/* |
||||
* Copyright (c) 2023 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
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 androidx.compose.runtime.rememberCoroutineScope |
||||
import com.squareup.anvil.annotations.ContributesTo |
||||
import dagger.Binds |
||||
import dagger.Module |
||||
import dagger.assisted.Assisted |
||||
import dagger.assisted.AssistedFactory |
||||
import dagger.assisted.AssistedInject |
||||
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.libraries.architecture.Async |
||||
import io.element.android.libraries.architecture.Presenter |
||||
import io.element.android.libraries.architecture.runUpdatingState |
||||
import io.element.android.libraries.di.RoomScope |
||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader |
||||
import io.element.android.libraries.matrix.api.media.MediaFile |
||||
import io.element.android.libraries.matrix.api.media.toFile |
||||
import io.element.android.libraries.ui.utils.time.formatShort |
||||
import kotlinx.coroutines.launch |
||||
import kotlin.time.Duration.Companion.milliseconds |
||||
|
||||
@Module |
||||
@ContributesTo(RoomScope::class) |
||||
interface VoiceMessagePresenterModule { |
||||
@Binds |
||||
@IntoMap |
||||
@TimelineItemEventContentKey(TimelineItemVoiceContent::class) |
||||
fun bindVoiceMessagePresenterFactory(factory: VoiceMessagePresenter.Factory): TimelineItemPresenterFactory<*, *> |
||||
} |
||||
|
||||
class VoiceMessagePresenter @AssistedInject constructor( |
||||
private val mediaLoader: MatrixMediaLoader, |
||||
voiceMessagePlayerFactory: VoiceMessagePlayer.Factory, |
||||
voiceMessageCacheFactory: VoiceMessageCache.Factory, |
||||
@Assisted private val content: TimelineItemVoiceContent, |
||||
) : Presenter<VoiceMessageState> { |
||||
|
||||
@AssistedFactory |
||||
fun interface Factory : TimelineItemPresenterFactory<TimelineItemVoiceContent, VoiceMessageState> { |
||||
override fun create(content: TimelineItemVoiceContent): VoiceMessagePresenter |
||||
} |
||||
|
||||
private val voiceCache = voiceMessageCacheFactory.create(mxcUri = content.mediaSource.url) |
||||
|
||||
private val player = voiceMessagePlayerFactory.create( |
||||
eventId = content.eventId, |
||||
mediaPath = voiceCache.cachePath |
||||
) |
||||
|
||||
@Composable |
||||
override fun present(): VoiceMessageState { |
||||
|
||||
val scope = rememberCoroutineScope() |
||||
|
||||
val playerState by player.state.collectAsState(VoiceMessagePlayer.State(isPlaying = false, isMyMedia = false, currentPosition = 0L)) |
||||
val mediaFile = remember { mutableStateOf<Async<MediaFile>>(Async.Uninitialized) } |
||||
|
||||
val button by remember { |
||||
derivedStateOf { |
||||
when { |
||||
content.eventId == null -> VoiceMessageState.Button.Disabled |
||||
playerState.isPlaying -> VoiceMessageState.Button.Pause |
||||
mediaFile.value is Async.Loading -> VoiceMessageState.Button.Downloading |
||||
mediaFile.value is Async.Failure -> VoiceMessageState.Button.Retry |
||||
else -> VoiceMessageState.Button.Play |
||||
} |
||||
} |
||||
} |
||||
val progress by remember { |
||||
derivedStateOf { if (playerState.isMyMedia) playerState.currentPosition / content.duration.toMillis().toFloat() else 0f } |
||||
} |
||||
val time by remember { |
||||
derivedStateOf { |
||||
val time = if (playerState.isMyMedia) playerState.currentPosition else content.duration.toMillis() |
||||
time.milliseconds.formatShort() |
||||
} |
||||
} |
||||
|
||||
suspend fun downloadCacheAndPlay() { |
||||
mediaFile.runUpdatingState { |
||||
mediaLoader.downloadMediaFile( |
||||
source = content.mediaSource, |
||||
mimeType = content.mimeType, |
||||
body = content.body, |
||||
).mapCatching { |
||||
if (voiceCache.moveToCache(it.toFile())) { |
||||
player.acquireControlAndPlay() |
||||
it |
||||
} else { |
||||
error("Failed to move file to cache.") |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
fun eventSink(event: VoiceMessageEvents) { |
||||
when (event) { |
||||
is VoiceMessageEvents.PlayPause -> { |
||||
if (playerState.isMyMedia) { |
||||
if (playerState.isPlaying) { |
||||
player.pause() |
||||
} else { |
||||
player.play() |
||||
} |
||||
} else { |
||||
if (voiceCache.isInCache()) { |
||||
player.acquireControlAndPlay() |
||||
} else { |
||||
scope.launch { downloadCacheAndPlay() } |
||||
} |
||||
} |
||||
} |
||||
is VoiceMessageEvents.Seek -> { |
||||
player.seekTo((event.percentage * content.duration.toMillis()).toLong()) |
||||
} |
||||
} |
||||
} |
||||
|
||||
return VoiceMessageState( |
||||
button = button, |
||||
progress = progress, |
||||
time = time, |
||||
eventSink = { eventSink(it) }, |
||||
) |
||||
} |
||||
} |
@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
|
||||
/* |
||||
* Copyright (c) 2023 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.messages.impl.voicemessages.timeline |
||||
|
||||
data class VoiceMessageState( |
||||
val button: Button, |
||||
val progress: Float, |
||||
val time: String, |
||||
val eventSink: (event: VoiceMessageEvents) -> Unit, |
||||
) { |
||||
enum class Button { |
||||
Play, |
||||
Pause, |
||||
Downloading, |
||||
Retry, |
||||
Disabled, |
||||
} |
||||
} |
@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
|
||||
/* |
||||
* Copyright (c) 2023 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
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( |
||||
VoiceMessageState( |
||||
VoiceMessageState.Button.Downloading, |
||||
progress = 0f, |
||||
time = "00:00", |
||||
eventSink = {}, |
||||
), |
||||
VoiceMessageState( |
||||
VoiceMessageState.Button.Retry, |
||||
progress = 0.5f, |
||||
time = "00:00", |
||||
eventSink = {} |
||||
), |
||||
VoiceMessageState( |
||||
VoiceMessageState.Button.Play, |
||||
progress = 1f, |
||||
time = "00:00", |
||||
eventSink = {} |
||||
), |
||||
VoiceMessageState( |
||||
VoiceMessageState.Button.Pause, |
||||
progress = 0.2f, |
||||
time = "00:00", |
||||
eventSink = {} |
||||
), |
||||
VoiceMessageState( |
||||
VoiceMessageState.Button.Disabled, |
||||
progress = 0.2f, |
||||
time = "00:00", |
||||
eventSink = {} |
||||
), |
||||
) |
||||
} |
@ -0,0 +1,96 @@
@@ -0,0 +1,96 @@
|
||||
/* |
||||
* Copyright (c) 2023 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.messages.impl.voicemessages.timeline |
||||
|
||||
import androidx.compose.foundation.layout.Column |
||||
import androidx.compose.runtime.Composable |
||||
import androidx.compose.runtime.getValue |
||||
import androidx.compose.runtime.mutableStateOf |
||||
import androidx.compose.runtime.remember |
||||
import androidx.compose.runtime.setValue |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.graphics.SolidColor |
||||
import androidx.compose.ui.unit.dp |
||||
import com.linc.audiowaveform.AudioWaveform |
||||
import io.element.android.libraries.designsystem.preview.ElementPreview |
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight |
||||
import io.element.android.libraries.theme.ElementTheme |
||||
import kotlinx.collections.immutable.ImmutableList |
||||
import kotlinx.collections.immutable.persistentListOf |
||||
import kotlinx.collections.immutable.toPersistentList |
||||
|
||||
@Composable |
||||
fun WaveformProgressIndicator( |
||||
progress: Float, |
||||
amplitudes: ImmutableList<Int>, |
||||
modifier: Modifier = Modifier, |
||||
onSeek: (progress: Float) -> Unit = {}, |
||||
) { |
||||
var seekProgress: Float? by remember { mutableStateOf(null) } |
||||
val scaledAmplitudes = remember(amplitudes) { amplitudes.scaleAmplitudes() } |
||||
AudioWaveform( |
||||
modifier = modifier, |
||||
waveformBrush = SolidColor(ElementTheme.colors.iconQuaternary), |
||||
progressBrush = SolidColor(ElementTheme.colors.iconSecondary), |
||||
onProgressChangeFinished = { |
||||
// This is to send just one onSeek callback after the user has finished seeking. |
||||
// Otherwise the AudioWaveform library would send multiple callbacks while the user is seeking. |
||||
val p = seekProgress!! |
||||
seekProgress = null |
||||
onSeek(p) |
||||
}, |
||||
spikeWidth = 1.6.dp, |
||||
spikeRadius = 0.8.dp, |
||||
spikePadding = 3.dp, |
||||
progress = seekProgress ?: progress, |
||||
amplitudes = scaledAmplitudes, |
||||
onProgressChange = { seekProgress = it }, |
||||
) |
||||
} |
||||
|
||||
@PreviewsDayNight |
||||
@Composable |
||||
internal fun WaveformProgressIndicatorPreview() = ElementPreview { |
||||
Column { |
||||
WaveformProgressIndicator( |
||||
progress = 0.5f, |
||||
amplitudes = persistentListOf(), |
||||
) |
||||
WaveformProgressIndicator( |
||||
progress = 0.5f, |
||||
amplitudes = persistentListOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0), |
||||
) |
||||
WaveformProgressIndicator( |
||||
progress = 0.5f, |
||||
amplitudes = List(1024) { it }.toPersistentList() |
||||
) |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Scale amplitudes to fit in the waveform view. |
||||
* |
||||
* It seems amplitudes > 128 are clipped by the waveform library. |
||||
* Workaround for https://github.com/lincollincol/compose-audiowaveform/issues/22 |
||||
* |
||||
* TODO Voice messages: Remove this workaround when the waveform library is fixed. |
||||
*/ |
||||
private fun ImmutableList<Int>.scaleAmplitudes(): List<Int> { |
||||
val maxAmplitude = if (isEmpty()) 1 else maxOf { it } |
||||
val scalingFactor = 128 / maxAmplitude.toFloat() |
||||
return map { (it * scalingFactor).toInt() } |
||||
} |
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" |
||||
android:width="24dp" |
||||
android:height="24dp" |
||||
android:viewportWidth="24" |
||||
android:viewportHeight="24"> |
||||
<path |
||||
android:pathData="M16,19C15.45,19 14.979,18.804 14.587,18.413C14.196,18.021 14,17.55 14,17V7C14,6.45 14.196,5.979 14.587,5.588C14.979,5.196 15.45,5 16,5C16.55,5 17.021,5.196 17.413,5.588C17.804,5.979 18,6.45 18,7V17C18,17.55 17.804,18.021 17.413,18.413C17.021,18.804 16.55,19 16,19ZM8,19C7.45,19 6.979,18.804 6.588,18.413C6.196,18.021 6,17.55 6,17V7C6,6.45 6.196,5.979 6.588,5.588C6.979,5.196 7.45,5 8,5C8.55,5 9.021,5.196 9.413,5.588C9.804,5.979 10,6.45 10,7V17C10,17.55 9.804,18.021 9.413,18.413C9.021,18.804 8.55,19 8,19Z" |
||||
android:fillColor="#656D77"/> |
||||
</vector> |
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" |
||||
android:width="24dp" |
||||
android:height="24dp" |
||||
android:viewportWidth="24" |
||||
android:viewportHeight="24"> |
||||
<path |
||||
android:pathData="M9.525,18.025C9.192,18.242 8.854,18.254 8.512,18.063C8.171,17.871 8,17.575 8,17.175V6.825C8,6.425 8.171,6.129 8.512,5.938C8.854,5.746 9.192,5.759 9.525,5.975L17.675,11.15C17.975,11.35 18.125,11.634 18.125,12C18.125,12.367 17.975,12.65 17.675,12.85L9.525,18.025Z" |
||||
android:fillColor="#656D77"/> |
||||
</vector> |
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" |
||||
android:width="24dp" |
||||
android:height="24dp" |
||||
android:viewportWidth="24" |
||||
android:viewportHeight="24"> |
||||
<path |
||||
android:pathData="M12,20C9.767,20 7.875,19.225 6.325,17.675C4.775,16.125 4,14.233 4,12C4,9.767 4.775,7.875 6.325,6.325C7.875,4.775 9.767,4 12,4C13.15,4 14.25,4.238 15.3,4.713C16.35,5.188 17.25,5.867 18,6.75V5C18,4.717 18.096,4.479 18.288,4.287C18.479,4.096 18.717,4 19,4C19.283,4 19.521,4.096 19.712,4.287C19.904,4.479 20,4.717 20,5V10C20,10.283 19.904,10.521 19.712,10.712C19.521,10.904 19.283,11 19,11H14C13.717,11 13.479,10.904 13.288,10.712C13.096,10.521 13,10.283 13,10C13,9.717 13.096,9.479 13.288,9.288C13.479,9.096 13.717,9 14,9H17.2C16.667,8.067 15.938,7.333 15.012,6.8C14.087,6.267 13.083,6 12,6C10.333,6 8.917,6.583 7.75,7.75C6.583,8.917 6,10.333 6,12C6,13.667 6.583,15.083 7.75,16.25C8.917,17.417 10.333,18 12,18C13.133,18 14.171,17.712 15.113,17.138C16.054,16.563 16.783,15.792 17.3,14.825C17.433,14.592 17.621,14.429 17.862,14.337C18.104,14.246 18.35,14.242 18.6,14.325C18.867,14.408 19.058,14.583 19.175,14.85C19.292,15.117 19.283,15.367 19.15,15.6C18.467,16.933 17.492,18 16.225,18.8C14.958,19.6 13.55,20 12,20Z" |
||||
android:fillColor="#656D77"/> |
||||
</vector> |
@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
|
||||
/* |
||||
* Copyright (c) 2023 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.messages.mediaplayer |
||||
|
||||
import io.element.android.features.messages.impl.mediaplayer.MediaPlayer |
||||
import kotlinx.coroutines.flow.MutableStateFlow |
||||
import kotlinx.coroutines.flow.StateFlow |
||||
import kotlinx.coroutines.flow.asStateFlow |
||||
import kotlinx.coroutines.flow.update |
||||
|
||||
/** |
||||
* Fake implementation of [MediaPlayer] for testing purposes. |
||||
*/ |
||||
class FakeMediaPlayer : MediaPlayer { |
||||
private val _state = MutableStateFlow(MediaPlayer.State(false, null, 0L)) |
||||
|
||||
override val state: StateFlow<MediaPlayer.State> = _state.asStateFlow() |
||||
|
||||
override fun acquireControlAndPlay(uri: String, mediaId: String, mimeType: String) { |
||||
_state.update { |
||||
it.copy( |
||||
isPlaying = true, |
||||
mediaId = mediaId, |
||||
currentPosition = it.currentPosition + 1000L, |
||||
) |
||||
} |
||||
} |
||||
|
||||
override fun play() { |
||||
_state.update { |
||||
it.copy( |
||||
isPlaying = true, |
||||
currentPosition = it.currentPosition + 1000L, |
||||
) |
||||
} |
||||
} |
||||
|
||||
override fun pause() { |
||||
_state.update { |
||||
it.copy( |
||||
isPlaying = false, |
||||
) |
||||
} |
||||
} |
||||
|
||||
override fun seekTo(positionMs: Long) { |
||||
_state.update { |
||||
it.copy( |
||||
currentPosition = positionMs, |
||||
) |
||||
} |
||||
} |
||||
|
||||
override fun close() { |
||||
// no-op |
||||
} |
||||
} |
@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
|
||||
/* |
||||
* Copyright (c) 2023 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.messages.voicemessages.timeline |
||||
|
||||
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageCache |
||||
import java.io.File |
||||
|
||||
/** |
||||
* A fake implementation of [VoiceMessageCache] for testing purposes. |
||||
*/ |
||||
class FakeVoiceMessageCache : VoiceMessageCache { |
||||
|
||||
private var _cachePath: String = "" |
||||
private var _isInCache: Boolean = false |
||||
private var _moveToCache: Boolean = false |
||||
|
||||
override val cachePath: String |
||||
get() = _cachePath |
||||
|
||||
override fun isInCache(): Boolean = _isInCache |
||||
|
||||
override fun moveToCache(file: File): Boolean = _moveToCache |
||||
|
||||
fun givenCachePath(cachePath: String) { |
||||
_cachePath = cachePath |
||||
} |
||||
|
||||
fun givenIsInCache(isInCache: Boolean) { |
||||
_isInCache = isInCache |
||||
} |
||||
|
||||
fun givenMoveToCache(moveToCache: Boolean) { |
||||
_moveToCache = moveToCache |
||||
} |
||||
} |
@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
|
||||
/* |
||||
* Copyright (c) 2023 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.messages.voicemessages.timeline |
||||
|
||||
import com.google.common.truth.Truth |
||||
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageCacheImpl |
||||
import org.junit.Rule |
||||
import org.junit.Test |
||||
import org.junit.rules.TemporaryFolder |
||||
import java.io.File |
||||
|
||||
class VoiceMessageCacheTest { |
||||
|
||||
@get:Rule |
||||
val temporaryFolder = TemporaryFolder() |
||||
|
||||
@Test |
||||
fun `moveToVoiceCache() should move the file to the voice cache dir`() { |
||||
val rootPath = temporaryFolder.root.path |
||||
val file = File("$rootPath/myFile.txt").apply { createNewFile() } |
||||
val cacheDir = File("$rootPath/cacheDir").apply { if (!exists()) mkdirs() } |
||||
val mxcUri = "mxc://matrix.org/1234567890abcdefg" |
||||
val cache = VoiceMessageCacheImpl(cacheDir, mxcUri) |
||||
|
||||
Truth.assertThat(cache.moveToCache(file)) |
||||
.isTrue() |
||||
Truth.assertThat(File("$rootPath/cacheDir/temp/voice/matrix.org/1234567890abcdefg").exists()) |
||||
.isTrue() |
||||
} |
||||
|
||||
@Test |
||||
fun `voiceCachePath() should point to cacheDir-temp-voice-mxcUri2fileName`() { |
||||
val rootPath = temporaryFolder.root.path |
||||
val cacheDir = File("$rootPath/cacheDir") |
||||
val mxcUri = "mxc://matrix.org/1234567890abcdefg" |
||||
val cache = VoiceMessageCacheImpl(cacheDir, mxcUri) |
||||
|
||||
Truth.assertThat(cache.cachePath) |
||||
.isEqualTo("$rootPath/cacheDir/temp/voice/matrix.org/1234567890abcdefg") |
||||
} |
||||
|
||||
@Test |
||||
fun `isInVoiceCache() should return true if the file exists`() { |
||||
val rootPath = temporaryFolder.root.path |
||||
val cacheDir = File("$rootPath/cacheDir") |
||||
val mxcUri = "mxc://matrix.org/1234567890abcdefg" |
||||
val file = File("$rootPath/cacheDir/temp/voice/matrix.org/1234567890abcdefg").apply { |
||||
parentFile?.mkdirs() |
||||
createNewFile() |
||||
} |
||||
val cache = VoiceMessageCacheImpl(cacheDir, mxcUri) |
||||
|
||||
Truth.assertThat(cache.isInCache()) |
||||
.isTrue() |
||||
} |
||||
|
||||
@Test |
||||
fun `isInVoiceCache() should return false if the file does not exist`() { |
||||
val rootPath = temporaryFolder.root.path |
||||
val cacheDir = File("$rootPath/cacheDir") |
||||
val mxcUri = "mxc://matrix.org/1234567890abcdefg" |
||||
val cache = VoiceMessageCacheImpl(cacheDir, mxcUri) |
||||
|
||||
Truth.assertThat(cache.isInCache()) |
||||
.isFalse() |
||||
} |
||||
|
||||
@Test(expected = IllegalStateException::class) |
||||
fun `isInVoiceCache() throws IllegalStateException on bogus mxc uri`() { |
||||
val cacheDir = File("") |
||||
val mxcUri = "bogus" |
||||
val cache = VoiceMessageCacheImpl(cacheDir, mxcUri) |
||||
|
||||
cache.isInCache() |
||||
} |
||||
} |
@ -0,0 +1,294 @@
@@ -0,0 +1,294 @@
|
||||
/* |
||||
* Copyright (c) 2023 New Vector Ltd |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0 |
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package io.element.android.features.messages.voicemessages.timeline |
||||
|
||||
import app.cash.molecule.RecompositionMode |
||||
import app.cash.molecule.moleculeFlow |
||||
import app.cash.turbine.test |
||||
import com.google.common.truth.Truth |
||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent |
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent |
||||
import io.element.android.features.messages.mediaplayer.FakeMediaPlayer |
||||
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageEvents |
||||
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessagePlayerImpl |
||||
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessagePresenter |
||||
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState |
||||
import io.element.android.libraries.matrix.test.media.FakeMediaLoader |
||||
import kotlinx.coroutines.test.runTest |
||||
import org.junit.Test |
||||
|
||||
class VoiceMessagePresenterTest { |
||||
|
||||
private val fakeMediaLoader = FakeMediaLoader() |
||||
private val fakeVoiceCache = FakeVoiceMessageCache() |
||||
|
||||
@Test |
||||
fun `initial state has proper default values`() = runTest { |
||||
val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache) |
||||
moleculeFlow(RecompositionMode.Immediate) { |
||||
presenter.present() |
||||
}.test { |
||||
awaitItem().let { |
||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) |
||||
Truth.assertThat(it.progress).isEqualTo(0f) |
||||
Truth.assertThat(it.time).isEqualTo("1:01") |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `pressing play with file in cache plays`() = runTest { |
||||
fakeVoiceCache.apply { |
||||
givenIsInCache(true) |
||||
} |
||||
val content = aTimelineItemVoiceContent(durationMs = 2_000) |
||||
val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache, content) |
||||
moleculeFlow(RecompositionMode.Immediate) { |
||||
presenter.present() |
||||
}.test { |
||||
val initialState = awaitItem().also { |
||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) |
||||
Truth.assertThat(it.progress).isEqualTo(0f) |
||||
Truth.assertThat(it.time).isEqualTo("0:02") |
||||
} |
||||
|
||||
initialState.eventSink(VoiceMessageEvents.PlayPause) |
||||
|
||||
awaitItem().also { |
||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause) |
||||
Truth.assertThat(it.progress).isEqualTo(0.5f) |
||||
Truth.assertThat(it.time).isEqualTo("0:01") |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `pressing play with file not in cache downloads it but fails`() = runTest { |
||||
fakeMediaLoader.apply { |
||||
shouldFail = true |
||||
} |
||||
fakeVoiceCache.apply { |
||||
givenIsInCache(false) |
||||
givenMoveToCache(true) |
||||
} |
||||
val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache) |
||||
moleculeFlow(RecompositionMode.Immediate) { |
||||
presenter.present() |
||||
}.test { |
||||
val initialState = awaitItem().also { |
||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) |
||||
Truth.assertThat(it.progress).isEqualTo(0f) |
||||
Truth.assertThat(it.time).isEqualTo("1:01") |
||||
} |
||||
|
||||
initialState.eventSink(VoiceMessageEvents.PlayPause) |
||||
|
||||
awaitItem().also { |
||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading) |
||||
Truth.assertThat(it.progress).isEqualTo(0f) |
||||
Truth.assertThat(it.time).isEqualTo("1:01") |
||||
} |
||||
|
||||
awaitItem().also { |
||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Retry) |
||||
Truth.assertThat(it.progress).isEqualTo(0f) |
||||
Truth.assertThat(it.time).isEqualTo("1:01") |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `pressing play with file not in cache downloads it but then caching fails`() = runTest { |
||||
fakeMediaLoader.apply { |
||||
shouldFail = false |
||||
} |
||||
fakeVoiceCache.apply { |
||||
givenIsInCache(false) |
||||
givenMoveToCache(false) |
||||
} |
||||
val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache) |
||||
moleculeFlow(RecompositionMode.Immediate) { |
||||
presenter.present() |
||||
}.test { |
||||
val initialState = awaitItem().also { |
||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) |
||||
Truth.assertThat(it.progress).isEqualTo(0f) |
||||
Truth.assertThat(it.time).isEqualTo("1:01") |
||||
} |
||||
|
||||
initialState.eventSink(VoiceMessageEvents.PlayPause) |
||||
|
||||
awaitItem().also { |
||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading) |
||||
Truth.assertThat(it.progress).isEqualTo(0f) |
||||
Truth.assertThat(it.time).isEqualTo("1:01") |
||||
} |
||||
|
||||
awaitItem().also { |
||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Retry) |
||||
Truth.assertThat(it.progress).isEqualTo(0f) |
||||
Truth.assertThat(it.time).isEqualTo("1:01") |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `acquire control then play then play and pause while having control`() = runTest { |
||||
fakeVoiceCache.apply { |
||||
givenIsInCache(true) |
||||
} |
||||
val content = aTimelineItemVoiceContent(durationMs = 2_000) |
||||
val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache, content) |
||||
moleculeFlow(RecompositionMode.Immediate) { |
||||
presenter.present() |
||||
}.test { |
||||
val initialState = awaitItem().also { |
||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) |
||||
Truth.assertThat(it.progress).isEqualTo(0f) |
||||
Truth.assertThat(it.time).isEqualTo("0:02") |
||||
} |
||||
|
||||
initialState.eventSink(VoiceMessageEvents.PlayPause) |
||||
|
||||
awaitItem().also { |
||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause) |
||||
Truth.assertThat(it.progress).isEqualTo(0.5f) |
||||
Truth.assertThat(it.time).isEqualTo("0:01") |
||||
} |
||||
|
||||
initialState.eventSink(VoiceMessageEvents.PlayPause) |
||||
|
||||
awaitItem().also { |
||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) |
||||
Truth.assertThat(it.progress).isEqualTo(0.5f) |
||||
Truth.assertThat(it.time).isEqualTo("0:01") |
||||
} |
||||
|
||||
initialState.eventSink(VoiceMessageEvents.PlayPause) |
||||
|
||||
awaitItem().also { |
||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause) |
||||
Truth.assertThat(it.progress).isEqualTo(1.0f) |
||||
Truth.assertThat(it.time).isEqualTo("0:02") |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `pressing play with file not in cache downloads it successfully`() = runTest { |
||||
fakeMediaLoader.apply { |
||||
shouldFail = false |
||||
} |
||||
fakeVoiceCache.apply { |
||||
givenIsInCache(false) |
||||
givenMoveToCache(true) |
||||
} |
||||
val content = aTimelineItemVoiceContent(durationMs = 2_000) |
||||
val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache, content) |
||||
moleculeFlow(RecompositionMode.Immediate) { |
||||
presenter.present() |
||||
}.test { |
||||
val initialState = awaitItem().also { |
||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) |
||||
Truth.assertThat(it.progress).isEqualTo(0f) |
||||
Truth.assertThat(it.time).isEqualTo("0:02") |
||||
} |
||||
|
||||
initialState.eventSink(VoiceMessageEvents.PlayPause) |
||||
|
||||
awaitItem().also { |
||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading) |
||||
Truth.assertThat(it.progress).isEqualTo(0f) |
||||
Truth.assertThat(it.time).isEqualTo("0:02") |
||||
} |
||||
|
||||
awaitItem().also { |
||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause) |
||||
Truth.assertThat(it.progress).isEqualTo(0.5f) |
||||
Truth.assertThat(it.time).isEqualTo("0:01") |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `content with null eventId shows disabled button`() = runTest { |
||||
fakeMediaLoader.apply { |
||||
shouldFail = false |
||||
} |
||||
fakeVoiceCache.apply { |
||||
givenIsInCache(false) |
||||
givenMoveToCache(true) |
||||
} |
||||
val content = aTimelineItemVoiceContent(eventId = null) |
||||
val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache, content) |
||||
moleculeFlow(RecompositionMode.Immediate) { |
||||
presenter.present() |
||||
}.test { |
||||
val initialState = awaitItem().also { |
||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Disabled) |
||||
Truth.assertThat(it.progress).isEqualTo(0f) |
||||
Truth.assertThat(it.time).isEqualTo("1:01") |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Test |
||||
fun `seeking seeks`() = runTest { |
||||
fakeVoiceCache.apply { |
||||
givenIsInCache(true) |
||||
} |
||||
val content = aTimelineItemVoiceContent(durationMs = 10_000) |
||||
|
||||
val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache, content) |
||||
moleculeFlow(RecompositionMode.Immediate) { |
||||
presenter.present() |
||||
}.test { |
||||
val initialState = awaitItem().also { |
||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) |
||||
Truth.assertThat(it.progress).isEqualTo(0f) |
||||
Truth.assertThat(it.time).isEqualTo("0:10") |
||||
} |
||||
|
||||
initialState.eventSink(VoiceMessageEvents.PlayPause) |
||||
|
||||
awaitItem().also { |
||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause) |
||||
Truth.assertThat(it.progress).isEqualTo(0.1f) |
||||
Truth.assertThat(it.time).isEqualTo("0:01") |
||||
} |
||||
|
||||
initialState.eventSink(VoiceMessageEvents.Seek(0.5f)) |
||||
|
||||
awaitItem().also { |
||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause) |
||||
Truth.assertThat(it.progress).isEqualTo(0.5f) |
||||
Truth.assertThat(it.time).isEqualTo("0:05") |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
fun createVoiceMessagePresenter( |
||||
fakeMediaLoader: FakeMediaLoader, |
||||
voiceCacheFake: FakeVoiceMessageCache, |
||||
content: TimelineItemVoiceContent = aTimelineItemVoiceContent(), |
||||
) = VoiceMessagePresenter( |
||||
mediaLoader = fakeMediaLoader, |
||||
voiceMessagePlayerFactory = { eventId, mediaPath -> VoiceMessagePlayerImpl(FakeMediaPlayer(), eventId, mediaPath) }, |
||||
voiceMessageCacheFactory = { voiceCacheFake }, |
||||
content = content, |
||||
) |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue