added feature to render and play audio/* mime type mms attachments

This commit is contained in:
gavine99
2025-01-22 16:26:04 +11:00
parent 75c5b1acb6
commit 955bfda460
11 changed files with 371 additions and 7 deletions
@@ -27,6 +27,8 @@ fun MmsPart.isImage() = ContentType.isImageType(type)
fun MmsPart.isVideo() = ContentType.isVideoType(type)
fun MmsPart.isAudio() = ContentType.isAudioType(type)
fun MmsPart.isText() = ContentType.TEXT_PLAIN == type
fun MmsPart.isVCard() = ContentType.TEXT_VCARD == type
@@ -0,0 +1,45 @@
package com.moez.QKSMS.common
import android.media.MediaPlayer
// singleton class for a media player that can be re-used throughout the app
// because we probably don't ever want to play multiple media files at the
// same time
object QkMediaPlayer : MediaPlayer() {
private var onCompletionListener: OnCompletionListener? = null
enum class PlayingState {
Stopped,
Playing,
Paused
}
private var playingState = PlayingState.Stopped
override fun start() {
playingState = PlayingState.Playing
super.start()
}
override fun pause() {
playingState = PlayingState.Paused
super.pause()
}
override fun reset() {
if (playingState != PlayingState.Stopped)
stop()
onCompletionListener = null
super.reset()
}
override fun stop() {
playingState = PlayingState.Stopped
super.stop()
onCompletionListener?.onCompletion(this) // and manually call on completion handler
}
override fun setOnCompletionListener(listener: OnCompletionListener?) {
onCompletionListener = listener
super.setOnCompletionListener(listener)
}
}
@@ -50,6 +50,7 @@ import com.google.android.flexbox.FlexboxLayoutManager
import com.google.android.material.snackbar.Snackbar
import com.jakewharton.rxbinding2.view.clicks
import com.jakewharton.rxbinding2.widget.textChanges
import com.moez.QKSMS.common.QkMediaPlayer
import com.uber.autodispose.android.lifecycle.scope
import com.uber.autodispose.autoDisposable
import dagger.android.AndroidInjection
@@ -231,6 +232,14 @@ class ComposeActivity : QkThemedActivity(), ComposeView {
activityVisibleIntent.onNext(false)
}
override fun onDestroy() {
super.onDestroy()
// stop any playing audio
QkMediaPlayer.reset()
}
override fun render(state: ComposeState) {
if (state.hasError) {
finish()
@@ -0,0 +1,209 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* This file is part of QKSMS.
*
* QKSMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QKSMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QKSMS. If not, see <http://www.gnu.org/licenses/>.
*/
package dev.octoshrimpy.quik.feature.compose.part
import android.content.Context
import android.content.res.ColorStateList
import android.media.AudioAttributes
import android.media.MediaMetadataRetriever
import android.view.View
import android.widget.SeekBar
import com.moez.QKSMS.common.QkMediaPlayer
import dev.octoshrimpy.quik.R
import dev.octoshrimpy.quik.common.base.QkViewHolder
import dev.octoshrimpy.quik.common.util.Colors
import dev.octoshrimpy.quik.common.util.extensions.resolveThemeColor
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.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
class AudioBinder @Inject constructor(colors: Colors, private val context: Context) :
PartBinder() {
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()
override fun bindPart(
holder: QkViewHolder,
part: MmsPart,
message: Message,
canGroupWithPrevious: Boolean,
canGroupWithNext: Boolean
) {
// play/pause button click handling
holder.playPause.setOnClickListener { view ->
var seekBarUpdater: Disposable? = null
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()
}
}
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
}
}
}
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
}
// tint colours
val secondaryColor =
if (!message.isMe())
theme.theme
else
holder.containerView.context.resolveThemeColor(R.attr.bubbleColor)
val primaryColor =
if (!message.isMe())
theme.textPrimary
else
holder.containerView.context.resolveThemeColor(android.R.attr.textColorPrimary)
val metaDataRetriever = MediaMetadataRetriever()
metaDataRetriever.setDataSource(context, part.getUri())
// sound wave
holder.soundWave.setTint(primaryColor)
// seek bar
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)
QkMediaPlayer.seekTo(progress)
}
override fun onStartTrackingTouch(p0: SeekBar?) { /* nothing */ }
override fun onStopTrackingTouch(p0: SeekBar?) { /* nothing */ }
})
}
// playPause button
holder.playPause. apply {
setTint(secondaryColor)
setBackgroundTint(primaryColor)
}
holder.metadataTitle.apply {
// metadata title
text = metaDataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE) ?: ""
if (text == "")
visibility = View.GONE
else {
setTextColor(primaryColor)
setBackgroundTint(0xccffffff.toInt() and secondaryColor) // hex value is alpha
}
}
// 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()
}
}
@@ -29,12 +29,12 @@ import dev.octoshrimpy.quik.extensions.isVideo
import dev.octoshrimpy.quik.model.Message
import dev.octoshrimpy.quik.model.MmsPart
import dev.octoshrimpy.quik.util.GlideApp
import kotlinx.android.synthetic.main.mms_preview_list_item.*
import kotlinx.android.synthetic.main.mms_image_preview_list_item.*
import javax.inject.Inject
class MediaBinder @Inject constructor(colors: Colors, private val context: Context) : PartBinder() {
class ImageBinder @Inject constructor(colors: Colors, private val context: Context) : PartBinder() {
override val partLayout = R.layout.mms_preview_list_item
override val partLayout = R.layout.mms_image_preview_list_item
override var theme = colors.theme()
override fun canBindPart(part: MmsPart) = part.isImage() || part.isVideo()
@@ -37,11 +37,12 @@ import javax.inject.Inject
class PartsAdapter @Inject constructor(
colors: Colors,
fileBinder: FileBinder,
mediaBinder: MediaBinder,
imageBinder: ImageBinder,
audioBinder: AudioBinder,
vCardBinder: VCardBinder
) : QkAdapter<MmsPart>() {
private val partBinders = listOf(mediaBinder, vCardBinder, fileBinder)
private val partBinders = listOf(audioBinder, imageBinder, vCardBinder, fileBinder)
var theme: Colors.Theme = colors.theme()
set(value) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

@@ -76,7 +76,7 @@
app:layout_constraintStart_toEndOf="@id/avatar"
app:layout_constraintTop_toBottomOf="@id/timestamp"
tools:itemCount="1"
tools:listitem="@layout/mms_preview_list_item" />
tools:listitem="@layout/mms_image_preview_list_item" />
<dev.octoshrimpy.quik.common.widget.AvatarView
android:id="@+id/avatar"
@@ -77,7 +77,7 @@
app:layout_constraintHorizontal_bias="1"
app:layout_constraintTop_toBottomOf="@id/timestamp"
tools:itemCount="1"
tools:listitem="@layout/mms_preview_list_item" />
tools:listitem="@layout/mms_image_preview_list_item" />
<dev.octoshrimpy.quik.common.widget.TightTextView
android:id="@+id/body"
@@ -0,0 +1,98 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2019 Moez Bhatti <moez.bhatti@gmail.com>
~
~ This file is part of QKSMS.
~
~ QKSMS is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ QKSMS is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with QKSMS. If not, see <http://www.gnu.org/licenses/>.
-->
<FrameLayout 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/frame"
android:layout_width="280dp"
android:layout_height="280dp"
android:layout_marginTop="2dp"
android:clipChildren="false">
<dev.octoshrimpy.quik.common.widget.BubbleImageView
android:id="@+id/thumbnail"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:adjustViewBounds="false"
android:minHeight="140dp"
android:scaleType="centerCrop"
android:src="@drawable/rectangle" />
<ImageView
android:id="@+id/soundWave"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:adjustViewBounds="true"
app:srcCompat="@drawable/audio_preview_foreground" />
<SeekBar
android:id="@+id/seekBar"
style="@style/Widget.AppCompat.SeekBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:max="10"
android:progress="5"
android:progressTint="@color/tools_theme"
android:thumbTint="@color/tools_theme" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:layout_marginBottom="12dp"
android:clipChildren="false"
android:orientation="horizontal">
<ImageView
android:id="@+id/playPause"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_gravity="bottom"
android:layout_marginStart="12dp"
android:background="@drawable/circle"
android:elevation="4dp"
android:padding="6dp"
app:srcCompat="@drawable/exo_icon_play"
app:tint="@color/tools_theme" />
<dev.octoshrimpy.quik.common.widget.QkTextView
android:id="@+id/metadataTitle"
style="@style/TextPrimary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:background="@drawable/rounded_rectangle_22dp"
android:backgroundTint="@color/tools_theme"
android:ellipsize="marquee"
android:fadingEdge="horizontal"
android:focusable="true"
android:focusableInTouchMode="true"
android:padding="4dp"
android:scrollHorizontally="true"
android:singleLine="true"
android:text="@tools:sample/lorem/random[0]"
android:textColor="@color/black" />
</LinearLayout>
</FrameLayout>