Merge pull request #5586 from vector-im/feature/eric/audio-files-player

Audio files in the timeline now appear with the audio player
This commit is contained in:
Eric Decanini 2022-04-07 10:25:58 +01:00 committed by GitHub
commit 45104f8cec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 576 additions and 190 deletions

1
changelog.d/5586.feature Normal file
View file

@ -0,0 +1 @@
Adds the ability for audio attachments to be played in the timeline

View file

@ -38,7 +38,7 @@ import im.vector.app.databinding.ActivityRoomDetailBinding
import im.vector.app.features.analytics.plan.MobileScreen
import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsFragment
import im.vector.app.features.home.room.detail.arguments.TimelineArgs
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker
import im.vector.app.features.matrixto.MatrixToBottomSheet
import im.vector.app.features.navigation.Navigator
import im.vector.app.features.room.RequireActiveMembershipAction
@ -75,7 +75,7 @@ class RoomDetailActivity :
}
private var lastKnownPlayingOrRecordingState: Boolean? = null
private val playbackActivityListener = VoiceMessagePlaybackTracker.ActivityListener { isPlayingOrRecording ->
private val playbackActivityListener = AudioMessagePlaybackTracker.ActivityListener { isPlayingOrRecording ->
if (lastKnownPlayingOrRecordingState == isPlayingOrRecording) return@ActivityListener
when (isPlayingOrRecording) {
true -> keepScreenOn()
@ -86,7 +86,7 @@ class RoomDetailActivity :
override fun getCoordinatorLayout() = views.coordinatorLayout
@Inject lateinit var playbackTracker: VoiceMessagePlaybackTracker
@Inject lateinit var playbackTracker: AudioMessagePlaybackTracker
private lateinit var sharedActionViewModel: RoomDetailSharedActionViewModel
private val requireActiveMembershipViewModel: RequireActiveMembershipViewModel by viewModel()
@ -152,7 +152,7 @@ class RoomDetailActivity :
override fun onDestroy() {
supportFragmentManager.unregisterFragmentLifecycleCallbacks(fragmentLifecycleCallbacks)
views.drawerLayout.removeDrawerListener(drawerListener)
playbackTracker.unTrackActivity(playbackActivityListener)
playbackTracker.untrackActivity(playbackActivityListener)
super.onDestroy()
}

View file

@ -156,10 +156,11 @@ import im.vector.app.features.home.room.detail.timeline.action.EventSharedAction
import im.vector.app.features.home.room.detail.timeline.action.MessageActionsBottomSheet
import im.vector.app.features.home.room.detail.timeline.action.MessageSharedActionViewModel
import im.vector.app.features.home.room.detail.timeline.edithistory.ViewEditHistoryBottomSheet
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker
import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
import im.vector.app.features.home.room.detail.timeline.image.buildImageContentRendererData
import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem
import im.vector.app.features.home.room.detail.timeline.item.MessageAudioItem
import im.vector.app.features.home.room.detail.timeline.item.MessageFileItem
import im.vector.app.features.home.room.detail.timeline.item.MessageImageVideoItem
import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData
@ -264,7 +265,7 @@ class TimelineFragment @Inject constructor(
private val roomDetailPendingActionStore: RoomDetailPendingActionStore,
private val pillsPostProcessorFactory: PillsPostProcessor.Factory,
private val callManager: WebRtcCallManager,
private val voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker,
private val audioMessagePlaybackTracker: AudioMessagePlaybackTracker,
private val clock: Clock,
private val matrixConfiguration: MatrixConfiguration
) :
@ -737,7 +738,7 @@ class TimelineFragment @Inject constructor(
}
private fun setupVoiceMessageView() {
voiceMessagePlaybackTracker.track(VoiceMessagePlaybackTracker.RECORDING_ID, views.voiceMessageRecorderView)
audioMessagePlaybackTracker.track(AudioMessagePlaybackTracker.RECORDING_ID, views.voiceMessageRecorderView)
views.voiceMessageRecorderView.callback = object : VoiceMessageRecorderView.Callback {
override fun onVoiceRecordingStarted() {
@ -794,13 +795,13 @@ class TimelineFragment @Inject constructor(
override fun onVoiceWaveformTouchedUp(percentage: Float, duration: Int) {
messageComposerViewModel.handle(
MessageComposerAction.VoiceWaveformTouchedUp(VoiceMessagePlaybackTracker.RECORDING_ID, duration, percentage)
MessageComposerAction.VoiceWaveformTouchedUp(AudioMessagePlaybackTracker.RECORDING_ID, duration, percentage)
)
}
override fun onVoiceWaveformMoved(percentage: Float, duration: Int) {
messageComposerViewModel.handle(
MessageComposerAction.VoiceWaveformTouchedUp(VoiceMessagePlaybackTracker.RECORDING_ID, duration, percentage)
MessageComposerAction.VoiceWaveformTouchedUp(AudioMessagePlaybackTracker.RECORDING_ID, duration, percentage)
)
}
@ -908,6 +909,7 @@ class TimelineFragment @Inject constructor(
}
override fun onDestroyView() {
audioMessagePlaybackTracker.makeAllPlaybacksIdle()
lazyLoadedViews.unBind()
timelineEventController.callback = null
timelineEventController.removeModelBuildListener(modelBuildListener)
@ -1202,13 +1204,10 @@ class TimelineFragment @Inject constructor(
}
val messageContent: MessageContent? = event.getLastMessageContent()
val nonFormattedBody = if (messageContent is MessageAudioContent && messageContent.voiceMessageIndicator != null) {
val formattedDuration = DateUtils.formatElapsedTime(((messageContent.audioInfo?.duration ?: 0) / 1000).toLong())
getString(R.string.voice_message_reply_content, formattedDuration)
} else if (messageContent is MessagePollContent) {
messageContent.getBestPollCreationInfo()?.question?.getBestQuestion()
} else {
messageContent?.body ?: ""
val nonFormattedBody = when (messageContent) {
is MessageAudioContent -> getAudioContentBodyText(messageContent)
is MessagePollContent -> messageContent.getBestPollCreationInfo()?.question?.getBestQuestion()
else -> messageContent?.body.orEmpty()
}
var formattedBody: CharSequence? = null
if (messageContent is MessageTextContent && messageContent.format == MessageFormat.FORMAT_MATRIX_HTML) {
@ -1247,6 +1246,15 @@ class TimelineFragment @Inject constructor(
focusComposerAndShowKeyboard()
}
private fun getAudioContentBodyText(messageContent: MessageAudioContent): String {
val formattedDuration = DateUtils.formatElapsedTime(((messageContent.audioInfo?.duration ?: 0) / 1000).toLong())
return if (messageContent.voiceMessageIndicator != null) {
getString(R.string.voice_message_reply_content, formattedDuration)
} else {
getString(R.string.audio_message_reply_content, messageContent.body, formattedDuration)
}
}
override fun onResume() {
super.onResume()
notificationDrawerManager.setCurrentRoom(timelineArgs.roomId)
@ -1271,7 +1279,7 @@ class TimelineFragment @Inject constructor(
override fun onPause() {
super.onPause()
notificationDrawerManager.setCurrentRoom(null)
voiceMessagePlaybackTracker.unTrack(VoiceMessagePlaybackTracker.RECORDING_ID)
audioMessagePlaybackTracker.pauseAllPlaybacks()
if (withState(messageComposerViewModel) { it.isVoiceRecording } && requireActivity().isChangingConfigurations) {
// we're rotating, maintain any active recordings
@ -1399,6 +1407,7 @@ class TimelineFragment @Inject constructor(
}
return when (model) {
is MessageFileItem,
is MessageAudioItem,
is MessageVoiceItem,
is MessageImageVideoItem,
is MessageTextItem -> {
@ -2088,6 +2097,10 @@ class TimelineFragment @Inject constructor(
messageComposerViewModel.handle(MessageComposerAction.VoiceWaveformMovedTo(eventId, duration, percentage))
}
override fun onAudioSeekBarMovedTo(eventId: String, duration: Int, percentage: Float) {
messageComposerViewModel.handle(MessageComposerAction.AudioSeekBarMovedTo(eventId, duration, percentage))
}
private fun onShareActionClicked(action: EventSharedAction.Share) {
when (action.messageContent) {
is MessageTextContent -> shareText(requireContext(), action.messageContent.body)

View file

@ -21,7 +21,7 @@ import android.media.AudioAttributes
import android.media.MediaPlayer
import androidx.core.content.FileProvider
import im.vector.app.BuildConfig
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker
import im.vector.app.features.voice.VoiceFailure
import im.vector.app.features.voice.VoiceRecorder
import im.vector.app.features.voice.VoiceRecorderProvider
@ -40,12 +40,13 @@ import javax.inject.Inject
/**
* Helper class to record audio for voice messages.
*/
class VoiceMessageHelper @Inject constructor(
class AudioMessageHelper @Inject constructor(
private val context: Context,
private val playbackTracker: VoiceMessagePlaybackTracker,
private val playbackTracker: AudioMessagePlaybackTracker,
voiceRecorderProvider: VoiceRecorderProvider
) {
private var mediaPlayer: MediaPlayer? = null
private var currentPlayingId: String? = null
private var voiceRecorder: VoiceRecorder = voiceRecorderProvider.provideVoiceRecorder()
private val amplitudeList = mutableListOf<Int>()
@ -58,7 +59,7 @@ class VoiceMessageHelper @Inject constructor(
amplitudeList.clear()
attachmentData.waveform?.let {
amplitudeList.addAll(it)
playbackTracker.updateCurrentRecording(VoiceMessagePlaybackTracker.RECORDING_ID, amplitudeList)
playbackTracker.updateCurrentRecording(AudioMessagePlaybackTracker.RECORDING_ID, amplitudeList)
}
}
@ -127,7 +128,7 @@ class VoiceMessageHelper @Inject constructor(
fun startOrPauseRecordingPlayback() {
voiceRecorder.getCurrentRecord()?.let {
startOrPausePlayback(VoiceMessagePlaybackTracker.RECORDING_ID, it)
startOrPausePlayback(AudioMessagePlaybackTracker.RECORDING_ID, it)
}
}
@ -136,7 +137,8 @@ class VoiceMessageHelper @Inject constructor(
mediaPlayer?.stop()
stopPlaybackTicker()
stopRecordingAmplitudes()
if (playbackState is VoiceMessagePlaybackTracker.Listener.State.Playing) {
currentPlayingId = null
if (playbackState is AudioMessagePlaybackTracker.Listener.State.Playing) {
playbackTracker.pausePlayback(id)
} else {
startPlayback(id, file)
@ -163,6 +165,7 @@ class VoiceMessageHelper @Inject constructor(
seekTo(currentPlaybackTime)
}
}
currentPlayingId = id
} catch (failure: Throwable) {
Timber.e(failure, "Unable to start playback")
throw VoiceFailure.UnableToPlay(failure)
@ -171,17 +174,24 @@ class VoiceMessageHelper @Inject constructor(
}
fun stopPlayback() {
playbackTracker.pausePlayback(VoiceMessagePlaybackTracker.RECORDING_ID)
playbackTracker.pausePlayback(AudioMessagePlaybackTracker.RECORDING_ID)
mediaPlayer?.stop()
stopPlaybackTicker()
currentPlayingId = null
}
fun movePlaybackTo(id: String, percentage: Float, totalDuration: Int) {
val toMillisecond = (totalDuration * percentage).toInt()
playbackTracker.updateCurrentPlaybackTime(id, toMillisecond, percentage)
playbackTracker.pauseAllPlaybacks()
stopPlayback()
playbackTracker.pausePlayback(id)
if (currentPlayingId == id) {
mediaPlayer?.seekTo(toMillisecond)
playbackTracker.updatePlayingAtPlaybackTime(id, toMillisecond, percentage)
} else {
mediaPlayer?.pause()
playbackTracker.updatePausedAtPlaybackTime(id, toMillisecond, percentage)
stopPlaybackTicker()
}
}
private fun startRecordingAmplitudes() {
@ -200,7 +210,7 @@ class VoiceMessageHelper @Inject constructor(
try {
val maxAmplitude = voiceRecorder.getMaxAmplitude()
amplitudeList.add(maxAmplitude)
playbackTracker.updateCurrentRecording(VoiceMessagePlaybackTracker.RECORDING_ID, amplitudeList)
playbackTracker.updateCurrentRecording(AudioMessagePlaybackTracker.RECORDING_ID, amplitudeList)
} catch (e: IllegalStateException) {
Timber.e(e, "Cannot get max amplitude. Amplitude recording timer will be stopped.")
stopRecordingAmplitudes()
@ -233,7 +243,7 @@ class VoiceMessageHelper @Inject constructor(
val currentPosition = mediaPlayer?.currentPosition ?: 0
val totalDuration = mediaPlayer?.duration ?: 0
val percentage = currentPosition.toFloat() / totalDuration
playbackTracker.updateCurrentPlaybackTime(id, currentPosition, percentage)
playbackTracker.updatePlayingAtPlaybackTime(id, currentPosition, percentage)
} else {
playbackTracker.stopPlayback(id)
stopPlaybackTicker()

View file

@ -42,4 +42,5 @@ sealed class MessageComposerAction : VectorViewModelAction {
data class EndAllVoiceActions(val deleteRecord: Boolean = true) : MessageComposerAction()
data class VoiceWaveformTouchedUp(val eventId: String, val duration: Int, val percentage: Float) : MessageComposerAction()
data class VoiceWaveformMovedTo(val eventId: String, val duration: Int, val percentage: Float) : MessageComposerAction()
data class AudioSeekBarMovedTo(val eventId: String, val duration: Int, val percentage: Float) : MessageComposerAction()
}

View file

@ -73,7 +73,7 @@ class MessageComposerViewModel @AssistedInject constructor(
private val vectorPreferences: VectorPreferences,
private val commandParser: CommandParser,
private val rainbowGenerator: RainbowGenerator,
private val voiceMessageHelper: VoiceMessageHelper,
private val audioMessageHelper: AudioMessageHelper,
private val analyticsTracker: AnalyticsTracker,
private val voicePlayerHelper: VoicePlayerHelper
) : VectorViewModel<MessageComposerViewState, MessageComposerAction, MessageComposerViewEvents>(initialState) {
@ -90,7 +90,6 @@ class MessageComposerViewModel @AssistedInject constructor(
}
override fun handle(action: MessageComposerAction) {
Timber.v("Handle action: $action")
when (action) {
is MessageComposerAction.EnterEditMode -> handleEnterEditMode(action)
is MessageComposerAction.EnterQuoteMode -> handleEnterQuoteMode(action)
@ -110,6 +109,7 @@ class MessageComposerViewModel @AssistedInject constructor(
is MessageComposerAction.OnEntersBackground -> handleEntersBackground(action.composerText)
is MessageComposerAction.VoiceWaveformTouchedUp -> handleVoiceWaveformTouchedUp(action)
is MessageComposerAction.VoiceWaveformMovedTo -> handleVoiceWaveformMovedTo(action)
is MessageComposerAction.AudioSeekBarMovedTo -> handleAudioSeekBarMovedTo(action)
}
}
@ -811,18 +811,18 @@ class MessageComposerViewModel @AssistedInject constructor(
private fun handleStartRecordingVoiceMessage() {
try {
voiceMessageHelper.startRecording(room.roomId)
audioMessageHelper.startRecording(room.roomId)
} catch (failure: Throwable) {
_viewEvents.post(MessageComposerViewEvents.VoicePlaybackOrRecordingFailure(failure))
}
}
private fun handleEndRecordingVoiceMessage(isCancelled: Boolean, rootThreadEventId: String? = null) {
voiceMessageHelper.stopPlayback()
audioMessageHelper.stopPlayback()
if (isCancelled) {
voiceMessageHelper.deleteRecording()
audioMessageHelper.deleteRecording()
} else {
voiceMessageHelper.stopRecording(convertForSending = true)?.let { audioType ->
audioMessageHelper.stopRecording(convertForSending = true)?.let { audioType ->
if (audioType.duration > 1000) {
room.sendMedia(
attachment = audioType.toContentAttachmentData(isVoiceMessage = true),
@ -830,7 +830,7 @@ class MessageComposerViewModel @AssistedInject constructor(
roomIds = emptySet(),
rootThreadEventId = rootThreadEventId)
} else {
voiceMessageHelper.deleteRecording()
audioMessageHelper.deleteRecording()
}
}
}
@ -845,7 +845,7 @@ class MessageComposerViewModel @AssistedInject constructor(
// Conversion can fail, fallback to the original file in this case and let the player fail for us
val convertedFile = voicePlayerHelper.convertFile(audioFile) ?: audioFile
// Play can fail
voiceMessageHelper.startOrPausePlayback(action.eventId, convertedFile)
audioMessageHelper.startOrPausePlayback(action.eventId, convertedFile)
} catch (failure: Throwable) {
_viewEvents.post(MessageComposerViewEvents.VoicePlaybackOrRecordingFailure(failure))
}
@ -853,35 +853,38 @@ class MessageComposerViewModel @AssistedInject constructor(
}
private fun handlePlayOrPauseRecordingPlayback() {
voiceMessageHelper.startOrPauseRecordingPlayback()
audioMessageHelper.startOrPauseRecordingPlayback()
}
private fun handleEndAllVoiceActions(deleteRecord: Boolean) {
voiceMessageHelper.clearTracker()
voiceMessageHelper.stopAllVoiceActions(deleteRecord)
audioMessageHelper.clearTracker()
audioMessageHelper.stopAllVoiceActions(deleteRecord)
}
private fun handleInitializeVoiceRecorder(attachmentData: ContentAttachmentData) {
voiceMessageHelper.initializeRecorder(attachmentData)
audioMessageHelper.initializeRecorder(attachmentData)
setState { copy(voiceRecordingUiState = VoiceMessageRecorderView.RecordingUiState.Draft) }
}
private fun handlePauseRecordingVoiceMessage() {
voiceMessageHelper.pauseRecording()
audioMessageHelper.pauseRecording()
}
private fun handleVoiceWaveformTouchedUp(action: MessageComposerAction.VoiceWaveformTouchedUp) {
voiceMessageHelper.movePlaybackTo(action.eventId, action.percentage, action.duration)
audioMessageHelper.movePlaybackTo(action.eventId, action.percentage, action.duration)
}
private fun handleVoiceWaveformMovedTo(action: MessageComposerAction.VoiceWaveformMovedTo) {
voiceMessageHelper.movePlaybackTo(action.eventId, action.percentage, action.duration)
audioMessageHelper.movePlaybackTo(action.eventId, action.percentage, action.duration)
}
private fun handleAudioSeekBarMovedTo(action: MessageComposerAction.AudioSeekBarMovedTo) {
audioMessageHelper.movePlaybackTo(action.eventId, action.percentage, action.duration)
}
private fun handleEntersBackground(composerText: String) {
// Always stop all voice actions. It may be playing in timeline or active recording
val playingAudioContent = voiceMessageHelper.stopAllVoiceActions(deleteRecord = false)
voiceMessageHelper.clearTracker()
val playingAudioContent = audioMessageHelper.stopAllVoiceActions(deleteRecord = false)
val isVoiceRecording = com.airbnb.mvrx.withState(this) { it.isVoiceRecording }
if (isVoiceRecording) {

View file

@ -27,7 +27,7 @@ import im.vector.app.core.hardware.vibrate
import im.vector.app.core.time.Clock
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.databinding.ViewVoiceMessageRecorderBinding
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker
import im.vector.lib.core.utils.timer.CountUpTimer
import javax.inject.Inject
import kotlin.math.floor
@ -40,7 +40,7 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr), VoiceMessagePlaybackTracker.Listener {
) : ConstraintLayout(context, attrs, defStyleAttr), AudioMessagePlaybackTracker.Listener {
interface Callback {
fun onVoiceRecordingStarted()
@ -222,16 +222,16 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
recordingTicker = null
}
override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) {
override fun onUpdate(state: AudioMessagePlaybackTracker.Listener.State) {
when (state) {
is VoiceMessagePlaybackTracker.Listener.State.Recording -> {
is AudioMessagePlaybackTracker.Listener.State.Recording -> {
voiceMessageViews.renderRecordingWaveform(state.amplitudeList.toTypedArray())
}
is VoiceMessagePlaybackTracker.Listener.State.Playing -> {
is AudioMessagePlaybackTracker.Listener.State.Playing -> {
voiceMessageViews.renderPlaying(state)
}
is VoiceMessagePlaybackTracker.Listener.State.Paused,
is VoiceMessagePlaybackTracker.Listener.State.Idle -> {
is AudioMessagePlaybackTracker.Listener.State.Paused,
is AudioMessagePlaybackTracker.Listener.State.Idle -> {
voiceMessageViews.renderIdle()
}
}

View file

@ -35,7 +35,7 @@ import im.vector.app.core.utils.DimensionConverter
import im.vector.app.databinding.ViewVoiceMessageRecorderBinding
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.DraggingState
import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView.RecordingUiState
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker
import im.vector.app.features.themes.ThemeUtils
import im.vector.app.features.voice.AudioWaveformView
@ -303,7 +303,7 @@ class VoiceMessageViews(
views.voicePlaybackWaveform.post { views.voicePlaybackWaveform.clear() }
}
fun renderPlaying(state: VoiceMessagePlaybackTracker.Listener.State.Playing) {
fun renderPlaying(state: AudioMessagePlaybackTracker.Listener.State.Playing) {
views.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_pause)
views.voicePlaybackControlButton.contentDescription = resources.getString(R.string.a11y_pause_voice_message)
val formattedTimerText = DateUtils.formatElapsedTime((state.playbackTime / 1000).toLong())

View file

@ -148,6 +148,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
fun onVoiceWaveformTouchedUp(eventId: String, duration: Int, percentage: Float)
fun onVoiceWaveformMovedTo(eventId: String, duration: Int, percentage: Float)
fun onAudioSeekBarMovedTo(eventId: String, duration: Int, percentage: Float)
fun onAddMoreReaction(event: TimelineEvent)
}

View file

@ -34,6 +34,7 @@ import im.vector.app.core.resources.StringProvider
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.core.utils.containsOnlyEmojis
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker
import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider
import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder
import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
@ -41,8 +42,9 @@ import im.vector.app.features.home.room.detail.timeline.helper.LocationPinProvid
import im.vector.app.features.home.room.detail.timeline.helper.MessageInformationDataFactory
import im.vector.app.features.home.room.detail.timeline.helper.MessageItemAttributesFactory
import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem
import im.vector.app.features.home.room.detail.timeline.item.MessageAudioItem
import im.vector.app.features.home.room.detail.timeline.item.MessageAudioItem_
import im.vector.app.features.home.room.detail.timeline.item.MessageFileItem
import im.vector.app.features.home.room.detail.timeline.item.MessageFileItem_
import im.vector.app.features.home.room.detail.timeline.item.MessageImageVideoItem
@ -109,7 +111,6 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageVerification
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
import org.matrix.android.sdk.api.session.room.model.message.PollAnswer
import org.matrix.android.sdk.api.session.room.model.message.PollType
import org.matrix.android.sdk.api.session.room.model.message.getFileName
import org.matrix.android.sdk.api.session.room.model.message.getFileUrl
import org.matrix.android.sdk.api.session.room.model.message.getThumbnailUrl
import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent
@ -140,7 +141,7 @@ class MessageItemFactory @Inject constructor(
private val lightweightSettingsStorage: LightweightSettingsStorage,
private val spanUtils: SpanUtils,
private val session: Session,
private val voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker,
private val audioMessagePlaybackTracker: AudioMessagePlaybackTracker,
private val locationPinProvider: LocationPinProvider,
private val vectorPreferences: VectorPreferences,
private val urlMapProvider: UrlMapProvider,
@ -201,13 +202,7 @@ class MessageItemFactory @Inject constructor(
is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessageFileContent -> buildFileMessageItem(messageContent, highlight, attributes)
is MessageAudioContent -> {
if (messageContent.voiceMessageIndicator != null) {
buildVoiceMessageItem(params, messageContent, informationData, highlight, attributes)
} else {
buildAudioMessageItem(messageContent, informationData, highlight, attributes)
}
}
is MessageAudioContent -> buildAudioContent(params, messageContent, informationData, highlight, attributes)
is MessageVerificationRequestContent -> buildVerificationRequestMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessagePollContent -> buildPollItem(messageContent, informationData, highlight, callback, attributes)
is MessageLocationContent -> {
@ -279,9 +274,9 @@ class MessageItemFactory @Inject constructor(
}
private fun createPollState(
informationData: MessageInformationData,
pollResponseSummary: PollResponseData?,
pollContent: MessagePollContent,
informationData: MessageInformationData,
pollResponseSummary: PollResponseData?,
pollContent: MessagePollContent,
): PollState = when {
!informationData.sendState.isSent() -> Sending
pollResponseSummary?.isClosed.orFalse() -> Ended
@ -291,8 +286,8 @@ class MessageItemFactory @Inject constructor(
}
private fun List<PollAnswer>.mapToOptions(
pollState: PollState,
informationData: MessageInformationData,
pollState: PollState,
informationData: MessageInformationData,
) = map { answer ->
val pollResponseSummary = informationData.pollResponseAggregatedSummary
val winnerVoteCount = pollResponseSummary?.winnerVoteCount
@ -314,9 +309,9 @@ class MessageItemFactory @Inject constructor(
}
private fun createPollQuestion(
informationData: MessageInformationData,
question: String,
callback: TimelineEventController.Callback?,
informationData: MessageInformationData,
question: String,
callback: TimelineEventController.Callback?,
) = if (informationData.hasBeenEdited) {
annotateWithEdited(question, callback, informationData)
} else {
@ -324,8 +319,8 @@ class MessageItemFactory @Inject constructor(
}.toEpoxyCharSequence()
private fun createTotalVotesText(
pollState: PollState,
pollResponseSummary: PollResponseData?,
pollState: PollState,
pollResponseSummary: PollResponseData?,
): String {
val votes = pollResponseSummary?.totalVotes ?: 0
return when {
@ -338,58 +333,62 @@ class MessageItemFactory @Inject constructor(
}
private fun buildAudioMessageItem(
messageContent: MessageAudioContent,
@Suppress("UNUSED_PARAMETER")
informationData: MessageInformationData,
highlight: Boolean,
attributes: AbsMessageItem.Attributes,
): MessageFileItem? {
val fileUrl = messageContent.getFileUrl()?.let {
if (informationData.sentByMe && !informationData.sendState.isSent()) {
it
} else {
it.takeIf { it.isMxcUrl() }
}
} ?: ""
return MessageFileItem_()
.attributes(attributes)
.izLocalFile(localFilesHelper.isLocalFile(fileUrl))
.izDownloaded(session.fileService().isFileInCache(
fileUrl,
messageContent.getFileName(),
messageContent.mimeType,
messageContent.encryptedFileInfo?.toElementToDecrypt())
)
.mxcUrl(fileUrl)
.contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
.contentDownloadStateTrackerBinder(contentDownloadStateTrackerBinder)
.highlighted(highlight)
.leftGuideline(avatarSizeProvider.leftGuideline)
.filename(messageContent.body)
.iconRes(R.drawable.ic_headphones)
params: TimelineItemFactoryParams,
messageContent: MessageAudioContent,
informationData: MessageInformationData,
highlight: Boolean,
attributes: AbsMessageItem.Attributes
): MessageAudioItem {
val fileUrl = getAudioFileUrl(messageContent, informationData)
val playbackControlButtonClickListener = createOnPlaybackButtonClickListener(messageContent, informationData, params)
val duration = messageContent.audioInfo?.duration ?: 0
return MessageAudioItem_()
.attributes(attributes)
.filename(messageContent.body)
.duration(messageContent.audioInfo?.duration ?: 0)
.playbackControlButtonClickListener(playbackControlButtonClickListener)
.audioMessagePlaybackTracker(audioMessagePlaybackTracker)
.isLocalFile(localFilesHelper.isLocalFile(fileUrl))
.fileSize(messageContent.audioInfo?.size ?: 0L)
.onSeek { params.callback?.onAudioSeekBarMovedTo(informationData.eventId, duration, it) }
.mxcUrl(fileUrl)
.contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
.contentDownloadStateTrackerBinder(contentDownloadStateTrackerBinder)
.highlighted(highlight)
.leftGuideline(avatarSizeProvider.leftGuideline)
}
private fun getAudioFileUrl(
messageContent: MessageAudioContent,
informationData: MessageInformationData,
) = messageContent.getFileUrl()?.let {
if (informationData.sentByMe && !informationData.sendState.isSent()) {
it
} else {
it.takeIf { it.isMxcUrl() }
}
} ?: ""
private fun createOnPlaybackButtonClickListener(
messageContent: MessageAudioContent,
informationData: MessageInformationData,
params: TimelineItemFactoryParams,
) = object : ClickListener {
override fun invoke(view: View) {
params.callback?.onVoiceControlButtonClicked(informationData.eventId, messageContent)
}
}
private fun buildVoiceMessageItem(
params: TimelineItemFactoryParams,
messageContent: MessageAudioContent,
@Suppress("UNUSED_PARAMETER")
informationData: MessageInformationData,
highlight: Boolean,
attributes: AbsMessageItem.Attributes,
): MessageVoiceItem? {
val fileUrl = messageContent.getFileUrl()?.let {
if (informationData.sentByMe && !informationData.sendState.isSent()) {
it
} else {
it.takeIf { it.isMxcUrl() }
}
} ?: ""
val playbackControlButtonClickListener: ClickListener = object : ClickListener {
override fun invoke(view: View) {
params.callback?.onVoiceControlButtonClicked(informationData.eventId, messageContent)
}
}
params: TimelineItemFactoryParams,
messageContent: MessageAudioContent,
informationData: MessageInformationData,
highlight: Boolean,
attributes: AbsMessageItem.Attributes
): MessageVoiceItem {
val fileUrl = getAudioFileUrl(messageContent, informationData)
val playbackControlButtonClickListener = createOnPlaybackButtonClickListener(messageContent, informationData, params)
val waveformTouchListener: MessageVoiceItem.WaveformTouchListener = object : MessageVoiceItem.WaveformTouchListener {
override fun onWaveformTouchedUp(percentage: Float) {
@ -409,14 +408,8 @@ class MessageItemFactory @Inject constructor(
.waveform(messageContent.audioWaveformInfo?.waveform?.toFft().orEmpty())
.playbackControlButtonClickListener(playbackControlButtonClickListener)
.waveformTouchListener(waveformTouchListener)
.voiceMessagePlaybackTracker(voiceMessagePlaybackTracker)
.izLocalFile(localFilesHelper.isLocalFile(fileUrl))
.izDownloaded(session.fileService().isFileInCache(
fileUrl,
messageContent.getFileName(),
messageContent.mimeType,
messageContent.encryptedFileInfo?.toElementToDecrypt())
)
.audioMessagePlaybackTracker(audioMessagePlaybackTracker)
.isLocalFile(localFilesHelper.isLocalFile(fileUrl))
.mxcUrl(fileUrl)
.contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
.contentDownloadStateTrackerBinder(contentDownloadStateTrackerBinder)
@ -470,13 +463,13 @@ class MessageItemFactory @Inject constructor(
messageContent: MessageFileContent,
highlight: Boolean,
attributes: AbsMessageItem.Attributes,
): MessageFileItem? {
): MessageFileItem {
val mxcUrl = messageContent.getFileUrl() ?: ""
return MessageFileItem_()
.attributes(attributes)
.leftGuideline(avatarSizeProvider.leftGuideline)
.izLocalFile(localFilesHelper.isLocalFile(messageContent.getFileUrl()))
.izDownloaded(session.fileService().isFileInCache(messageContent))
.isLocalFile(localFilesHelper.isLocalFile(messageContent.getFileUrl()))
.isDownloaded(session.fileService().isFileInCache(messageContent))
.mxcUrl(mxcUrl)
.contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
.contentDownloadStateTrackerBinder(contentDownloadStateTrackerBinder)
@ -485,13 +478,24 @@ class MessageItemFactory @Inject constructor(
.iconRes(R.drawable.ic_paperclip)
}
private fun buildAudioContent(
params: TimelineItemFactoryParams,
messageContent: MessageAudioContent,
informationData: MessageInformationData,
highlight: Boolean,
attributes: AbsMessageItem.Attributes,
) = if (messageContent.voiceMessageIndicator != null) {
buildVoiceMessageItem(params, messageContent, informationData, highlight, attributes)
} else {
buildAudioMessageItem(params, messageContent, informationData, highlight, attributes)
}
private fun buildNotHandledMessageItem(
messageContent: MessageContent,
informationData: MessageInformationData,
highlight: Boolean,
callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes,
): MessageTextItem? {
messageContent: MessageContent,
informationData: MessageInformationData,
highlight: Boolean,
callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes): MessageTextItem? {
// For compatibility reason we should display the body
return buildMessageTextItem(messageContent.body, false, informationData, highlight, callback, attributes)
}

View file

@ -22,7 +22,7 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class VoiceMessagePlaybackTracker @Inject constructor() {
class AudioMessagePlaybackTracker @Inject constructor() {
private val mainHandler = Handler(Looper.getMainLooper())
private val listeners = mutableMapOf<String, Listener>()
@ -33,7 +33,7 @@ class VoiceMessagePlaybackTracker @Inject constructor() {
activityListeners.add(listener)
}
fun unTrackActivity(listener: ActivityListener) {
fun untrackActivity(listener: ActivityListener) {
activityListeners.remove(listener)
}
@ -46,10 +46,16 @@ class VoiceMessagePlaybackTracker @Inject constructor() {
}
}
fun unTrack(id: String) {
fun untrack(id: String) {
listeners.remove(id)
}
fun pauseAllPlaybacks() {
listeners.keys.forEach { key ->
pausePlayback(key)
}
}
fun makeAllPlaybacksIdle() {
listeners.keys.forEach { key ->
setState(key, Listener.State.Idle)
@ -87,19 +93,25 @@ class VoiceMessagePlaybackTracker @Inject constructor() {
}
fun pausePlayback(id: String) {
val currentPlaybackTime = getPlaybackTime(id)
val currentPercentage = getPercentage(id)
setState(id, Listener.State.Paused(currentPlaybackTime, currentPercentage))
if (getPlaybackState(id) is Listener.State.Playing) {
val currentPlaybackTime = getPlaybackTime(id)
val currentPercentage = getPercentage(id)
setState(id, Listener.State.Paused(currentPlaybackTime, currentPercentage))
}
}
fun stopPlayback(id: String) {
setState(id, Listener.State.Idle)
}
fun updateCurrentPlaybackTime(id: String, time: Int, percentage: Float) {
fun updatePlayingAtPlaybackTime(id: String, time: Int, percentage: Float) {
setState(id, Listener.State.Playing(time, percentage))
}
fun updatePausedAtPlaybackTime(id: String, time: Int, percentage: Float) {
setState(id, Listener.State.Paused(time, percentage))
}
fun updateCurrentRecording(id: String, amplitudeList: List<Int>) {
setState(id, Listener.State.Recording(amplitudeList))
}

View file

@ -0,0 +1,213 @@
/*
* 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.home.room.detail.timeline.item
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Color
import android.text.format.DateUtils
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.SeekBar
import android.widget.TextView
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
import im.vector.app.core.epoxy.ClickListener
import im.vector.app.core.epoxy.onClick
import im.vector.app.core.utils.TextUtils
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker
import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder
import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
import im.vector.app.features.themes.ThemeUtils
@EpoxyModelClass(layout = R.layout.item_timeline_event_base)
abstract class MessageAudioItem : AbsMessageItem<MessageAudioItem.Holder>() {
@EpoxyAttribute
var filename: String = ""
@EpoxyAttribute
var mxcUrl: String = ""
@EpoxyAttribute
var duration: Int = 0
@EpoxyAttribute
var fileSize: Long = 0
@EpoxyAttribute
@JvmField
var isLocalFile = false
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var onSeek: ((percentage: Float) -> Unit)? = null
@EpoxyAttribute
lateinit var contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder
@EpoxyAttribute
lateinit var contentDownloadStateTrackerBinder: ContentDownloadStateTrackerBinder
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var playbackControlButtonClickListener: ClickListener? = null
@EpoxyAttribute
lateinit var audioMessagePlaybackTracker: AudioMessagePlaybackTracker
private var isUserSeeking = false
override fun bind(holder: Holder) {
super.bind(holder)
renderSendState(holder.rootLayout, null)
bindViewAttributes(holder)
bindUploadState(holder)
applyLayoutTint(holder)
bindSeekBar(holder)
holder.audioPlaybackControlButton.setOnClickListener { playbackControlButtonClickListener?.invoke(it) }
renderStateBasedOnAudioPlayback(holder)
}
private fun bindUploadState(holder: Holder) {
if (attributes.informationData.sendState.hasFailed()) {
holder.audioPlaybackControlButton.setImageResource(R.drawable.ic_cross)
holder.audioPlaybackControlButton.contentDescription =
holder.view.context.getString(R.string.error_audio_message_unable_to_play, filename)
holder.progressLayout.isVisible = false
} else {
contentUploadStateTrackerBinder.bind(attributes.informationData.eventId, isLocalFile, holder.progressLayout)
}
}
private fun applyLayoutTint(holder: Holder) {
val backgroundTint = if (attributes.informationData.messageLayout is TimelineMessageLayout.Bubble) {
Color.TRANSPARENT
} else {
ThemeUtils.getColor(holder.view.context, R.attr.vctr_content_quinary)
}
holder.mainLayout.backgroundTintList = ColorStateList.valueOf(backgroundTint)
}
private fun bindViewAttributes(holder: Holder) {
val formattedDuration = formatPlaybackTime(duration)
val formattedFileSize = TextUtils.formatFileSize(holder.rootLayout.context, fileSize, true)
val durationContentDescription = getPlaybackTimeContentDescription(holder.rootLayout.context, duration)
holder.filenameView.text = filename
holder.filenameView.onClick(attributes.itemClickListener)
holder.audioPlaybackDuration.text = formattedDuration
holder.fileSize.text = holder.rootLayout.context.getString(
R.string.audio_message_file_size, formattedFileSize
)
holder.mainLayout.contentDescription = holder.rootLayout.context.getString(
R.string.a11y_audio_message_item, filename, durationContentDescription, formattedFileSize
)
}
private fun bindSeekBar(holder: Holder) {
holder.audioSeekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
holder.audioPlaybackTime.text = formatPlaybackTime(
(duration * (progress.toFloat() / 100)).toInt()
)
}
override fun onStartTrackingTouch(seekBar: SeekBar) {
isUserSeeking = true
}
override fun onStopTrackingTouch(seekBar: SeekBar) {
isUserSeeking = false
val percentage = seekBar.progress.toFloat() / 100
onSeek?.invoke(percentage)
}
})
}
private fun renderStateBasedOnAudioPlayback(holder: Holder) {
audioMessagePlaybackTracker.track(attributes.informationData.eventId, object : AudioMessagePlaybackTracker.Listener {
override fun onUpdate(state: AudioMessagePlaybackTracker.Listener.State) {
when (state) {
is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder)
is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state)
is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state)
is AudioMessagePlaybackTracker.Listener.State.Recording -> Unit
}
}
})
}
private fun renderIdleState(holder: Holder) {
holder.audioPlaybackControlButton.setImageResource(R.drawable.ic_play_pause_play)
holder.audioPlaybackControlButton.contentDescription =
holder.view.context.getString(R.string.a11y_play_audio_message, filename)
holder.audioPlaybackTime.text = formatPlaybackTime(duration)
holder.audioSeekBar.progress = 0
}
private fun renderPlayingState(holder: Holder, state: AudioMessagePlaybackTracker.Listener.State.Playing) {
holder.audioPlaybackControlButton.setImageResource(R.drawable.ic_play_pause_pause)
holder.audioPlaybackControlButton.contentDescription =
holder.view.context.getString(R.string.a11y_pause_audio_message, filename)
if (!isUserSeeking) {
holder.audioPlaybackTime.text = formatPlaybackTime(state.playbackTime)
holder.audioSeekBar.progress = (state.percentage * 100).toInt()
}
}
private fun renderPausedState(holder: Holder, state: AudioMessagePlaybackTracker.Listener.State.Paused) {
holder.audioPlaybackControlButton.setImageResource(R.drawable.ic_play_pause_play)
holder.audioPlaybackControlButton.contentDescription =
holder.view.context.getString(R.string.a11y_play_audio_message, filename)
holder.audioPlaybackTime.text = formatPlaybackTime(state.playbackTime)
holder.audioSeekBar.progress = (state.percentage * 100).toInt()
}
private fun formatPlaybackTime(time: Int) = DateUtils.formatElapsedTime((time / 1000).toLong())
private fun getPlaybackTimeContentDescription(context: Context, time: Int): String {
val formattedPlaybackTime = formatPlaybackTime(time)
val (minutes, seconds) = formattedPlaybackTime.split(":").map { it.toIntOrNull() ?: 0 }
return context.getString(R.string.a11y_audio_playback_duration, minutes, seconds)
}
override fun unbind(holder: Holder) {
super.unbind(holder)
contentUploadStateTrackerBinder.unbind(attributes.informationData.eventId)
contentDownloadStateTrackerBinder.unbind(mxcUrl)
audioMessagePlaybackTracker.untrack(attributes.informationData.eventId)
}
override fun getViewStubId() = STUB_ID
class Holder : AbsMessageItem.Holder(STUB_ID) {
val rootLayout by bind<ViewGroup>(R.id.messageRootLayout)
val mainLayout by bind<ViewGroup>(R.id.messageMainInnerLayout)
val filenameView by bind<TextView>(R.id.messageFilenameView)
val audioPlaybackControlButton by bind<ImageButton>(R.id.audioPlaybackControlButton)
val audioPlaybackTime by bind<TextView>(R.id.audioPlaybackTime)
val progressLayout by bind<ViewGroup>(R.id.messageFileUploadProgressLayout)
val fileSize by bind<TextView>(R.id.fileSize)
val audioPlaybackDuration by bind<TextView>(R.id.audioPlaybackDuration)
val audioSeekBar by bind<SeekBar>(R.id.audioSeekBar)
}
companion object {
private const val STUB_ID = R.id.messageContentAudioStub
}
}

View file

@ -47,14 +47,13 @@ abstract class MessageFileItem : AbsMessageItem<MessageFileItem.Holder>() {
@DrawableRes
var iconRes: Int = 0
// @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
// var clickListener: ClickListener? = null
@EpoxyAttribute
@JvmField
var isLocalFile = false
@EpoxyAttribute
var izLocalFile = false
@EpoxyAttribute
var izDownloaded = false
@JvmField
var isDownloaded = false
@EpoxyAttribute
lateinit var contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder
@ -65,17 +64,20 @@ abstract class MessageFileItem : AbsMessageItem<MessageFileItem.Holder>() {
override fun bind(holder: Holder) {
super.bind(holder)
renderSendState(holder.fileLayout, holder.filenameView)
if (!attributes.informationData.sendState.hasFailed()) {
contentUploadStateTrackerBinder.bind(attributes.informationData.eventId, izLocalFile, holder.progressLayout)
contentUploadStateTrackerBinder.bind(attributes.informationData.eventId, isLocalFile, holder.progressLayout)
} else {
holder.fileImageView.setImageResource(R.drawable.ic_cross)
holder.progressLayout.isVisible = false
}
holder.filenameView.text = filename
if (attributes.informationData.sendState.isSending()) {
holder.fileImageView.setImageResource(iconRes)
} else {
if (izDownloaded) {
if (isDownloaded) {
holder.fileImageView.setImageResource(iconRes)
holder.fileDownloadProgress.progress = 0
} else {
@ -83,7 +85,7 @@ abstract class MessageFileItem : AbsMessageItem<MessageFileItem.Holder>() {
holder.fileImageView.setImageResource(R.drawable.ic_download)
}
}
// holder.view.setOnClickListener(clickListener)
val backgroundTint = if (attributes.informationData.messageLayout is TimelineMessageLayout.Bubble) {
Color.TRANSPARENT
} else {

View file

@ -30,9 +30,9 @@ import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
import im.vector.app.core.epoxy.ClickListener
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker
import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder
import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
import im.vector.app.features.themes.ThemeUtils
import im.vector.app.features.voice.AudioWaveformView
@ -55,10 +55,8 @@ abstract class MessageVoiceItem : AbsMessageItem<MessageVoiceItem.Holder>() {
var waveform: List<Int> = emptyList()
@EpoxyAttribute
var izLocalFile = false
@EpoxyAttribute
var izDownloaded = false
@JvmField
var isLocalFile = false
@EpoxyAttribute
lateinit var contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder
@ -73,13 +71,13 @@ abstract class MessageVoiceItem : AbsMessageItem<MessageVoiceItem.Holder>() {
var waveformTouchListener: WaveformTouchListener? = null
@EpoxyAttribute
lateinit var voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker
lateinit var audioMessagePlaybackTracker: AudioMessagePlaybackTracker
override fun bind(holder: Holder) {
super.bind(holder)
renderSendState(holder.voiceLayout, null)
if (!attributes.informationData.sendState.hasFailed()) {
contentUploadStateTrackerBinder.bind(attributes.informationData.eventId, izLocalFile, holder.progressLayout)
contentUploadStateTrackerBinder.bind(attributes.informationData.eventId, isLocalFile, holder.progressLayout)
} else {
holder.voicePlaybackControlButton.setImageResource(R.drawable.ic_cross)
holder.voicePlaybackControlButton.contentDescription = holder.view.context.getString(R.string.error_voice_message_unable_to_play)
@ -96,11 +94,11 @@ abstract class MessageVoiceItem : AbsMessageItem<MessageVoiceItem.Holder>() {
ThemeUtils.getColor(holder.view.context, R.attr.vctr_content_quinary)
}
holder.voicePlaybackLayout.backgroundTintList = ColorStateList.valueOf(backgroundTint)
holder.voicePlaybackControlButton.setOnClickListener { playbackControlButtonClickListener?.invoke(it) }
}
private fun onWaveformViewReady(holder: Holder) {
holder.voicePlaybackWaveform.setOnLongClickListener(attributes.itemLongClickListener)
holder.voicePlaybackControlButton.setOnClickListener { playbackControlButtonClickListener?.invoke(it) }
val waveformColorIdle = ThemeUtils.getColor(holder.view.context, R.attr.vctr_content_quaternary)
val waveformColorPlayed = ThemeUtils.getColor(holder.view.context, R.attr.vctr_content_secondary)
@ -125,13 +123,13 @@ abstract class MessageVoiceItem : AbsMessageItem<MessageVoiceItem.Holder>() {
true
}
voiceMessagePlaybackTracker.track(attributes.informationData.eventId, object : VoiceMessagePlaybackTracker.Listener {
override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) {
audioMessagePlaybackTracker.track(attributes.informationData.eventId, object : AudioMessagePlaybackTracker.Listener {
override fun onUpdate(state: AudioMessagePlaybackTracker.Listener.State) {
when (state) {
is VoiceMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder, waveformColorIdle, waveformColorPlayed)
is VoiceMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state, waveformColorIdle, waveformColorPlayed)
is VoiceMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state, waveformColorIdle, waveformColorPlayed)
is VoiceMessagePlaybackTracker.Listener.State.Recording -> Unit
is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder, waveformColorIdle, waveformColorPlayed)
is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state, waveformColorIdle, waveformColorPlayed)
is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state, waveformColorIdle, waveformColorPlayed)
is AudioMessagePlaybackTracker.Listener.State.Recording -> Unit
}
}
})
@ -146,14 +144,14 @@ abstract class MessageVoiceItem : AbsMessageItem<MessageVoiceItem.Holder>() {
holder.voicePlaybackWaveform.updateColors(0f, playedColor, idleColor)
}
private fun renderPlayingState(holder: Holder, state: VoiceMessagePlaybackTracker.Listener.State.Playing, idleColor: Int, playedColor: Int) {
private fun renderPlayingState(holder: Holder, state: AudioMessagePlaybackTracker.Listener.State.Playing, idleColor: Int, playedColor: Int) {
holder.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_pause)
holder.voicePlaybackControlButton.contentDescription = holder.view.context.getString(R.string.a11y_pause_voice_message)
holder.voicePlaybackTime.text = formatPlaybackTime(state.playbackTime)
holder.voicePlaybackWaveform.updateColors(state.percentage, playedColor, idleColor)
}
private fun renderPausedState(holder: Holder, state: VoiceMessagePlaybackTracker.Listener.State.Paused, idleColor: Int, playedColor: Int) {
private fun renderPausedState(holder: Holder, state: AudioMessagePlaybackTracker.Listener.State.Paused, idleColor: Int, playedColor: Int) {
holder.voicePlaybackControlButton.setImageResource(R.drawable.ic_play_pause_play)
holder.voicePlaybackControlButton.contentDescription = holder.view.context.getString(R.string.a11y_play_voice_message)
holder.voicePlaybackTime.text = formatPlaybackTime(state.playbackTime)
@ -166,7 +164,7 @@ abstract class MessageVoiceItem : AbsMessageItem<MessageVoiceItem.Holder>() {
super.unbind(holder)
contentUploadStateTrackerBinder.unbind(attributes.informationData.eventId)
contentDownloadStateTrackerBinder.unbind(mxcUrl)
voiceMessagePlaybackTracker.unTrack(attributes.informationData.eventId)
audioMessagePlaybackTracker.untrack(attributes.informationData.eventId)
}
override fun getViewStubId() = STUB_ID

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/background">
<shape
android:shape="line">
<stroke
android:color="?vctr_content_quaternary"
android:width="2dp"/>
</shape>
</item>
<item android:id="@android:id/progress">
<clip>
<shape
android:shape="line">
<stroke
android:color="?vctr_content_tertiary"
android:width="2dp"/>
</shape>
</clip>
</item>
</layer-list>

View file

@ -1,16 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M3,18V12C3,7.0294 7.0294,3 12,3C16.9706,3 21,7.0294 21,12V18"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#2E2F32"
android:strokeLineCap="round"/>
<path
android:pathData="M21,14H22C22,13.4477 21.5523,13 21,13V14ZM3,14V13C2.4477,13 2,13.4477 2,14H3ZM20,19C20,19.5523 19.5523,20 19,20V22C20.6569,22 22,20.6569 22,19H20ZM19,20H18V22H19V20ZM18,20C17.4477,20 17,19.5523 17,19H15C15,20.6569 16.3431,22 18,22V20ZM17,19V16H15V19H17ZM17,16C17,15.4477 17.4477,15 18,15V13C16.3431,13 15,14.3431 15,16H17ZM18,15H21V13H18V15ZM20,14V19H22V14H20ZM2,19C2,20.6569 3.3431,22 5,22V20C4.4477,20 4,19.5523 4,19H2ZM5,22H6V20H5V22ZM6,22C7.6568,22 9,20.6569 9,19H7C7,19.5523 6.5523,20 6,20V22ZM9,19V16H7V19H9ZM9,16C9,14.3431 7.6568,13 6,13V15C6.5523,15 7,15.4477 7,16H9ZM6,13H3V15H6V13ZM2,14V19H4V14H2Z"
android:fillColor="#2E2F32"/>
</vector>

View file

@ -0,0 +1,107 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/messageRootLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
tools:viewBindingIgnore="true">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/messageMainInnerLayout"
style="@style/TimelineContentMediaPillStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:viewBindingIgnore="true">
<ImageButton
android:id="@+id/audioPlaybackControlButton"
android:layout_width="@dimen/item_event_message_media_button_size"
android:layout_height="@dimen/item_event_message_media_button_size"
android:background="@drawable/bg_voice_play_pause_button"
android:backgroundTint="?android:colorBackground"
android:contentDescription="@string/a11y_play_voice_message"
android:src="@drawable/ic_play_pause_play"
android:layout_marginTop="10dp"
android:layout_marginStart="4dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="?vctr_content_secondary" />
<TextView
android:id="@+id/messageFilenameView"
style="@style/Widget.Vector.TextView.Body"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:autoLink="none"
android:maxLines="1"
android:ellipsize="end"
android:layout_marginTop="6dp"
app:layout_constraintStart_toEndOf="@id/audioPlaybackControlButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Filename.mp3" />
<TextView
android:id="@+id/audioPlaybackDuration"
style="@style/Widget.Vector.TextView.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="?vctr_content_tertiary"
app:layout_constraintStart_toStartOf="@id/messageFilenameView"
app:layout_constraintTop_toBottomOf="@id/messageFilenameView"
tools:text="0:23" />
<TextView
android:id="@+id/fileSize"
style="@style/Widget.Vector.TextView.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="?vctr_content_tertiary"
android:layout_marginStart="4dp"
app:layout_constraintStart_toEndOf="@id/audioPlaybackDuration"
app:layout_constraintBottom_toBottomOf="@id/audioPlaybackDuration"
tools:text="(2MB)" />
<SeekBar
android:id="@+id/audioSeekBar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:layout_marginBottom="10dp"
android:progressDrawable="@drawable/bg_seek_bar"
android:thumbTint="?vctr_content_tertiary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/audioPlaybackTime"
app:layout_constraintTop_toBottomOf="@id/audioPlaybackControlButton"
app:layout_constraintBottom_toBottomOf="parent"
tools:progress="40" />
<TextView
android:id="@+id/audioPlaybackTime"
style="@style/Widget.Vector.TextView.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="?vctr_content_tertiary"
android:layout_marginEnd="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/audioSeekBar"
app:layout_constraintBottom_toBottomOf="@id/audioSeekBar"
tools:text="0:23" />
</androidx.constraintlayout.widget.ConstraintLayout>
<include
android:id="@+id/messageFileUploadProgressLayout"
layout="@layout/media_upload_download_progress_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginEnd="32dp"
android:visibility="gone"
tools:visibility="visible" />
</LinearLayout>

View file

@ -40,6 +40,13 @@
android:layout="@layout/item_timeline_event_voice_stub"
tools:visibility="gone" />
<ViewStub
android:id="@+id/messageContentAudioStub"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout="@layout/item_timeline_event_audio_stub"
tools:visibility="gone" />
<ViewStub
android:id="@+id/messageContentPollStub"
android:layout_width="match_parent"

View file

@ -2892,6 +2892,14 @@
<string name="error_voice_message_cannot_reply_or_edit">Cannot reply or edit while voice message is active</string>
<string name="voice_message_reply_content">Voice Message (%1$s)</string>
<string name="a11y_audio_message_item">%1$s, %2$s, %3$s</string> <!-- filename, duration, file size -->
<string name="a11y_audio_playback_duration">%1$d minutes %2$d seconds</string>
<string name="a11y_play_audio_message">Play %1$s</string>
<string name="a11y_pause_audio_message">Pause %1$s</string>
<string name="error_audio_message_unable_to_play">Unable to play %1$s</string>
<string name="audio_message_reply_content">%1$s (%2$s)</string>
<string name="audio_message_file_size">(%1$s)</string>
<string name="upgrade_room_for_restricted">Anyone in %s will be able to find and join this room - no need to manually invite everyone. Youll be able to change this in room settings anytime.</string>
<string name="upgrade_room_for_restricted_no_param">Anyone in a parent space will be able to find and join this room - no need to manually invite everyone. Youll be able to change this in room settings anytime.</string>