Merge pull request #5963 from element-hq/feature/bma/variablePlayBackSpeed
Voice message: variable play back speed
This commit is contained in:
commit
b01135aeef
75 changed files with 478 additions and 223 deletions
|
|
@ -9,6 +9,7 @@
|
||||||
package io.element.android.features.messages.impl.timeline.components.event
|
package io.element.android.features.messages.impl.timeline.components.event
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
|
@ -42,6 +43,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
|
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.timeline.model.event.TimelineItemVoiceContentProvider
|
||||||
|
import io.element.android.libraries.designsystem.atomic.atoms.PlaybackSpeedButton
|
||||||
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
|
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
|
@ -51,7 +53,7 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.libraries.ui.utils.time.isTalkbackActive
|
import io.element.android.libraries.ui.utils.time.isTalkbackActive
|
||||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent
|
||||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
@ -64,26 +66,26 @@ fun TimelineItemVoiceView(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
fun playPause() {
|
fun playPause() {
|
||||||
state.eventSink(VoiceMessageEvents.PlayPause)
|
state.eventSink(VoiceMessageEvent.PlayPause)
|
||||||
}
|
}
|
||||||
|
|
||||||
val a11y = stringResource(CommonStrings.common_voice_message)
|
val a11y = stringResource(CommonStrings.common_voice_message)
|
||||||
val a11yActionLabel = stringResource(
|
val a11yActionLabel = stringResource(
|
||||||
when (state.button) {
|
when (state.buttonType) {
|
||||||
VoiceMessageState.Button.Play -> CommonStrings.a11y_play
|
VoiceMessageState.ButtonType.Play -> CommonStrings.a11y_play
|
||||||
VoiceMessageState.Button.Pause -> CommonStrings.a11y_pause
|
VoiceMessageState.ButtonType.Pause -> CommonStrings.a11y_pause
|
||||||
VoiceMessageState.Button.Downloading -> CommonStrings.common_downloading
|
VoiceMessageState.ButtonType.Downloading -> CommonStrings.common_downloading
|
||||||
VoiceMessageState.Button.Retry -> CommonStrings.action_retry
|
VoiceMessageState.ButtonType.Retry -> CommonStrings.action_retry
|
||||||
VoiceMessageState.Button.Disabled -> CommonStrings.error_unknown
|
VoiceMessageState.ButtonType.Disabled -> CommonStrings.error_unknown
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
Row(
|
Row(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.clearAndSetSemantics {
|
.clearAndSetSemantics {
|
||||||
contentDescription = a11y
|
contentDescription = a11y
|
||||||
if (state.button == VoiceMessageState.Button.Disabled) {
|
if (state.buttonType == VoiceMessageState.ButtonType.Disabled) {
|
||||||
disabled()
|
disabled()
|
||||||
} else if (state.button in listOf(VoiceMessageState.Button.Play, VoiceMessageState.Button.Pause)) {
|
} else if (state.buttonType in listOf(VoiceMessageState.ButtonType.Play, VoiceMessageState.ButtonType.Pause)) {
|
||||||
onClick(label = a11yActionLabel) {
|
onClick(label = a11yActionLabel) {
|
||||||
playPause()
|
playPause()
|
||||||
true
|
true
|
||||||
|
|
@ -101,30 +103,41 @@ fun TimelineItemVoiceView(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
if (!isTalkbackActive()) {
|
if (!isTalkbackActive()) {
|
||||||
when (state.button) {
|
when (state.buttonType) {
|
||||||
VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause)
|
VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause)
|
||||||
VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause)
|
VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause)
|
||||||
VoiceMessageState.Button.Downloading -> ProgressButton()
|
VoiceMessageState.ButtonType.Downloading -> ProgressButton()
|
||||||
VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause)
|
VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause)
|
||||||
VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false)
|
VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text(
|
Column(
|
||||||
text = state.time,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
color = ElementTheme.colors.textSecondary,
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
style = ElementTheme.typography.fontBodySmMedium,
|
) {
|
||||||
maxLines = 1,
|
PlaybackSpeedButton(
|
||||||
overflow = TextOverflow.Ellipsis,
|
speed = state.playbackSpeed,
|
||||||
)
|
onClick = { state.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) },
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = state.time,
|
||||||
|
color = ElementTheme.colors.textSecondary,
|
||||||
|
style = ElementTheme.typography.fontBodySmMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
WaveformPlaybackView(
|
WaveformPlaybackView(
|
||||||
showCursor = state.showCursor,
|
showCursor = state.showCursor,
|
||||||
playbackProgress = state.progress,
|
playbackProgress = state.progress,
|
||||||
waveform = content.waveform,
|
waveform = content.waveform,
|
||||||
modifier = Modifier.height(34.dp),
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(34.dp),
|
||||||
seekEnabled = !isTalkbackActive(),
|
seekEnabled = !isTalkbackActive(),
|
||||||
onSeek = { state.eventSink(VoiceMessageEvents.Seek(it)) },
|
onSeek = { state.eventSink(VoiceMessageEvent.Seek(it)) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.designsystem.atomic.atoms
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
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.unit.dp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.designsystem.theme.messageFromMeBackground
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlaybackSpeedButton(
|
||||||
|
speed: Float,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val speedText = when (speed) {
|
||||||
|
0.5f -> "0.5×"
|
||||||
|
1.0f -> "1×"
|
||||||
|
1.5f -> "1.5×"
|
||||||
|
2.0f -> "2×"
|
||||||
|
else -> "$speed×"
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(
|
||||||
|
color = ElementTheme.colors.bgCanvasDefault,
|
||||||
|
)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = speedText,
|
||||||
|
color = ElementTheme.colors.iconSecondary,
|
||||||
|
style = ElementTheme.typography.fontBodyXsMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun PlaybackSpeedButtonPreview() = ElementPreview {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(ElementTheme.colors.messageFromMeBackground)
|
||||||
|
.padding(4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
listOf(0.5f, 1.0f, 1.5f, 2.0f, 3.0f).forEach { speed ->
|
||||||
|
PlaybackSpeedButton(
|
||||||
|
speed = speed,
|
||||||
|
onClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -47,6 +47,12 @@ interface MediaPlayer : AutoCloseable {
|
||||||
*/
|
*/
|
||||||
fun seekTo(positionMs: Long)
|
fun seekTo(positionMs: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the playback speed.
|
||||||
|
* @param speed The playback speed (e.g., 0.5f for half speed, 1.0f for normal, 2.0f for double speed)
|
||||||
|
*/
|
||||||
|
fun setPlaybackSpeed(speed: Float)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Releases any resources associated with this player.
|
* Releases any resources associated with this player.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,10 @@ class DefaultMediaPlayer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun setPlaybackSpeed(speed: Float) {
|
||||||
|
player.setPlaybackSpeed(speed)
|
||||||
|
}
|
||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
player.release()
|
player.release()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ interface SimplePlayer {
|
||||||
fun isPlaying(): Boolean
|
fun isPlaying(): Boolean
|
||||||
fun pause()
|
fun pause()
|
||||||
fun seekTo(positionMs: Long)
|
fun seekTo(positionMs: Long)
|
||||||
|
fun setPlaybackSpeed(speed: Float)
|
||||||
fun release()
|
fun release()
|
||||||
interface Listener {
|
interface Listener {
|
||||||
fun onIsPlayingChanged(isPlaying: Boolean)
|
fun onIsPlayingChanged(isPlaying: Boolean)
|
||||||
|
|
@ -88,5 +89,9 @@ class DefaultSimplePlayer(
|
||||||
|
|
||||||
override fun seekTo(positionMs: Long) = p.seekTo(positionMs)
|
override fun seekTo(positionMs: Long) = p.seekTo(positionMs)
|
||||||
|
|
||||||
|
override fun setPlaybackSpeed(speed: Float) {
|
||||||
|
p.setPlaybackParameters(p.playbackParameters.withSpeed(speed))
|
||||||
|
}
|
||||||
|
|
||||||
override fun release() = p.release()
|
override fun release() = p.release()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ class FakeSimplePlayer(
|
||||||
private val isPlayingLambda: () -> Boolean = { lambdaError() },
|
private val isPlayingLambda: () -> Boolean = { lambdaError() },
|
||||||
private val pauseLambda: () -> Unit = { lambdaError() },
|
private val pauseLambda: () -> Unit = { lambdaError() },
|
||||||
private val seekToLambda: (Long) -> Unit = { lambdaError() },
|
private val seekToLambda: (Long) -> Unit = { lambdaError() },
|
||||||
|
private val setPlaybackSpeedLambda: (Float) -> Unit = { lambdaError() },
|
||||||
private val releaseLambda: () -> Unit = { lambdaError() },
|
private val releaseLambda: () -> Unit = { lambdaError() },
|
||||||
) : SimplePlayer {
|
) : SimplePlayer {
|
||||||
private val listeners = mutableListOf<SimplePlayer.Listener>()
|
private val listeners = mutableListOf<SimplePlayer.Listener>()
|
||||||
|
|
@ -45,6 +46,7 @@ class FakeSimplePlayer(
|
||||||
override fun isPlaying() = isPlayingLambda()
|
override fun isPlaying() = isPlayingLambda()
|
||||||
override fun pause() = pauseLambda()
|
override fun pause() = pauseLambda()
|
||||||
override fun seekTo(positionMs: Long) = seekToLambda(positionMs)
|
override fun seekTo(positionMs: Long) = seekToLambda(positionMs)
|
||||||
|
override fun setPlaybackSpeed(speed: Float) = setPlaybackSpeedLambda(speed)
|
||||||
override fun release() = releaseLambda()
|
override fun release() = releaseLambda()
|
||||||
|
|
||||||
fun simulateIsPlayingChanged(isPlaying: Boolean) {
|
fun simulateIsPlayingChanged(isPlaying: Boolean) {
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,10 @@ class FakeMediaPlayer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun setPlaybackSpeed(speed: Float) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
|
||||||
override fun close() {
|
override fun close() {
|
||||||
// no-op
|
// no-op
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ package io.element.android.libraries.mediaviewer.impl.gallery.ui
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
|
@ -38,6 +39,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
|
import io.element.android.libraries.designsystem.atomic.atoms.PlaybackSpeedButton
|
||||||
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
|
import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
|
||||||
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
|
@ -50,7 +52,7 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
|
||||||
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVoice
|
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVoice
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent
|
||||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider
|
||||||
import io.element.android.libraries.voiceplayer.api.aVoiceMessageState
|
import io.element.android.libraries.voiceplayer.api.aVoiceMessageState
|
||||||
|
|
@ -92,7 +94,7 @@ private fun VoiceInfoRow(
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
fun playPause() {
|
fun playPause() {
|
||||||
state.eventSink(VoiceMessageEvents.PlayPause)
|
state.eventSink(VoiceMessageEvent.PlayPause)
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
|
|
@ -112,21 +114,30 @@ private fun VoiceInfoRow(
|
||||||
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
.padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
when (state.button) {
|
when (state.buttonType) {
|
||||||
VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause)
|
VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause)
|
||||||
VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause)
|
VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause)
|
||||||
VoiceMessageState.Button.Downloading -> ProgressButton()
|
VoiceMessageState.ButtonType.Downloading -> ProgressButton()
|
||||||
VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause)
|
VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause)
|
||||||
VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false)
|
VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(8.dp))
|
||||||
Text(
|
Column(
|
||||||
text = if (state.progress > 0f) state.time else voice.mediaInfo.duration ?: state.time,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
color = ElementTheme.colors.textSecondary,
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
style = ElementTheme.typography.fontBodyMdMedium,
|
) {
|
||||||
maxLines = 1,
|
PlaybackSpeedButton(
|
||||||
overflow = TextOverflow.Ellipsis,
|
speed = state.playbackSpeed,
|
||||||
)
|
onClick = { state.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) },
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (state.progress > 0f) state.time else voice.mediaInfo.duration ?: state.time,
|
||||||
|
color = ElementTheme.colors.textSecondary,
|
||||||
|
style = ElementTheme.typography.fontBodyMdMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
WaveformPlaybackView(
|
WaveformPlaybackView(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -136,7 +147,7 @@ private fun VoiceInfoRow(
|
||||||
playbackProgress = state.progress,
|
playbackProgress = state.progress,
|
||||||
waveform = voice.mediaInfo.waveform.orEmpty().toImmutableList(),
|
waveform = voice.mediaInfo.waveform.orEmpty().toImmutableList(),
|
||||||
onSeek = {
|
onSeek = {
|
||||||
state.eventSink(VoiceMessageEvents.Seek(it))
|
state.eventSink(VoiceMessageEvent.Seek(it))
|
||||||
},
|
},
|
||||||
seekEnabled = true,
|
seekEnabled = true,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@
|
||||||
|
|
||||||
package io.element.android.libraries.voiceplayer.api
|
package io.element.android.libraries.voiceplayer.api
|
||||||
|
|
||||||
sealed interface VoiceMessageEvents {
|
sealed interface VoiceMessageEvent {
|
||||||
data object PlayPause : VoiceMessageEvents
|
data object PlayPause : VoiceMessageEvent
|
||||||
data class Seek(val percentage: Float) : VoiceMessageEvents
|
data class Seek(val percentage: Float) : VoiceMessageEvent
|
||||||
|
data object ChangePlaybackSpeed : VoiceMessageEvent
|
||||||
}
|
}
|
||||||
|
|
@ -9,13 +9,14 @@
|
||||||
package io.element.android.libraries.voiceplayer.api
|
package io.element.android.libraries.voiceplayer.api
|
||||||
|
|
||||||
data class VoiceMessageState(
|
data class VoiceMessageState(
|
||||||
val button: Button,
|
val buttonType: ButtonType,
|
||||||
val progress: Float,
|
val progress: Float,
|
||||||
val time: String,
|
val time: String,
|
||||||
val showCursor: Boolean,
|
val showCursor: Boolean,
|
||||||
val eventSink: (event: VoiceMessageEvents) -> Unit,
|
val playbackSpeed: Float,
|
||||||
|
val eventSink: (event: VoiceMessageEvent) -> Unit,
|
||||||
) {
|
) {
|
||||||
enum class Button {
|
enum class ButtonType {
|
||||||
Play,
|
Play,
|
||||||
Pause,
|
Pause,
|
||||||
Downloading,
|
Downloading,
|
||||||
|
|
|
||||||
|
|
@ -14,29 +14,29 @@ open class VoiceMessageStateProvider : PreviewParameterProvider<VoiceMessageStat
|
||||||
override val values: Sequence<VoiceMessageState>
|
override val values: Sequence<VoiceMessageState>
|
||||||
get() = sequenceOf(
|
get() = sequenceOf(
|
||||||
aVoiceMessageState(
|
aVoiceMessageState(
|
||||||
VoiceMessageState.Button.Downloading,
|
VoiceMessageState.ButtonType.Downloading,
|
||||||
progress = 0f,
|
progress = 0f,
|
||||||
time = "0:00",
|
time = "0:00",
|
||||||
),
|
),
|
||||||
aVoiceMessageState(
|
aVoiceMessageState(
|
||||||
VoiceMessageState.Button.Retry,
|
VoiceMessageState.ButtonType.Retry,
|
||||||
progress = 0.5f,
|
progress = 0.5f,
|
||||||
time = "0:01",
|
time = "0:01",
|
||||||
),
|
),
|
||||||
aVoiceMessageState(
|
aVoiceMessageState(
|
||||||
VoiceMessageState.Button.Play,
|
VoiceMessageState.ButtonType.Play,
|
||||||
progress = 1f,
|
progress = 1f,
|
||||||
time = "1:00",
|
time = "1:00",
|
||||||
showCursor = true,
|
showCursor = true,
|
||||||
),
|
),
|
||||||
aVoiceMessageState(
|
aVoiceMessageState(
|
||||||
VoiceMessageState.Button.Pause,
|
VoiceMessageState.ButtonType.Pause,
|
||||||
progress = 0.2f,
|
progress = 0.2f,
|
||||||
time = "10:00",
|
time = "10:00",
|
||||||
showCursor = true,
|
showCursor = true,
|
||||||
),
|
),
|
||||||
aVoiceMessageState(
|
aVoiceMessageState(
|
||||||
VoiceMessageState.Button.Disabled,
|
VoiceMessageState.ButtonType.Disabled,
|
||||||
progress = 0.2f,
|
progress = 0.2f,
|
||||||
time = "30:00",
|
time = "30:00",
|
||||||
),
|
),
|
||||||
|
|
@ -44,14 +44,16 @@ open class VoiceMessageStateProvider : PreviewParameterProvider<VoiceMessageStat
|
||||||
}
|
}
|
||||||
|
|
||||||
fun aVoiceMessageState(
|
fun aVoiceMessageState(
|
||||||
button: VoiceMessageState.Button = VoiceMessageState.Button.Play,
|
buttonType: VoiceMessageState.ButtonType = VoiceMessageState.ButtonType.Play,
|
||||||
progress: Float = 0f,
|
progress: Float = 0f,
|
||||||
time: String = "1:00",
|
time: String = "1:00",
|
||||||
showCursor: Boolean = false,
|
showCursor: Boolean = false,
|
||||||
|
playbackSpeed: Float = 1.0f,
|
||||||
) = VoiceMessageState(
|
) = VoiceMessageState(
|
||||||
button = button,
|
buttonType = buttonType,
|
||||||
progress = progress,
|
progress = progress,
|
||||||
time = time,
|
time = time,
|
||||||
showCursor = showCursor,
|
showCursor = showCursor,
|
||||||
|
playbackSpeed = playbackSpeed,
|
||||||
eventSink = {},
|
eventSink = {},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,12 @@ dependencies {
|
||||||
implementation(projects.libraries.di)
|
implementation(projects.libraries.di)
|
||||||
implementation(projects.libraries.matrix.api)
|
implementation(projects.libraries.matrix.api)
|
||||||
implementation(projects.libraries.mediaplayer.api)
|
implementation(projects.libraries.mediaplayer.api)
|
||||||
|
implementation(projects.libraries.preferences.api)
|
||||||
implementation(projects.libraries.uiUtils)
|
implementation(projects.libraries.uiUtils)
|
||||||
implementation(projects.services.analytics.api)
|
implementation(projects.services.analytics.api)
|
||||||
|
|
||||||
implementation(libs.androidx.annotationjvm)
|
implementation(libs.androidx.annotationjvm)
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.coroutines.core)
|
implementation(libs.coroutines.core)
|
||||||
|
|
||||||
testCommonDependencies(libs)
|
testCommonDependencies(libs)
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ class DefaultVoiceMessagePresenterFactory(
|
||||||
@SessionCoroutineScope
|
@SessionCoroutineScope
|
||||||
private val sessionCoroutineScope: CoroutineScope,
|
private val sessionCoroutineScope: CoroutineScope,
|
||||||
private val voiceMessagePlayerFactory: VoiceMessagePlayer.Factory,
|
private val voiceMessagePlayerFactory: VoiceMessagePlayer.Factory,
|
||||||
|
private val voicePlayerStore: VoicePlayerStore,
|
||||||
) : VoiceMessagePresenterFactory {
|
) : VoiceMessagePresenterFactory {
|
||||||
override fun createVoiceMessagePresenter(
|
override fun createVoiceMessagePresenter(
|
||||||
eventId: EventId?,
|
eventId: EventId?,
|
||||||
|
|
@ -44,6 +45,7 @@ class DefaultVoiceMessagePresenterFactory(
|
||||||
return VoiceMessagePresenter(
|
return VoiceMessagePresenter(
|
||||||
analyticsService = analyticsService,
|
analyticsService = analyticsService,
|
||||||
sessionCoroutineScope = sessionCoroutineScope,
|
sessionCoroutineScope = sessionCoroutineScope,
|
||||||
|
voicePlayerStore = voicePlayerStore,
|
||||||
player = player,
|
player = player,
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
duration = duration,
|
duration = duration,
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,13 @@ interface VoiceMessagePlayer {
|
||||||
*/
|
*/
|
||||||
fun seekTo(positionMs: Long)
|
fun seekTo(positionMs: Long)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the playback speed.
|
||||||
|
*
|
||||||
|
* @param speed The playback speed (e.g., 0.5f for half speed, 1.0f for normal, 2.0f for double speed)
|
||||||
|
*/
|
||||||
|
fun setPlaybackSpeed(speed: Float)
|
||||||
|
|
||||||
data class State(
|
data class State(
|
||||||
/**
|
/**
|
||||||
* Whether the player is ready to play.
|
* Whether the player is ready to play.
|
||||||
|
|
@ -217,6 +224,10 @@ class DefaultVoiceMessagePlayer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun setPlaybackSpeed(speed: Float) {
|
||||||
|
mediaPlayer.setPlaybackSpeed(speed)
|
||||||
|
}
|
||||||
|
|
||||||
private val MediaPlayer.State.isMyTrack: Boolean
|
private val MediaPlayer.State.isMyTrack: Boolean
|
||||||
get() = if (eventId == null) false else this.mediaId == eventId.value
|
get() = if (eventId == null) false else this.mediaId == eventId.value
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,13 @@
|
||||||
package io.element.android.libraries.voiceplayer.impl
|
package io.element.android.libraries.voiceplayer.impl
|
||||||
|
|
||||||
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
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.architecture.runUpdatingState
|
import io.element.android.libraries.architecture.runUpdatingState
|
||||||
|
|
@ -21,7 +23,7 @@ import io.element.android.libraries.core.extensions.flatMap
|
||||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.ui.utils.time.formatShort
|
import io.element.android.libraries.ui.utils.time.formatShort
|
||||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent
|
||||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageException
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageException
|
||||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
|
|
@ -33,6 +35,7 @@ import kotlin.time.Duration.Companion.milliseconds
|
||||||
class VoiceMessagePresenter(
|
class VoiceMessagePresenter(
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
private val sessionCoroutineScope: CoroutineScope,
|
private val sessionCoroutineScope: CoroutineScope,
|
||||||
|
private val voicePlayerStore: VoicePlayerStore,
|
||||||
private val player: VoiceMessagePlayer,
|
private val player: VoiceMessagePlayer,
|
||||||
private val eventId: EventId?,
|
private val eventId: EventId?,
|
||||||
private val duration: Duration,
|
private val duration: Duration,
|
||||||
|
|
@ -41,6 +44,7 @@ class VoiceMessagePresenter(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): VoiceMessageState {
|
override fun present(): VoiceMessageState {
|
||||||
|
val localCoroutineScope = rememberCoroutineScope()
|
||||||
val playerState by player.state.collectAsState(
|
val playerState by player.state.collectAsState(
|
||||||
VoiceMessagePlayer.State(
|
VoiceMessagePlayer.State(
|
||||||
isReady = false,
|
isReady = false,
|
||||||
|
|
@ -51,14 +55,20 @@ class VoiceMessagePresenter(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
val button by remember {
|
val playbackSpeedIndex by voicePlayerStore.playBackSpeedIndex().collectAsState(0)
|
||||||
|
|
||||||
|
LaunchedEffect(playbackSpeedIndex) {
|
||||||
|
player.setPlaybackSpeed(VoicePlayerConfig.availablePlaybackSpeeds[playbackSpeedIndex])
|
||||||
|
}
|
||||||
|
|
||||||
|
val buttonType by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
when {
|
when {
|
||||||
eventId == null -> VoiceMessageState.Button.Disabled
|
eventId == null -> VoiceMessageState.ButtonType.Disabled
|
||||||
playerState.isPlaying -> VoiceMessageState.Button.Pause
|
playerState.isPlaying -> VoiceMessageState.ButtonType.Pause
|
||||||
play.value is AsyncData.Loading -> VoiceMessageState.Button.Downloading
|
play.value is AsyncData.Loading -> VoiceMessageState.ButtonType.Downloading
|
||||||
play.value is AsyncData.Failure -> VoiceMessageState.Button.Retry
|
play.value is AsyncData.Failure -> VoiceMessageState.ButtonType.Retry
|
||||||
else -> VoiceMessageState.Button.Play
|
else -> VoiceMessageState.ButtonType.Play
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -85,9 +95,9 @@ class VoiceMessagePresenter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleEvent(event: VoiceMessageEvents) {
|
fun handleEvent(event: VoiceMessageEvent) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is VoiceMessageEvents.PlayPause -> {
|
is VoiceMessageEvent.PlayPause -> {
|
||||||
if (playerState.isPlaying) {
|
if (playerState.isPlaying) {
|
||||||
player.pause()
|
player.pause()
|
||||||
} else if (playerState.isReady) {
|
} else if (playerState.isReady) {
|
||||||
|
|
@ -109,17 +119,23 @@ class VoiceMessagePresenter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is VoiceMessageEvents.Seek -> {
|
is VoiceMessageEvent.Seek -> {
|
||||||
player.seekTo((event.percentage * duration).toLong())
|
player.seekTo((event.percentage * duration).toLong())
|
||||||
}
|
}
|
||||||
|
is VoiceMessageEvent.ChangePlaybackSpeed -> localCoroutineScope.launch {
|
||||||
|
voicePlayerStore.setPlayBackSpeedIndex(
|
||||||
|
(playbackSpeedIndex + 1) % VoicePlayerConfig.availablePlaybackSpeeds.size
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return VoiceMessageState(
|
return VoiceMessageState(
|
||||||
button = button,
|
buttonType = buttonType,
|
||||||
progress = progress,
|
progress = progress,
|
||||||
time = time,
|
time = time,
|
||||||
showCursor = showCursor,
|
showCursor = showCursor,
|
||||||
|
playbackSpeed = VoicePlayerConfig.availablePlaybackSpeeds[playbackSpeedIndex],
|
||||||
eventSink = ::handleEvent,
|
eventSink = ::handleEvent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voiceplayer.impl
|
||||||
|
|
||||||
|
object VoicePlayerConfig {
|
||||||
|
// Available playback speeds for voice messages, the first one is the default speed, and
|
||||||
|
// the UI will allow to change to the next speed in the list, in loop.
|
||||||
|
val availablePlaybackSpeeds = listOf(1.0f, 1.5f, 2.0f, 0.5f)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voiceplayer.impl
|
||||||
|
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
|
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
interface VoicePlayerStore {
|
||||||
|
suspend fun setPlayBackSpeedIndex(index: Int)
|
||||||
|
fun playBackSpeedIndex(): Flow<Int>
|
||||||
|
}
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class PreferencesVoicePlayerStore(
|
||||||
|
preferenceDataStoreFactory: PreferenceDataStoreFactory,
|
||||||
|
) : VoicePlayerStore {
|
||||||
|
private val store = preferenceDataStoreFactory.create("elementx_voice_player")
|
||||||
|
private val playbackSpeedIndex = intPreferencesKey("playback_speed_index")
|
||||||
|
|
||||||
|
override fun playBackSpeedIndex(): Flow<Int> {
|
||||||
|
return store.data.map { prefs ->
|
||||||
|
prefs[playbackSpeedIndex] ?: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setPlayBackSpeedIndex(index: Int) {
|
||||||
|
store.edit { prefs ->
|
||||||
|
prefs[playbackSpeedIndex] = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Element Creations Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.voiceplayer.impl
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
internal class InMemoryVoicePlayerStore(
|
||||||
|
defaultPlaybackSpeedIndex: Int = 0,
|
||||||
|
) : VoicePlayerStore {
|
||||||
|
private val playBackSpeedIndex = MutableStateFlow(defaultPlaybackSpeedIndex)
|
||||||
|
|
||||||
|
override fun playBackSpeedIndex(): Flow<Int> {
|
||||||
|
return playBackSpeedIndex.asStateFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setPlayBackSpeedIndex(index: Int) {
|
||||||
|
playBackSpeedIndex.emit(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,19 +8,17 @@
|
||||||
|
|
||||||
package io.element.android.libraries.voiceplayer.impl
|
package io.element.android.libraries.voiceplayer.impl
|
||||||
|
|
||||||
import app.cash.molecule.RecompositionMode
|
|
||||||
import app.cash.molecule.moleculeFlow
|
|
||||||
import app.cash.turbine.test
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.matrix.api.core.EventId
|
import io.element.android.libraries.matrix.api.core.EventId
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
||||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent
|
||||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageException
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageException
|
||||||
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
import io.element.android.libraries.voiceplayer.api.VoiceMessageState
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
|
import io.element.android.tests.testutils.test
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -31,11 +29,9 @@ class VoiceMessagePresenterTest {
|
||||||
@Test
|
@Test
|
||||||
fun `initial state has proper default values`() = runTest {
|
fun `initial state has proper default values`() = runTest {
|
||||||
val presenter = createVoiceMessagePresenter()
|
val presenter = createVoiceMessagePresenter()
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
awaitItem().let {
|
awaitItem().let {
|
||||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
|
||||||
assertThat(it.progress).isEqualTo(0f)
|
assertThat(it.progress).isEqualTo(0f)
|
||||||
assertThat(it.time).isEqualTo("1:01")
|
assertThat(it.time).isEqualTo("1:01")
|
||||||
}
|
}
|
||||||
|
|
@ -48,29 +44,27 @@ class VoiceMessagePresenterTest {
|
||||||
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
||||||
duration = 2_000.milliseconds,
|
duration = 2_000.milliseconds,
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem().also {
|
val initialState = awaitItem().also {
|
||||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
|
||||||
assertThat(it.progress).isEqualTo(0f)
|
assertThat(it.progress).isEqualTo(0f)
|
||||||
assertThat(it.time).isEqualTo("0:02")
|
assertThat(it.time).isEqualTo("0:02")
|
||||||
}
|
}
|
||||||
|
|
||||||
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
initialState.eventSink(VoiceMessageEvent.PlayPause)
|
||||||
|
|
||||||
awaitItem().also {
|
awaitItem().also {
|
||||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
|
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Downloading)
|
||||||
assertThat(it.progress).isEqualTo(0f)
|
assertThat(it.progress).isEqualTo(0f)
|
||||||
assertThat(it.time).isEqualTo("0:02")
|
assertThat(it.time).isEqualTo("0:02")
|
||||||
}
|
}
|
||||||
awaitItem().also {
|
awaitItem().also {
|
||||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
|
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Downloading)
|
||||||
assertThat(it.progress).isEqualTo(0f)
|
assertThat(it.progress).isEqualTo(0f)
|
||||||
assertThat(it.time).isEqualTo("0:00")
|
assertThat(it.time).isEqualTo("0:00")
|
||||||
}
|
}
|
||||||
awaitItem().also {
|
awaitItem().also {
|
||||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
|
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Pause)
|
||||||
assertThat(it.progress).isEqualTo(0.5f)
|
assertThat(it.progress).isEqualTo(0.5f)
|
||||||
assertThat(it.time).isEqualTo("0:01")
|
assertThat(it.time).isEqualTo("0:01")
|
||||||
}
|
}
|
||||||
|
|
@ -86,24 +80,22 @@ class VoiceMessagePresenterTest {
|
||||||
analyticsService = analyticsService,
|
analyticsService = analyticsService,
|
||||||
duration = 2_000.milliseconds,
|
duration = 2_000.milliseconds,
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem().also {
|
val initialState = awaitItem().also {
|
||||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
|
||||||
assertThat(it.progress).isEqualTo(0f)
|
assertThat(it.progress).isEqualTo(0f)
|
||||||
assertThat(it.time).isEqualTo("0:02")
|
assertThat(it.time).isEqualTo("0:02")
|
||||||
}
|
}
|
||||||
|
|
||||||
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
initialState.eventSink(VoiceMessageEvent.PlayPause)
|
||||||
|
|
||||||
awaitItem().also {
|
awaitItem().also {
|
||||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
|
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Downloading)
|
||||||
assertThat(it.progress).isEqualTo(0f)
|
assertThat(it.progress).isEqualTo(0f)
|
||||||
assertThat(it.time).isEqualTo("0:02")
|
assertThat(it.time).isEqualTo("0:02")
|
||||||
}
|
}
|
||||||
awaitItem().also {
|
awaitItem().also {
|
||||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Retry)
|
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Retry)
|
||||||
assertThat(it.progress).isEqualTo(0f)
|
assertThat(it.progress).isEqualTo(0f)
|
||||||
assertThat(it.time).isEqualTo("0:02")
|
assertThat(it.time).isEqualTo("0:02")
|
||||||
}
|
}
|
||||||
|
|
@ -122,27 +114,25 @@ class VoiceMessagePresenterTest {
|
||||||
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
||||||
duration = 2_000.milliseconds,
|
duration = 2_000.milliseconds,
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem().also {
|
val initialState = awaitItem().also {
|
||||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
|
||||||
assertThat(it.progress).isEqualTo(0f)
|
assertThat(it.progress).isEqualTo(0f)
|
||||||
assertThat(it.time).isEqualTo("0:02")
|
assertThat(it.time).isEqualTo("0:02")
|
||||||
}
|
}
|
||||||
|
|
||||||
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
initialState.eventSink(VoiceMessageEvent.PlayPause)
|
||||||
skipItems(2) // skip downloading states
|
skipItems(2) // skip downloading states
|
||||||
|
|
||||||
val playingState = awaitItem().also {
|
val playingState = awaitItem().also {
|
||||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
|
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Pause)
|
||||||
assertThat(it.progress).isEqualTo(0.5f)
|
assertThat(it.progress).isEqualTo(0.5f)
|
||||||
assertThat(it.time).isEqualTo("0:01")
|
assertThat(it.time).isEqualTo("0:01")
|
||||||
}
|
}
|
||||||
|
|
||||||
playingState.eventSink(VoiceMessageEvents.PlayPause)
|
playingState.eventSink(VoiceMessageEvent.PlayPause)
|
||||||
awaitItem().also {
|
awaitItem().also {
|
||||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
|
||||||
assertThat(it.progress).isEqualTo(0.5f)
|
assertThat(it.progress).isEqualTo(0.5f)
|
||||||
assertThat(it.time).isEqualTo("0:01")
|
assertThat(it.time).isEqualTo("0:01")
|
||||||
}
|
}
|
||||||
|
|
@ -154,11 +144,9 @@ class VoiceMessagePresenterTest {
|
||||||
val presenter = createVoiceMessagePresenter(
|
val presenter = createVoiceMessagePresenter(
|
||||||
eventId = null,
|
eventId = null,
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
awaitItem().also {
|
awaitItem().also {
|
||||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Disabled)
|
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Disabled)
|
||||||
assertThat(it.progress).isEqualTo(0f)
|
assertThat(it.progress).isEqualTo(0f)
|
||||||
assertThat(it.time).isEqualTo("1:01")
|
assertThat(it.time).isEqualTo("1:01")
|
||||||
}
|
}
|
||||||
|
|
@ -171,19 +159,17 @@ class VoiceMessagePresenterTest {
|
||||||
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000),
|
||||||
duration = 10_000.milliseconds,
|
duration = 10_000.milliseconds,
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem().also {
|
val initialState = awaitItem().also {
|
||||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
|
||||||
assertThat(it.progress).isEqualTo(0f)
|
assertThat(it.progress).isEqualTo(0f)
|
||||||
assertThat(it.time).isEqualTo("0:10")
|
assertThat(it.time).isEqualTo("0:10")
|
||||||
}
|
}
|
||||||
|
|
||||||
initialState.eventSink(VoiceMessageEvents.Seek(0.5f))
|
initialState.eventSink(VoiceMessageEvent.Seek(0.5f))
|
||||||
|
|
||||||
awaitItem().also {
|
awaitItem().also {
|
||||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
|
||||||
assertThat(it.progress).isEqualTo(0.5f)
|
assertThat(it.progress).isEqualTo(0.5f)
|
||||||
assertThat(it.time).isEqualTo("0:05")
|
assertThat(it.time).isEqualTo("0:05")
|
||||||
}
|
}
|
||||||
|
|
@ -195,40 +181,66 @@ class VoiceMessagePresenterTest {
|
||||||
val presenter = createVoiceMessagePresenter(
|
val presenter = createVoiceMessagePresenter(
|
||||||
duration = 10_000.milliseconds,
|
duration = 10_000.milliseconds,
|
||||||
)
|
)
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
presenter.test {
|
||||||
presenter.present()
|
|
||||||
}.test {
|
|
||||||
val initialState = awaitItem().also {
|
val initialState = awaitItem().also {
|
||||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
|
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play)
|
||||||
assertThat(it.progress).isEqualTo(0f)
|
assertThat(it.progress).isEqualTo(0f)
|
||||||
assertThat(it.time).isEqualTo("0:10")
|
assertThat(it.time).isEqualTo("0:10")
|
||||||
}
|
}
|
||||||
|
|
||||||
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
initialState.eventSink(VoiceMessageEvent.PlayPause)
|
||||||
|
|
||||||
skipItems(2) // skip downloading states
|
skipItems(2) // skip downloading states
|
||||||
|
|
||||||
awaitItem().also {
|
awaitItem().also {
|
||||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
|
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Pause)
|
||||||
assertThat(it.progress).isEqualTo(0.1f)
|
assertThat(it.progress).isEqualTo(0.1f)
|
||||||
assertThat(it.time).isEqualTo("0:01")
|
assertThat(it.time).isEqualTo("0:01")
|
||||||
|
it.eventSink(VoiceMessageEvent.Seek(0.5f))
|
||||||
}
|
}
|
||||||
|
|
||||||
initialState.eventSink(VoiceMessageEvents.Seek(0.5f))
|
|
||||||
|
|
||||||
awaitItem().also {
|
awaitItem().also {
|
||||||
assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
|
assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Pause)
|
||||||
assertThat(it.progress).isEqualTo(0.5f)
|
assertThat(it.progress).isEqualTo(0.5f)
|
||||||
assertThat(it.time).isEqualTo("0:05")
|
assertThat(it.time).isEqualTo("0:05")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `changing playback speed cycles through available speeds`() = runTest {
|
||||||
|
val presenter = createVoiceMessagePresenter(
|
||||||
|
duration = 10_000.milliseconds,
|
||||||
|
)
|
||||||
|
presenter.test {
|
||||||
|
awaitItem().also {
|
||||||
|
assertThat(it.playbackSpeed).isEqualTo(1.0f)
|
||||||
|
it.eventSink(VoiceMessageEvent.ChangePlaybackSpeed)
|
||||||
|
}
|
||||||
|
awaitItem().also {
|
||||||
|
assertThat(it.playbackSpeed).isEqualTo(1.5f)
|
||||||
|
it.eventSink(VoiceMessageEvent.ChangePlaybackSpeed)
|
||||||
|
}
|
||||||
|
awaitItem().also {
|
||||||
|
assertThat(it.playbackSpeed).isEqualTo(2.0f)
|
||||||
|
it.eventSink(VoiceMessageEvent.ChangePlaybackSpeed)
|
||||||
|
}
|
||||||
|
awaitItem().also {
|
||||||
|
assertThat(it.playbackSpeed).isEqualTo(0.5f)
|
||||||
|
it.eventSink(VoiceMessageEvent.ChangePlaybackSpeed)
|
||||||
|
}
|
||||||
|
awaitItem().also {
|
||||||
|
assertThat(it.playbackSpeed).isEqualTo(1.0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun TestScope.createVoiceMessagePresenter(
|
fun TestScope.createVoiceMessagePresenter(
|
||||||
mediaPlayer: FakeMediaPlayer = FakeMediaPlayer(),
|
mediaPlayer: FakeMediaPlayer = FakeMediaPlayer(),
|
||||||
voiceMessageMediaRepo: VoiceMessageMediaRepo = FakeVoiceMessageMediaRepo(),
|
voiceMessageMediaRepo: VoiceMessageMediaRepo = FakeVoiceMessageMediaRepo(),
|
||||||
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
analyticsService: AnalyticsService = FakeAnalyticsService(),
|
||||||
|
voicePlayerStore: VoicePlayerStore = InMemoryVoicePlayerStore(),
|
||||||
eventId: EventId? = EventId("\$anEventId"),
|
eventId: EventId? = EventId("\$anEventId"),
|
||||||
filename: String = "filename doesn't really matter for a voice message",
|
filename: String = "filename doesn't really matter for a voice message",
|
||||||
duration: Duration = 61_000.milliseconds,
|
duration: Duration = 61_000.milliseconds,
|
||||||
|
|
@ -246,6 +258,7 @@ fun TestScope.createVoiceMessagePresenter(
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
filename = filename
|
filename = filename
|
||||||
),
|
),
|
||||||
|
voicePlayerStore = voicePlayerStore,
|
||||||
eventId = eventId,
|
eventId = eventId,
|
||||||
duration = duration,
|
duration = duration,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:45f61d7b50a14b483d9459846f002b9ac94c6ccb632c636f891616629ad47248
|
oid sha256:21cfef3eb8bb8a0493b1bf22a2b01d7c94a152c7e7e0d1bb558fae1e64e177c5
|
||||||
size 39899
|
size 43941
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:80ce1738cf56d99afbe781c82e32cdef03564a31fec0450aa66a84c56085ce81
|
oid sha256:3caf6bcec609664f2f2e39b1f51b8b08af00d59c7d1f66836e9e0e1751853c2f
|
||||||
size 39956
|
size 45675
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:7a7a5c1978ed1ea48e849e0de8023ac588a29e84152946a9eb0ee6c0680d7a1c
|
oid sha256:6aa352e97a7f4ffba249fb4da75e508c264aa75335dc27b473b7cc54d56474ab
|
||||||
size 4927
|
size 5212
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:2d1cb7c04c321146e7425bc095b9344edf913eeec3588600e500466e326a8107
|
oid sha256:0e2aaee1ad3bc909945881c352ad92669f2385d311be551ab0b961011d86e994
|
||||||
size 8165
|
size 8380
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:68c8f0a94de98773a3dae69fd44fa87fd3eeee5c6bc44afa710a2ba32eacde7e
|
oid sha256:2cb0fc5226b16779d984986d8944bead8b4541a1922dcd81892ccc731d0c7b49
|
||||||
size 8620
|
size 8845
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b98fde923272c2959c407fb3f62cb150f1b0fac3f54d29d94f30646096d889d2
|
oid sha256:4528b5800ad94ad7d6880adbb99430b11e3724fbcf3a1732529a8673d9ad63bf
|
||||||
size 8261
|
size 8465
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:473877bac2a76f565fed0056ee27b8fa91380b5661fd772d17e110caed0fecff
|
oid sha256:d1e6ae40a1f540c16c5482040c36dc4fe5f03b5b8271a3755129d7cd4a5825ac
|
||||||
size 8425
|
size 8779
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:f6e969ae817abeaa153edbc06cbe873439cd3e65be31184f22db1d8becc7953a
|
oid sha256:706d26007463123c7afbb7b151f07deca03bb1924a27eea4544aac0b932b922c
|
||||||
size 8487
|
size 8821
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:fd514ccd8d00f7f1ea2dc0bce9b786be8fe99c98b44a1b99add18e132d0dc1c1
|
oid sha256:33eeb6ecf24ce156fa12dc204f7443bf65682fd1ff8471460b851ae62e6e8a8e
|
||||||
size 5171
|
size 5455
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:9e69b5e773119af8ab4749064e68f6584aac0aa41c11a054db9e9c14e7e9aab0
|
oid sha256:cbbe58a0d2c4052af93d199f5501176c79d42aa17765ac623894a9c59beda46a
|
||||||
size 4908
|
size 5194
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:07745c25bce426e0736b7546836e67cb5cb6d8da79b4f26448b4fef0486a13c7
|
oid sha256:d099ae7db3d850ff78122b5bcc2a7bbe50c7197a30e5aeadfba1174933f85dc2
|
||||||
size 5108
|
size 5408
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:df2ac9c5cbee9a350f54ca6892f42c6685d91d9d4fbe46943143b99c915e8ae1
|
oid sha256:2d09efdfad91b328c5d480a6e8e9652dea8ccd6f8af617734782ea61a07bb2ba
|
||||||
size 5223
|
size 5496
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:d5ebd66819a916fa8eeac654281b432b46de3218243d9831666ff962d412e04d
|
oid sha256:4baec63d3fa378db139ac563a10e414a463500fa3b6240543505a59b598b70ab
|
||||||
size 5486
|
size 5742
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:93a441c65d9ae6cfba78c4a89a72c037bfe1759325a7bbc0bee05dfbc40e96c7
|
oid sha256:35a46a9254a012efdea69e0336e6b9d536ea88265fbc933c23bcbca951f55be9
|
||||||
size 5732
|
size 6006
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a82b644e43af13b60c98ccd565873cfbe1c46e3391426bafcd5d71881fabe34c
|
oid sha256:da9749249afc0c35a171b1a41f1a6189f7e9f7906079be2fd3634f0da1867181
|
||||||
size 5434
|
size 5708
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:257b45fa5de31badce26360dd3c48b502b5ce928bc4d13434f9b42703f29dbb1
|
oid sha256:bbb6613af390663a0cbc3a8342cc44baf1b07e0388bafc20281fdeeca9a644b3
|
||||||
size 5640
|
size 5953
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:11adeb3f01fa93c159d692cc2323761873bfba37ad6a3a014e2fdbe80cd108b1
|
oid sha256:51fca90e22f3dcb48a3d7a958facb523bb20812f009bc384f1ccb70433aec035
|
||||||
size 5779
|
size 6083
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b13c460c659bc743e7aec561ba090ee6174b1acb49821417d13375feee46c6f7
|
oid sha256:b282fcfd5e7d4172b0880f2858cabeb6088cd132843ee735230a2739563910fa
|
||||||
size 4910
|
size 5304
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:bebeecb0d945b5b66535c6b1ef63c46a45232f304deb5e48e7fb4e0f92045716
|
oid sha256:b3e2391ad08c944c655c9a93b9aabb45bbd4b7c199f97eeb39daf87dd6bf1170
|
||||||
size 7786
|
size 8163
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b13a02e83748f671aa4aec7eb8052bd3637eeb8c898ded100e29aa219ffc1fbb
|
oid sha256:e4b9d60e3074cfbc779f9d789cbb33feccbafe6ca2fa55dbd81d2436b60eed9e
|
||||||
size 8337
|
size 8753
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:4ef577bfe5233d1194c7bfbaedb38b3690964fe8d3325a9f1c71c3e83fc81d8f
|
oid sha256:0d30758cd59e52f0b9e5c0d2e23cce3b489db2fc784bbe62df4bd91f38de910f
|
||||||
size 8149
|
size 8416
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:4788f5817016607483304bcc4557921da51e801d5378d6c410e8c2a5a700b713
|
oid sha256:8de312bc408bb1717042d0902469493d262f3df6e6a025b15b28a1129d801197
|
||||||
size 8087
|
size 8511
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:0464770461d9a59015fa3bd2149bdd17c00600ae256fe4d6b2eaec12a029d04d
|
oid sha256:2c3edf08e3a769fa3603a31d8c21cb2f8a105087bca91967629dbe73f966a904
|
||||||
size 8121
|
size 8522
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:b79152dddd997e11c52b910c19c02d21cf504d2a8ae228e11c9546f2496596da
|
oid sha256:a442a45bd1b80846060575e10377df39401e7496c306f1b44d6de955cf60b1e2
|
||||||
size 5109
|
size 5543
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:ebee4fdf176801a756018e9c4ea80a7e506e9a58dc789f1fb0b9df8306abacf0
|
oid sha256:4dfb8f775a2fbac7f7d7478107e25457b76cd1b9131da8fff50971d06c0a8ab2
|
||||||
size 4918
|
size 5295
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:6a4c30619c89ec5af57edf09b48429171eaeee88c04334b336e45b8478c1ebc0
|
oid sha256:fc51049f759f3248a4c376e608c3108e1fff496237de6b3061b2f4f1ff904698
|
||||||
size 5106
|
size 5525
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:8e1098a584fec0ec74838c2cebf0db3f1c6d75eea8575e2d0e946657cd065395
|
oid sha256:3cdadd897dfcf88848e3c8294afa7c5d34d0af8e5cfc6684a1239d4a53171b0a
|
||||||
size 5184
|
size 5579
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:c0d3ffddd38f5318e64b9adceecc5f09cf2f6a718327463ecde5b3495ff9bdb0
|
oid sha256:a5a6f5c94a4308e4fe3e66c6620b582726aeea0eec3ca7e84216f41fde9d42ac
|
||||||
size 5455
|
size 5803
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:8d530adf3152141fafb258bad3cc45349fc2726bd1c5a51440d1608a654dd031
|
oid sha256:333cafb2282afb0fdb076f5f8528df9bed46987a6f53993b6689425b60da767b
|
||||||
size 5707
|
size 6100
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:d397a13c3fba112e8bd2f3045c4e9fc9de10869204451b33cc7a88b2938b9797
|
oid sha256:b857f3c9a84373b292faae437ede2de4244fd7f5baa3bf7eb14f8978fd575c99
|
||||||
size 5451
|
size 5809
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:5c87ecb2cbb0bee46b088f1e253e1c969e20cc407bb64827dd98294871f22b92
|
oid sha256:6abc6eb10edfbbf0145e291689679eee6c25521564fc4458f402ec98d34af5f7
|
||||||
size 5635
|
size 6024
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:614d88406ef318b65475837e79b10759f3c9ee038dcfea963677d371874a7a1b
|
oid sha256:98a5873e4696e628f2c79f0c5dbb95d602a6e2facc3a60dee4c24ba4dc4a09c3
|
||||||
size 5774
|
size 6153
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:7a908b65efa9cfa89d121f3e23a0bfad0926d269d9600fe6e58455e2e70a4fac
|
oid sha256:24dd95b2320c849a5de7d6b23c4c5c429400dbbeb4bd9619eda115e940d8f547
|
||||||
size 54687
|
size 55922
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:c40bba2d435777c6d990b8cb699c19a2384b64c4c27e65d8baee6ef3dfd8b86f
|
oid sha256:25d2342f4bbd9adca42ae92f54dc3281e4a9c37f5fb5650c52cdd6a488855ad0
|
||||||
size 52591
|
size 53943
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:7bfa11446dc195a1912857a553f57df926660d32562e9d4af133e7f039cf37d8
|
||||||
|
size 8304
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:4c8ab3d7799fedc07b9fc39e22af4d61c237426b999e39663444944865954b67
|
||||||
|
size 8176
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:c0d4913ae2a0dc42a4c116f731a3b4d822801271423e1248728f86b9cb17eed1
|
oid sha256:511c0cb8323fb971989138aa25d0323102de60f43b8635953a12a6301091aac3
|
||||||
size 8620
|
size 9332
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:a0d9b0271872bb405ec0d6f8afa475404c9612b8d42dc5bb835ce8dcc8b1fa0a
|
oid sha256:9def0ceeac3ad39fb5c03d7d525c012e3c592ec988a9baa8fd5e424f49438119
|
||||||
size 9223
|
size 9874
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:9f969aeedd303cf4c6b90e3ab3b2144c7eddb7860e69af2c1ff395ff2202793f
|
oid sha256:1ae1bd729e7b91acdc57636a0fc8bd105403af99f4c37c1954917646dc13f54d
|
||||||
size 8892
|
size 9644
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:509f29fbd03496acb78f18d4fb51c207981d145a6c675e02a9d0b7b96811c2f8
|
oid sha256:266acb20ed53b40bd5fce5698e29e357442823a528db52781641d1e4ee9d7079
|
||||||
size 9014
|
size 9763
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:c4d16acd7a9caed097bb7e352c290cb543f70b330800ef2b29b235bd52a0a4dc
|
oid sha256:d34d9a892baeb6443b390b623f2d355e758f07292d7ca742210032313307b309
|
||||||
size 9213
|
size 9882
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:59784cf1d8c167ecdbe78e3db46b20aa8770a17ffd794193ab9ded221120d301
|
oid sha256:451677cf50c127f97fc37a1a1dfadaf62229d8d35d55554c6da0d085f78c4a99
|
||||||
size 8054
|
size 8777
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:f36874223538e2d4e5ee1c383ef3c1b83638a6e3c2d1791b46f9393e4bd7afea
|
oid sha256:193a50065c69de27fed36c7b3151351e6606c4006545b773b89e2c9ca96f68ea
|
||||||
size 8675
|
size 9336
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:874579b131a2956564603ba279803c4f753736a4545f50f9a1eff93ff2e414a3
|
oid sha256:e39948d6cf139439013100ba0559387591656b23b2cff14320f9edfd90c20a65
|
||||||
size 8417
|
size 9105
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:1d3da9ef5ffa433a5eaadc964e6d097a474c9b1245e2ebb60c067cc97a0f054b
|
oid sha256:71c08ef07e6bd0da6cc80de847507bd1f39d161365883eed986f0f600217e765
|
||||||
size 8431
|
size 9211
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:cf3ab6dc97d3f34b7e25062885a49eeecc563a69f6c337d346ad1d9ad0c45fd1
|
oid sha256:0858764ff6bda5d7a693336315023c7a216ffa5f59b2070a2f8d7342aeac6416
|
||||||
size 8583
|
size 9340
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:83593ae45c35e34d5057d45b8a7e66b582b3b52adc7f05fa2d9c3ecdc0bece9c
|
oid sha256:97818e454a5848c9bf1a1b8d5c4328cf1671b86131b93bb64888b1b28fa28869
|
||||||
size 8676
|
size 9400
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:69a4678aba6913f981f344eb730add0f4fdbe0529f7be015019150bac15f4709
|
oid sha256:cdc03c9793f1198aa6efdb3a93eb8982cf365055e21bc86553ae83cdcea0b99f
|
||||||
size 11009
|
size 11731
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:dac10693719b9cfe5f3644c25cbb9d1a59ed7c2f89e791eec17bf635c4d59efc
|
oid sha256:2ce01af104e3ce49a78b6fb9eab021f998938c59e487135a1b4d18f4b4a01b73
|
||||||
size 36507
|
size 37319
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:d234492829a729dcb05657af65a3fe97fe025ff42c79d9cf862e4e9925ddd832
|
oid sha256:3816f44134580eb501d2a6abb91d713f5209fea3b1c35e500db88bbde1a6003d
|
||||||
size 6944
|
size 7691
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:af28ed6a8b9d3b399531bf82d7be3c5a5b23ecc750654c241b7acc95a036132b
|
oid sha256:b7ddddb2a610a152474cd01c41b8dd50c7a9befee4f5a206f7ed91633f2d9fbd
|
||||||
size 8115
|
size 8841
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:44b0989e9ddb097d3a5ef0f331221a12a90f69ff017e3447e31ef5cccc53af68
|
oid sha256:c5686ac15dbfc6cd805662f5e30d17bef529b2fbe84630e158306c1c2ca579f9
|
||||||
size 10290
|
size 11008
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:98eb5d83d322022956b684699683f25ef6c09be55c60224689685589e0051983
|
oid sha256:91f5c2d2fc15eac0c52a6088427bc8ccebe2e2ac966e59d36c17f85023fe3c75
|
||||||
size 34883
|
size 35660
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:263371af4d0ac7efcd840a793e663505865d32c425d5b2efe9489a63655e33e1
|
oid sha256:ea7e795366ebbd6730f2621b748238f6914c7d83fd76f69f98a6a6f5c785b8a1
|
||||||
size 6563
|
size 7311
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:e4d1d09321fb1220d1be07e6f0ba54495764c935dcf6dc75bb3e7e5879770e46
|
oid sha256:1dfe35935e328d87472211f1c6af5ddef99342c4a032dcb6aebab95a2efb0796
|
||||||
size 32646
|
size 33445
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
version https://git-lfs.github.com/spec/v1
|
version https://git-lfs.github.com/spec/v1
|
||||||
oid sha256:46ab31307298aa5ce72f71db8eedefbde78eeebb3ff1426833bcb0a7c6af9ae3
|
oid sha256:394bde203edc4e5b91060e0c3ce22291ac550855a8a7d5162ed5a442161e0ae9
|
||||||
size 31131
|
size 31867
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue