diff --git a/CHANGELOG.md b/CHANGELOG.md index fce18c82f1d..77d1817f895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### 🐞 Fixed ### ⬆️ Improved +- Use `ExoPlayer` instead of `MediaPlayer` for audio message playback. [#5980](https://github.com/GetStream/stream-chat-android/pull/5980) ### ✅ Added diff --git a/stream-chat-android-client/build.gradle.kts b/stream-chat-android-client/build.gradle.kts index 1001e82abaa..73bf3ba0c9f 100644 --- a/stream-chat-android-client/build.gradle.kts +++ b/stream-chat-android-client/build.gradle.kts @@ -83,6 +83,7 @@ dependencies { implementation(libs.kotlin.reflect) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.process) + implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.work) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.android) 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 e0739366623..cfc4d40e82f 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 @@ -17,15 +17,15 @@ package io.getstream.chat.android.client import android.content.Context -import android.media.AudioAttributes -import android.media.MediaPlayer -import android.os.Build import android.util.Log import androidx.annotation.CheckResult import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C.AUDIO_CONTENT_TYPE_MUSIC +import androidx.media3.exoplayer.ExoPlayer import io.getstream.chat.android.client.ChatClient.Companion.MAX_COOLDOWN_TIME_SECONDS import io.getstream.chat.android.client.api.ChatApi import io.getstream.chat.android.client.api.ChatClientConfig @@ -69,7 +69,7 @@ import io.getstream.chat.android.client.api2.model.dto.DownstreamUserDto import io.getstream.chat.android.client.attachment.AttachmentsSender import io.getstream.chat.android.client.audio.AudioPlayer import io.getstream.chat.android.client.audio.NativeMediaPlayerImpl -import io.getstream.chat.android.client.audio.StreamMediaPlayer +import io.getstream.chat.android.client.audio.StreamAudioPlayer import io.getstream.chat.android.client.channel.ChannelClient import io.getstream.chat.android.client.channel.state.ChannelStateLogicProvider import io.getstream.chat.android.client.clientstate.DisconnectCause @@ -984,7 +984,7 @@ internal constructor( * @param file The image file that needs to be uploaded. * @param callback The callback to track progress. * - * @return Executable async [Call] which completes with [Result] containing an instance of [UploadedImage] + * @return Executable async [Call] which completes with [Result] containing an instance of [UploadedFile] * if the image was successfully uploaded. * * @see FileUploader @@ -1108,7 +1108,6 @@ internal constructor( * @see FileUploader */ @CheckResult - @JvmOverloads public fun deleteImage( url: String, ): Call = api.deleteImage(url) @@ -4602,8 +4601,8 @@ internal constructor( } /** - * Debug requests using [ApiRequestsAnalyser]. Use this to debug your requests. This shouldn't be enabled in - * release builds as it uses a memory cache. + * Debug requests using [io.getstream.chat.android.client.plugins.requests.ApiRequestsAnalyser]. Use this to + * debug your requests. This shouldn't be enabled in release builds as it uses a memory cache. */ public fun debugRequests(shouldDebug: Boolean): Builder = apply { this.debugRequests = shouldDebug @@ -4719,17 +4718,18 @@ internal constructor( val appSettingsManager = AppSettingManager(module.api()) - val audioPlayer: AudioPlayer = StreamMediaPlayer( - mediaPlayer = NativeMediaPlayerImpl { - MediaPlayer().apply { - AudioAttributes.Builder() - .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) - .build() - .let(this::setAudioAttributes) - } + val audioPlayer: AudioPlayer = StreamAudioPlayer( + mediaPlayer = NativeMediaPlayerImpl(appContext) { + ExoPlayer.Builder(appContext) + .setAudioAttributes( + AudioAttributes.Builder() + .setContentType(AUDIO_CONTENT_TYPE_MUSIC) + .build(), + true, + ) + .build() }, userScope = userScope, - isMarshmallowOrHigher = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M, ) return ChatClient( @@ -4878,7 +4878,6 @@ internal constructor( */ @Throws(IllegalStateException::class) @JvmStatic - @JvmOverloads public fun handlePushMessage(pushMessage: PushMessage) { ensureClientInitialized().run { val type = pushMessage.type.orEmpty() 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 22a50b79545..21c527996d9 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 @@ -70,7 +70,7 @@ public interface AudioPlayer { * Plays an audio track with sourceUrl. * * @param sourceUrl the URL of the audio track - * @param hash the identifier of the audio track + * @param audioHash the identifier of the audio track */ public fun play(sourceUrl: String, audioHash: Int) 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 3e0e56c6803..acf3bbd874f 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 @@ -16,13 +16,25 @@ package io.getstream.chat.android.client.audio -import android.media.MediaPlayer -import android.os.Build -import androidx.annotation.RequiresApi +import android.content.Context +import androidx.annotation.OptIn +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.source.ProgressiveMediaSource +import androidx.media3.extractor.DefaultExtractorsFactory import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.log.taggedLogger import java.io.IOException +/** + * Defines the contract for communicating with a native media player implementation (ex. ExoPlayer). + */ @InternalStreamChatApi public interface NativeMediaPlayer { @@ -30,135 +42,159 @@ public interface NativeMediaPlayer { /** * Unspecified media player error. */ + @Deprecated("This constant is no longer used. Check for [PlaybackException.errorCode] instead.") public const val MEDIA_ERROR_UNKNOWN: Int = 1 /** * Media server died. In this case, the application must release the * MediaPlayer object and instantiate a new one. */ + @Deprecated("This constant is no longer used. Check for [PlaybackException.errorCode] instead.") public const val MEDIA_ERROR_SERVER_DIED: Int = 100 /** * The video is streamed and its container is not valid for progressive * playback i.e the video's index (e.g moov atom) is not at the start of the file. */ + @Deprecated("This constant is no longer used. Check for [PlaybackException.errorCode] instead.") public const val MEDIA_ERROR_NOT_VALID_FOR_PROGRESSIVE_PLAYBACK: Int = 200 /** File or network related operation errors. */ + @Deprecated("This constant is no longer used. Check for [PlaybackException.errorCode] instead.") public const val MEDIA_ERROR_IO: Int = -1004 /** Bitstream is not conforming to the related coding standard or file spec. */ + @Deprecated("This constant is no longer used. Check for [PlaybackException.errorCode] instead.") public const val MEDIA_ERROR_MALFORMED: Int = -1007 /** * Bitstream is conforming to the related coding standard or file spec, but * the media framework does not support the feature. */ + @Deprecated("This constant is no longer used. Check for [PlaybackException.errorCode] instead.") public const val MEDIA_ERROR_UNSUPPORTED: Int = -1010 /** Some operation takes too long to complete, usually more than 3-5 seconds. */ + @Deprecated("This constant is no longer used. Check for [PlaybackException.errorCode] instead.") public const val MEDIA_ERROR_TIMED_OUT: Int = -110 /** * Unspecified low-level system error. This value originated from UNKNOWN_ERROR in * system/core/include/utils/Errors.h */ + @Deprecated("This constant is no longer used. Check for [PlaybackException.errorCode] instead.") public const val MEDIA_ERROR_SYSTEM: Int = -2147483648 /** * Unspecified low-level system error. This value originated from OK in * system/core/include/utils/Errors.h */ + @Deprecated("This constant is no longer used. Check for [PlaybackException.errorCode] instead.") public const val MEDIA_ERROR_OK: Int = 0 /** * Unspecified low-level system error. This value originated from UNKNOWN_ERROR in * system/core/include/utils/Errors.h */ + @Deprecated("This constant is no longer used. Check for [PlaybackException.errorCode] instead.") public const val MEDIA_ERROR_UNKNOWN_ERROR: Int = -2147483648 /** * Unspecified low-level system error. This value originated from NO_MEMORY in * system/core/include/utils/Errors.h */ + @Deprecated("This constant is no longer used. Check for [PlaybackException.errorCode] instead.") public const val MEDIA_ERROR_NO_MEMORY: Int = -12 /** * Unspecified low-level system error. This value originated from INVALID_OPERATION in * system/core/include/utils/Errors.h */ + @Deprecated("This constant is no longer used. Check for [PlaybackException.errorCode] instead.") public const val MEDIA_ERROR_INVALID_OPERATION: Int = -38 /** * Unspecified low-level system error. This value originated from BAD_VALUE in * system/core/include/utils/Errors.h */ + @Deprecated("This constant is no longer used. Check for [PlaybackException.errorCode] instead.") public const val MEDIA_ERROR_BAD_VALUE: Int = -22 /** * Unspecified low-level system error. This value originated from BAD_TYPE in * system/core/include/utils/Errors.h */ + @Deprecated("This constant is no longer used. Check for [PlaybackException.errorCode] instead.") public const val MEDIA_ERROR_BAD_TYPE: Int = -2147483647 /** * Unspecified low-level system error. This value originated from NAME_NOT_FOUND in * system/core/include/utils/Errors.h */ + @Deprecated("This constant is no longer used. Check for [PlaybackException.errorCode] instead.") public const val MEDIA_ERROR_NAME_NOT_FOUND: Int = -2 /** * Unspecified low-level system error. This value originated from PERMISSION_DENIED in * system/core/include/utils/Errors.h */ + @Deprecated("This constant is no longer used. Check for [PlaybackException.errorCode] instead.") public const val MEDIA_ERROR_PERMISSION_DENIED: Int = -1 /** * Unspecified low-level system error. This value originated from NO_INIT in * system/core/include/utils/Errors.h */ + @Deprecated("This constant is no longer used. Check for [PlaybackException.errorCode] instead.") public const val MEDIA_ERROR_NO_INIT: Int = -19 /** * Unspecified low-level system error. This value originated from ALREADY_EXISTS in * system/core/include/utils/Errors.h */ + @Deprecated("This constant is no longer used. Check for [PlaybackException.errorCode] instead.") public const val MEDIA_ERROR_ALREADY_EXISTS: Int = -17 /** * Unspecified low-level system error. This value originated from DEAD_OBJECT in * system/core/include/utils/Errors.h */ + @Deprecated("This constant is no longer used. Check for [PlaybackException.errorCode] instead.") public const val MEDIA_ERROR_DEAD_OBJECT: Int = -32 /** * Unspecified low-level system error. This value originated from FAILED_TRANSACTION in * system/core/include/utils/Errors.h */ + @Deprecated("This constant is no longer used. Check for [PlaybackException.errorCode] instead.") public const val MEDIA_ERROR_FAILED_TRANSACTION: Int = -2147483646 /** * Unspecified low-level system error. This value originated from JPARKS_BROKE_IT in * system/core/include/utils/Errors.h */ + @Deprecated("This constant is no longer used. Check for [PlaybackException.errorCode] instead.") public const val MEDIA_ERROR_JPARKS_BROKE_IT: Int = -32 /** * Unspecified low-level system error. This value originated from BAD_INDEX in * system/core/include/utils/Errors.h */ + @Deprecated("This constant is no longer used. Check for [PlaybackException.errorCode] instead.") public const val MEDIA_ERROR_BAD_INDEX: Int = -75 /** * Unspecified low-level system error. This value originated from NOT_ENOUGH_DATA in * system/core/include/utils/Errors.h */ + @Deprecated("This constant is no longer used. Check for [PlaybackException.errorCode] instead.") public const val MEDIA_ERROR_NOT_ENOUGH_DATA: Int = -61 /** * Unspecified low-level system error. This value originated from WOULD_BLOCK in * system/core/include/utils/Errors.h */ + @Deprecated("This constant is no longer used. Check for [PlaybackException.errorCode] instead.") public const val MEDIA_ERROR_WOULD_BLOCK: Int = -11 } @@ -170,8 +206,6 @@ public interface NativeMediaPlayer { * initialized or has been released. * @throws IllegalArgumentException when setting if params is not supported. */ - @get:RequiresApi(Build.VERSION_CODES.M) - @set:RequiresApi(Build.VERSION_CODES.M) @get:Throws(IllegalStateException::class) @set:Throws(IllegalStateException::class, IllegalArgumentException::class) public var speed: Float @@ -212,18 +246,6 @@ public interface NativeMediaPlayer { ) public fun setDataSource(path: String) - /** - * Prepares the player for playback, synchronously. - * - * After setting the datasource and the display surface, you need to either - * call prepare() or prepareAsync(). For files, it is OK to call prepare(), - * which blocks until MediaPlayer is ready for playback. - * - * @throws IllegalStateException if it is called in an invalid state - */ - @Throws(IOException::class, IllegalStateException::class) - public fun prepare() - /** * Prepares the player for playback, asynchronously. * @@ -308,7 +330,7 @@ public interface NativeMediaPlayer { * Returning false, or not having an OnErrorListener at all, will * cause the OnCompletionListener to be called. */ - public fun setOnErrorListener(listener: (what: Int, extra: Int) -> Boolean) + public fun setOnErrorListener(listener: (errorCode: Int) -> Boolean) /** * Register a callback to be invoked when the media source is ready @@ -333,8 +355,16 @@ public enum class NativeMediaPlayerState { ERROR, } +/** + * Default implementation of [NativeMediaPlayer] based on ExoPlayer. + * + * @param context The context. + * @param builder A builder function to create an [ExoPlayer] instance. + */ +@OptIn(UnstableApi::class) internal class NativeMediaPlayerImpl( - private val builder: () -> MediaPlayer, + context: Context, + private val builder: () -> ExoPlayer, ) : NativeMediaPlayer { companion object { @@ -343,64 +373,101 @@ 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 + private var _exoPlayer: ExoPlayer? = null set(value) { - if (DEBUG) logger.i { "[setMediaPlayerInstance] instance: $value" } + if (DEBUG) logger.i { "[setExoPlayerInstance] instance: $value" } field = value } - private val mediaPlayer: MediaPlayer get() { - return _mediaPlayer ?: builder().also { - _mediaPlayer = it.setupListeners() - state = NativeMediaPlayerState.IDLE + private val exoPlayer: ExoPlayer + get() { + return _exoPlayer ?: builder().also { + _exoPlayer = it.setupListeners() + state = NativeMediaPlayerState.IDLE + } } - } + + /** + * We need to build the factory with constant bitrate seeking enabled to allow seeking in the audio file. + * For more info see [ExoPlayer Progressive](https://developer.android.com/media/media3/exoplayer/progressive). + */ + private val mediaSourceFactory: MediaSource.Factory = ProgressiveMediaSource.Factory( + DefaultDataSource.Factory(context), + DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true), + ) private var onCompletionListener: (() -> Unit)? = null - private var onErrorListener: ((what: Int, extra: Int) -> Boolean)? = null + private var onErrorListener: ((errorCode: Int) -> Boolean)? = null private var onPreparedListener: (() -> Unit)? = null + private var isPreparing: Boolean = false + override var state: NativeMediaPlayerState = NativeMediaPlayerState.END set(value) { if (DEBUG) logger.d { "[setMediaPlayerState] state: $value <= $field" } field = value } - override var speed: Float - @RequiresApi(Build.VERSION_CODES.M) - @Throws(IllegalStateException::class) - get() = mediaPlayer.playbackParams.speed + private val playerListener = object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + if (DEBUG) logger.d { "[onPlaybackStateChanged] playbackState: $playbackState" } + when (playbackState) { + Player.STATE_IDLE -> { + // Do nothing - we manage IDLE state explicitly + } + + Player.STATE_BUFFERING -> { + if (isPreparing) { + state = NativeMediaPlayerState.PREPARING + } + } + + Player.STATE_READY -> { + if (isPreparing) { + isPreparing = false + state = NativeMediaPlayerState.PREPARED + onPreparedListener?.invoke() + } + } + + Player.STATE_ENDED -> { + state = NativeMediaPlayerState.PLAYBACK_COMPLETED + onCompletionListener?.invoke() + } + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (DEBUG) logger.d { "[onIsPlayingChanged] isPlaying: $isPlaying" } + if (isPlaying && state != NativeMediaPlayerState.STARTED) { + state = NativeMediaPlayerState.STARTED + } else if (!isPlaying && state == NativeMediaPlayerState.STARTED && + exoPlayer.playbackState != Player.STATE_ENDED + ) { + state = NativeMediaPlayerState.PAUSED + } + } + + override fun onPlayerError(error: PlaybackException) { + if (DEBUG) logger.e { "[onPlayerError] error: ${error.message}" } + state = NativeMediaPlayerState.ERROR + onErrorListener?.invoke(error.errorCode) + } + } - @RequiresApi(Build.VERSION_CODES.M) - @Throws(IllegalStateException::class, IllegalArgumentException::class) + override var speed: Float + get() = exoPlayer.playbackParameters.speed set(value) { - val mediaPlayer = mediaPlayer - if (DEBUG) logger.d { "[setSpeed] mediaPlayer: ${mediaPlayer.hashCode()}, speed: $value" } - mediaPlayer.playbackParams = mediaPlayer.playbackParams.setSpeed(value) + val player = exoPlayer + if (DEBUG) logger.d { "[setSpeed] exoPlayer: ${player.hashCode()}, speed: $value" } + player.playbackParameters = PlaybackParameters(value) } + override val currentPosition: Int - get() = mediaPlayer.currentPosition + get() = exoPlayer.currentPosition.toInt() override val duration: Int - get() = mediaPlayer.duration + get() = exoPlayer.duration.toInt() @Throws( IOException::class, @@ -409,75 +476,71 @@ internal class NativeMediaPlayerImpl( IllegalStateException::class, ) override fun setDataSource(path: String) { - val mediaPlayer = mediaPlayer - if (DEBUG) logger.d { "[setDataSource] mediaPlayer: ${mediaPlayer.hashCode()}, path: $path" } - mediaPlayer.setDataSource(path) + val player = exoPlayer + logger.d { "[setDataSource] exoPlayer: ${player.hashCode()}, path: $path" } + val mediaSource = mediaSourceFactory.createMediaSource(MediaItem.fromUri(path)) + player.setMediaSource(mediaSource) state = NativeMediaPlayerState.INITIALIZED } @Throws(IllegalStateException::class) override fun prepareAsync() { - val mediaPlayer = mediaPlayer - if (DEBUG) logger.d { "[prepareAsync] mediaPlayer: ${mediaPlayer.hashCode()}" } - mediaPlayer.prepareAsync() + val player = exoPlayer + if (DEBUG) logger.d { "[prepareAsync] exoPlayer: ${player.hashCode()}" } + isPreparing = true state = NativeMediaPlayerState.PREPARING - } - - @Throws(IOException::class, IllegalStateException::class) - override fun prepare() { - val mediaPlayer = mediaPlayer - if (DEBUG) logger.d { "[prepare] mediaPlayer: ${mediaPlayer.hashCode()}" } - mediaPlayer.prepare() - state = NativeMediaPlayerState.PREPARED + player.prepare() } @Throws(IllegalStateException::class) override fun seekTo(msec: Int) { - val mediaPlayer = mediaPlayer - if (DEBUG) logger.d { "[seekTo] mediaPlayer: ${mediaPlayer.hashCode()}, msec: $msec" } - mediaPlayer.seekTo(msec) + val player = exoPlayer + if (DEBUG) logger.d { "[seekTo] exoPlayer: ${player.hashCode()}, msec: $msec" } + player.seekTo(msec.toLong()) } @Throws(IllegalStateException::class) override fun start() { - val mediaPlayer = mediaPlayer - if (DEBUG) logger.d { "[start] mediaPlayer: ${mediaPlayer.hashCode()}" } - mediaPlayer.start() + val player = exoPlayer + if (DEBUG) logger.d { "[start] exoPlayer: ${player.hashCode()}" } + player.playWhenReady = true state = NativeMediaPlayerState.STARTED } @Throws(IllegalStateException::class) override fun pause() { - val mediaPlayer = mediaPlayer - if (DEBUG) logger.d { "[pause] mediaPlayer: ${mediaPlayer.hashCode()}" } - mediaPlayer.pause() + val player = exoPlayer + if (DEBUG) logger.d { "[pause] exoPlayer: ${player.hashCode()}" } + player.playWhenReady = false state = NativeMediaPlayerState.PAUSED } @Throws(IllegalStateException::class) override fun stop() { - val mediaPlayer = mediaPlayer - if (DEBUG) logger.d { "[stop] mediaPlayer: ${mediaPlayer.hashCode()}" } - mediaPlayer.stop() + val player = exoPlayer + if (DEBUG) logger.d { "[stop] exoPlayer: ${player.hashCode()}" } + player.stop() state = NativeMediaPlayerState.STOPPED } override fun reset() { - val mediaPlayer = mediaPlayer - if (DEBUG) logger.d { "[reset] mediaPlayer: ${mediaPlayer.hashCode()}" } - mediaPlayer.reset() + val player = exoPlayer + if (DEBUG) logger.d { "[reset] exoPlayer: ${player.hashCode()}" } + player.stop() + player.clearMediaItems() + isPreparing = false state = NativeMediaPlayerState.IDLE } override fun release() { - val mediaPlayer = _mediaPlayer ?: run { - if (DEBUG) logger.d { "[release] mediaPlayer is null" } + val player = _exoPlayer ?: run { + if (DEBUG) logger.d { "[release] exoPlayer is null" } return } - if (DEBUG) logger.d { "[release] mediaPlayer: ${mediaPlayer.hashCode()}" } - mediaPlayer.clearListeners().release() + if (DEBUG) logger.d { "[release] exoPlayer: ${player.hashCode()}" } + player.clearListeners().release() state = NativeMediaPlayerState.END - _mediaPlayer = null + _exoPlayer = null } override fun setOnPreparedListener(listener: () -> Unit) { @@ -490,24 +553,20 @@ internal class NativeMediaPlayerImpl( this.onCompletionListener = listener } - override fun setOnErrorListener(listener: (what: Int, extra: Int) -> Boolean) { + override fun setOnErrorListener(listener: (errorCode: Int) -> Boolean) { if (DEBUG) logger.d { "[setOnErrorListener] listener: $listener" } this.onErrorListener = listener } - private fun MediaPlayer.setupListeners(): MediaPlayer { - if (DEBUG) logger.d { "[setupListeners] mediaPlayer: ${this.hashCode()}" } - setOnErrorListener(_onErrorListener) - setOnPreparedListener(_onPreparedListener) - setOnCompletionListener(_onCompletionListener) + private fun ExoPlayer.setupListeners(): ExoPlayer { + if (DEBUG) logger.d { "[setupListeners] exoPlayer: ${this.hashCode()}" } + addListener(playerListener) return this } - private fun MediaPlayer.clearListeners(): MediaPlayer { - if (DEBUG) logger.d { "[clearListeners] mediaPlayer: ${this.hashCode()}" } - setOnErrorListener(null) - setOnPreparedListener(null) - setOnCompletionListener(null) + private fun ExoPlayer.clearListeners(): ExoPlayer { + if (DEBUG) logger.d { "[clearListeners] exoPlayer: ${this.hashCode()}" } + removeListener(playerListener) 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 41328be1126..1210ed1589e 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 @@ -16,9 +16,6 @@ package io.getstream.chat.android.client.audio -import android.os.Build -import androidx.annotation.ChecksSdkIntAtLeast -import androidx.core.net.toUri import io.getstream.chat.android.client.scope.UserScope import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider import io.getstream.log.taggedLogger @@ -29,11 +26,9 @@ import kotlinx.coroutines.launch import java.util.concurrent.atomic.AtomicInteger @Suppress("TooManyFunctions") -internal class StreamMediaPlayer( +internal class StreamAudioPlayer( private val mediaPlayer: NativeMediaPlayer, private val userScope: UserScope, - @ChecksSdkIntAtLeast(api = Build.VERSION_CODES.M) - private val isMarshmallowOrHigher: Boolean, private val progressUpdatePeriod: Long = 50, ) : AudioPlayer { @@ -43,7 +38,7 @@ internal class StreamMediaPlayer( private const val SPEED_INCREMENT = 0.5F } - private val logger by taggedLogger("Chat:StreamMediaPlayer") + private val logger by taggedLogger("Chat:StreamAudioPlayer") private val onStateListeners: MutableMap Unit>> = mutableMapOf() private val onProgressListeners: MutableMap Unit>> = mutableMapOf() @@ -137,7 +132,7 @@ internal class StreamMediaPlayer( } override fun changeSpeed() { - if (isMarshmallowOrHigher && mediaPlayer.isSpeedSettable()) { + if (mediaPlayer.isSpeedSettable()) { logger.i { "[changeSpeed] no args" } val currentSpeed = playingSpeed val newSpeed = if (currentSpeed >= 2 || currentSpeed < 1) { @@ -158,8 +153,7 @@ internal class StreamMediaPlayer( } } - override fun currentSpeed(): Float = - if (isMarshmallowOrHigher) mediaPlayer.speed else 1F + override fun currentSpeed(): Float = mediaPlayer.speed override fun dispose() { userScope.launch(DispatcherProvider.Main) { @@ -238,8 +232,8 @@ internal class StreamMediaPlayer( onComplete(audioHash) } - setOnErrorListener { what, extra -> - onError(audioHash, what, extra) + setOnErrorListener { errorCode -> + onError(audioHash, errorCode) } playerState = PlayerState.LOADING @@ -266,7 +260,7 @@ internal class StreamMediaPlayer( return } mediaPlayer.seekTo(seekTo) - if (isMarshmallowOrHigher && mediaPlayer.isSpeedSettable()) { + if (mediaPlayer.isSpeedSettable()) { mediaPlayer.speed = playingSpeed publishSpeed(currentAudioHash, playingSpeed) } @@ -331,8 +325,8 @@ internal class StreamMediaPlayer( } } - private fun onError(audioHash: Int, what: Int, extra: Int): Boolean { - logger.e { "[onError] audioHash: $audioHash, what: $what, extra: $extra" } + private fun onError(audioHash: Int, errorCode: Int): Boolean { + logger.e { "[onError] audioHash: $audioHash, errorCode: $errorCode" } complete(audioHash) resetPlayer() mediaPlayer.release() @@ -430,14 +424,6 @@ internal class StreamMediaPlayer( onSpeedListeners[audioHash]?.forEach { listener -> listener.invoke(speed) } } - private fun normalize(uri: String): String { - try { - return uri.toUri().toString() - } catch (_: Throwable) { - return uri - } - } - private fun NativeMediaPlayer.isSeekable(): Boolean { return when (state) { NativeMediaPlayerState.PREPARED, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.kt index e776b73b57f..ad538ee1ad7 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.kt @@ -20,7 +20,7 @@ import io.getstream.chat.android.client.api.ChatClientConfig import io.getstream.chat.android.client.api2.MoshiChatApi import io.getstream.chat.android.client.api2.mapping.DtoMapping import io.getstream.chat.android.client.attachment.AttachmentsSender -import io.getstream.chat.android.client.audio.StreamMediaPlayer +import io.getstream.chat.android.client.audio.StreamAudioPlayer import io.getstream.chat.android.client.clientstate.UserStateService import io.getstream.chat.android.client.events.ConnectedEvent import io.getstream.chat.android.client.notifications.ChatNotifications @@ -99,7 +99,7 @@ internal class MockClientBuilder( Mockito.`when`(tokenUtil.getUserId(token)) doReturn userId fileUploader = mock() notificationsManager = mock() - val streamPlayer = mock() + val streamPlayer = mock() api = mock() attachmentSender = mock() diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/audio/NativeMediaPlayerMock.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/audio/NativeMediaPlayerMock.kt index b9bf7d261e3..845acb02cd1 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/audio/NativeMediaPlayerMock.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/audio/NativeMediaPlayerMock.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.client.audio +import androidx.media3.common.PlaybackException import io.getstream.chat.android.client.scope.UserScope import io.getstream.log.taggedLogger import kotlinx.coroutines.delay @@ -31,7 +32,7 @@ internal class NativeMediaPlayerMock( private var _duration: Int = Int.MAX_VALUE private var _onCompletionListener: (() -> Unit)? = null - private var _onErrorListener: ((what: Int, extra: Int) -> Boolean)? = null + private var _onErrorListener: ((errorCode: Int) -> Boolean)? = null private var _onPreparedListener: (() -> Unit)? = null private val validStates = hashSetOf( @@ -56,7 +57,7 @@ internal class NativeMediaPlayerMock( override var speed: Float = 1.0f set(value) { if (_state !in validStates) { - onError("[setSpeed] invalid state: $_state", what = 1) + onError("[setSpeed] invalid state: $_state", 1) throw IllegalStateException("[setSpeed] invalid state: $_state") } field = value @@ -64,14 +65,14 @@ internal class NativeMediaPlayerMock( override val currentPosition: Int get() { if (_state !in validStates) { - onError("[getCurrentPosition] invalid state: $_state", what = 2) + onError("[getCurrentPosition] invalid state: $_state", 2) } return _currentPosition++ } override val duration: Int get() { if (_state !in validStates) { - onError("[getDuration] invalid state: $_state", what = 3) + onError("[getDuration] invalid state: $_state", 3) } return _duration } @@ -80,10 +81,6 @@ internal class NativeMediaPlayerMock( publishState(NativeMediaPlayerState.INITIALIZED) } - override fun prepare() { - publishState(NativeMediaPlayerState.PREPARED) - } - override fun prepareAsync() { publishState(NativeMediaPlayerState.PREPARING) userScope.launch { @@ -99,7 +96,7 @@ internal class NativeMediaPlayerMock( _state != NativeMediaPlayerState.STARTED && _state != NativeMediaPlayerState.PLAYBACK_COMPLETED ) { - onError("[seekTo] invalid state: $_state", what = NativeMediaPlayer.MEDIA_ERROR_INVALID_OPERATION) + onError("[seekTo] invalid state: $_state", PlaybackException.ERROR_CODE_BAD_VALUE) return } _currentPosition = msec @@ -110,7 +107,7 @@ internal class NativeMediaPlayerMock( _state != NativeMediaPlayerState.PAUSED && _state != NativeMediaPlayerState.PLAYBACK_COMPLETED ) { - onError("[start] invalid state: $_state", what = 4) + onError("[start] invalid state: $_state", 4) return } publishState(NativeMediaPlayerState.STARTED) @@ -140,7 +137,7 @@ internal class NativeMediaPlayerMock( _onCompletionListener = listener } - override fun setOnErrorListener(listener: (what: Int, extra: Int) -> Boolean) { + override fun setOnErrorListener(listener: (errorCode: Int) -> Boolean) { _onErrorListener = listener } @@ -148,10 +145,10 @@ internal class NativeMediaPlayerMock( _onPreparedListener = listener } - private fun onError(message: String, what: Int = 0, extra: Int = 0) { + private fun onError(message: String, errorCode: Int = 0) { // logger.e { message } if (publishState(NativeMediaPlayerState.ERROR)) { - _onErrorListener?.invoke(what, extra) + _onErrorListener?.invoke(errorCode) } } 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 a4928541a17..3910a069e14 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 @@ -38,16 +38,15 @@ internal class StreamMediaPlayerTest { private lateinit var userScope: UserTestScope private lateinit var mediaPlayer: NativeMediaPlayerMock - private lateinit var streamPlayer: StreamMediaPlayer + private lateinit var streamPlayer: StreamAudioPlayer @BeforeEach fun setUp() { userScope = UserTestScope(testCoroutines.scope) mediaPlayer = NativeMediaPlayerMock(userScope) - streamPlayer = StreamMediaPlayer( + streamPlayer = StreamAudioPlayer( mediaPlayer = mediaPlayer, userScope = userScope, - isMarshmallowOrHigher = true, ) } 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 69aac4c476f..b269a5702cd 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 @@ -37,7 +37,7 @@ public class AudioPlayerController( private val getRecordingUri: (Attachment) -> String?, ) { - private val logger by taggedLogger("Chat:PlayerController") + private val logger by taggedLogger("Chat:AudioPlayerController") public val state: MutableStateFlow = MutableStateFlow( AudioPlayerState(getRecordingUri = getRecordingUri),