diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt new file mode 100644 index 0000000000..7e30347d18 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.voicebroadcast + +import android.media.AudioAttributes +import android.media.MediaPlayer +import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker +import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker.Listener.State +import im.vector.app.features.voice.VoiceFailure +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.getRelationContent +import org.matrix.android.sdk.api.session.getRoom +import org.matrix.android.sdk.api.session.room.Room +import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent +import org.matrix.android.sdk.api.session.room.model.message.MessageAudioEvent +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class VoiceBroadcastPlayer @Inject constructor( + private val session: Session, + private val playbackTracker: AudioMessagePlaybackTracker, +) { + + private val mediaPlayerScope = CoroutineScope(Dispatchers.IO) + + private var currentMediaPlayer: MediaPlayer? = null + private var currentPlayingIndex: Int = -1 + private var playlist = emptyList() + private val currentVoiceBroadcastEventId + get() = playlist.firstOrNull()?.root?.getRelationContent()?.eventId + + private val mediaPlayerListener = MediaPlayerListener() + + fun play(roomId: String, eventId: String) { + val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId") + + when { + currentVoiceBroadcastEventId != eventId -> { + stop() + updatePlaylist(room, eventId) + startPlayback() + } + playbackTracker.getPlaybackState(eventId) is State.Playing -> pause() + else -> resumePlayback() + } + } + + fun pause() { + currentMediaPlayer?.pause() + currentVoiceBroadcastEventId?.let { playbackTracker.pausePlayback(it) } + } + + fun stop() { + currentMediaPlayer?.stop() + currentMediaPlayer?.release() + currentMediaPlayer?.setOnInfoListener(null) + currentMediaPlayer = null + currentVoiceBroadcastEventId?.let { playbackTracker.stopPlayback(it) } + playlist = emptyList() + currentPlayingIndex = -1 + } + + @Suppress("UNUSED_PARAMETER") + private fun updatePlaylist(room: Room, eventId: String) { + // TODO get the list of voice messages + } + + private fun startPlayback() { + val content = playlist.firstOrNull()?.content ?: run { Timber.w("## VoiceBroadcastPlayer: No content to play"); return } + mediaPlayerScope.launch { + try { + currentMediaPlayer = prepareMediaPlayer(content) + currentMediaPlayer?.start() + currentPlayingIndex = 0 + currentVoiceBroadcastEventId?.let { playbackTracker.startPlayback(it) } + prepareNextFile() + } catch (failure: Throwable) { + Timber.e(failure, "Unable to start playback") + throw VoiceFailure.UnableToPlay(failure) + } + } + } + + private fun resumePlayback() { + currentMediaPlayer?.start() + currentVoiceBroadcastEventId?.let { playbackTracker.startPlayback(it) } + } + + private suspend fun prepareNextFile() { + val nextContent = playlist.getOrNull(currentPlayingIndex + 1)?.content + if (nextContent == null) { + currentMediaPlayer?.setOnCompletionListener(mediaPlayerListener) + } else { + val nextMediaPlayer = prepareMediaPlayer(nextContent) + currentMediaPlayer?.setNextMediaPlayer(nextMediaPlayer) + } + } + + private suspend fun prepareMediaPlayer(messageAudioContent: MessageAudioContent): MediaPlayer { + val audioFile = session.fileService().downloadFile(messageAudioContent) + return audioFile.inputStream().use { fis -> + MediaPlayer().apply { + setAudioAttributes( + AudioAttributes.Builder() + // Do not use CONTENT_TYPE_SPEECH / USAGE_VOICE_COMMUNICATION because we want to play loud here + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .setUsage(AudioAttributes.USAGE_MEDIA) + .build() + ) + setDataSource(fis.fd) + setOnInfoListener(mediaPlayerListener) + setOnErrorListener(mediaPlayerListener) + prepare() + } + } + } + + inner class MediaPlayerListener : MediaPlayer.OnInfoListener, MediaPlayer.OnCompletionListener, MediaPlayer.OnErrorListener { + + override fun onInfo(mp: MediaPlayer, what: Int, extra: Int): Boolean { + when (what) { + MediaPlayer.MEDIA_INFO_STARTED_AS_NEXT -> { + currentMediaPlayer = mp + currentPlayingIndex++ + mediaPlayerScope.launch { prepareNextFile() } + } + } + return false + } + + override fun onCompletion(mp: MediaPlayer) { + // Verify that a new media has not been set in the mean time + if (!currentMediaPlayer?.isPlaying.orFalse()) { + stop() + } + } + + override fun onError(mp: MediaPlayer, what: Int, extra: Int): Boolean { + stop() + return true + } + } +}