From 0794b28acaa6da93b89d866e6b8a681085862f4b Mon Sep 17 00:00:00 2001 From: Kanat Kiialbaev Date: Wed, 25 Sep 2024 05:56:08 -0400 Subject: [PATCH] [PBE-4800] fix found voice recording issues (#5412) * fix N2 * fix N9 * fix N3 * fix N7 * fix N4 * fix N1 * fix N6 * code clean up * fix N8 * spotless & ktlint * fix N12 * fix N11 * fix 10 * fix N13 * spotless, api_dump, ktlint * fix unit tests * fix N14 * fix N14 - extra1 * fix N14 - extra2 * fix detekt * fix N15 * fix N16 * fix N17 * fix spotless * fix ktlint --------- Co-authored-by: Aleksandar Apostolov Co-authored-by: Alexey Alter-Pesotskiy --- .../api/stream-chat-android-client.api | 2 + .../detekt-baseline.xml | 1 + .../chat/android/client/ChatClient.kt | 2 +- .../chat/android/client/audio/AudioPlayer.kt | 18 ++ .../android/client/audio/NativeMediaPlayer.kt | 81 ++++--- .../android/client/audio/StreamAudioPlayer.kt | 96 ++++++-- .../client/extensions/AttachmentExtensions.kt | 6 + .../client/utils/message/MessageUtils.kt | 5 + .../client/audio/StreamMediaPlayerTest.kt | 2 +- .../api/stream-chat-android-compose.api | 60 ++++- .../detekt-baseline.xml | 11 +- .../attachments/StreamAttachmentFactories.kt | 3 +- .../ui/attachments/audio/AudioRecording.kt | 131 ----------- .../content/AudioRecordAttachmentContent.kt | 96 ++++---- .../AudioRecordAttachmentPreviewContent.kt | 14 +- .../AudioRecordAttachmentQuotedContent.kt | 86 +++++++ .../factory/QuotedAttachmentFactory.kt | 7 + .../compose/ui/channels/list/ChannelItem.kt | 1 + .../ui/components/audio/PlaybackTimer.kt | 110 +++++++++ .../ui/components/audio/WaveformSlider.kt | 138 ++++++----- .../ui/messages/composer/MessageComposer.kt | 22 +- .../DefaultMessageComposerRecordingContent.kt | 37 +-- .../android/compose/ui/theme/ChatTheme.kt | 15 ++ .../android/compose/ui/theme/StreamDimens.kt | 6 + .../ui/util/MessagePreviewFormatter.kt | 23 +- .../ui/util/MessagePreviewIconFactory.kt | 78 ++++++ .../compose/ui/util/MimeTypeIconProvider.kt | 1 + .../android/compose/ui/util/ModifierUtils.kt | 34 +++ .../messages/AudioPlayerViewModel.kt | 38 ++- .../src/main/res/values/strings.xml | 1 + .../api/stream-chat-android-ui-common.api | 33 +++ .../detekt-baseline.xml | 1 + .../composer/AudioRecordingController.kt | 122 +++++----- .../messages/list/AudioPlayerController.kt | 222 +++++++++++++----- .../state/messages/composer/RecordingState.kt | 6 +- .../state/messages/list/AudioPlayerState.kt | 57 +++-- 36 files changed, 1066 insertions(+), 500 deletions(-) delete mode 100644 stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/audio/AudioRecording.kt create mode 100644 stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/AudioRecordAttachmentQuotedContent.kt create mode 100644 stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/audio/PlaybackTimer.kt create mode 100644 stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/MessagePreviewIconFactory.kt diff --git a/stream-chat-android-client/api/stream-chat-android-client.api b/stream-chat-android-client/api/stream-chat-android-client.api index e912c4862f4..183e034d146 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -2222,6 +2222,7 @@ public final class io/getstream/chat/android/client/extensions/AttachmentExtensi public static final field EXTRA_UPLOAD_ID Ljava/lang/String; public static final field EXTRA_WAVEFORM_DATA Ljava/lang/String; public static final fun getDuration (Lio/getstream/chat/android/models/Attachment;)Ljava/lang/Float; + public static final fun getDurationInMs (Lio/getstream/chat/android/models/Attachment;)Ljava/lang/Integer; public static final fun getUploadId (Lio/getstream/chat/android/models/Attachment;)Ljava/lang/String; public static final fun getWaveformData (Lio/getstream/chat/android/models/Attachment;)Ljava/util/List; } @@ -2870,6 +2871,7 @@ public final class io/getstream/chat/android/client/utils/internal/toggle/Toggle } public final class io/getstream/chat/android/client/utils/message/MessageUtils { + public static final fun hasAudioRecording (Lio/getstream/chat/android/models/Message;)Z public static final fun isDeleted (Lio/getstream/chat/android/models/Message;)Z public static final fun isEphemeral (Lio/getstream/chat/android/models/Message;)Z public static final fun isError (Lio/getstream/chat/android/models/Message;)Z diff --git a/stream-chat-android-client/detekt-baseline.xml b/stream-chat-android-client/detekt-baseline.xml index fc309fe6dd8..08c1460443a 100644 --- a/stream-chat-android-client/detekt-baseline.xml +++ b/stream-chat-android-client/detekt-baseline.xml @@ -9,6 +9,7 @@ LongMethod:ChatClientDebuggerTest.kt$ChatClientDebuggerTest$@BeforeEach fun setUp() LongMethod:ChatSocket.kt$ChatSocket$@Suppress("ComplexMethod") private fun observeSocketStateService(): Job LongMethod:PinnedMessagesRequest.kt$PinnedMessagesRequest.Companion$fun create( limit: Int, sort: QuerySorter<Message>, pagination: PinnedMessagesPagination, ): PinnedMessagesRequest + MagicNumber:AttachmentExtensions.kt$1000 MagicNumber:Identifiers.kt$31 MagicNumber:StreamDateFormatter.kt$StreamDateFormatter$100 MagicNumber:StreamDateFormatter.kt$StreamDateFormatter$10000 diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt index 0377d9609c6..5817a2403df 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt @@ -3564,7 +3564,7 @@ internal constructor( } }, userScope = userScope, - isMarshmallowOrHigher = { Build.VERSION.SDK_INT >= Build.VERSION_CODES.M }, + isMarshmallowOrHigher = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M, ) return ChatClient( diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/audio/AudioPlayer.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/audio/AudioPlayer.kt index 863c73b3f44..22a50b79545 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/audio/AudioPlayer.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/audio/AudioPlayer.kt @@ -30,6 +30,12 @@ public interface AudioPlayer { */ public val currentState: AudioState + /** + * The identifier of the current audio track. + * If there is no current audio track, it returns -1. + */ + public val currentPlayingId: Int + /** * Subscribing for audio state changes for the audio of the hash * @@ -101,6 +107,13 @@ public interface AudioPlayer { */ public fun currentSpeed(): Float + /** + * Returns the current position of the audio track in milliseconds. + * + * @param audioHash the identifier of the audio track + */ + public fun getCurrentPositionInMs(audioHash: Int): Int + /** * Removes the current audio form the reproduction queue and removes the listeners */ @@ -111,6 +124,11 @@ public interface AudioPlayer { */ public fun removeAudios(audioHashList: List) + /** + * Resets the player to the initial state and removes all audios. + */ + public fun reset() + /** * Disposes the MediaPlayer and remove all audios. */ diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/audio/NativeMediaPlayer.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/audio/NativeMediaPlayer.kt index 0ca78b1b258..3e0e56c6803 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/audio/NativeMediaPlayer.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/audio/NativeMediaPlayer.kt @@ -343,6 +343,24 @@ internal class NativeMediaPlayerImpl( private val logger by taggedLogger("Chat:NativeMediaPlayer") + private val _onErrorListener = MediaPlayer.OnErrorListener { mp, what, extra -> + if (DEBUG) logger.e { "[onError] what: $what, extra: $extra, mp: ${mp.hashCode()}" } + state = NativeMediaPlayerState.ERROR + onErrorListener?.invoke(what, extra) ?: false + } + + private val _onPreparedListener = MediaPlayer.OnPreparedListener { + if (DEBUG) logger.d { "[onPrepared] no args" } + state = NativeMediaPlayerState.PREPARED + onPreparedListener?.invoke() + } + + private val _onCompletionListener = MediaPlayer.OnCompletionListener { + if (DEBUG) logger.d { "[onCompletion] no args" } + state = NativeMediaPlayerState.PLAYBACK_COMPLETED + onCompletionListener?.invoke() + } + private var _mediaPlayer: MediaPlayer? = null set(value) { if (DEBUG) logger.i { "[setMediaPlayerInstance] instance: $value" } @@ -374,7 +392,8 @@ internal class NativeMediaPlayerImpl( @RequiresApi(Build.VERSION_CODES.M) @Throws(IllegalStateException::class, IllegalArgumentException::class) set(value) { - if (DEBUG) logger.d { "[setSpeed] speed: $value" } + val mediaPlayer = mediaPlayer + if (DEBUG) logger.d { "[setSpeed] mediaPlayer: ${mediaPlayer.hashCode()}, speed: $value" } mediaPlayer.playbackParams = mediaPlayer.playbackParams.setSpeed(value) } override val currentPosition: Int @@ -390,61 +409,73 @@ internal class NativeMediaPlayerImpl( IllegalStateException::class, ) override fun setDataSource(path: String) { - if (DEBUG) logger.d { "[setDataSource] path: $path" } + val mediaPlayer = mediaPlayer + if (DEBUG) logger.d { "[setDataSource] mediaPlayer: ${mediaPlayer.hashCode()}, path: $path" } mediaPlayer.setDataSource(path) state = NativeMediaPlayerState.INITIALIZED } @Throws(IllegalStateException::class) override fun prepareAsync() { - if (DEBUG) logger.d { "[prepareAsync] no args" } + val mediaPlayer = mediaPlayer + if (DEBUG) logger.d { "[prepareAsync] mediaPlayer: ${mediaPlayer.hashCode()}" } mediaPlayer.prepareAsync() state = NativeMediaPlayerState.PREPARING } @Throws(IOException::class, IllegalStateException::class) override fun prepare() { - if (DEBUG) logger.d { "[prepare] no args" } + val mediaPlayer = mediaPlayer + if (DEBUG) logger.d { "[prepare] mediaPlayer: ${mediaPlayer.hashCode()}" } mediaPlayer.prepare() state = NativeMediaPlayerState.PREPARED } @Throws(IllegalStateException::class) override fun seekTo(msec: Int) { - if (DEBUG) logger.d { "[seekTo] msec: $msec" } + val mediaPlayer = mediaPlayer + if (DEBUG) logger.d { "[seekTo] mediaPlayer: ${mediaPlayer.hashCode()}, msec: $msec" } mediaPlayer.seekTo(msec) } @Throws(IllegalStateException::class) override fun start() { - if (DEBUG) logger.d { "[start] no args" } + val mediaPlayer = mediaPlayer + if (DEBUG) logger.d { "[start] mediaPlayer: ${mediaPlayer.hashCode()}" } mediaPlayer.start() state = NativeMediaPlayerState.STARTED } @Throws(IllegalStateException::class) override fun pause() { - if (DEBUG) logger.d { "[pause] no args" } + val mediaPlayer = mediaPlayer + if (DEBUG) logger.d { "[pause] mediaPlayer: ${mediaPlayer.hashCode()}" } mediaPlayer.pause() state = NativeMediaPlayerState.PAUSED } @Throws(IllegalStateException::class) override fun stop() { - if (DEBUG) logger.d { "[stop] no args" } + val mediaPlayer = mediaPlayer + if (DEBUG) logger.d { "[stop] mediaPlayer: ${mediaPlayer.hashCode()}" } mediaPlayer.stop() state = NativeMediaPlayerState.STOPPED } override fun reset() { - if (DEBUG) logger.d { "[reset] no args" } + val mediaPlayer = mediaPlayer + if (DEBUG) logger.d { "[reset] mediaPlayer: ${mediaPlayer.hashCode()}" } mediaPlayer.reset() state = NativeMediaPlayerState.IDLE } override fun release() { - if (DEBUG) logger.d { "[release] no args" } - mediaPlayer.release() + val mediaPlayer = _mediaPlayer ?: run { + if (DEBUG) logger.d { "[release] mediaPlayer is null" } + return + } + if (DEBUG) logger.d { "[release] mediaPlayer: ${mediaPlayer.hashCode()}" } + mediaPlayer.clearListeners().release() state = NativeMediaPlayerState.END _mediaPlayer = null } @@ -465,22 +496,18 @@ internal class NativeMediaPlayerImpl( } private fun MediaPlayer.setupListeners(): MediaPlayer { - setOnErrorListener { _, what, extra -> - if (DEBUG) logger.e { "[onError] what: $what, extra: $extra" } - state = NativeMediaPlayerState.ERROR - _mediaPlayer = null - onErrorListener?.invoke(what, extra) ?: false - } - setOnPreparedListener { - if (DEBUG) logger.d { "[onPrepared] no args" } - state = NativeMediaPlayerState.PREPARED - onPreparedListener?.invoke() - } - setOnCompletionListener { - if (DEBUG) logger.d { "[onCompletion] no args" } - state = NativeMediaPlayerState.PLAYBACK_COMPLETED - onCompletionListener?.invoke() - } + if (DEBUG) logger.d { "[setupListeners] mediaPlayer: ${this.hashCode()}" } + setOnErrorListener(_onErrorListener) + setOnPreparedListener(_onPreparedListener) + setOnCompletionListener(_onCompletionListener) + return this + } + + private fun MediaPlayer.clearListeners(): MediaPlayer { + if (DEBUG) logger.d { "[clearListeners] mediaPlayer: ${this.hashCode()}" } + setOnErrorListener(null) + setOnPreparedListener(null) + setOnCompletionListener(null) return this } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/audio/StreamAudioPlayer.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/audio/StreamAudioPlayer.kt index 513b693d4ae..cdc86ca3019 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/audio/StreamAudioPlayer.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/audio/StreamAudioPlayer.kt @@ -28,20 +28,19 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicInteger -private const val INITIAL_SPEED = 1F -private const val SPEED_INCREMENT = 0.5F - @Suppress("TooManyFunctions") internal class StreamMediaPlayer( private val mediaPlayer: NativeMediaPlayer, private val userScope: UserScope, @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.M) - private val isMarshmallowOrHigher: () -> Boolean, + private val isMarshmallowOrHigher: Boolean, private val progressUpdatePeriod: Long = 50, ) : AudioPlayer { companion object { private const val DEBUG_POLLING = false + private const val INITIAL_SPEED = 1F + private const val SPEED_INCREMENT = 0.5F } private val logger by taggedLogger("Chat:StreamMediaPlayer") @@ -62,6 +61,8 @@ internal class StreamMediaPlayer( private var playingSpeed = 1F private var currentIndex = 0 + override val currentPlayingId: Int get() = currentAudioHash + override val currentState: AudioState get() = when (playerState) { PlayerState.UNSET -> AudioState.UNSET @@ -72,21 +73,21 @@ internal class StreamMediaPlayer( } override fun registerOnAudioStateChange(audioHash: Int, onAudioStateChange: (AudioState) -> Unit) { - logger.i { "[registerOnAudioStateChange] audioHash: $audioHash" } + logger.v { "[registerOnAudioStateChange] audioHash: $audioHash, size: ${onStateListeners.size}" } onStateListeners[audioHash]?.add(onAudioStateChange) ?: run { onStateListeners[audioHash] = mutableListOf(onAudioStateChange) } } override fun registerOnProgressStateChange(audioHash: Int, onProgressDataChange: (ProgressData) -> Unit) { - logger.i { "[registerOnProgressStateChange] audioHash: $audioHash" } + logger.v { "[registerOnProgressStateChange] audioHash: $audioHash, size: ${onProgressListeners.size}" } onProgressListeners[audioHash]?.add(onProgressDataChange) ?: run { onProgressListeners[audioHash] = mutableListOf(onProgressDataChange) } } override fun registerOnSpeedChange(audioHash: Int, onSpeedChange: (Float) -> Unit) { - logger.i { "[registerOnSpeedChange] audioHash: $audioHash" } + logger.v { "[registerOnSpeedChange] audioHash: $audioHash, size: ${onSpeedListeners.size}" } onSpeedListeners[audioHash]?.add(onSpeedChange) ?: run { onSpeedListeners[audioHash] = mutableListOf(onSpeedChange) } @@ -113,16 +114,16 @@ internal class StreamMediaPlayer( override fun prepare(sourceUrl: String, audioHash: Int) { logger.d { "[prepare] audioHash: $audioHash, sourceUrl.hash: ${sourceUrl.hashCode()}" } if (audioHash != currentAudioHash) { - resetPlayer(currentAudioHash) + resetPlayer() setAudio(sourceUrl, audioHash, autoPlay = false) return } } override fun play(sourceUrl: String, audioHash: Int) { - logger.i { "[play] audioHash: $audioHash, sourceUrl.hash: ${sourceUrl.hashCode()}" } + logger.i { "[play] audioHash($currentAudioHash): $audioHash, playerState: $playerState" } if (audioHash != currentAudioHash) { - resetPlayer(currentAudioHash) + resetPlayer() setAudio(sourceUrl, audioHash, autoPlay = true) return } @@ -136,7 +137,7 @@ internal class StreamMediaPlayer( } override fun changeSpeed() { - if (isMarshmallowOrHigher()) { + if (isMarshmallowOrHigher && mediaPlayer.isSpeedSettable()) { logger.i { "[changeSpeed] no args" } val currentSpeed = playingSpeed val newSpeed = if (currentSpeed >= 2 || currentSpeed < 1) { @@ -145,18 +146,16 @@ internal class StreamMediaPlayer( currentSpeed + SPEED_INCREMENT } - playingSpeed = newSpeed - - if (playerState == PlayerState.PLAYING) { + if (playerState == PlayerState.PLAYING && mediaPlayer.isSpeedSettable()) { + playingSpeed = newSpeed mediaPlayer.speed = newSpeed + publishSpeed(currentAudioHash, newSpeed) } - - publishSpeed(currentAudioHash, newSpeed) } } override fun currentSpeed(): Float = - if (isMarshmallowOrHigher()) mediaPlayer.speed else 1F + if (isMarshmallowOrHigher) mediaPlayer.speed else 1F override fun dispose() { userScope.launch(DispatcherProvider.Main) { @@ -191,17 +190,31 @@ internal class StreamMediaPlayer( override fun resetAudio(audioHash: Int) { logger.i { "[resetAudio] playerState: $playerState, audioHash: $audioHash" } if (audioHash == currentAudioHash) { - resetPlayer(audioHash) + resetPlayer() } removeAudio(audioHash) } - private fun resetPlayer(audioHash: Int) { - logger.v { "[resetPlayer] playerState: $playerState, audioHash: $audioHash" } + override fun reset() { + logger.i { "[reset] playerState: $playerState, currentAudioHash: $currentAudioHash" } + resetPlayer() + onStateListeners.clear() + onProgressListeners.clear() + onSpeedListeners.clear() + audioTracks.clear() + seekMap.clear() + } + + private fun resetPlayer() { + logger.v { "[resetPlayer] playerState: $playerState, audioHash: $currentAudioHash" } stopPolling() mediaPlayer.reset() playerState = PlayerState.UNSET - publishAudioState(audioHash, AudioState.UNSET) + if (currentAudioHash != -1) { + publishAudioState(currentAudioHash, AudioState.UNSET) + seekMap.remove(currentAudioHash) + currentAudioHash = -1 + } } private fun setAudio(sourceUrl: String, audioHash: Int, autoPlay: Boolean = true) { @@ -234,15 +247,22 @@ internal class StreamMediaPlayer( private fun start() { val currentPosition = mediaPlayer.currentPosition + val currentAudioHash = currentAudioHash logger.d { "[start] currentAudioHash: $currentAudioHash" + ", currentPosition: $currentPosition, playerState: $playerState" } if (playerState == PlayerState.IDLE || playerState == PlayerState.PAUSE) { val seekTo = seekMap[currentAudioHash] ?: 0 - logger.v { "[start] seekTo: $seekTo" } + val duration = mediaPlayer.duration + logger.v { "[start] seekTo: $seekTo, duration: $duration" } + if (seekTo >= duration) { + publishProgress(currentAudioHash, ProgressData(duration, 1f, duration)) + postOnComplete(currentAudioHash) + return + } mediaPlayer.seekTo(seekTo) - if (isMarshmallowOrHigher()) { + if (isMarshmallowOrHigher && mediaPlayer.isSpeedSettable()) { mediaPlayer.speed = playingSpeed publishSpeed(currentAudioHash, playingSpeed) } @@ -265,7 +285,7 @@ internal class StreamMediaPlayer( } override fun resume(audioHash: Int) { - logger.d { "[pause] audioHash: $audioHash, playerState: $playerState" } + logger.d { "[resume] audioHash: $audioHash, playerState: $playerState" } val isIdleOrPaused = playerState == PlayerState.IDLE || playerState == PlayerState.PAUSE if (isIdleOrPaused && currentAudioHash == audioHash) { start() @@ -284,7 +304,7 @@ internal class StreamMediaPlayer( } } - fun getCurrentProgress(audioHash: Int): Int { + override fun getCurrentPositionInMs(audioHash: Int): Int { if (currentIndex == audioHash) { return mediaPlayer.currentPosition } @@ -292,6 +312,7 @@ internal class StreamMediaPlayer( } override fun startSeek(audioHash: Int) { + logger.d { "[startSeek] audioHash: $audioHash, playerState: $playerState" } if (playerState == PlayerState.PLAYING && currentAudioHash == audioHash) { pause() } @@ -309,9 +330,18 @@ internal class StreamMediaPlayer( private fun onError(audioHash: Int, what: Int, extra: Int): Boolean { logger.e { "[onError] audioHash: $audioHash, what: $what, extra: $extra" } complete(audioHash) + resetPlayer() + mediaPlayer.release() return true } + private fun postOnComplete(audioHash: Int) { + logger.v { "[postOnComplete] audioHash: $audioHash" } + userScope.launch(DispatcherProvider.Main) { + complete(audioHash) + } + } + private fun onComplete(audioHash: Int) { logger.i { "[onComplete] audioHash: $audioHash" } complete(audioHash) @@ -329,7 +359,7 @@ internal class StreamMediaPlayer( logger.v { "[complete] currentIndex: $currentIndex, lastIndex: ${audioTracks.lastIndex}" } if (currentIndex < audioTracks.lastIndex) { val trackInfo = audioTracks[currentIndex + 1] - resetPlayer(audioHash) + resetPlayer() setAudio(trackInfo.url, trackInfo.hash) } } @@ -415,6 +445,20 @@ internal class StreamMediaPlayer( else -> false } } + + private fun NativeMediaPlayer.isSpeedSettable(): Boolean { + return when (state) { + NativeMediaPlayerState.INITIALIZED, + NativeMediaPlayerState.PREPARED, + NativeMediaPlayerState.STARTED, + NativeMediaPlayerState.PAUSED, + NativeMediaPlayerState.PLAYBACK_COMPLETED, + NativeMediaPlayerState.ERROR, + -> true + + else -> false + } + } } internal class TrackInfo(val url: String, val hash: Int, private val positionInt: Int) : Comparable { diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/AttachmentExtensions.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/AttachmentExtensions.kt index acaa6119374..b8c67f4af16 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/AttachmentExtensions.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/AttachmentExtensions.kt @@ -36,6 +36,12 @@ public val Attachment.uploadId: String? public val Attachment.duration: Float? get() = (extraData[EXTRA_DURATION] as? Number)?.toFloat() +/** + * Duration of the attachment in milliseconds. + */ +public val Attachment.durationInMs: Int? + get() = duration?.times(1000)?.toInt() + /** * Waveform data of the attachment. */ diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/message/MessageUtils.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/message/MessageUtils.kt index 8d09444679b..77257c21bb4 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/message/MessageUtils.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/message/MessageUtils.kt @@ -125,6 +125,11 @@ public fun Message.isError(): Boolean = type == MessageType.ERROR */ public fun Message.isGiphy(): Boolean = command == AttachmentType.GIPHY +/** + * @return If the message has an audio recording attachment. + */ +public fun Message.hasAudioRecording(): Boolean = attachments.any { it.type == AttachmentType.AUDIO_RECORDING } + /** * @return If the message is related to the poll. */ diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/audio/StreamMediaPlayerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/audio/StreamMediaPlayerTest.kt index fa03040e620..a4928541a17 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/audio/StreamMediaPlayerTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/audio/StreamMediaPlayerTest.kt @@ -47,7 +47,7 @@ internal class StreamMediaPlayerTest { streamPlayer = StreamMediaPlayer( mediaPlayer = mediaPlayer, userScope = userScope, - isMarshmallowOrHigher = { true }, + isMarshmallowOrHigher = true, ) } diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index d9915ade665..f8c47783f35 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -400,10 +400,6 @@ public final class io/getstream/chat/android/compose/ui/attachments/StreamAttach public final fun defaultQuotedFactories ()Ljava/util/List; } -public final class io/getstream/chat/android/compose/ui/attachments/audio/AudioRecordingKt { - public static final fun RunningWaveForm-rJsmZDw (Ljava/lang/Object;ILjava/lang/Integer;Landroidx/compose/ui/Modifier;IFFFJLandroidx/compose/ui/graphics/Brush;Landroidx/compose/runtime/Composer;II)V -} - public final class io/getstream/chat/android/compose/ui/attachments/content/AudioRecordAttachmentContentKt { public static final fun AudioRecordAttachmentContent (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/compose/state/messages/attachments/AttachmentState;Lio/getstream/chat/android/compose/viewmodel/messages/AudioPlayerViewModelFactory;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V public static final fun AudioRecordAttachmentContent (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/models/Attachment;Lio/getstream/chat/android/ui/common/state/messages/list/AudioPlayerState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V @@ -416,6 +412,10 @@ public final class io/getstream/chat/android/compose/ui/attachments/content/Audi public static final fun AudioRecordAttachmentPreviewContentItem (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/models/Attachment;Lio/getstream/chat/android/ui/common/state/messages/list/AudioPlayerState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V } +public final class io/getstream/chat/android/compose/ui/attachments/content/AudioRecordAttachmentQuotedContentKt { + public static final fun AudioRecordAttachmentQuotedContent (Lio/getstream/chat/android/models/Attachment;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + public final class io/getstream/chat/android/compose/ui/attachments/content/ComposableSingletons$AudioRecordAttachmentContentKt { public static final field INSTANCE Lio/getstream/chat/android/compose/ui/attachments/content/ComposableSingletons$AudioRecordAttachmentContentKt; public static field lambda-1 Lkotlin/jvm/functions/Function3; @@ -886,8 +886,16 @@ public final class io/getstream/chat/android/compose/ui/components/attachments/i public static final fun ImagesPicker (Ljava/util/List;Lkotlin/jvm/functions/Function1;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V } +public final class io/getstream/chat/android/compose/ui/components/audio/ComposableSingletons$PlaybackTimerKt { + public static final field INSTANCE Lio/getstream/chat/android/compose/ui/components/audio/ComposableSingletons$PlaybackTimerKt; + public static field lambda-1 Lkotlin/jvm/functions/Function2; + public fun ()V + public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; +} + public final class io/getstream/chat/android/compose/ui/components/audio/WaveformSliderKt { - public static final fun WaveformSlider (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/compose/ui/theme/WaveformSliderStyle;Ljava/util/List;IZFZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final fun StaticWaveformSlider (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/compose/ui/theme/WaveformSliderStyle;Ljava/util/List;IZFZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V + public static final fun WaveformSlider (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/compose/ui/theme/WaveformSliderStyle;Ljava/util/List;IZFZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V } public final class io/getstream/chat/android/compose/ui/components/avatar/AvatarKt { @@ -1953,6 +1961,7 @@ public final class io/getstream/chat/android/compose/ui/theme/ChatTheme { public final fun getMessageOptionsTheme (Landroidx/compose/runtime/Composer;I)Lio/getstream/chat/android/compose/ui/theme/MessageOptionsTheme; public final fun getMessageOptionsUserReactionAlignment (Landroidx/compose/runtime/Composer;I)Lio/getstream/chat/android/ui/common/state/messages/list/MessageOptionsUserReactionAlignment; public final fun getMessagePreviewFormatter (Landroidx/compose/runtime/Composer;I)Lio/getstream/chat/android/compose/ui/util/MessagePreviewFormatter; + public final fun getMessagePreviewIconFactory (Landroidx/compose/runtime/Composer;I)Lio/getstream/chat/android/compose/ui/util/MessagePreviewIconFactory; public final fun getMessageTextFormatter (Landroidx/compose/runtime/Composer;I)Lio/getstream/chat/android/compose/ui/util/MessageTextFormatter; public final fun getMessageUnreadSeparatorTheme (Landroidx/compose/runtime/Composer;I)Lio/getstream/chat/android/compose/ui/theme/MessageUnreadSeparatorTheme; public final fun getOtherMessageTheme (Landroidx/compose/runtime/Composer;I)Lio/getstream/chat/android/compose/ui/theme/MessageTheme; @@ -1976,7 +1985,7 @@ public final class io/getstream/chat/android/compose/ui/theme/ChatTheme { } public final class io/getstream/chat/android/compose/ui/theme/ChatThemeKt { - public static final fun ChatTheme (ZZZZLio/getstream/chat/android/compose/ui/theme/StreamColors;Lio/getstream/chat/android/compose/ui/theme/StreamDimens;Lio/getstream/chat/android/compose/ui/theme/StreamTypography;Lio/getstream/chat/android/compose/ui/theme/StreamShapes;Landroidx/compose/material/ripple/RippleTheme;Ljava/util/List;Ljava/util/List;Ljava/util/List;Lio/getstream/chat/android/compose/ui/util/ReactionIconFactory;Lio/getstream/chat/android/compose/ui/theme/ReactionOptionsTheme;Lio/getstream/chat/android/compose/ui/util/PollSwitchItemFactory;ZLio/getstream/chat/android/ui/common/helper/DateFormatter;Lio/getstream/chat/android/ui/common/helper/TimeProvider;Lio/getstream/chat/android/ui/common/utils/ChannelNameFormatter;Lio/getstream/chat/android/compose/ui/util/MessagePreviewFormatter;Lio/getstream/chat/android/compose/ui/util/SearchResultNameFormatter;Lio/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory;Lio/getstream/chat/android/ui/common/helper/ImageHeadersProvider;Lio/getstream/chat/android/compose/ui/util/MessageAlignmentProvider;Lio/getstream/chat/android/compose/ui/theme/MessageOptionsTheme;Lio/getstream/chat/android/ui/common/state/messages/list/MessageOptionsUserReactionAlignment;Ljava/util/List;ZLio/getstream/chat/android/ui/common/images/resizing/StreamCdnImageResizing;ZLio/getstream/chat/android/compose/ui/theme/MessageTheme;Lio/getstream/chat/android/compose/ui/theme/MessageTheme;Lio/getstream/chat/android/compose/ui/theme/MessageDateSeparatorTheme;Lio/getstream/chat/android/compose/ui/theme/MessageUnreadSeparatorTheme;Lio/getstream/chat/android/compose/ui/theme/MessageComposerTheme;Lio/getstream/chat/android/compose/ui/theme/AttachmentPickerTheme;Lio/getstream/chat/android/compose/ui/util/MessageTextFormatter;Lio/getstream/chat/android/compose/ui/util/QuotedMessageTextFormatter;Lio/getstream/sdk/chat/audio/recording/StreamMediaRecorder;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;IIIIII)V + public static final fun ChatTheme (ZZZZLio/getstream/chat/android/compose/ui/theme/StreamColors;Lio/getstream/chat/android/compose/ui/theme/StreamDimens;Lio/getstream/chat/android/compose/ui/theme/StreamTypography;Lio/getstream/chat/android/compose/ui/theme/StreamShapes;Landroidx/compose/material/ripple/RippleTheme;Ljava/util/List;Ljava/util/List;Ljava/util/List;Lio/getstream/chat/android/compose/ui/util/ReactionIconFactory;Lio/getstream/chat/android/compose/ui/theme/ReactionOptionsTheme;Lio/getstream/chat/android/compose/ui/util/MessagePreviewIconFactory;Lio/getstream/chat/android/compose/ui/util/PollSwitchItemFactory;ZLio/getstream/chat/android/ui/common/helper/DateFormatter;Lio/getstream/chat/android/ui/common/helper/TimeProvider;Lio/getstream/chat/android/ui/common/utils/ChannelNameFormatter;Lio/getstream/chat/android/compose/ui/util/MessagePreviewFormatter;Lio/getstream/chat/android/compose/ui/util/SearchResultNameFormatter;Lio/getstream/chat/android/compose/ui/util/StreamCoilImageLoaderFactory;Lio/getstream/chat/android/ui/common/helper/ImageHeadersProvider;Lio/getstream/chat/android/compose/ui/util/MessageAlignmentProvider;Lio/getstream/chat/android/compose/ui/theme/MessageOptionsTheme;Lio/getstream/chat/android/ui/common/state/messages/list/MessageOptionsUserReactionAlignment;Ljava/util/List;ZLio/getstream/chat/android/ui/common/images/resizing/StreamCdnImageResizing;ZLio/getstream/chat/android/compose/ui/theme/MessageTheme;Lio/getstream/chat/android/compose/ui/theme/MessageTheme;Lio/getstream/chat/android/compose/ui/theme/MessageDateSeparatorTheme;Lio/getstream/chat/android/compose/ui/theme/MessageUnreadSeparatorTheme;Lio/getstream/chat/android/compose/ui/theme/MessageComposerTheme;Lio/getstream/chat/android/compose/ui/theme/AttachmentPickerTheme;Lio/getstream/chat/android/compose/ui/util/MessageTextFormatter;Lio/getstream/chat/android/compose/ui/util/QuotedMessageTextFormatter;Lio/getstream/sdk/chat/audio/recording/StreamMediaRecorder;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;IIIIIII)V } public final class io/getstream/chat/android/compose/ui/theme/ComponentOffset { @@ -2380,8 +2389,8 @@ public final class io/getstream/chat/android/compose/ui/theme/StreamColors$Compa public final class io/getstream/chat/android/compose/ui/theme/StreamDimens { public static final field $stable I public static final field Companion Lio/getstream/chat/android/compose/ui/theme/StreamDimens$Companion; - public synthetic fun (FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFIILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFLkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFLkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1-D9Ej5fM ()F public final fun component10-D9Ej5fM ()F public final fun component11-D9Ej5fM ()F @@ -2431,12 +2440,14 @@ public final class io/getstream/chat/android/compose/ui/theme/StreamDimens { public final fun component51-D9Ej5fM ()F public final fun component52-D9Ej5fM ()F public final fun component53-D9Ej5fM ()F + public final fun component54-D9Ej5fM ()F + public final fun component55-D9Ej5fM ()F public final fun component6-D9Ej5fM ()F public final fun component7-D9Ej5fM ()F public final fun component8-D9Ej5fM ()F public final fun component9-D9Ej5fM ()F - public final fun copy-m-3Q45M (FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF)Lio/getstream/chat/android/compose/ui/theme/StreamDimens; - public static synthetic fun copy-m-3Q45M$default (Lio/getstream/chat/android/compose/ui/theme/StreamDimens;FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFIILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/StreamDimens; + public final fun copy-B0eyH1I (FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF)Lio/getstream/chat/android/compose/ui/theme/StreamDimens; + public static synthetic fun copy-B0eyH1I$default (Lio/getstream/chat/android/compose/ui/theme/StreamDimens;FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFIILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/StreamDimens; public fun equals (Ljava/lang/Object;)Z public final fun getAttachmentsContentFileUploadWidth-D9Ej5fM ()F public final fun getAttachmentsContentFileWidth-D9Ej5fM ()F @@ -2473,6 +2484,8 @@ public final class io/getstream/chat/android/compose/ui/theme/StreamDimens { public final fun getQuotedMessageAttachmentBottomPadding-D9Ej5fM ()F public final fun getQuotedMessageAttachmentEndPadding-D9Ej5fM ()F public final fun getQuotedMessageAttachmentPreviewSize-D9Ej5fM ()F + public final fun getQuotedMessageAttachmentSpacerHorizontal-D9Ej5fM ()F + public final fun getQuotedMessageAttachmentSpacerVertical-D9Ej5fM ()F public final fun getQuotedMessageAttachmentStartPadding-D9Ej5fM ()F public final fun getQuotedMessageAttachmentTopPadding-D9Ej5fM ()F public final fun getQuotedMessageTextHorizontalPadding-D9Ej5fM ()F @@ -2985,6 +2998,13 @@ public final class io/getstream/chat/android/compose/ui/util/ComposableSingleton public final fun getLambda-2$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; } +public final class io/getstream/chat/android/compose/ui/util/ComposableSingletons$MessagePreviewIconFactoryKt { + public static final field INSTANCE Lio/getstream/chat/android/compose/ui/util/ComposableSingletons$MessagePreviewIconFactoryKt; + public static field lambda-1 Lkotlin/jvm/functions/Function3; + public fun ()V + public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function3; +} + public final class io/getstream/chat/android/compose/ui/util/DefaultPollSwitchItemFactory : io/getstream/chat/android/compose/ui/util/PollSwitchItemFactory { public static final field $stable I public fun (Landroid/content/Context;)V @@ -3026,6 +3046,15 @@ public final class io/getstream/chat/android/compose/ui/util/MessagePreviewForma public final fun defaultFormatter (Landroid/content/Context;ZLio/getstream/chat/android/compose/ui/theme/StreamTypography;Ljava/util/List;)Lio/getstream/chat/android/compose/ui/util/MessagePreviewFormatter; } +public abstract interface class io/getstream/chat/android/compose/ui/util/MessagePreviewIconFactory { + public static final field Companion Lio/getstream/chat/android/compose/ui/util/MessagePreviewIconFactory$Companion; + public abstract fun createPreviewIcons ()Ljava/util/Map; +} + +public final class io/getstream/chat/android/compose/ui/util/MessagePreviewIconFactory$Companion { + public final fun defaultFactory ()Lio/getstream/chat/android/compose/ui/util/MessagePreviewIconFactory; +} + public abstract interface class io/getstream/chat/android/compose/ui/util/MessageTextFormatter { public static final field Companion Lio/getstream/chat/android/compose/ui/util/MessageTextFormatter$Companion; public abstract fun format (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;)Landroidx/compose/ui/text/AnnotatedString; @@ -3246,6 +3275,17 @@ public final class io/getstream/chat/android/compose/viewmodel/messages/Attachme public final fun setPolls (Ljava/util/List;)V } +public final class io/getstream/chat/android/compose/viewmodel/messages/AudioPlayerViewModel : androidx/lifecycle/ViewModel { + public static final field $stable I + public fun (Lio/getstream/chat/android/ui/common/feature/messages/list/AudioPlayerController;)V + public final fun changeSpeed (Lio/getstream/chat/android/models/Attachment;)V + public final fun getState ()Lkotlinx/coroutines/flow/StateFlow; + public final fun playOrPause (Lio/getstream/chat/android/models/Attachment;)V + public final fun reset (Lio/getstream/chat/android/models/Attachment;)V + public final fun seekTo (Lio/getstream/chat/android/models/Attachment;F)V + public final fun startSeek (Lio/getstream/chat/android/models/Attachment;)V +} + public final class io/getstream/chat/android/compose/viewmodel/messages/MessageComposerViewModel : androidx/lifecycle/ViewModel { public static final field $stable I public fun (Lio/getstream/chat/android/ui/common/feature/messages/composer/MessageComposerController;)V diff --git a/stream-chat-android-compose/detekt-baseline.xml b/stream-chat-android-compose/detekt-baseline.xml index 6b7f5f0e1eb..a221ecf1b87 100644 --- a/stream-chat-android-compose/detekt-baseline.xml +++ b/stream-chat-android-compose/detekt-baseline.xml @@ -12,9 +12,8 @@ LongMethod:AttachmentsPickerPollTabFactory.kt$AttachmentsPickerPollTabFactory$@Composable override fun PickerTabContent( onAttachmentPickerAction: (AttachmentPickerAction) -> Unit, attachments: List<AttachmentPickerItemState>, onAttachmentsChanged: (List<AttachmentPickerItemState>) -> Unit, onAttachmentItemSelected: (AttachmentPickerItemState) -> Unit, onAttachmentsSubmitted: (List<AttachmentMetaData>) -> Unit, ) LongMethod:AudioRecordingTheme.kt$AudioRecordingTheme.Companion$@Composable public fun defaultTheme( isInDarkMode: Boolean = isSystemInDarkTheme(), typography: StreamTypography = StreamTypography.defaultTypography(), colors: StreamColors = when (isInDarkMode) { true -> StreamColors.defaultDarkColors() else -> StreamColors.defaultColors() }, ): AudioRecordingTheme LongMethod:DefaultMessageComposerRecordingContent.kt$@Composable @OptIn(ExperimentalPermissionsApi::class) internal fun DefaultAudioRecordButton( state: RecordingState, recordingActions: AudioRecordingActions = AudioRecordingActions.None, holdToRecordThreshold: Long = HOLD_TO_RECORD_THRESHOLD, holdToRecordDismissTimeout: Long = HOLD_TO_RECORD_DISMISS_TIMEOUT, permissionRationaleDismissTimeout: Long = PERMISSION_RATIONALE_DISMISS_TIMEOUT, ) - LongMethod:DefaultMessageComposerRecordingContent.kt$@Composable internal fun DefaultMessageComposerRecordingContent( messageComposerState: MessageComposerState, recordingActions: AudioRecordingActions = AudioRecordingActions.None, ) - LongMethod:DefaultMessageComposerRecordingContent.kt$@Composable private fun DefaultMessageComposerRecordingContent( modifier: Modifier = Modifier, recordingTimeMs: Int = 0, waveformVisible: Boolean = true, waveformThumbVisible: Boolean = false, waveformData: List<Float>, waveformPlaying: Boolean = false, waveformProgress: Float = 0f, slideToCancelVisible: Boolean = true, holdControlsVisible: Boolean = false, holdControlsLocked: Boolean = false, holdControlsOffset: IntOffset = IntOffset.Zero, recordingControlsVisible: Boolean = true, recordingStopControlVisible: Boolean = true, recordingActions: AudioRecordingActions = AudioRecordingActions.None, ) - LongMethod:DefaultMessageComposerRecordingContent.kt$@Composable private fun RecordingContent( modifier: Modifier = Modifier, recordingTimeMs: Int = 0, waveformVisible: Boolean = true, waveformThumbVisible: Boolean = false, waveformData: List<Float>, waveformPlaying: Boolean = false, waveformProgress: Float = 0f, slideToCancelVisible: Boolean = true, slideToCancelProgress: Float = 0f, holdControlsOffset: IntOffset = IntOffset.Zero, onToggleRecordingPlayback: () -> Unit, onSliderDragStart: (Float) -> Unit, onSliderDragStop: (Float) -> Unit, ) + LongMethod:DefaultMessageComposerRecordingContent.kt$@Composable private fun DefaultMessageComposerRecordingContent( modifier: Modifier = Modifier, durationInMs: Int = 0, waveformVisible: Boolean = true, waveformThumbVisible: Boolean = false, waveformData: List<Float>, waveformPlaying: Boolean = false, waveformProgress: Float = 0f, slideToCancelVisible: Boolean = true, holdControlsVisible: Boolean = false, holdControlsLocked: Boolean = false, holdControlsOffset: IntOffset = IntOffset.Zero, recordingControlsVisible: Boolean = true, recordingStopControlVisible: Boolean = true, recordingActions: AudioRecordingActions = AudioRecordingActions.None, ) + LongMethod:DefaultMessageComposerRecordingContent.kt$@Composable private fun RecordingContent( modifier: Modifier = Modifier, durationInMs: Int = 0, waveformVisible: Boolean = true, waveformThumbVisible: Boolean = false, waveformData: List<Float>, waveformPlaying: Boolean = false, waveformProgress: Float = 0f, slideToCancelVisible: Boolean = true, slideToCancelProgress: Float = 0f, holdControlsOffset: IntOffset = IntOffset.Zero, onToggleRecordingPlayback: () -> Unit, onSliderDragStart: (Float) -> Unit, onSliderDragStop: (Float) -> Unit, ) LongMethod:DefaultMessageComposerRecordingContent.kt$@Composable private fun RecordingControlButtons( isStopControlVisible: Boolean, onDeleteRecording: () -> Unit, onStopRecording: () -> Unit, onCompleteRecording: (Boolean) -> Unit, ) LongMethod:GiphyMessageContent.kt$@Composable public fun GiphyMessageContent( message: Message, modifier: Modifier = Modifier, onGiphyActionClick: (GiphyAction) -> Unit = {}, ) LongMethod:GroupAvatar.kt$@Composable public fun GroupAvatar( users: List<User>, modifier: Modifier = Modifier, shape: Shape = ChatTheme.shapes.avatar, textStyle: TextStyle = ChatTheme.typography.captionBold, onClick: (() -> Unit)? = null, ) @@ -32,8 +31,7 @@ LongMethod:PollOptionList.kt$@Composable public fun PollOptionList( modifier: Modifier = Modifier, lazyListState: LazyListState = rememberLazyListState(), title: String = stringResource(id = R.string.stream_compose_poll_option_title), optionItems: List<PollOptionItem> = emptyList(), onQuestionsChanged: (List<PollOptionItem>) -> Unit, itemHeightSize: Dp = ChatTheme.dimens.pollOptionInputHeight, itemInnerPadding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 4.dp), ) LongMethod:PollSwitchList.kt$@Composable public fun PollSwitchList( modifier: Modifier = Modifier, pollSwitchItems: List<PollSwitchItem>, onSwitchesChanged: (List<PollSwitchItem>) -> Unit, itemHeightSize: Dp = ChatTheme.dimens.pollOptionInputHeight, itemInnerPadding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 16.dp), ) LongMethod:StreamTypography.kt$StreamTypography.Companion$public fun defaultTypography(fontFamily: FontFamily? = null): StreamTypography - LongMethod:WaveformSlider.kt$@Composable public fun WaveformSlider( modifier: Modifier = Modifier, style: WaveformSliderStyle = WaveformSliderStyle.defaultStyle(), waveformData: List<Float>, visibleBarLimit: Int = 100, adjustBarWidthToLimit: Boolean = false, progress: Float, isThumbVisible: Boolean = true, onDragStart: (Float) -> Unit = { StreamLog.w("WaveformSeekBar") { "[onDragStart] no args" } }, onDragStop: (Float) -> Unit = { StreamLog.e("WaveformSeekBar") { "[onDragStop] progress: $it" } }, ) - LongParameterList:AudioRecordAttachmentContent.kt$( modifier: Modifier = Modifier, attachment: Attachment, playerState: AudioPlayerState?, size: ComponentSize, padding: ComponentPadding, playbackToggleStyle: (isPlaying: Boolean) -> IconContainerStyle, timerStyle: TextContainerStyle, waveformSliderStyle: WaveformSliderLayoutStyle, onPlayToggleClick: (Attachment) -> Unit = {}, onThumbDragStart: (Attachment) -> Unit = {}, onThumbDragStop: (Attachment, Float) -> Unit = { _, _ -> }, tailContent: @Composable (isPlaying: Boolean) -> Unit = {}, ) + LongParameterList:AudioRecordAttachmentContent.kt$( modifier: Modifier = Modifier, attachment: Attachment, playerState: AudioPlayerState, size: ComponentSize, padding: ComponentPadding, playbackToggleStyle: (isPlaying: Boolean) -> IconContainerStyle, timerStyle: TextContainerStyle, waveformSliderStyle: WaveformSliderLayoutStyle, onPlayToggleClick: (Attachment) -> Unit = {}, onThumbDragStart: (Attachment) -> Unit = {}, onThumbDragStop: (Attachment, Float) -> Unit = { _, _ -> }, tailContent: @Composable (isPlaying: Boolean) -> Unit = {}, ) LongParameterList:MediaAttachmentContent.kt$( attachment: Attachment, message: Message, skipEnrichUrl: Boolean, onMediaGalleryPreviewResult: (MediaGalleryPreviewResult?) -> Unit = {}, onLongItemClick: (Message) -> Unit, onContentItemClick: ( mediaGalleryPreviewLauncher: ManagedActivityResultLauncher<MediaGalleryPreviewContract.Input, MediaGalleryPreviewResult?>, message: Message, attachmentPosition: Int, videoThumbnailsEnabled: Boolean, streamCdnImageResizing: StreamCdnImageResizing, skipEnrichUrl: Boolean, ) -> Unit, overlayContent: @Composable (attachmentType: String?) -> Unit, ) LongParameterList:MediaAttachmentContent.kt$( mediaGalleryPreviewLauncher: ManagedActivityResultLauncher<MediaGalleryPreviewContract.Input, MediaGalleryPreviewResult?>, message: Message, attachmentPosition: Int, videoThumbnailsEnabled: Boolean, streamCdnImageResizing: StreamCdnImageResizing, skipEnrichUrl: Boolean, ) LongParameterList:MediaGalleryPreviewActivity.kt$MediaGalleryPreviewActivity$( context: Context, mediaGalleryPreviewAction: MediaGalleryPreviewAction, currentPage: Int, attachments: List<Attachment>, writePermissionState: PermissionState, downloadPayload: MutableState<Attachment?>, ) @@ -47,7 +45,6 @@ LoopWithTooManyJumpStatements:DefaultMessageComposerRecordingContent.kt$while MagicNumber:AudioRecordAttachmentPreviewContent.kt$100 MagicNumber:AudioRecordAttachmentPreviewContent.kt$1000 - MagicNumber:AudioRecording.kt$2.5f MagicNumber:AudioWaveSeekbar.kt$0.4 MagicNumber:AudioWaveSeekbar.kt$100F MagicNumber:AudioWaveSeekbar.kt$10F @@ -66,6 +63,7 @@ MagicNumber:MediaGalleryPreviewActivity.kt$MediaGalleryPreviewActivity$8f MagicNumber:Messages.kt$3 MagicNumber:Messages.kt$5 + MagicNumber:PlaybackTimer.kt$100 MagicNumber:PollMessageContent.kt$0.5f MagicNumber:PollMessageContent.kt$10 MagicNumber:PollMoreOptionsDialog.kt$200 @@ -106,6 +104,7 @@ TopLevelPropertyNaming:DefaultMessageComposerRecordingContent.kt$private const val HOLD_TO_RECORD_DISMISS_TIMEOUT = 1000L TopLevelPropertyNaming:DefaultMessageComposerRecordingContent.kt$private const val HOLD_TO_RECORD_THRESHOLD = 1000L TopLevelPropertyNaming:DefaultMessageComposerRecordingContent.kt$private const val PERMISSION_RATIONALE_DISMISS_TIMEOUT = 1000L + UnusedPrivateMember:DefaultMessageComposerRecordingContent.kt$private fun formatMillis(milliseconds: Int): String UnusedPrivateMember:MessageComposer.kt$private fun Offset.toRestrictedCoordinates(): Pair<Float, Float> diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/StreamAttachmentFactories.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/StreamAttachmentFactories.kt index a23292f3eb6..47291225f49 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/StreamAttachmentFactories.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/StreamAttachmentFactories.kt @@ -115,8 +115,7 @@ public object StreamAttachmentFactories { AudioRecordAttachmentFactory( viewModelFactory = AudioPlayerViewModelFactory( getAudioPlayer = { getChatClient().audioPlayer }, - hasRecordingUri = { it.upload != null || it.assetUrl != null }, - getRecordingUri = { it.upload?.toUri()?.toString() ?: it.assetUrl }, + getRecordingUri = { it.assetUrl ?: it.upload?.toUri()?.toString() }, ), getCurrentUserId = { getChatClient().getCurrentOrStoredUserId() }, ), diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/audio/AudioRecording.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/audio/AudioRecording.kt deleted file mode 100644 index 7eed9b22cbe..00000000000 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/audio/AudioRecording.kt +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright (c) 2014-2023 Stream.io Inc. All rights reserved. - * - * Licensed under the Stream License; - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.getstream.chat.android.compose.ui.attachments.audio - -import androidx.compose.foundation.Canvas -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.drawscope.clipRect -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import io.getstream.chat.android.compose.ui.theme.ChatTheme - -/** - * A stateful composable that creates a "waveform" visualizer used to display mic input during recording. - * You should poll the amplitude of the audio input and update the composable's [latestValue] input when each - * new value is emitted. Null values are ignored. - * - * Since the composable is stateful, [restartKey] is used to remember the state. If you want to reset the composable's - * state simply pass in a new key. - * - * @param restartKey Used to reset the state of the composable when a value different to the previous value is passed - * in. - * @param newValueKey Should be incremented upon every new [latestValue]. Used so that if the same [latestValue] is - * posted twice in a valid way, we add a new bar for each emission. - * @param latestValue Represents the latest value from the audio input. The composable will draw a new bar for each - * latest value passed in. - * @param modifier Modifier for styling. - * @param maxInputValue The maximum amplitude of the input. A [latestValue] equaling [maxInputValue] will result - * in the bar height equaling the height of the whole composable. - * @param barMinHeight Minimum bar height, expressed as a percentage of the complete height of the composable. - * @param barWidth The width of a single bar representing audio input. - * @param barGap The gap between two bars. - * @param barCornerRadius The corner radius if the bars. - * @param barBrush The brush used to outline the bars. - */ -@Composable -public fun RunningWaveForm( - restartKey: Any, - newValueKey: Int, - latestValue: Int?, - modifier: Modifier = Modifier, - maxInputValue: Int = 20, - barMinHeight: Float = 0.1f, - barWidth: Dp = 8.dp, - barGap: Dp = 2.dp, - barCornerRadius: CornerRadius = CornerRadius(barWidth.value / 2.5f, barWidth.value / 2.5f), - barBrush: Brush = Brush.linearGradient( - Pair(0f, ChatTheme.colors.primaryAccent), - Pair(1f, ChatTheme.colors.primaryAccent), - ), -) { - val waveformData = remember(restartKey) { - mutableStateListOf() - } - - var canvasWidth by remember { mutableFloatStateOf(0f) } - var canvasHeight by remember { mutableFloatStateOf(0f) } - - val maxBars by remember(canvasWidth) { - derivedStateOf { (canvasWidth / (barWidth.value + barGap.value)).toInt() } - } - - LaunchedEffect(newValueKey) { - latestValue?.let { - if (waveformData.count() <= maxBars) { - waveformData.add(latestValue) - } else { - waveformData.removeFirst() - waveformData.add(latestValue) - } - println(waveformData.toList()) - } - } - - val minBarHeightFloat by remember(canvasHeight, barMinHeight) { - derivedStateOf { canvasHeight * barMinHeight } - } - - Canvas(modifier) { - clipRect { - canvasWidth = this.size.width - canvasHeight = this.size.height - - canvasWidth = this.size.width - canvasHeight = this.size.height - - waveformData.forEachIndexed { index, waveformItem -> - val barHeight = (size.height * (waveformItem.toFloat() / maxInputValue)) - .coerceIn( - minimumValue = minBarHeightFloat, - maximumValue = this.size.height, - ) - - val xOffset = (barGap.value + barWidth.value) * index.toFloat() - val yOffset = (this.size.height - barHeight) / 2 - - this.drawRoundRect( - cornerRadius = barCornerRadius, - brush = barBrush, - topLeft = Offset(xOffset, yOffset), - size = Size(width = barWidth.value, height = barHeight), - ) - } - } - } -} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/AudioRecordAttachmentContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/AudioRecordAttachmentContent.kt index f8ce1bc4600..1d85cbd6539 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/AudioRecordAttachmentContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/AudioRecordAttachmentContent.kt @@ -16,7 +16,6 @@ package io.getstream.chat.android.compose.ui.attachments.content -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -34,19 +33,25 @@ import androidx.compose.material.Surface import androidx.compose.material.Text import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel -import io.getstream.chat.android.client.extensions.duration +import io.getstream.chat.android.client.extensions.durationInMs import io.getstream.chat.android.client.extensions.waveformData import io.getstream.chat.android.client.utils.attachment.isAudioRecording import io.getstream.chat.android.client.utils.message.isMine import io.getstream.chat.android.compose.state.messages.attachments.AttachmentState -import io.getstream.chat.android.compose.ui.components.audio.WaveformSlider +import io.getstream.chat.android.compose.ui.components.audio.PlaybackTimer +import io.getstream.chat.android.compose.ui.components.audio.StaticWaveformSlider import io.getstream.chat.android.compose.ui.theme.ChatPreviewTheme import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.theme.ComponentPadding @@ -62,7 +67,6 @@ import io.getstream.chat.android.compose.viewmodel.messages.AudioPlayerViewModel import io.getstream.chat.android.extensions.isInt import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.ui.common.state.messages.list.AudioPlayerState -import io.getstream.chat.android.ui.common.utils.DurationFormatter /** * Represents the audio recording attachment content. @@ -101,7 +105,7 @@ public fun AudioRecordGroupContent( public fun AudioRecordAttachmentContent( modifier: Modifier = Modifier, attachment: Attachment, - playerState: AudioPlayerState?, + playerState: AudioPlayerState, onPlayToggleClick: (Attachment) -> Unit, onPlaySpeedClick: (Attachment) -> Unit, onScrubberDragStart: (Attachment) -> Unit = {}, @@ -180,13 +184,14 @@ public fun AudioRecordAttachmentContent( public fun AudioRecordAttachmentContentItem( modifier: Modifier = Modifier, attachment: Attachment, - playerState: AudioPlayerState?, + playerState: AudioPlayerState, isMine: Boolean = false, onPlayToggleClick: (Attachment) -> Unit = {}, onPlaySpeedClick: (Attachment) -> Unit = {}, onThumbDragStart: (Attachment) -> Unit = {}, onThumbDragStop: (Attachment, Float) -> Unit = { _, _ -> }, ) { + val currentAttachment by rememberUpdatedState(attachment) val theme = when (isMine) { true -> ChatTheme.ownMessageTheme.audioRecording else -> ChatTheme.otherMessageTheme.audioRecording @@ -211,8 +216,8 @@ public fun AudioRecordAttachmentContentItem( contentAlignment = Alignment.Center, ) { if (isPlaying) { - val speed = playerState?.playingSpeed ?: 1F - SpeedButton(speed, theme.speedButton) { onPlaySpeedClick(attachment) } + val speed = playerState.current.playingSpeed + SpeedButton(speed, theme.speedButton) { onPlaySpeedClick(currentAttachment) } } else { ContentTypeIcon(theme.contentTypeIcon) } @@ -225,7 +230,7 @@ public fun AudioRecordAttachmentContentItem( internal fun AudioRecordAttachmentContentItemBase( modifier: Modifier = Modifier, attachment: Attachment, - playerState: AudioPlayerState?, + playerState: AudioPlayerState, size: ComponentSize, padding: ComponentPadding, playbackToggleStyle: (isPlaying: Boolean) -> IconContainerStyle, @@ -236,18 +241,17 @@ internal fun AudioRecordAttachmentContentItemBase( onThumbDragStop: (Attachment, Float) -> Unit = { _, _ -> }, tailContent: @Composable (isPlaying: Boolean) -> Unit = {}, ) { - val isAttachmentPlaying = playerState?.attachment?.assetUrl == attachment.assetUrl - val trackProgress = playerState?.playingProgress?.takeIf { isAttachmentPlaying } ?: 0F - val playing = isAttachmentPlaying && playerState?.isPlaying == true - val playbackText = when (playing) { - true -> (playerState?.playbackInMs ?: 0).let(DurationFormatter::formatDurationInMillis) - else -> (attachment.duration ?: 0f).let(DurationFormatter::formatDurationInSeconds) - } + val attachmentUrl = attachment.assetUrl + val isCurrentAttachment = attachmentUrl == playerState.current.audioUri + val trackProgress = playerState.current.playingProgress.takeIf { isCurrentAttachment } + ?: attachmentUrl?.let { playerState.seekTo.getOrDefault(it.hashCode(), 0f) } ?: 0f + val playing = isCurrentAttachment && playerState.current.isPlaying val waveform = when (playing) { - true -> playerState?.waveform ?: emptyList() - else -> attachment.waveformData ?: emptyList() - } + true -> playerState.current.waveform + else -> attachment.waveformData + } ?: emptyList() + val currentAttachment by rememberUpdatedState(attachment) Surface( modifier = modifier .padding(2.dp), @@ -260,19 +264,31 @@ internal fun AudioRecordAttachmentContentItemBase( .padding(padding), verticalAlignment = Alignment.CenterVertically, ) { - PlaybackToggleButton(playbackToggleStyle(playing)) { onPlayToggleClick(attachment) } + PlaybackToggleButton(playbackToggleStyle(playing)) { onPlayToggleClick(currentAttachment) } + + var currentProgress by remember { mutableFloatStateOf(trackProgress) } + LaunchedEffect(attachmentUrl, playing, trackProgress) { currentProgress = trackProgress } - PlaybackTimer(playbackText, timerStyle) + PlaybackTimer(currentProgress, currentAttachment.durationInMs, timerStyle) - WaveformSlider( + StaticWaveformSlider( modifier = Modifier .height(waveformSliderStyle.height) .weight(1f), style = waveformSliderStyle.style, waveformData = waveform, - progress = trackProgress, - onDragStart = { onThumbDragStart(attachment) }, - onDragStop = { progress -> onThumbDragStop(attachment, progress) }, + progress = currentProgress, + onDragStart = { + currentProgress = it + onThumbDragStart(currentAttachment) + }, + onDrag = { + currentProgress = it + }, + onDragStop = { + currentProgress = it + onThumbDragStop(currentAttachment, it) + }, ) tailContent(playing) @@ -280,30 +296,6 @@ internal fun AudioRecordAttachmentContentItemBase( } } -/** - * Represents the playback timer. - * - * @param playbackText The text to display. - * @param style The style for the timer component. - */ -@Composable -internal fun PlaybackTimer( - playbackText: String, - style: TextContainerStyle, -) { - Box( - modifier = Modifier.size(style.size) - .padding(style.padding) - .background(style.backgroundColor), - contentAlignment = Alignment.Center, - ) { - Text( - style = style.textStyle, - text = playbackText, - ) - } -} - /** * Represents the playback toggle button. * @@ -394,8 +386,10 @@ internal fun AudioRecordAttachmentContentItemPreview() { .height(60.dp), attachment = attachment, playerState = AudioPlayerState( - attachment = attachment, - isPlaying = true, + current = AudioPlayerState.CurrentAudioState( + audioUri = attachment.assetUrl.orEmpty(), + isPlaying = true, + ), ), onPlayToggleClick = {}, onPlaySpeedClick = {}, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/AudioRecordAttachmentPreviewContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/AudioRecordAttachmentPreviewContent.kt index 95b5f900a3a..3257935d3a9 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/AudioRecordAttachmentPreviewContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/AudioRecordAttachmentPreviewContent.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview @@ -85,12 +86,13 @@ public fun AudioRecordAttachmentPreviewContent( public fun AudioRecordAttachmentPreviewContentItem( modifier: Modifier = Modifier, attachment: Attachment, - playerState: AudioPlayerState?, + playerState: AudioPlayerState, onPlayToggleClick: (Attachment) -> Unit = {}, onThumbDragStart: (Attachment) -> Unit = {}, onThumbDragStop: (Attachment, Float) -> Unit = { _, _ -> }, onAttachmentRemoved: (Attachment) -> Unit = {}, ) { + val currentAttachment by rememberUpdatedState(attachment) val theme = ChatTheme.messageComposerTheme.attachmentsPreview.audioRecording AudioRecordAttachmentContentItemBase( modifier = modifier, @@ -108,7 +110,7 @@ public fun AudioRecordAttachmentPreviewContentItem( CancelIcon( modifier = Modifier .padding(4.dp), - onClick = { onAttachmentRemoved(attachment) }, + onClick = { onAttachmentRemoved(currentAttachment) }, ) }, ) @@ -134,9 +136,11 @@ internal fun AudioRecordAttachmentPreviewContentItemPreview() { .height(60.dp), attachment = attachment, playerState = AudioPlayerState( - attachment = attachment, - waveform = waveformData, - isPlaying = false, + current = AudioPlayerState.CurrentAudioState( + audioUri = attachment.assetUrl.orEmpty(), + waveform = waveformData, + isPlaying = false, + ), ), ) } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/AudioRecordAttachmentQuotedContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/AudioRecordAttachmentQuotedContent.kt new file mode 100644 index 00000000000..dcf4f1004cc --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/content/AudioRecordAttachmentQuotedContent.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.attachments.content + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +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.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import io.getstream.chat.android.client.extensions.duration +import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.models.Attachment +import io.getstream.chat.android.ui.common.utils.DurationFormatter + +/** + * Builds an audio record attachment quoted message which shows a single audio in the attachments list. + * + * @param attachment The attachment we wish to show to users. + */ +@Composable +public fun AudioRecordAttachmentQuotedContent( + attachment: Attachment, + modifier: Modifier = Modifier, +) { + val durationInSeconds = attachment.duration ?: 0f + + Row( + modifier = modifier + .padding( + start = ChatTheme.dimens.quotedMessageAttachmentStartPadding, + top = ChatTheme.dimens.quotedMessageAttachmentTopPadding, + bottom = ChatTheme.dimens.quotedMessageAttachmentBottomPadding, + end = ChatTheme.dimens.quotedMessageAttachmentEndPadding, + ) + .clip(ChatTheme.shapes.quotedAttachment), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(id = R.drawable.stream_compose_ic_file_aac), + contentDescription = null, + tint = Color.Unspecified, + modifier = Modifier.size(24.dp), + ) + + Spacer(modifier = Modifier.size(ChatTheme.dimens.quotedMessageAttachmentSpacerHorizontal)) + + Column { + Text( + text = stringResource(id = R.string.stream_compose_audio_recording_preview), + style = ChatTheme.typography.bodyBold, + color = ChatTheme.colors.textHighEmphasis, + ) + Spacer(modifier = Modifier.size(ChatTheme.dimens.quotedMessageAttachmentSpacerVertical)) + Text( + text = DurationFormatter.formatDurationInSeconds(durationInSeconds), + color = ChatTheme.colors.textLowEmphasis, + style = ChatTheme.typography.footnote, + ) + } + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/factory/QuotedAttachmentFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/factory/QuotedAttachmentFactory.kt index 82dfe3995bd..715ce214de2 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/factory/QuotedAttachmentFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/attachments/factory/QuotedAttachmentFactory.kt @@ -17,11 +17,13 @@ package io.getstream.chat.android.compose.ui.attachments.factory import androidx.compose.runtime.Composable +import io.getstream.chat.android.client.utils.attachment.isAudioRecording import io.getstream.chat.android.client.utils.attachment.isFile import io.getstream.chat.android.client.utils.attachment.isGiphy import io.getstream.chat.android.client.utils.attachment.isImage import io.getstream.chat.android.client.utils.attachment.isVideo import io.getstream.chat.android.compose.ui.attachments.AttachmentFactory +import io.getstream.chat.android.compose.ui.attachments.content.AudioRecordAttachmentQuotedContent import io.getstream.chat.android.compose.ui.attachments.content.FileAttachmentQuotedContent import io.getstream.chat.android.compose.ui.attachments.content.MediaAttachmentQuotedContent import io.getstream.chat.android.uiutils.extension.hasLink @@ -37,6 +39,7 @@ public object QuotedAttachmentFactory : AttachmentFactory( it.firstOrNull() ?.let { attachment -> attachment.isFile() || + attachment.isAudioRecording() || attachment.isImage() || attachment.isVideo() || attachment.isGiphy() || @@ -47,6 +50,10 @@ public object QuotedAttachmentFactory : AttachmentFactory( val attachment = attachmentState.message.attachments.first() when { + attachment.isAudioRecording() -> AudioRecordAttachmentQuotedContent( + modifier = modifier, + attachment = attachment, + ) attachment.isImage() || attachment.isVideo() || attachment.isGiphy() || attachment.hasLink() -> { MediaAttachmentQuotedContent(modifier = modifier, attachment = attachment) } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt index 104ab30befd..4f52222536d 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt @@ -220,6 +220,7 @@ internal fun RowScope.DefaultChannelItemCenterContent( overflow = TextOverflow.Ellipsis, style = ChatTheme.typography.body, color = ChatTheme.colors.textLowEmphasis, + inlineContent = ChatTheme.messagePreviewIconFactory.createPreviewIcons(), ) } } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/audio/PlaybackTimer.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/audio/PlaybackTimer.kt new file mode 100644 index 00000000000..f3166f8dde1 --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/audio/PlaybackTimer.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.components.audio + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.getstream.chat.android.compose.ui.theme.ChatPreviewTheme +import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.theme.TextContainerStyle +import io.getstream.chat.android.compose.ui.util.padding +import io.getstream.chat.android.compose.ui.util.size +import io.getstream.chat.android.ui.common.utils.DurationFormatter + +/** + * Represents the playback timer. + * + * @param progress The progress of the audio playback. + * @param durationInMs The duration of the audio in milliseconds. + * @param style The style for the timer component. + */ +@Composable +internal fun PlaybackTimer( + progress: Float, + durationInMs: Int?, + style: TextContainerStyle, +) { + val finalDurationInMs = durationInMs ?: 0 + val playbackInMs = (progress * finalDurationInMs).toInt() + val playbackText = when (progress > 0) { + true -> DurationFormatter.formatDurationInMillis(playbackInMs) + else -> DurationFormatter.formatDurationInMillis(finalDurationInMs) + } + Box( + modifier = Modifier + .size(style.size) + .padding(style.padding) + .background(style.backgroundColor), + contentAlignment = Alignment.Center, + ) { + PlaybackTimerText(progress, durationInMs, style.textStyle) + } +} + +@Composable +internal fun PlaybackTimerText( + progress: Float, + durationInMs: Int?, + style: TextStyle, +) { + val finalDurationInMs = durationInMs ?: 0 + val playbackInMs = (progress * finalDurationInMs).toInt() + val playbackText = when (progress > 0) { + true -> DurationFormatter.formatDurationInMillis(playbackInMs) + else -> DurationFormatter.formatDurationInMillis(finalDurationInMs) + } + Text( + style = style, + text = playbackText, + ) +} + +@Preview(showBackground = true) +@Composable +internal fun PlaybackTimerPreview() { + val waveform = mutableListOf() + val barCount = 100 + for (i in 0 until barCount) { + waveform.add((i + 1) / barCount.toFloat()) + } + + ChatPreviewTheme { + Box( + modifier = Modifier + .width(250.dp) + .height(80.dp) + .background(Color.Black), + contentAlignment = Alignment.Center, + ) { + PlaybackTimer( + progress = 1f, + durationInMs = 120_000, + style = ChatTheme.ownMessageTheme.audioRecording.timerStyle, + ) + } + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/audio/WaveformSlider.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/audio/WaveformSlider.kt index 7d15028ad86..9d732e1f773 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/audio/WaveformSlider.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/audio/WaveformSlider.kt @@ -19,8 +19,6 @@ package io.getstream.chat.android.compose.ui.components.audio import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -34,6 +32,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -41,7 +40,6 @@ import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.tooling.preview.Preview @@ -51,7 +49,7 @@ import io.getstream.chat.android.compose.ui.theme.ChatPreviewTheme import io.getstream.chat.android.compose.ui.theme.WaveformSliderStyle import io.getstream.chat.android.compose.ui.theme.WaveformThumbStyle import io.getstream.chat.android.compose.ui.theme.WaveformTrackStyle -import io.getstream.log.StreamLog +import io.getstream.chat.android.compose.ui.util.dragPointerInput import kotlin.random.Random /** @@ -64,6 +62,7 @@ import kotlin.random.Random * @param adjustBarWidthToLimit Whether to adjust the bar width to fit the visible bar limit. * @param progress The current progress of the waveform. * @param isThumbVisible Whether to display the thumb. + * @param isTouchable Whether the waveform is touchable. * @param onDragStart Callback when the user starts dragging the thumb. * @param onDragStop Callback when the user stops dragging the thumb. */ @@ -76,67 +75,86 @@ public fun WaveformSlider( adjustBarWidthToLimit: Boolean = false, progress: Float, isThumbVisible: Boolean = true, - onDragStart: (Float) -> Unit = { StreamLog.w("WaveformSeekBar") { "[onDragStart] no args" } }, - onDragStop: (Float) -> Unit = { StreamLog.e("WaveformSeekBar") { "[onDragStop] progress: $it" } }, + onDragStart: (Float) -> Unit = {}, + onDrag: (Float) -> Unit = {}, + onDragStop: (Float) -> Unit = {}, ) { - var widthPx by remember { mutableFloatStateOf(0f) } - var pressed by remember { mutableStateOf(false) } var currentProgress by remember { mutableFloatStateOf(progress) } + LaunchedEffect(progress) { currentProgress = progress } - // Sync currentProgress when progress changes from parent - LaunchedEffect(progress) { - currentProgress = progress - } + StaticWaveformSlider( + modifier = modifier, + style = style, + waveformData = waveformData, + visibleBarLimit = visibleBarLimit, + adjustBarWidthToLimit = adjustBarWidthToLimit, + progress = currentProgress, + isThumbVisible = isThumbVisible, + onDragStart = { + currentProgress = it + onDragStart(it) + }, + onDrag = { + currentProgress = it + onDrag(it) + }, + onDragStop = { + currentProgress = it + onDragStop(it) + }, + ) +} +/** + * A slider that displays a waveform. + * + * @param modifier Modifier for styling. + * @param waveformData The waveform data to display. + * @param style The style for the waveform slider. + * @param visibleBarLimit The number of bars to display at once. + * @param adjustBarWidthToLimit Whether to adjust the bar width to fit the visible bar limit. + * @param progress The current progress of the waveform. + * @param isThumbVisible Whether to display the thumb. + * @param onDragStart Callback when the user starts dragging the thumb. + * @param onDrag Callback when the user drags the thumb. + * @param onDragStop Callback when the user stops dragging the thumb. + */ +@Composable +public fun StaticWaveformSlider( + modifier: Modifier = Modifier, + style: WaveformSliderStyle = WaveformSliderStyle.defaultStyle(), + waveformData: List, + visibleBarLimit: Int = 100, + adjustBarWidthToLimit: Boolean = false, + progress: Float, + isThumbVisible: Boolean = true, + onDragStart: (Float) -> Unit = {}, + onDrag: (Float) -> Unit = {}, + onDragStop: (Float) -> Unit = {}, +) { + val currentProcess by rememberUpdatedState(progress) + var widthPx by remember { mutableFloatStateOf(0f) } + var pressed by remember { mutableStateOf(false) } Box( modifier = modifier .fillMaxSize() - .pointerInput(Unit) { - detectDragGestures( - onDragEnd = { - StreamLog.v("WaveformSeekBar") { "[detectHorizontalDragGestures] end" } - onDragStop(currentProgress) - pressed = false - }, - onDragCancel = { - StreamLog.v("WaveformSeekBar") { "[detectHorizontalDragGestures] cancel" } - onDragStop(currentProgress) - pressed = false - }, - ) { change, dragAmount -> - change.consume() - if (widthPx > 0) { - currentProgress = (change.position.x / widthPx).coerceIn(0f, 1f) - } - } - } - .pointerInput(Unit) { - detectTapGestures( - onPress = { - StreamLog.v("WaveformSeekBar") { - "[detectTapGestures] press: $it" - } - pressed = true - if (widthPx > 0) { - currentProgress = (it.x / widthPx).coerceIn(0f, 1f) - onDragStart(currentProgress) - } - }, - ) { offset -> - - StreamLog.v("WaveformSeekBar") { - "[detectTapGestures] tap: $offset" - } - onDragStop(currentProgress) - pressed = false - } - } .onSizeChanged { size -> - StreamLog.v("WaveformSeekBar") { - "[onSizeChanged] Size changed: $size" - } widthPx = size.width.toFloat() - }, + } + .dragPointerInput( + enabled = isThumbVisible, + onDragStart = { + pressed = true + onDragStart(it.toHorizontalProgress(widthPx)) + }, + onDrag = { + onDrag(it.toHorizontalProgress(widthPx)) + }, + onDragStop = { + pressed = false + onDragStop(it?.toHorizontalProgress(widthPx) ?: currentProcess) + }, + ), ) { // Draw the waveform WaveformTrack( @@ -145,7 +163,7 @@ public fun WaveformSlider( style = style.trackerStyle, visibleBarLimit = visibleBarLimit, adjustBarWidthToLimit = adjustBarWidthToLimit, - progress = currentProgress, + progress = progress, ) // Draw the thumb @@ -153,7 +171,7 @@ public fun WaveformSlider( WaveformThumb( style = style.thumbStyle, pressed = pressed, - progress = currentProgress, + progress = progress, parentWidthPx = widthPx, ) } @@ -317,3 +335,7 @@ internal fun WaveformTrackPreview() { } } } + +private fun Offset.toHorizontalProgress(base: Float): Float { + return if (base > 0) (x / base).coerceIn(0f, 1f) else 0f +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/MessageComposer.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/MessageComposer.kt index e82ce75cb02..c8938d3fc65 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/MessageComposer.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/MessageComposer.kt @@ -70,6 +70,7 @@ import io.getstream.chat.android.compose.ui.messages.composer.internal.DefaultMe import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.util.AboveAnchorPopupPositionProvider import io.getstream.chat.android.compose.ui.util.mirrorRtl +import io.getstream.chat.android.compose.ui.util.size import io.getstream.chat.android.compose.viewmodel.messages.MessageComposerViewModel import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.ChannelCapabilities @@ -323,7 +324,7 @@ public fun MessageComposer( val (_, _, activeAction, validationErrors, mentionSuggestions, commandSuggestions) = messageComposerState val snackbarHostState = remember { SnackbarHostState() } - val noRecording = messageComposerState.recording is RecordingState.Idle + val isRecording = messageComposerState.recording !is RecordingState.Idle MessageInputValidationError( validationErrors = validationErrors, @@ -350,10 +351,10 @@ public fun MessageComposer( ) } - if (!noRecording) { + input(messageComposerState) + + if (isRecording) { audioRecordingContent(messageComposerState) - } else { - input(messageComposerState) } trailingContent(messageComposerState) @@ -607,11 +608,16 @@ private fun RowScope.DefaultComposerInputContent( onAttachmentRemoved: (Attachment) -> Unit, label: @Composable (MessageComposerState) -> Unit, ) { + val isRecording = messageComposerState.recording !is RecordingState.Idle MessageInput( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - .weight(1f), + modifier = if (isRecording) { + Modifier.size(0.dp) + } else { + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .weight(1f) + }, label = label, messageComposerState = messageComposerState, onValueChange = onValueChange, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/DefaultMessageComposerRecordingContent.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/DefaultMessageComposerRecordingContent.kt index 3986c71fac7..a62e57eb972 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/DefaultMessageComposerRecordingContent.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/messages/composer/internal/DefaultMessageComposerRecordingContent.kt @@ -44,6 +44,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -72,7 +73,8 @@ import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.shouldShowRationale import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.ui.components.SimpleDialog -import io.getstream.chat.android.compose.ui.components.audio.WaveformSlider +import io.getstream.chat.android.compose.ui.components.audio.PlaybackTimerText +import io.getstream.chat.android.compose.ui.components.audio.StaticWaveformSlider import io.getstream.chat.android.compose.ui.messages.composer.actions.AudioRecordingActions import io.getstream.chat.android.compose.ui.theme.ChatPreviewTheme import io.getstream.chat.android.compose.ui.theme.ChatTheme @@ -342,12 +344,9 @@ internal fun DefaultMessageComposerRecordingContent( ) { val recordingState = messageComposerState.recording - val recordingTimeMs = when (recordingState) { + val durationInMs = when (recordingState) { is RecordingState.Recording -> recordingState.durationInMs - is RecordingState.Overview -> when (recordingState.isPlaying) { - true -> (recordingState.durationInMs * recordingState.playingProgress).toInt() - else -> recordingState.durationInMs - } + is RecordingState.Overview -> recordingState.durationInMs else -> 0 } @@ -395,7 +394,7 @@ internal fun DefaultMessageComposerRecordingContent( val recordingStopControlVisible = recordingState is RecordingState.Locked DefaultMessageComposerRecordingContent( - recordingTimeMs = recordingTimeMs, + durationInMs = durationInMs, waveformVisible = waveformVisible, waveformData = waveformData, waveformPlaying = waveformPlaying, @@ -414,7 +413,7 @@ internal fun DefaultMessageComposerRecordingContent( @Composable private fun DefaultMessageComposerRecordingContent( modifier: Modifier = Modifier, - recordingTimeMs: Int = 0, + durationInMs: Int = 0, waveformVisible: Boolean = true, waveformThumbVisible: Boolean = false, waveformData: List, @@ -444,7 +443,7 @@ private fun DefaultMessageComposerRecordingContent( }, ) { RecordingContent( - recordingTimeMs = recordingTimeMs, + durationInMs = durationInMs, waveformVisible = waveformVisible, waveformThumbVisible = waveformThumbVisible, waveformData = waveformData, @@ -517,7 +516,7 @@ private fun DefaultMessageComposerRecordingContent( @Composable private fun RecordingContent( modifier: Modifier = Modifier, - recordingTimeMs: Int = 0, + durationInMs: Int = 0, waveformVisible: Boolean = true, waveformThumbVisible: Boolean = false, waveformData: List, @@ -575,10 +574,13 @@ private fun RecordingContent( } } - Text( - text = formatMillis(recordingTimeMs), + var currentProgress by remember { mutableFloatStateOf(waveformProgress) } + LaunchedEffect(waveformProgress, durationInMs) { currentProgress = waveformProgress } + + PlaybackTimerText( + progress = currentProgress, + durationInMs = durationInMs, style = playbackTheme.timerTextStyle, - modifier = Modifier, ) Box( @@ -588,7 +590,7 @@ private fun RecordingContent( contentAlignment = Alignment.CenterEnd, ) { if (waveformVisible) { - WaveformSlider( + StaticWaveformSlider( modifier = Modifier .fillMaxSize() .align(Alignment.CenterStart) @@ -598,9 +600,10 @@ private fun RecordingContent( visibleBarLimit = 100, adjustBarWidthToLimit = true, isThumbVisible = waveformThumbVisible, - progress = waveformProgress, - onDragStart = onSliderDragStart, - onDragStop = onSliderDragStop, + progress = currentProgress, + onDragStart = { currentProgress = it.also(onSliderDragStart) }, + onDrag = { currentProgress = it }, + onDragStop = { currentProgress = it.also(onSliderDragStop) }, ) } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatTheme.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatTheme.kt index 2ef515c4cb3..96d25c44f0d 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatTheme.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatTheme.kt @@ -45,6 +45,7 @@ import io.getstream.chat.android.compose.ui.util.DefaultPollSwitchItemFactory import io.getstream.chat.android.compose.ui.util.LocalStreamImageLoader import io.getstream.chat.android.compose.ui.util.MessageAlignmentProvider import io.getstream.chat.android.compose.ui.util.MessagePreviewFormatter +import io.getstream.chat.android.compose.ui.util.MessagePreviewIconFactory import io.getstream.chat.android.compose.ui.util.MessageTextFormatter import io.getstream.chat.android.compose.ui.util.PollSwitchItemFactory import io.getstream.chat.android.compose.ui.util.QuotedMessageTextFormatter @@ -94,6 +95,9 @@ private val LocalReactionIconFactory = compositionLocalOf { private val LocalReactionOptionsTheme = compositionLocalOf { error("No ReactionOptionsTheme provided! Make sure to wrap all usages of Stream components in a ChatTheme.") } +private val LocalMessagePreviewIconFactory = compositionLocalOf { + error("No message preview icon factory provided! Make sure to wrap all usages of Stream components in a ChatTheme.") +} private val LocalPollSwitchItemFactory = compositionLocalOf { error( "No reaction poll switch item factory provided! Make sure to wrap all usages of Stream components " + @@ -211,6 +215,7 @@ private val LocalStreamMediaRecorder = compositionLocalOf { * @param reactionIconFactory Used to create an icon [Painter] for the given reaction type. * @param reactionOptionsTheme [ReactionOptionsTheme] Theme for the reaction option list in the selected message menu. * For theming the message option list in the same menu, use [messageOptionsTheme]. + * @param messagePreviewIconFactory Used to create a preview icon for the given message type. * @param allowUIAutomationTest Allow to simulate ui automation with given test tags. * @param dateFormatter [DateFormatter] Used throughout the app for date and time information. * @param timeProvider [TimeProvider] Used throughout the app for time information. @@ -253,6 +258,7 @@ public fun ChatTheme( quotedAttachmentFactories: List = StreamAttachmentFactories.defaultQuotedFactories(), reactionIconFactory: ReactionIconFactory = ReactionIconFactory.defaultFactory(), reactionOptionsTheme: ReactionOptionsTheme = ReactionOptionsTheme.defaultTheme(), + messagePreviewIconFactory: MessagePreviewIconFactory = MessagePreviewIconFactory.defaultFactory(), pollSwitchItemFactory: PollSwitchItemFactory = DefaultPollSwitchItemFactory(context = LocalContext.current), allowUIAutomationTest: Boolean = false, dateFormatter: DateFormatter = DateFormatter.from(LocalContext.current), @@ -337,6 +343,7 @@ public fun ChatTheme( LocalAttachmentPreviewHandlers provides attachmentPreviewHandlers, LocalQuotedAttachmentFactories provides quotedAttachmentFactories, LocalReactionIconFactory provides reactionIconFactory, + LocalMessagePreviewIconFactory provides messagePreviewIconFactory, LocalReactionOptionsTheme provides reactionOptionsTheme, LocalPollSwitchItemFactory provides pollSwitchItemFactory, LocalDateFormatter provides dateFormatter, @@ -459,6 +466,14 @@ public object ChatTheme { @ReadOnlyComposable get() = LocalReactionOptionsTheme.current + /** + * Retrieves the current message preview icon factory at the call site's position in the hierarchy. + */ + public val messagePreviewIconFactory: MessagePreviewIconFactory + @Composable + @ReadOnlyComposable + get() = LocalMessagePreviewIconFactory.current + /** * Retrieves the current [PollSwitchItemFactory] at the call site's position in the hierarchy. */ diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/StreamDimens.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/StreamDimens.kt index 7b98369d93d..482bce70669 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/StreamDimens.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/StreamDimens.kt @@ -65,6 +65,8 @@ import androidx.compose.ui.unit.dp * @param quotedMessageAttachmentBottomPadding The bottom padding of the quoted message attachment preview. * @param quotedMessageAttachmentStartPadding The start padding of the quoted message attachment preview. * @param quotedMessageAttachmentEndPadding The end padding of the quoted message attachment preview. + * @param quotedMessageAttachmentSpacerHorizontal The horizontal spacing between quoted message attachment components. + * @param quotedMessageAttachmentSpacerVertical The vertical spacing between quoted message attachment components. * @param groupAvatarInitialsXOffset The x offset of the user initials inside avatar when there are more than two * users. * @param groupAvatarInitialsYOffset The y offset of the user initials inside avatar when there are more than two @@ -128,6 +130,8 @@ public data class StreamDimens( public val quotedMessageAttachmentBottomPadding: Dp, public val quotedMessageAttachmentStartPadding: Dp, public val quotedMessageAttachmentEndPadding: Dp, + public val quotedMessageAttachmentSpacerHorizontal: Dp, + public val quotedMessageAttachmentSpacerVertical: Dp, public val groupAvatarInitialsXOffset: Dp, public val groupAvatarInitialsYOffset: Dp, public val attachmentsPickerHeight: Dp, @@ -190,6 +194,8 @@ public data class StreamDimens( quotedMessageAttachmentTopPadding = 6.dp, quotedMessageAttachmentStartPadding = 8.dp, quotedMessageAttachmentEndPadding = 0.dp, + quotedMessageAttachmentSpacerHorizontal = 8.dp, + quotedMessageAttachmentSpacerVertical = 2.dp, groupAvatarInitialsXOffset = 1.5.dp, groupAvatarInitialsYOffset = 2.5.dp, attachmentsPickerHeight = 350.dp, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/MessagePreviewFormatter.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/MessagePreviewFormatter.kt index e7a7a1d052a..5ae1c2605fa 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/MessagePreviewFormatter.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/MessagePreviewFormatter.kt @@ -17,10 +17,12 @@ package io.getstream.chat.android.compose.ui.util import android.content.Context +import androidx.compose.foundation.text.appendInlineContent import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString +import io.getstream.chat.android.client.utils.message.hasAudioRecording import io.getstream.chat.android.client.utils.message.isPoll import io.getstream.chat.android.client.utils.message.isPollClosed import io.getstream.chat.android.client.utils.message.isSystem @@ -90,6 +92,10 @@ private class DefaultMessagePreviewFormatter( private val attachmentFactories: List, ) : MessagePreviewFormatter { + private companion object { + private const val SPACE = " " + } + /** * Generates a preview text for the given message. * @@ -101,19 +107,12 @@ private class DefaultMessagePreviewFormatter( message: Message, currentUser: User?, ): AnnotatedString { - val getTranslatedText: (Message, User?) -> String = { message, currentUser -> - when (autoTranslationEnabled) { - true -> currentUser?.language?.let { message.getTranslation(it) } ?: message.text - else -> message.text - } - } return buildAnnotatedString { message.let { message -> - val translatedText = getTranslatedText(message, currentUser) - - val userLanguage = currentUser?.language.orEmpty() val displayedText = when (autoTranslationEnabled) { - true -> message.getTranslation(userLanguage).ifEmpty { message.text } + true -> currentUser?.language?.let { userLanguage -> + message.getTranslation(userLanguage).ifEmpty { message.text } + } ?: message.text else -> message.text }.trim() @@ -135,6 +134,10 @@ private class DefaultMessagePreviewFormatter( ), ) } + } else if (message.hasAudioRecording()) { + appendInlineContent(DefaultMessagePreviewIconFactory.VOICE_MESSAGE) + append(SPACE) + append(context.getString(R.string.stream_compose_audio_recording_preview)) } else { appendSenderName( message = message, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/MessagePreviewIconFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/MessagePreviewIconFactory.kt new file mode 100644 index 00000000000..e38be0bf1ba --- /dev/null +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/MessagePreviewIconFactory.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2014-2024 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.ui.util + +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.material.Icon +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.unit.sp +import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.ui.theme.ChatTheme + +/** + * An interface that allows the creation of message preview icons for message types. + */ +public interface MessagePreviewIconFactory { + + /** + * Creates [InlineTextContent] for all the supported message types. + */ + public fun createPreviewIcons(): Map + + public companion object { + /** + * Builds the default message preview icon factory that creates preview icons from + * drawable resources. + * + * @return The default implementation of [ReactionIconFactory]. + */ + public fun defaultFactory(): MessagePreviewIconFactory = DefaultMessagePreviewIconFactory() + } +} + +/** + * The default implementation of [MessagePreviewIconFactory] that uses drawable resources + */ +internal class DefaultMessagePreviewIconFactory : MessagePreviewIconFactory { + + companion object { + /** + * The key for the voice message preview icon. + */ + internal const val VOICE_MESSAGE = "voice_message" + } + + override fun createPreviewIcons(): Map { + return mapOf( + VOICE_MESSAGE to InlineTextContent( + placeholder = Placeholder( + width = 16.sp, + height = 16.sp, + placeholderVerticalAlign = PlaceholderVerticalAlign.Center, + ), + ) { + Icon( + painter = painterResource(id = R.drawable.stream_compose_ic_mic), + contentDescription = null, + tint = ChatTheme.colors.textLowEmphasis, + ) + }, + ) + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/MimeTypeIconProvider.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/MimeTypeIconProvider.kt index 2b07490d231..377d1af845e 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/MimeTypeIconProvider.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/MimeTypeIconProvider.kt @@ -47,6 +47,7 @@ internal object MimeTypeIconProvider { MimeType.MIME_TYPE_MP4 to R.drawable.stream_compose_ic_file_mp4, MimeType.MIME_TYPE_M4A to R.drawable.stream_compose_ic_file_m4a, MimeType.MIME_TYPE_MP3 to R.drawable.stream_compose_ic_file_mp3, + MimeType.MIME_TYPE_AAC to R.drawable.stream_compose_ic_file_aac, // For compatibility with other front end SDKs MimeType.MIME_TYPE_QUICKTIME to R.drawable.stream_compose_ic_file_mov, MimeType.MIME_TYPE_VIDEO_QUICKTIME to R.drawable.stream_compose_ic_file_mov, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ModifierUtils.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ModifierUtils.kt index b31af48171d..0031a5646db 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ModifierUtils.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/util/ModifierUtils.kt @@ -16,12 +16,16 @@ package io.getstream.chat.android.compose.ui.util +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.Dp import io.getstream.chat.android.compose.ui.theme.ComponentPadding import io.getstream.chat.android.compose.ui.theme.ComponentSize @@ -50,3 +54,33 @@ internal fun Modifier.size(size: ComponentSize): Modifier = when { size.height == Dp.Unspecified -> this.width(size.width) else -> this.composeSize(width = size.width, height = size.height) } + +/** + * Adds drag pointer input to the modifier. + */ +internal fun Modifier.dragPointerInput( + enabled: Boolean = true, + onDragStart: (Offset) -> Unit = {}, + onDrag: (Offset) -> Unit = {}, + onDragStop: (Offset?) -> Unit = {}, +): Modifier { + if (enabled.not()) { + return this + } + return this.pointerInput(Unit) { + detectDragGestures( + onDragStart = { onDrag(it) }, + onDrag = { change, _ -> + change.consume() + onDrag(change.position) + }, + onDragEnd = { onDragStop(null) }, + onDragCancel = { onDragStop(null) }, + ) + }.pointerInput(Unit) { + detectTapGestures( + onPress = { onDragStart(it) }, + onTap = { onDragStop(it) }, + ) + } +} diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AudioPlayerViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AudioPlayerViewModel.kt index 0cafc2d3108..24036d9c5c0 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AudioPlayerViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/messages/AudioPlayerViewModel.kt @@ -25,34 +25,55 @@ import io.getstream.chat.android.ui.common.feature.messages.list.AudioPlayerCont import io.getstream.chat.android.ui.common.state.messages.list.AudioPlayerState import kotlinx.coroutines.flow.StateFlow -internal class AudioPlayerViewModel( +/** + * ViewModel class for the AudioPlayer. + */ +public class AudioPlayerViewModel( private val controller: AudioPlayerController, ) : ViewModel() { - val state: StateFlow = controller.state + /** + * State of the audio player. + */ + public val state: StateFlow = controller.state override fun onCleared() { super.onCleared() controller.reset() } - fun playOrPause(attachment: Attachment) { + /** + * Play or pause the audio. + */ + public fun playOrPause(attachment: Attachment) { controller.togglePlayback(attachment) } - fun changeSpeed(attachment: Attachment) { + /** + * Change the speed of the audio. + */ + public fun changeSpeed(attachment: Attachment) { controller.changeSpeed(attachment) } - fun seekTo(attachment: Attachment, progress: Float) { + /** + * Seek to a specific progress in the audio. + */ + public fun seekTo(attachment: Attachment, progress: Float) { controller.seekTo(attachment, progress) } - fun startSeek(attachment: Attachment) { + /** + * Start seeking the audio. + */ + public fun startSeek(attachment: Attachment) { controller.startSeek(attachment) } - fun reset(attachment: Attachment) { + /** + * Stop seeking the audio. + */ + public fun reset(attachment: Attachment) { controller.resetAudio(attachment) } } @@ -60,11 +81,10 @@ internal class AudioPlayerViewModel( @InternalStreamChatApi public class AudioPlayerViewModelFactory( private val getAudioPlayer: () -> AudioPlayer, - private val hasRecordingUri: (Attachment) -> Boolean, private val getRecordingUri: (Attachment) -> String?, ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return AudioPlayerViewModel(AudioPlayerController(getAudioPlayer(), hasRecordingUri, getRecordingUri)) as T + return AudioPlayerViewModel(AudioPlayerController(getAudioPlayer(), getRecordingUri)) as T } } diff --git a/stream-chat-android-compose/src/main/res/values/strings.xml b/stream-chat-android-compose/src/main/res/values/strings.xml index 6d7dede3306..c56ecdef706 100644 --- a/stream-chat-android-compose/src/main/res/values/strings.xml +++ b/stream-chat-android-compose/src/main/res/values/strings.xml @@ -125,6 +125,7 @@ Slide to cancel Hold to start, release to send. + Voice message Send a message You can\'t send messages in this channel diff --git a/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api b/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api index bc58b17353f..9dc752a16b8 100644 --- a/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api +++ b/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api @@ -1003,6 +1003,7 @@ public final class io/getstream/chat/android/ui/common/state/messages/composer/R public fun equals (Ljava/lang/Object;)Z public final fun getAttachment ()Lio/getstream/chat/android/models/Attachment; public final fun getDurationInMs ()I + public final fun getHasPlayingId ()Z public final fun getPlayingId ()I public final fun getPlayingProgress ()F public final fun getWaveform ()Ljava/util/List; @@ -1074,6 +1075,38 @@ public final class io/getstream/chat/android/ui/common/state/messages/composer/V public fun toString ()Ljava/lang/String; } +public final class io/getstream/chat/android/ui/common/state/messages/list/AudioPlayerState$CurrentAudioState { + public static final field $stable I + public fun ()V + public fun (IFFLjava/lang/String;Ljava/util/List;IIZZZ)V + public synthetic fun (IFFLjava/lang/String;Ljava/util/List;IIZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()I + public final fun component10 ()Z + public final fun component2 ()F + public final fun component3 ()F + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/util/List; + public final fun component6 ()I + public final fun component7 ()I + public final fun component8 ()Z + public final fun component9 ()Z + public final fun copy (IFFLjava/lang/String;Ljava/util/List;IIZZZ)Lio/getstream/chat/android/ui/common/state/messages/list/AudioPlayerState$CurrentAudioState; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/state/messages/list/AudioPlayerState$CurrentAudioState;IFFLjava/lang/String;Ljava/util/List;IIZZZILjava/lang/Object;)Lio/getstream/chat/android/ui/common/state/messages/list/AudioPlayerState$CurrentAudioState; + public fun equals (Ljava/lang/Object;)Z + public final fun getAudioUri ()Ljava/lang/String; + public final fun getDurationInMs ()I + public final fun getPlaybackInMs ()I + public final fun getPlayingId ()I + public final fun getPlayingProgress ()F + public final fun getPlayingSpeed ()F + public final fun getWaveform ()Ljava/util/List; + public fun hashCode ()I + public final fun isLoading ()Z + public final fun isPlaying ()Z + public final fun isSeeking ()Z + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/chat/android/ui/common/state/messages/list/CancelGiphy : io/getstream/chat/android/ui/common/state/messages/list/GiphyAction { public static final field $stable I public fun (Lio/getstream/chat/android/models/Message;)V diff --git a/stream-chat-android-ui-common/detekt-baseline.xml b/stream-chat-android-ui-common/detekt-baseline.xml index 5224b14ed53..5b64b763097 100644 --- a/stream-chat-android-ui-common/detekt-baseline.xml +++ b/stream-chat-android-ui-common/detekt-baseline.xml @@ -16,6 +16,7 @@ MagicNumber:DefaultStreamMediaRecorder.kt$DefaultStreamMediaRecorder$20 MagicNumber:DefaultStreamMediaRecorder.kt$DefaultStreamMediaRecorder$31 MagicNumber:DurationFormatter.kt$DurationFormatter$1000 + MaxLineLength:AudioPlayerController.kt$AudioPlayerController$seekTo ReturnCount:AudioPlayerController.kt$AudioPlayerController$public fun changeSpeed(attachment: Attachment) ReturnCount:AudioPlayerController.kt$AudioPlayerController$public fun resetAudio(attachment: Attachment) ReturnCount:AudioPlayerController.kt$AudioPlayerController$public fun resume() diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/AudioRecordingController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/AudioRecordingController.kt index 67ab3c5a9a9..c20337d14f2 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/AudioRecordingController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/AudioRecordingController.kt @@ -108,7 +108,7 @@ internal class AudioRecordingController( logger.v { "[onRecorderDurationChanged] duration: $durationMs, state: $state" } if (state is RecordingState.Recording) { // TODO make duration Int - recordingState.value = state.copy(duration = durationMs.toInt()) + setState(state.copy(duration = durationMs.toInt())) } } } @@ -153,7 +153,7 @@ internal class AudioRecordingController( val state = recordingState.value if (state is RecordingState.Recording) { logger.v { "[processWave] waveform.size($normalized): ${waveform.size}" } - recordingState.value = state.copy(waveform = ArrayList(waveform)) + setState(state.copy(waveform = ArrayList(waveform))) } } @@ -166,7 +166,7 @@ internal class AudioRecordingController( logger.i { "[startRecording] state: $state" } val recordingName = "audio_recording_${Date()}" mediaRecorder.startAudioRecording(recordingName, realPollingInterval.toLong()) - this.recordingState.value = RecordingState.Hold(offset = offset ?: RecordingState.Hold.ZeroOffset) + setState(RecordingState.Hold(offset = offset ?: RecordingState.Hold.ZeroOffset)) } public fun holdRecording(offset: Pair? = null) { @@ -180,7 +180,7 @@ internal class AudioRecordingController( return } logger.v { "[holdRecording] offset: Offset(${offset.first}:${offset.second})" } - this.recordingState.value = state.copy(offset = offset) + setState(state.copy(offset = offset)) } public fun lockRecording() { @@ -190,7 +190,7 @@ internal class AudioRecordingController( return } logger.i { "[lockRecording] state: $state" } - this.recordingState.value = RecordingState.Locked(state.durationInMs, state.waveform) + setState(RecordingState.Locked(state.durationInMs, state.waveform)) } public fun cancelRecording() { @@ -205,41 +205,42 @@ internal class AudioRecordingController( audioPlayer.resetAudio(state.playingId) } clearData() - this.recordingState.value = RecordingState.Idle + setState(RecordingState.Idle) } public fun toggleRecordingPlayback() { val state = this.recordingState.value if (state !is RecordingState.Overview) { - logger.w { "[toggleRecordingPlayback] rejected (state is not Locked): $state" } + logger.v { "[toggleRecordingPlayback] rejected (state is not Locked): $state" } return } - logger.i { "[toggleRecordingPlayback] state: $state" } + logger.i { "[toggleRecordingPlayback] state: $state, playerState: ${audioPlayer.currentState}" } val audioFile = state.attachment.upload ?: run { - logger.w { "[toggleRecordingPlayback] rejected (audioFile is null)" } + logger.v { "[toggleRecordingPlayback] rejected (audioFile is null)" } return } - if (state.isPlaying) { - logger.v { "[toggleRecordingPlayback] pause playback" } - audioPlayer.pause() - this.recordingState.value = state.copy(isPlaying = false) - return - } - if (state.playingId != -1) { - logger.v { "[toggleRecordingPlayback] resume playback" } - // audioPlayer.play(fileToUri(audioFile), state.playingId) - audioPlayer.resume(state.playingId) - this.recordingState.value = state.copy(isPlaying = true) + if (state.hasPlayingId && state.playingId == audioPlayer.currentPlayingId) { + if (state.isPlaying) { + logger.d { "[toggleRecordingPlayback] pause playback" } + audioPlayer.pause() + setState(state.copy(isPlaying = false)) + } else { + logger.d { "[toggleRecordingPlayback] resume playback" } + audioPlayer.resume(state.playingId) + setState(state.copy(isPlaying = true)) + } return } - logger.v { "[toggleRecordingPlayback] start playback" } - val hash = audioFile.hashCode() - audioPlayer.registerOnProgressStateChange(hash, ::onAudioPlayingProgress) - audioPlayer.registerOnAudioStateChange(hash, ::onAudioStateChanged) - audioPlayer.play(fileToUri(audioFile), hash) - this.recordingState.value = state.copy( - isPlaying = true, - playingId = hash, + val audioHash = audioFile.hashCode() + logger.d { "[toggleRecordingPlayback] start playback: $audioHash" } + audioPlayer.registerOnProgressStateChange(audioHash, ::onAudioPlayingProgress) + audioPlayer.registerOnAudioStateChange(audioHash, ::onAudioStateChanged) + audioPlayer.play(fileToUri(audioFile), audioHash) + setState( + state.copy( + isPlaying = true, + playingId = audioHash, + ), ) } @@ -250,25 +251,28 @@ internal class AudioRecordingController( return } logger.d { "[onAudioStateChanged] playbackState: $playbackState" } - this.recordingState.value = state.copy( - isPlaying = playbackState == AudioState.PLAYING, - playingProgress = when (playbackState) { - AudioState.PLAYING, - AudioState.PAUSE, - -> state.playingProgress - else -> 0f - }, + setState( + state.copy( + isPlaying = playbackState == AudioState.PLAYING, + playingProgress = when (playbackState) { + AudioState.PLAYING, + AudioState.PAUSE, + -> state.playingProgress + else -> 0f + }, + ), ) } private fun onAudioPlayingProgress(progressState: ProgressData) { - // logger.d { "[onAudioPlayingProgress] progressState: $progressState" } val curState = this.recordingState.value if (curState is RecordingState.Overview) { - this.recordingState.value = curState.copy( - isPlaying = true, - playingProgress = progressState.progress, - durationInMs = progressState.duration, + setState( + curState.copy( + isPlaying = true, + playingProgress = progressState.progress, + durationInMs = progressState.duration.takeIf { it > 0 } ?: curState.durationInMs, + ), ) } } @@ -284,7 +288,7 @@ internal class AudioRecordingController( if (result.isFailure) { logger.e { "[stopRecording] failed: ${result.errorOrNull()}" } clearData() - recordingState.value = RecordingState.Idle + setState(RecordingState.Idle) return } val adjusted = samples.downsampleMax(samplesTarget) @@ -292,7 +296,7 @@ internal class AudioRecordingController( clearData() val recorded = result.getOrThrow() logger.v { "[stopRecording] recorded: $recorded" } - recordingState.value = RecordingState.Overview(recorded.durationInMs, normalized, recorded.attachment) + setState(RecordingState.Overview(recorded.durationInMs, normalized, recorded.attachment)) } public fun seekRecordingTo(progress: Float) { @@ -307,10 +311,9 @@ internal class AudioRecordingController( } val positionInMs = (progress * state.durationInMs).toInt() logger.i { "[seekRecordingTo] progress: $progress (${positionInMs}ms), state: $state" } - val hash = audioFile.hashCode() - // audioPlayer.prepare(fileToUri(audioFile), hash) - audioPlayer.seekTo(positionInMs, hash) - this.recordingState.value = state.copy(playingProgress = progress, playingId = hash) + val audioHash = audioFile.hashCode() + audioPlayer.seekTo(positionInMs, audioHash) + setState(state.copy(playingProgress = progress)) } public fun pauseRecording() { @@ -321,7 +324,7 @@ internal class AudioRecordingController( } logger.i { "[pauseRecording] state: $state" } audioPlayer.startSeek(state.playingId) - this.recordingState.value = state.copy(isPlaying = false) + setState(state.copy(isPlaying = false)) } public fun completeRecording() { @@ -335,21 +338,23 @@ internal class AudioRecordingController( logger.d { "[completeRecording] completing from Overview state" } audioPlayer.resetAudio(state.playingId) clearData() - this.recordingState.value = RecordingState.Complete( - state.attachment.copy( - extraData = state.attachment.extraData + mapOf( - EXTRA_WAVEFORM_DATA to state.waveform, + setState( + RecordingState.Complete( + state.attachment.copy( + extraData = state.attachment.extraData + mapOf( + EXTRA_WAVEFORM_DATA to state.waveform, + ), ), ), ) - this.recordingState.value = RecordingState.Idle + setState(RecordingState.Idle) return } val result = mediaRecorder.stopRecording() if (result.isFailure) { logger.e { "[completeRecording] failed: ${result.errorOrNull()}" } clearData() - recordingState.value = RecordingState.Idle + setState(RecordingState.Idle) return } val adjusted = samples.downsampleMax(samplesTarget) @@ -365,8 +370,8 @@ internal class AudioRecordingController( ) } logger.d { "[completeRecording] complete from state: $state" } - recordingState.value = RecordingState.Complete(recorded.attachment) - recordingState.value = RecordingState.Idle + setState(RecordingState.Complete(recorded.attachment)) + setState(RecordingState.Idle) } public fun onCleared() { @@ -387,6 +392,11 @@ internal class AudioRecordingController( samples.clear() samplesBuffer.clear() samplesBufferLimit = 1 + setState(RecordingState.Idle) + } + + private fun setState(state: RecordingState) { + recordingState.value = state } private fun List.normalize(): List { diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/AudioPlayerController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/AudioPlayerController.kt index aea89d32975..7925bb77d3f 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/AudioPlayerController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/AudioPlayerController.kt @@ -16,6 +16,8 @@ package io.getstream.chat.android.ui.common.feature.messages.list +import androidx.collection.IntFloatMap +import androidx.collection.MutableIntFloatMap import io.getstream.chat.android.client.audio.AudioPlayer import io.getstream.chat.android.client.audio.AudioState import io.getstream.chat.android.client.audio.ProgressData @@ -31,13 +33,12 @@ import kotlinx.coroutines.flow.MutableStateFlow @InternalStreamChatApi public class AudioPlayerController( private val audioPlayer: AudioPlayer, - private val hasRecordingUri: (Attachment) -> Boolean, private val getRecordingUri: (Attachment) -> String?, ) { private val logger by taggedLogger("Chat:PlayerController") - public val state: MutableStateFlow = MutableStateFlow(null) + public val state: MutableStateFlow = MutableStateFlow(AudioPlayerState()) public fun resetAudio(attachment: Attachment) { if (attachment.isAudioRecording().not()) { @@ -49,7 +50,7 @@ public class AudioPlayerController( return } val curState = state.value - if (curState?.playingId != audioHash) { + if (curState.current.playingId != audioHash) { logger.v { "[resetAudio] rejected (not playing): $audioHash" } return } @@ -64,22 +65,24 @@ public class AudioPlayerController( logger.v { "[togglePlayback] rejected (not an audio recording): ${attachment.type}" } return } - if (!hasRecordingUri(attachment)) { + val audioHash = getRecordingUri(attachment)?.hashCode() ?: run { logger.v { "[togglePlayback] rejected (no recordingUri): $attachment" } return } val curState = state.value - if (curState?.attachment == attachment) { - if (curState.isPlaying) { - logger.d { "[togglePlayback] pause" } - pause() - } else { - logger.d { "[togglePlayback] resume" } - resume() + val currentPlayingId = audioPlayer.currentPlayingId + val isCurrentTrack = curState.current.playingId == audioHash + val isProgressRunning = curState.current.playingProgress.let { it > 0 && it < 1 } + logger.d { + "[togglePlayback] audioHash: $audioHash, currentPlayingId; $currentPlayingId, " + + "isCurrentTrack: $isCurrentTrack, isProgressRunning: $isProgressRunning, state: ${curState.stringify()}" + } + when (isCurrentTrack && isProgressRunning) { + true -> when (curState.current.isPlaying) { + true -> pause() + else -> resume() } - } else { - logger.d { "[togglePlayback] play" } - play(attachment) + else -> play(attachment) } } @@ -93,7 +96,7 @@ public class AudioPlayerController( return } val curState = state.value - if (curState?.playingId != audioHash) { + if (curState.current.playingId != audioHash) { logger.v { "[startSeek] rejected (not playing): $audioHash" } return } @@ -110,12 +113,24 @@ public class AudioPlayerController( return } val curState = state.value - if (curState?.playingId != audioHash) { + if (curState.current.playingId != audioHash) { logger.v { "[startSeek] rejected (not playing): $audioHash" } return } - logger.i { "[startSeek] audioHash: ${curState.playingId}" } - audioPlayer.startSeek(curState.playingId) + logger.i { "[startSeek] audioHash: ${curState.current.playingId}" } + audioPlayer.startSeek(curState.current.playingId) + + val audioState = audioPlayer.currentState + val newState = curState.copy( + current = when (curState.current.playingId == audioHash) { + true -> curState.current.copy( + isPlaying = audioState == AudioState.PLAYING, + isSeeking = true, + ) + else -> curState.current + }, + ) + setState(newState) } public fun seekTo(attachment: Attachment, progress: Float) { @@ -127,15 +142,28 @@ public class AudioPlayerController( logger.v { "[seekTo] rejected (no recordingUri): $attachment" } return } - // val curState = state.value - // if (curState?.attachment != attachment) { - // logger.v { "[seekTo] rejected (not playing): $attachment" } - // return - // } + val curState = state.value + val isCurrentAudio = curState.current.playingId == audioHash val durationInSeconds = attachment.duration ?: NULL_DURATION val positionInMs = (progress * durationInSeconds * MILLIS_IN_SECOND).toInt() - logger.i { "[seekTo] positionInMs: $positionInMs, audioHash: $audioHash" } + logger.i { + "[seekTo] isCurrentAudio: $isCurrentAudio, positionInMs: $positionInMs, " + + "audioHash: $audioHash, state: ${curState.stringify()}" + } audioPlayer.seekTo(positionInMs, audioHash) + + val newState = curState.copy( + current = when (isCurrentAudio) { + true -> curState.current.copy( + isSeeking = false, + playingProgress = progress, + playbackInMs = positionInMs, + ) + else -> curState.current + }, + seekTo = curState.seekTo + (audioHash to progress), + ) + setState(newState) } /** @@ -152,45 +180,54 @@ public class AudioPlayerController( logger.v { "[play] rejected (no recordingUri): $attachment" } return } + val curState = state.value - if (curState != null) { - audioPlayer.resetAudio(curState.playingId) + val audioHash = recordingUri.hashCode() + val waveform = attachment.waveformData ?: emptyList() + var playbackInMs = audioPlayer.getCurrentPositionInMs(audioHash) + val durationInMs = ((attachment.duration ?: NULL_DURATION) * MILLIS_IN_SECOND).toInt() + val seekTo = curState.seekTo.getOrDefault(audioHash, 0f) + if (seekTo > 0) { + playbackInMs = (seekTo * durationInMs).toInt() + logger.v { "[play] seekTo: $playbackInMs" } } + logger.d { "[play] audioHash: $audioHash, playbackInMs: $playbackInMs, state: ${curState.stringify()}" } - val audioHash = recordingUri.hashCode() + // Set the initial state first cause the audio player may emit progress before the state is updated + val initialState = curState.newCurrentState(audioHash, recordingUri, waveform, playbackInMs, durationInMs) + setState(initialState) + + if (seekTo > 0) audioPlayer.seekTo(playbackInMs, audioHash) audioPlayer.registerOnAudioStateChange(audioHash, this::onAudioStateChanged) audioPlayer.registerOnProgressStateChange(audioHash, this::onAudioPlayingProgress) audioPlayer.registerOnSpeedChange(audioHash, this::onAudioPlayingSpeed) audioPlayer.play(recordingUri, audioHash) val audioState = audioPlayer.currentState - val durationInMs = ((attachment.duration ?: NULL_DURATION) * MILLIS_IN_SECOND).toInt() - logger.d { "[play] audioHash: $audioHash" } - setState( - AudioPlayerState( - attachment = attachment, - waveform = attachment.waveformData ?: emptyList(), - durationInMs = durationInMs, + val nowState = state.value + val newState = nowState.copy( + current = nowState.current.copy( isLoading = audioState == AudioState.LOADING, isPlaying = audioState == AudioState.PLAYING, - playingId = audioHash, ), ) + setState(newState) } /** * Pauses the current audio recording. */ public fun pause() { - val curState = state.value ?: run { - logger.d { "[pause] rejected (no state)" } + val curState = state.value + if (curState.current.playingId == NO_ID) { + logger.v { "[pause] rejected (no playingId)" } return } - if (curState.isPlaying.not()) { + if (curState.current.isPlaying.not()) { logger.d { "[pause] rejected (not playing)" } return } - logger.d { "[pause] audioHash: ${curState.playingId}" } + logger.d { "[pause] audioHash: ${curState.current.playingId}" } audioPlayer.pause() } @@ -198,11 +235,12 @@ public class AudioPlayerController( * Resumes the current audio recording. */ public fun resume() { - val curState = state.value ?: run { - logger.v { "[resume] rejected (no state)" } + val curState = state.value + if (curState.current.playingId == NO_ID) { + logger.v { "[resume] rejected (no playingId)" } return } - if (curState.isPlaying) { + if (curState.current.isPlaying) { logger.v { "[resume] rejected (already playing)" } return } @@ -212,7 +250,7 @@ public class AudioPlayerController( logger.v { "[resume] rejected (not idle or paused): $playerState" } return } - val audioHash = curState.playingId + val audioHash = curState.current.playingId logger.d { "[resume] audioHash: $audioHash" } audioPlayer.resume(audioHash) } @@ -221,51 +259,115 @@ public class AudioPlayerController( * Resets the current audio recording. */ public fun reset() { - audioPlayer.clearTracks() - state.value?.playingId?.also { audioPlayer.resetAudio(it) } - setState(null) + val curState = state.value + logger.d { "[reset] state.playingId: ${curState.current.playingId}" } + audioPlayer.reset() + setState(AudioPlayerState()) } private fun onAudioStateChanged(playbackState: AudioState) { - val curState = state.value ?: return - setState( - curState.copy( + val curState = state.value + if (curState.current.playingId == NO_ID) { + logger.v { "[onAudioStateChanged] rejected (no playingId)" } + return + } + logger.d { "[onAudioStateChanged] playbackState: $playbackState" } + val newState = curState.copy( + current = curState.current.copy( isLoading = playbackState == AudioState.LOADING, isPlaying = playbackState == AudioState.PLAYING, ), ) + setState(newState) } private fun onAudioPlayingProgress(progressState: ProgressData) { - // logger.d { "[onAudioPlayingProgress] progressState: $progressState" } - val curState = state.value ?: return - setState( - curState.copy( - isPlaying = progressState.currentPosition > 0, + val curState = state.value + if (curState.current.playingId == NO_ID) { + logger.v { "[onAudioPlayingProgress] rejected (no playingId)" } + return + } + val newState = curState.copy( + current = curState.current.copy( + isPlaying = true, playingProgress = progressState.progress, playbackInMs = progressState.currentPosition, durationInMs = progressState.duration, ), + seekTo = curState.seekTo - curState.current.playingId, ) + setState(newState) } private fun onAudioPlayingSpeed(speed: Float) { - // logger.d { "[onAudioPlayingProgress] speed: $speed" } - val curState = state.value ?: return - setState( - curState.copy( + val curState = state.value + if (curState.current.playingId == NO_ID) { + logger.v { "[onAudioPlayingSpeed] rejected (no playingId)" } + return + } + logger.d { "[onAudioPlayingSpeed] speed: $speed, state: ${curState.stringify()}" } + val newState = curState.copy( + current = curState.current.copy( playingSpeed = speed, ), ) + setState(newState) } - private fun setState(newState: AudioPlayerState?) { - // logger.v { "[setState] ${state.value?.stringify()} => ${newState?.stringify()}" } + private fun setState(newState: AudioPlayerState) { state.value = newState } + private fun AudioPlayerState.newCurrentState( + audioHash: Int, + recordingUri: String, + waveform: List, + playbackInMs: Int, + durationInMs: Int, + ): AudioPlayerState { + return copy( + current = AudioPlayerState.CurrentAudioState( + playingId = audioHash, + audioUri = recordingUri, + waveform = waveform, + durationInMs = durationInMs, + playbackInMs = playbackInMs, + playingProgress = playbackInMs.toFloat() / durationInMs, + ), + seekTo = when (current.playingId != audioHash && current.playingId != NO_ID && current.playingProgress > 0) { + true -> seekTo + (current.playingId to current.playingProgress) + else -> seekTo + }, + ) + } + private companion object { + private const val NO_ID = -1 private const val NULL_DURATION = 0f private const val MILLIS_IN_SECOND = 1000f } + + internal operator fun IntFloatMap.plus(that: Pair): IntFloatMap { + val newMap = MutableIntFloatMap(this.size + 1) + this.forEach { key, value -> + newMap[key] = value + } + val (key, value) = that + newMap[key] = value + return newMap + } + + internal operator fun IntFloatMap.minus(key: Int): IntFloatMap { + if (this.contains(key).not()) { + return this + } + + val newMap = MutableIntFloatMap(this.size) + this.forEach { k, v -> + if (k != key) { + newMap[k] = v + } + } + return newMap + } } diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/composer/RecordingState.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/composer/RecordingState.kt index e9741737e27..4f55b199401 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/composer/RecordingState.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/composer/RecordingState.kt @@ -65,12 +65,16 @@ public sealed class RecordingState { val playingProgress: Float = 0f, val playingId: Int = -1, ) : RecordingState() { + + val hasPlayingId: Boolean get() = playingId != -1 + override fun toString(): String = "Recording.Overview(" + + "playingId=$playingId, " + "waveform=${waveform.size}, " + "duration=${durationInMs}ms, " + "isPlaying=$isPlaying, " + "playingProgress=$playingProgress, " + - "attachment=${attachment.upload}" + + "attachment=${attachment.upload?.hashCode()}" + ")" } diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/list/AudioPlayerState.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/list/AudioPlayerState.kt index 545fab53df1..34c6ae0fccc 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/list/AudioPlayerState.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/list/AudioPlayerState.kt @@ -16,47 +16,62 @@ package io.getstream.chat.android.ui.common.state.messages.list +import androidx.collection.IntFloatMap +import androidx.collection.intFloatMapOf import androidx.compose.runtime.Immutable import io.getstream.chat.android.core.internal.InternalStreamChatApi -import io.getstream.chat.android.models.Attachment /** * Represents the state of the audio player. * - * @property attachment The attachment that is being played. - * @property waveform The waveform of the audio. - * @property playbackInMs The current playback position in milliseconds. - * @property durationInMs The duration of the audio in milliseconds. - * @property isLoading If the audio is currently loading. - * @property isPlaying If the audio is currently playing. - * @property playingSpeed The speed of the audio playback. - * @property playingProgress The progress of the audio playback. - * @property playingId The ID of the audio that is currently playing. + * @property current The ongoing state of the audio player. + * @property seekTo The seekTo state of the audio player. */ @Immutable @InternalStreamChatApi public data class AudioPlayerState( - val attachment: Attachment = Attachment(), - val waveform: List = emptyList(), - val playbackInMs: Int = 0, - val durationInMs: Int = 0, - val isLoading: Boolean = false, - val isPlaying: Boolean = false, - val playingSpeed: Float = 0.0f, - val playingProgress: Float = 0f, - val playingId: Int = -1, + val current: CurrentAudioState = CurrentAudioState(), + val seekTo: IntFloatMap = intFloatMapOf(), ) { - public fun stringify(): String { + /** + * Represents the ongoing state of the audio player. + * + * @property playingId The ID of the audio that is currently playing. + * @property audioUri The URI of the audio that is currently playing. + * @property waveform The waveform of the audio that is currently playing. + * @property playbackInMs The current playback position in milliseconds. + * @property durationInMs The duration of the audio that is currently playing. + * @property isLoading If the audio is currently loading. + * @property isPlaying If the audio is currently playing. + * @property playingSpeed The speed of the audio playback. + * @property playingProgress The progress of the audio playback. + */ + public data class CurrentAudioState( + val playingId: Int = -1, + val playingSpeed: Float = 1.0f, + val playingProgress: Float = 0f, + val audioUri: String = "", + val waveform: List = emptyList(), + val playbackInMs: Int = 0, + val durationInMs: Int = 0, + val isLoading: Boolean = false, + val isPlaying: Boolean = false, + val isSeeking: Boolean = false, + ) + + public fun stringify(): String = with(current) { return "AudioPlayerState(" + "playingId=$playingId, " + "playingProgress=$playingProgress, " + "durationInMs=$durationInMs, " + "isPlaying=$isPlaying, " + + "isSeeking=$isSeeking, " + "isLoading=$isLoading, " + "playingSpeed=$playingSpeed, " + "waveform.size=${waveform.size}, " + - "assertUrlHash=${attachment.assetUrl.hashCode()}" + + "playingUriHash=${audioUri.hashCode()}, " + + "seekTo.size=${seekTo.size}" + ")" } }