diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt new file mode 100644 index 0000000000..269a35596b --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt @@ -0,0 +1,164 @@ +/* + * Created by Ayaan on 02/02/23, 10:30 pm + * Copyright (c) 2023 . All rights reserved. + * Last modified 02/02/23, 9:56 pm + */ + +package com.vitorpamplona.amethyst.service.tts + +import android.content.Context +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import android.util.Log +import java.util.Locale + +const val DEF_SPEECH_AND_PITCH = 0.8f + +fun getErrorText(errorCode: Int): String = when (errorCode) { + TextToSpeech.ERROR -> "ERROR" + TextToSpeech.ERROR_INVALID_REQUEST -> "ERROR_INVALID_REQUEST" + TextToSpeech.ERROR_NETWORK -> "ERROR_NETWORK" + TextToSpeech.ERROR_NETWORK_TIMEOUT -> "ERROR_NETWORK_TIMEOUT" + TextToSpeech.ERROR_SERVICE -> "ERROR_SERVICE" + TextToSpeech.ERROR_SYNTHESIS -> "ERROR_SYNTHESIS" + TextToSpeech.ERROR_NOT_INSTALLED_YET -> "ERROR_NOT_INSTALLED_YET" + else -> "UNKNOWN" +} + +class TextToSpeechEngine private constructor() { + private var tts: TextToSpeech? = null + + private var defaultPitch = 0.8f + private var defaultSpeed = 0.8f + private var defLanguage = Locale.getDefault() + private var onStartListener: (() -> Unit)? = null + private var onDoneListener: (() -> Unit)? = null + private var onErrorListener: ((String) -> Unit)? = null + private var onHighlightListener: ((Int, Int) -> Unit)? = null + private var message: String? = null + + companion object { + private var instance: TextToSpeechEngine? = null + fun getInstance(): TextToSpeechEngine { + if (instance == null) { + instance = TextToSpeechEngine() + } + return instance!! + } + } + + fun initTTS(context: Context, message: String) { + tts = TextToSpeech(context) { + if (it == TextToSpeech.SUCCESS) { + tts!!.language = defLanguage + tts!!.setPitch(defaultPitch) + tts!!.setSpeechRate(defaultSpeed) + tts!!.setListener( + onStart = { + onStartListener?.invoke() + }, + onError = { e -> + e?.let { error -> + onErrorListener?.invoke(error) + } + }, + onRange = { start, end -> + if (this@TextToSpeechEngine.message != null) { + onHighlightListener?.invoke(start, end) + } + }, + onDone = { + onStartListener?.invoke() + } + ) + speak(message) + } else { + Log.d("AAA", "initTTS: $it") + onErrorListener?.invoke(getErrorText(it)) + } + } + } + + private fun speak(message: String): TextToSpeechEngine { + tts!!.speak( + message, + TextToSpeech.QUEUE_FLUSH, + null, + TextToSpeech.ACTION_TTS_QUEUE_PROCESSING_COMPLETED + ) + return this + } + + fun setPitchAndSpeed(pitch: Float, speed: Float) { + defaultPitch = pitch + defaultSpeed = speed + } + + fun resetPitchAndSpeed() { + defaultPitch = DEF_SPEECH_AND_PITCH + defaultSpeed = DEF_SPEECH_AND_PITCH + } + + fun setLanguage(local: Locale): TextToSpeechEngine { + this.defLanguage = local + return this + } + + fun setHighlightedMessage(message: String) { + this.message = message + } + + fun setOnStartListener(onStartListener: (() -> Unit)): TextToSpeechEngine { + this.onStartListener = onStartListener + return this + } + + fun setOnCompletionListener(onDoneListener: () -> Unit): TextToSpeechEngine { + this.onDoneListener = onDoneListener + return this + } + + fun setOnErrorListener(onErrorListener: (String) -> Unit): TextToSpeechEngine { + this.onErrorListener = onErrorListener + return this + } + + fun setOnHighlightListener(onHighlightListener: (Int, Int) -> Unit): TextToSpeechEngine { + this.onHighlightListener = onHighlightListener + return this + } + + fun destroy() { + tts?.stop() + tts?.shutdown() + tts = null + instance = null + } +} + +inline fun TextToSpeech.setListener( + crossinline onStart: (String?) -> Unit = {}, + crossinline onError: (String?) -> Unit = {}, + crossinline onRange: (Int, Int) -> Unit = { _, _ -> }, + crossinline onDone: (String?) -> Unit +) = this.apply { + setOnUtteranceProgressListener(object : UtteranceProgressListener() { + override fun onStart(p0: String?) { + onStart.invoke(p0) + } + + override fun onDone(p0: String?) { + onDone.invoke(p0) + } + + @Deprecated("Deprecated in Java", ReplaceWith("onError.invoke(p0)")) + override fun onError(p0: String?) { + onError.invoke(p0) + } + + override fun onRangeStart(utteranceId: String?, start: Int, end: Int, frame: Int) { + super.onRangeStart(utteranceId, start, end, frame) + onRange.invoke(start, end) + } + }) +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechHelper.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechHelper.kt new file mode 100644 index 0000000000..3bb5511cdb --- /dev/null +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechHelper.kt @@ -0,0 +1,158 @@ +/* + * Created by Ayaan on 02/02/23, 10:30 pm + * Copyright (c) 2023 . All rights reserved. + * Last modified 02/02/23, 10:19 pm + */ + +package com.vitorpamplona.amethyst.service.tts + +import android.content.Context +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import java.lang.ref.WeakReference +import java.util.Locale + +class TextToSpeechHelper private constructor(private val context: WeakReference) : + LifecycleEventObserver { + private val appContext + get() = context.get()!!.applicationContext + + private var message: String? = null + + private var tts: TextToSpeechEngine? = null + + private var onStart: (() -> Unit)? = null + + private var onDoneListener: (() -> Unit)? = null + + private var onErrorListener: ((String) -> Unit)? = null + + private var onHighlightListener: ((Pair) -> Unit)? = null + + private var customActionForDestroy: (() -> Unit)? = null + + init { + initTTS() + } + + fun registerLifecycle(owner: LifecycleOwner): TextToSpeechHelper { + owner.lifecycle.addObserver(this) + return this + } + + private fun initTTS() = context.get()?.run { + tts = TextToSpeechEngine.getInstance() + .setOnCompletionListener { onDoneListener?.invoke() } + .setOnErrorListener { onErrorListener?.invoke(it) } + .setOnStartListener { onStart?.invoke() } + } + + fun speak(message: String): TextToSpeechHelper { + if (tts == null) { + initTTS() + } + this.message = message + + tts?.initTTS( + appContext, + message + ) + return this + } + + /** + * This method will highlight the text in the textView + * + * @exception Exception("Message can't be null for highlighting !! Call speak() first") + */ + fun highlight(): TextToSpeechHelper { + if (message == null) throw Exception("Message can't be null for highlighting !! Call speak() first") + tts?.setHighlightedMessage(message!!) + tts?.setOnHighlightListener { i, i2 -> + onHighlightListener?.invoke(Pair(i, i2)) + } + return this + } + + fun removeHighlight(): TextToSpeechHelper { + message = null + onHighlightListener = null + return this + } + + fun destroy( + action: (() -> Unit) = {} + ) { + tts?.destroy() + tts = null + action.invoke() + INSTANCE = null + } + + fun onStart(onStartListener: () -> Unit): TextToSpeechHelper { + this.onStart = onStartListener + return this + } + + fun onDone(onCompleteListener: () -> Unit): TextToSpeechHelper { + this.onDoneListener = onCompleteListener + return this + } + + fun onError(onErrorListener: (String) -> Unit): TextToSpeechHelper { + this.onErrorListener = onErrorListener + return this + } + + fun onHighlight(onHighlightListener: (Pair) -> Unit): TextToSpeechHelper { + this.onHighlightListener = onHighlightListener + return this + } + + fun setCustomActionForDestroy(action: () -> Unit): TextToSpeechHelper { + customActionForDestroy = action + return this + } + + fun setLanguage(locale: Locale): TextToSpeechHelper { + tts?.setLanguage(locale) + return this + } + + fun setPitchAndSpeed( + pitch: Float = DEF_SPEECH_AND_PITCH, + speed: Float = DEF_SPEECH_AND_PITCH + ): TextToSpeechHelper { + tts?.setPitchAndSpeed(pitch, speed) + return this + } + + fun resetPitchAndSpeed(): TextToSpeechHelper { + tts?.resetPitchAndSpeed() + return this + } + + companion object { + private var INSTANCE: TextToSpeechHelper? = null + fun getInstance(context: Context): TextToSpeechHelper { + synchronized(TextToSpeechHelper::class.java) { + if (INSTANCE == null) { + INSTANCE = TextToSpeechHelper(WeakReference(context)) + } + return INSTANCE!! + } + } + } + + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (event == Lifecycle.Event.ON_DESTROY || + event == Lifecycle.Event.ON_STOP || + event == Lifecycle.Event.ON_PAUSE + ) { + destroy { + customActionForDestroy?.invoke() + } + } + } +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt index 955c00ce0b..775b5dff3f 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/UsernameDisplay.kt @@ -1,16 +1,28 @@ package com.vitorpamplona.amethyst.ui.note +import android.content.Context +import android.util.Log +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.PlayCircle import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.LifecycleOwner import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.tts.TextToSpeechHelper import com.vitorpamplona.amethyst.ui.actions.ImmutableListOfLists import com.vitorpamplona.amethyst.ui.actions.toImmutableListOfLists import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji @@ -45,6 +57,9 @@ private fun UserNameDisplay( tags: ImmutableListOfLists?, modifier: Modifier ) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + if (bestUserName != null && bestDisplayName != null) { CreateTextWithEmoji( text = bestDisplayName, @@ -59,6 +74,17 @@ private fun UserNameDisplay( overflow = TextOverflow.Ellipsis, modifier = modifier ) + IconButton( + onClick = { speak(bestDisplayName, context, lifecycleOwner) }, + modifier = Modifier.size(20.dp) + ) { + Icon( + imageVector = Icons.Outlined.PlayCircle, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colors.placeholderText + ) + } } else if (bestDisplayName != null) { CreateTextWithEmoji( text = bestDisplayName, @@ -68,6 +94,17 @@ private fun UserNameDisplay( overflow = TextOverflow.Ellipsis, modifier = modifier ) + IconButton( + onClick = { speak(bestDisplayName, context, lifecycleOwner) }, + modifier = Modifier.size(20.dp) + ) { + Icon( + imageVector = Icons.Outlined.PlayCircle, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colors.placeholderText + ) + } } else if (bestUserName != null) { CreateTextWithEmoji( text = remember { "@$bestUserName" }, @@ -77,6 +114,17 @@ private fun UserNameDisplay( overflow = TextOverflow.Ellipsis, modifier = modifier ) + IconButton( + onClick = { speak(bestUserName, context, lifecycleOwner) }, + modifier = Modifier.size(20.dp) + ) { + Icon( + imageVector = Icons.Outlined.PlayCircle, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colors.placeholderText + ) + } } else { Text( npubDisplay, @@ -87,3 +135,21 @@ private fun UserNameDisplay( ) } } + +private fun speak( + message: String, + context: Context, + owner: LifecycleOwner +) { + TextToSpeechHelper + .getInstance(context) + .registerLifecycle(owner) + .speak(message) + .highlight() + .onDone { + Log.d("TextToSpeak", "speak: done") + } + .onError { + Log.d("TextToSpeak", "speak error: $it") + } +}