diff --git a/presentation/src/main/java/com/moez/QKSMS/common/QkMediaPlayer.kt b/presentation/src/main/java/com/moez/QKSMS/common/QkMediaPlayer.kt index a28d98e6..623bd519 100644 --- a/presentation/src/main/java/com/moez/QKSMS/common/QkMediaPlayer.kt +++ b/presentation/src/main/java/com/moez/QKSMS/common/QkMediaPlayer.kt @@ -9,6 +9,7 @@ object QkMediaPlayer : MediaPlayer() { private var onCompletionListener: OnCompletionListener? = null enum class PlayingState { + None, Stopped, Playing, Paused diff --git a/presentation/src/main/java/com/moez/QKSMS/common/util/extensions/ViewExtensions.kt b/presentation/src/main/java/com/moez/QKSMS/common/util/extensions/ViewExtensions.kt index dbb98d11..526126a8 100644 --- a/presentation/src/main/java/com/moez/QKSMS/common/util/extensions/ViewExtensions.kt +++ b/presentation/src/main/java/com/moez/QKSMS/common/util/extensions/ViewExtensions.kt @@ -53,20 +53,29 @@ fun EditText.hideKeyboard() { imm.hideSoftInputFromWindow(windowToken, 0) } -fun ImageView.setTint(color: Int) { - imageTintList = ColorStateList.valueOf(color) +fun ImageView.setTint(color: Int?) { + imageTintList = + if (color == null) null + else ColorStateList.valueOf(color) } -fun ProgressBar.setTint(color: Int) { - indeterminateTintList = ColorStateList.valueOf(color) - progressTintList = ColorStateList.valueOf(color) +fun ProgressBar.setTint(color: Int?) { + indeterminateTintList = + if (color == null) null + else ColorStateList.valueOf(color) + progressTintList = + if (color == null) null + else ColorStateList.valueOf(color) + } -fun View.setBackgroundTint(color: Int) { +fun View.setBackgroundTint(color: Int?) { // API 21 doesn't support this - backgroundTintList = ColorStateList.valueOf(color) + backgroundTintList = + if (color == null) null + else ColorStateList.valueOf(color) } fun View.setPadding(left: Int? = null, top: Int? = null, right: Int? = null, bottom: Int? = null) { diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/MessagesAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/MessagesAdapter.kt index d8dd5061..9e92bb2d 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/MessagesAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/MessagesAdapter.kt @@ -32,6 +32,7 @@ import android.widget.ImageView import android.widget.ProgressBar import androidx.recyclerview.widget.RecyclerView import com.jakewharton.rxbinding2.view.clicks +import com.moez.QKSMS.common.QkMediaPlayer import dev.octoshrimpy.quik.R import dev.octoshrimpy.quik.common.base.QkRealmAdapter import dev.octoshrimpy.quik.common.base.QkViewHolder @@ -55,6 +56,7 @@ import dev.octoshrimpy.quik.model.Message import dev.octoshrimpy.quik.model.Recipient import dev.octoshrimpy.quik.util.PhoneNumberUtils import dev.octoshrimpy.quik.util.Preferences +import io.reactivex.disposables.Disposable import io.reactivex.subjects.PublishSubject import io.reactivex.subjects.Subject import io.realm.RealmResults @@ -83,6 +85,13 @@ class MessagesAdapter @Inject constructor( private val textViewStyler: TextViewStyler ) : QkRealmAdapter() { + class AudioState( + var partId: Long = -1, + var state: QkMediaPlayer.PlayingState = QkMediaPlayer.PlayingState.Stopped, + var seekBarUpdater: Disposable? = null, + var viewHolder: QkViewHolder? = null + ) + companion object { private const val VIEW_TYPE_MESSAGE_IN = 0 private const val VIEW_TYPE_MESSAGE_OUT = 1 @@ -127,11 +136,12 @@ class MessagesAdapter @Inject constructor( private val contactCache = ContactCache() private val expanded = HashMap() - private val partsViewPool = RecyclerView.RecycledViewPool() private val subs = subscriptionManager.activeSubscriptionInfoList var theme: Colors.Theme = colors.theme() + private val audioState = AudioState() + /** * If the viewType is negative, then the viewHolder has an attachment. We'll consider * this a unique viewType even though it uses the same view, so that regular messages @@ -156,7 +166,6 @@ class MessagesAdapter @Inject constructor( val partsAdapter = partsAdapterProvider.get() partsAdapter.clicks.subscribe(partClicks) view.attachments.adapter = partsAdapter - view.attachments.setRecycledViewPool(partsViewPool) view.body.forwardTouches(view) return QkViewHolder(view).apply { @@ -283,7 +292,7 @@ class MessagesAdapter @Inject constructor( // Bind the attachments val partsAdapter = holder.attachments.adapter as PartsAdapter partsAdapter.theme = theme - partsAdapter.setData(message, previous, next, holder) + partsAdapter.setData(message, previous, next, holder, audioState) } private fun bindStatus(holder: QkViewHolder, message: Message, next: Message?) { diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/AudioBinder.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/AudioBinder.kt index c956deeb..b253185c 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/AudioBinder.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/AudioBinder.kt @@ -33,16 +33,14 @@ import dev.octoshrimpy.quik.common.util.extensions.setBackgroundTint import dev.octoshrimpy.quik.common.util.extensions.setTint import dev.octoshrimpy.quik.common.widget.BubbleImageView import dev.octoshrimpy.quik.extensions.isAudio +import dev.octoshrimpy.quik.feature.compose.MessagesAdapter import dev.octoshrimpy.quik.model.Message import dev.octoshrimpy.quik.model.MmsPart import dev.octoshrimpy.quik.util.GlideApp import io.reactivex.Observable import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import kotlinx.android.synthetic.main.mms_audio_preview_list_item.* -import kotlinx.android.synthetic.main.mms_audio_preview_list_item.view.playPause -import kotlinx.android.synthetic.main.mms_audio_preview_list_item.view.seekBar import kotlinx.android.synthetic.main.mms_image_preview_list_item.thumbnail import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -53,91 +51,132 @@ class AudioBinder @Inject constructor(colors: Colors, private val context: Conte override val partLayout = R.layout.mms_audio_preview_list_item override var theme = colors.theme() - private var thisPlayingState = QkMediaPlayer.PlayingState.Stopped override fun canBindPart(part: MmsPart) = part.isAudio() + var audioState = MessagesAdapter.AudioState(-1, QkMediaPlayer.PlayingState.Stopped) + + private fun startSeekBarUpdateTimer() { + audioState.apply { + seekBarUpdater?.dispose() + seekBarUpdater = Observable.interval(500, TimeUnit.MILLISECONDS) + .subscribeOn(Schedulers.single()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { + viewHolder?.seekBar?.progress = QkMediaPlayer.currentPosition + } + .subscribe() + } + } + + private fun uiToPlaying(viewHolder: QkViewHolder) { + viewHolder.seekBar.max = QkMediaPlayer.duration + viewHolder.seekBar.isEnabled = true + viewHolder.seekBar.progress = QkMediaPlayer.currentPosition + viewHolder.playPause.setImageResource(R.drawable.exo_icon_pause) + viewHolder.playPause.tag = QkMediaPlayer.PlayingState.Playing + viewHolder.metadataTitle.isSelected = true // start marquee + } + + private fun uiToPaused(viewHolder: QkViewHolder) { + viewHolder.playPause.setImageResource(R.drawable.exo_icon_play) + viewHolder.playPause.tag = QkMediaPlayer.PlayingState.Paused + } + + private fun uiToStopped(viewHolder: QkViewHolder) { + viewHolder.seekBar.progress = 0 + viewHolder.seekBar.max = 0 + viewHolder.seekBar.isEnabled = false + viewHolder.playPause.setImageResource(R.drawable.exo_icon_play) + viewHolder.playPause.tag = QkMediaPlayer.PlayingState.Stopped + viewHolder.metadataTitle.isSelected = false // stop marquee + } + override fun bindPart( holder: QkViewHolder, part: MmsPart, message: Message, canGroupWithPrevious: Boolean, - canGroupWithNext: Boolean + canGroupWithNext: Boolean, ) { // play/pause button click handling - holder.playPause.setOnClickListener { view -> - var seekBarUpdater: Disposable? = null + holder.playPause.setOnClickListener { + when (holder.playPause.tag) { + QkMediaPlayer.PlayingState.Playing -> { + if (audioState.partId == part.id) { + QkMediaPlayer.pause() + uiToPaused(holder) + audioState.state = QkMediaPlayer.PlayingState.Paused - when (thisPlayingState) { - QkMediaPlayer.PlayingState.Stopped -> { - QkMediaPlayer.apply { - reset() // make sure reset before trying to (re-)use - - setAudioAttributes( - AudioAttributes.Builder() - .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) // music, maybe?? could be voice. don't want to use CONTENT_TYPE_UNKNOWN though - .setUsage(AudioAttributes.USAGE_MEDIA) - .build() - ) - - setDataSource(context, part.getUri()) - - setOnPreparedListener { - // start timer to update seek bar - seekBarUpdater = Observable.interval(500, TimeUnit.MILLISECONDS) - .subscribeOn(Schedulers.single()) - .observeOn(AndroidSchedulers.mainThread()) - .map { System.nanoTime() } - .doOnNext { holder.seekBar.progress = QkMediaPlayer.currentPosition } - .subscribe() - - // set seek bar max value - holder.seekBar.max = QkMediaPlayer.duration - - start() - - thisPlayingState = QkMediaPlayer.PlayingState.Playing - view.playPause.setImageResource(R.drawable.exo_icon_pause) - holder.containerView.seekBar.isEnabled = true - holder.metadataTitle.isSelected = true - } - - setOnCompletionListener { // also called on error because we don't have an onerrorlistener - seekBarUpdater?.dispose() // stop timer - - thisPlayingState = QkMediaPlayer.PlayingState.Stopped - holder.containerView.seekBar.seekBar.isEnabled = false - holder.containerView.seekBar.seekBar.progress = 0 - view.playPause.setImageResource(R.drawable.exo_icon_play) - holder.metadataTitle.isSelected = false - } - - // prepare to play - prepareAsync() + // stop progress bar update timer + audioState.seekBarUpdater?.dispose() } } - QkMediaPlayer.PlayingState.Playing -> { - QkMediaPlayer.pause() - view.playPause.setImageResource(R.drawable.exo_icon_play) - thisPlayingState = QkMediaPlayer.PlayingState.Paused - } QkMediaPlayer.PlayingState.Paused -> { - QkMediaPlayer.start() - view.playPause.setImageResource(R.drawable.exo_icon_pause) - thisPlayingState = QkMediaPlayer.PlayingState.Playing + if (audioState.partId == part.id) { + QkMediaPlayer.start() + uiToPlaying(holder) + audioState.state = QkMediaPlayer.PlayingState.Playing + + // start progress bar update timer + startSeekBarUpdateTimer() + } + } + else -> { + QkMediaPlayer.reset() // make sure reset before trying to (re-)use + + QkMediaPlayer.setOnPreparedListener { + // start media playing + QkMediaPlayer.start() + + uiToPlaying(holder) + + // set current view holder and part as active + audioState.apply { + audioState.state = QkMediaPlayer.PlayingState.Playing + partId = part.id + viewHolder = holder + } + + // start progress bar update timer + startSeekBarUpdateTimer() + } + + QkMediaPlayer.setOnCompletionListener { // also called on error because we don't have an onerrorlistener + audioState.apply { + // if this part is currently active, set it to stopped and inactive + if ((partId == part.id) && (viewHolder != null)) + uiToStopped(viewHolder!!) + + state = QkMediaPlayer.PlayingState.Stopped + partId = -1 + viewHolder = null + } + } + + // start the media player play sequence + QkMediaPlayer.setAudioAttributes( + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) // music, maybe?? could be voice. don't want to use CONTENT_TYPE_UNKNOWN though + .setUsage(AudioAttributes.USAGE_MEDIA) + .build() + ) + + QkMediaPlayer.setDataSource(context, part.getUri()) + + QkMediaPlayer.prepareAsync() } } } - holder.thumbnail.bubbleStyle = when { - !canGroupWithPrevious && canGroupWithNext -> - if (message.isMe()) BubbleImageView.Style.OUT_FIRST else BubbleImageView.Style.IN_FIRST - canGroupWithPrevious && canGroupWithNext -> - if (message.isMe()) BubbleImageView.Style.OUT_MIDDLE else BubbleImageView.Style.IN_MIDDLE - canGroupWithPrevious && !canGroupWithNext -> - if (message.isMe()) BubbleImageView.Style.OUT_LAST else BubbleImageView.Style.IN_LAST - else -> BubbleImageView.Style.ONLY - } + holder.containerView.setOnClickListener { clicks.onNext(part.id) } + + // if this item is the active active audio item update the active view holder + if (audioState.partId == part.id) + audioState.viewHolder = holder + // else, this is not the active item so ensure the stored view holder is not this one + else if (audioState.viewHolder == holder) + audioState.viewHolder = null // tint colours val secondaryColor = @@ -151,9 +190,6 @@ class AudioBinder @Inject constructor(colors: Colors, private val context: Conte else holder.containerView.context.resolveThemeColor(android.R.attr.textColorPrimary) - val metaDataRetriever = MediaMetadataRetriever() - metaDataRetriever.setDataSource(context, part.getUri()) - // sound wave holder.soundWave.setTint(primaryColor) @@ -161,11 +197,11 @@ class AudioBinder @Inject constructor(colors: Colors, private val context: Conte holder.seekBar.apply { setTint(secondaryColor) thumbTintList = ColorStateList.valueOf(secondaryColor) - max = 0 - isEnabled = false + setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { override fun onProgressChanged(p0: SeekBar?, progress: Int, fromUser: Boolean) { - if ((thisPlayingState != QkMediaPlayer.PlayingState.Stopped) && fromUser) + // if seek was initiated by the user and this part is currently playing + if (fromUser) QkMediaPlayer.seekTo(progress) } override fun onStartTrackingTouch(p0: SeekBar?) { /* nothing */ } @@ -175,35 +211,65 @@ class AudioBinder @Inject constructor(colors: Colors, private val context: Conte // playPause button holder.playPause. apply { + if ((audioState.partId == part.id) && + (audioState.state == QkMediaPlayer.PlayingState.Playing)) + uiToPlaying(holder) + else if ((audioState.partId == part.id) && + (audioState.state == QkMediaPlayer.PlayingState.Paused)) + uiToPaused(holder) + else + uiToStopped(holder) + setTint(secondaryColor) setBackgroundTint(primaryColor) } - holder.metadataTitle.apply { - // metadata title - text = metaDataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE) ?: "" + MediaMetadataRetriever().use { + it.setDataSource(context, part.getUri()) - if (text == "") - visibility = View.GONE - else { - setTextColor(primaryColor) - setBackgroundTint(0xccffffff.toInt() and secondaryColor) // hex value is alpha + // metadata title + holder.metadataTitle.apply { + text = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE) + + if (text.isEmpty()) + visibility = View.GONE + else { + visibility = View.VISIBLE + setTextColor(primaryColor) + setBackgroundTint(0xccffffff.toInt() and secondaryColor) // hex value is alpha + } + } + + // bubble / embedded image + holder.thumbnail.apply { + bubbleStyle = when { + !canGroupWithPrevious && canGroupWithNext -> + if (message.isMe()) BubbleImageView.Style.OUT_FIRST else BubbleImageView.Style.IN_FIRST + canGroupWithPrevious && canGroupWithNext -> + if (message.isMe()) BubbleImageView.Style.OUT_MIDDLE else BubbleImageView.Style.IN_MIDDLE + canGroupWithPrevious && !canGroupWithNext -> + if (message.isMe()) BubbleImageView.Style.OUT_LAST else BubbleImageView.Style.IN_LAST + else -> BubbleImageView.Style.ONLY + } + + val embeddedPicture = it.embeddedPicture + if (embeddedPicture == null) { + holder.frame.layoutParams.height = (holder.frame.layoutParams.width / 2) + setTint(secondaryColor) + setImageResource(R.drawable.rectangle) + } else { + holder.frame.layoutParams.height = holder.frame.layoutParams.width + setTint(null) + GlideApp.with(context) + .asBitmap() + .load(embeddedPicture) + .override( + holder.frame.layoutParams.width, + holder.frame.layoutParams.height + ) + .into(this) + } } } - - // embedded image - val embeddedPicture = metaDataRetriever.embeddedPicture - if (embeddedPicture == null) { - holder.thumbnail.setTint(secondaryColor) - holder.frame.layoutParams.height /= 2 - holder.thumbnail.requestLayout() - } else { - GlideApp.with(context) - .asBitmap() - .load(embeddedPicture) - .into(holder.thumbnail) - } - - metaDataRetriever.close() } } \ No newline at end of file diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/PartsAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/PartsAdapter.kt index 68d26a1c..7449616c 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/PartsAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/part/PartsAdapter.kt @@ -28,6 +28,7 @@ import dev.octoshrimpy.quik.common.util.extensions.forwardTouches import dev.octoshrimpy.quik.extensions.isSmil import dev.octoshrimpy.quik.extensions.isText import dev.octoshrimpy.quik.feature.compose.BubbleUtils.canGroup +import dev.octoshrimpy.quik.feature.compose.MessagesAdapter import dev.octoshrimpy.quik.model.Message import dev.octoshrimpy.quik.model.MmsPart import io.reactivex.Observable @@ -39,7 +40,7 @@ class PartsAdapter @Inject constructor( fileBinder: FileBinder, imageBinder: ImageBinder, audioBinder: AudioBinder, - vCardBinder: VCardBinder + vCardBinder: VCardBinder, ) : QkAdapter() { private val partBinders = listOf(audioBinder, imageBinder, vCardBinder, fileBinder) @@ -57,14 +58,22 @@ class PartsAdapter @Inject constructor( private var next: Message? = null private var holder: QkViewHolder? = null private var bodyVisible: Boolean = true + private var audioState: MessagesAdapter.AudioState? = null - fun setData(message: Message, previous: Message?, next: Message?, holder: QkViewHolder) { + fun setData( + message: Message, + previous: Message?, + next: Message?, + holder: QkViewHolder, + audioState: MessagesAdapter.AudioState? + ) { this.message = message this.previous = previous this.next = next this.holder = holder this.bodyVisible = holder.body.visibility == View.VISIBLE this.data = message.parts.filter { !it.isSmil() && !it.isText() } + this.audioState = audioState } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QkViewHolder { @@ -80,9 +89,13 @@ class PartsAdapter @Inject constructor( val canGroupWithPrevious = canGroup(message, previous) || position > 0 val canGroupWithNext = canGroup(message, next) || position < itemCount - 1 || bodyVisible - partBinders - .firstOrNull { it.canBindPart(part) } - ?.bindPart(holder, part, message, canGroupWithPrevious, canGroupWithNext) + val binder = partBinders.firstOrNull { it.canBindPart(part) } + + // if audioState is set and binder is audio type, set audioState into it + if ((audioState != null) && (binder is AudioBinder)) + binder.audioState = audioState!! + + binder?.bindPart(holder, part, message, canGroupWithPrevious, canGroupWithNext) } override fun getItemViewType(position: Int): Int { diff --git a/presentation/src/main/res/drawable/audio_preview_foreground.png b/presentation/src/main/res/drawable/audio_preview_foreground.png index bf87f59e..21a73548 100644 Binary files a/presentation/src/main/res/drawable/audio_preview_foreground.png and b/presentation/src/main/res/drawable/audio_preview_foreground.png differ diff --git a/presentation/src/main/res/layout/mms_audio_preview_list_item.xml b/presentation/src/main/res/layout/mms_audio_preview_list_item.xml index bcf28071..97fc8839 100644 --- a/presentation/src/main/res/layout/mms_audio_preview_list_item.xml +++ b/presentation/src/main/res/layout/mms_audio_preview_list_item.xml @@ -22,7 +22,7 @@ xmlns:tools="http://schemas.android.com/tools" android:id="@+id/frame" android:layout_width="280dp" - android:layout_height="280dp" + android:layout_height="140dp" android:layout_marginTop="2dp" android:clipChildren="false">