Merge pull request #5963 from element-hq/feature/bma/variablePlayBackSpeed

Voice message: variable play back speed
This commit is contained in:
Benoit Marty 2026-01-05 13:58:01 +01:00 committed by GitHub
commit b01135aeef
75 changed files with 478 additions and 223 deletions

View file

@ -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)) },
) )
} }
} }

View file

@ -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 = {},
)
}
}
}

View file

@ -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.
*/ */

View file

@ -159,6 +159,10 @@ class DefaultMediaPlayer(
} }
} }
override fun setPlaybackSpeed(speed: Float) {
player.setPlaybackSpeed(speed)
}
override fun close() { override fun close() {
player.release() player.release()
} }

View file

@ -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()
} }

View file

@ -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) {

View file

@ -96,6 +96,10 @@ class FakeMediaPlayer(
} }
} }
override fun setPlaybackSpeed(speed: Float) {
// no-op
}
override fun close() { override fun close() {
// no-op // no-op
} }

View file

@ -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,
) )

View file

@ -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
} }

View file

@ -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,

View file

@ -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 = {},
) )

View file

@ -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)

View file

@ -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,

View file

@ -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

View file

@ -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,
) )
} }

View file

@ -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)
}

View file

@ -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
}
}
}

View file

@ -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)
}
}

View file

@ -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,
) )

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7bfa11446dc195a1912857a553f57df926660d32562e9d4af133e7f039cf37d8
size 8304

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4c8ab3d7799fedc07b9fc39e22af4d61c237426b999e39663444944865954b67
size 8176

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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