diff --git a/android/app/src/main/kotlin/com/rtirl/chat/MainActivity.kt b/android/app/src/main/kotlin/com/rtirl/chat/MainActivity.kt index bbdc619e0..635495e23 100644 --- a/android/app/src/main/kotlin/com/rtirl/chat/MainActivity.kt +++ b/android/app/src/main/kotlin/com/rtirl/chat/MainActivity.kt @@ -4,10 +4,15 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context import android.content.Intent +import android.media.AudioAttributes +import android.media.AudioFocusRequest import android.media.AudioManager +import android.media.AudioManager.OnAudioFocusChangeListener import android.net.Uri import android.os.Build import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.provider.Settings import android.speech.tts.TextToSpeech import android.speech.tts.UtteranceProgressListener @@ -24,9 +29,14 @@ import io.flutter.plugin.common.MethodChannel.Result import java.util.Locale import java.util.UUID -class MainActivity : FlutterActivity() { +class MainActivity : FlutterActivity(), AudioManager.OnAudioFocusChangeListener { private var sharedData: String = "" + private lateinit var audioManager: AudioManager + private lateinit var focusRequest: AudioFocusRequest + private val handler = Handler(Looper.getMainLooper()) + private var playbackDelayed = false + private var wasDucking = false companion object { var methodChannel: MethodChannel? = null @@ -36,6 +46,16 @@ class MainActivity : FlutterActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) handleIntent() + audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager + focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK).run { + setAudioAttributes(AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build()) + setAcceptsDelayedFocusGain(true) + setOnAudioFocusChangeListener(this@MainActivity, handler) + build() + } } private fun startNotificationService() { @@ -48,17 +68,38 @@ class MainActivity : FlutterActivity() { } } + override fun onAudioFocusChange(focusChange: Int) { + when (focusChange) { + AudioManager.AUDIOFOCUS_GAIN -> { + if (wasDucking) { + methodChannel?.invokeMethod("audioVolume", 1.0) + wasDucking = false + } + + if (playbackDelayed) { + playbackDelayed = false + } else { + methodChannel?.invokeMethod("audioVolume", 1.0) + } + } + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { + methodChannel?.invokeMethod("audioVolume", 0.3) + wasDucking = true + } + AudioManager.AUDIOFOCUS_LOSS, + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + if (!wasDucking) { + // handle other cases if necessary + } + } + } + } + override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { val ttsPlugin = TextToSpeechPlugin(this) - val ttsChannel = MethodChannel( - flutterEngine.dartExecutor.binaryMessenger, - "ttsPlugin" - ) - - val notificationChannel = MethodChannel( - flutterEngine.dartExecutor.binaryMessenger, - "tts_notifications" - ) + val ttsChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "ttsPlugin") + val notificationChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "tts_notifications") + val volumeChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "volume_channel") methodChannel = notificationChannel @@ -81,18 +122,36 @@ class MainActivity : FlutterActivity() { } } + volumeChannel.setMethodCallHandler { call, result -> + when (call.method) { + "tts_on" -> { + val res = audioManager.requestAudioFocus(focusRequest) + when (res) { + AudioManager.AUDIOFOCUS_REQUEST_GRANTED -> { + methodChannel?.invokeMethod("audioFocus", "gained") + Log.d("Permission granted", "response granted") + } + AudioManager.AUDIOFOCUS_REQUEST_DELAYED -> { + playbackDelayed = true + methodChannel?.invokeMethod("audioFocus", "delayed") + } + else -> methodChannel?.invokeMethod("audioFocus", "failed") + } + } + "tts_off" -> { + audioManager.abandonAudioFocusRequest(focusRequest) + methodChannel?.invokeMethod("audioFocus", "lost") + } + else -> result.notImplemented() + } + } + ttsChannel.setMethodCallHandler(ttsPlugin) - MethodChannel( - flutterEngine.dartExecutor.binaryMessenger, - "com.rtirl.chat/audio" - ).setMethodCallHandler { call, result -> + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.rtirl.chat/audio").setMethodCallHandler { call, result -> when (call.method) { "set" -> { val intent = Intent(this, AudioService::class.java) - intent.putStringArrayListExtra( - "urls", - ArrayList(call.argument>("urls") ?: listOf()) - ) + intent.putStringArrayListExtra("urls", ArrayList(call.argument>("urls") ?: listOf())) intent.action = AudioService.ACTION_START_SERVICE startService(intent) result.success(true) @@ -104,35 +163,19 @@ class MainActivity : FlutterActivity() { result.success(true) } "hasPermission" -> { - result.success( - Build.VERSION.SDK_INT < Build.VERSION_CODES.M || - Settings.canDrawOverlays(this) - ) + result.success(Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(this)) } "requestPermission" -> { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && - !Settings.canDrawOverlays(this) - ) { - startActivityForResult( - Intent( - Settings.ACTION_MANAGE_OVERLAY_PERMISSION, - Uri.parse("package:$packageName") - ), 8675309 - ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(this)) { + startActivityForResult(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:$packageName")), 8675309) } - result.success( - Build.VERSION.SDK_INT < Build.VERSION_CODES.M || - Settings.canDrawOverlays(this) - ) + result.success(Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(this)) } else -> result.notImplemented() } } - MethodChannel( - flutterEngine.dartExecutor.binaryMessenger, - "com.rtirl.chat/share" - ).setMethodCallHandler { call, result -> + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.rtirl.chat/share").setMethodCallHandler { call, result -> if (call.method == "getSharedData") { result.success(sharedData) sharedData = "" @@ -143,7 +186,6 @@ class MainActivity : FlutterActivity() { } private fun handleIntent() { - // Handle the received text share intent if (intent?.action == Intent.ACTION_SEND && intent.type == "text/plain") { intent.getStringExtra(Intent.EXTRA_TEXT)?.let { intentData -> sharedData = intentData @@ -152,7 +194,6 @@ class MainActivity : FlutterActivity() { } } - class TextToSpeechPlugin(private val context: Context) : MethodCallHandler, TextToSpeech.OnInitListener { private var tts: TextToSpeech? = null private val audioManager: AudioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager @@ -228,11 +269,7 @@ class TextToSpeechPlugin(private val context: Context) : MethodCallHandler, Text tts?.setOnUtteranceProgressListener(object : UtteranceProgressListener() { override fun onStart(utteranceId: String) { if (volume != null && Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - audioManager.setStreamVolume( - AudioManager.STREAM_MUSIC, - (audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * volume).toInt(), - 0 - ) + audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, (audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * volume).toInt(), 0) } } diff --git a/lib/models/tts.dart b/lib/models/tts.dart index d98248138..303bcb18b 100644 --- a/lib/models/tts.dart +++ b/lib/models/tts.dart @@ -16,6 +16,7 @@ import 'package:rtchat/models/messages/twitch/user.dart'; import 'package:rtchat/models/tts/language.dart'; import 'package:rtchat/models/tts/bytes_audio_source.dart'; import 'package:rtchat/models/user.dart'; +import 'package:rtchat/volume_plugin.dart'; import 'package:flutter_tts/flutter_tts.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -173,6 +174,10 @@ class TtsModel extends ChangeNotifier { if (value) { _lastMessageTime = DateTime.now(); } + if (value) { + VolumePlugin.reduceVolumeOnTtsStart(); + } + say( localizations, SystemMessageModel( @@ -384,6 +389,9 @@ class TtsModel extends ChangeNotifier { await audioPlayer.setAudioSource(BytesAudioSource(bytes)); await audioPlayer.play(); await Future.delayed(audioPlayer.duration ?? const Duration()); + if (_pending.isEmpty) { + VolumePlugin.increaseVolumeOnTtsStop(); + } } } @@ -395,10 +403,14 @@ class TtsModel extends ChangeNotifier { void unsay(String messageId) { _pending.remove(messageId); + if (_pending.isEmpty) { + VolumePlugin.increaseVolumeOnTtsStop(); + } } void stop() { _pending.clear(); + VolumePlugin.increaseVolumeOnTtsStop(); } void updateFromJson(Map json) { diff --git a/lib/screens/home.dart b/lib/screens/home.dart index aea991c7e..26b700742 100644 --- a/lib/screens/home.dart +++ b/lib/screens/home.dart @@ -27,6 +27,7 @@ import 'package:rtchat/models/tts.dart'; import 'package:rtchat/models/user.dart'; import 'package:rtchat/notifications_plugin.dart'; import 'package:rtchat/tts_plugin.dart'; +import 'package:rtchat/volume_plugin.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; class ResizableWidget extends StatefulWidget { @@ -298,6 +299,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { "Text to speech disabled"); await TextToSpeechPlugin.disableTTS(); NotificationsPlugin.cancelNotification(); + VolumePlugin.reduceVolumeOnTtsStart(); } else { // Start listening to the stream before toggling newTtsEnabled channelStreamController.stream @@ -312,6 +314,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { "${userModel.activeChannel?.provider}:${userModel.activeChannel?.channelId}", ); NotificationsPlugin.showNotification(); + VolumePlugin.increaseVolumeOnTtsStop(); NotificationsPlugin.listenToTts(ttsModel); } } diff --git a/lib/tts_plugin.dart b/lib/tts_plugin.dart index e169c0858..79b97e107 100644 --- a/lib/tts_plugin.dart +++ b/lib/tts_plugin.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:rtchat/main.dart'; import 'package:rtchat/notifications_plugin.dart'; +import 'package:rtchat/volume_plugin.dart'; class TextToSpeechPlugin { static const MethodChannel channel = MethodChannel('ttsPlugin'); @@ -93,6 +94,7 @@ class TTSQueue { } if (queue.isNotEmpty) { + VolumePlugin.reduceVolumeOnTtsStart(); final previous = queue.last; queue.addLast(element); await previous.completer.future; @@ -101,10 +103,18 @@ class TTSQueue { } await TextToSpeechPlugin.speak(text, speed: speed ?? 1.5, volume: volume); completer.complete(); + + if (isEmpty) { + VolumePlugin.increaseVolumeOnTtsStop(); + } } else { queue.addLast(element); await TextToSpeechPlugin.speak(text, speed: speed ?? 1.5, volume: volume); completer.complete(); + + if (isEmpty) { + VolumePlugin.increaseVolumeOnTtsStop(); + } } queue.remove(element); } diff --git a/lib/volume_plugin.dart b/lib/volume_plugin.dart new file mode 100644 index 000000000..dfe32d96a --- /dev/null +++ b/lib/volume_plugin.dart @@ -0,0 +1,13 @@ +import 'package:flutter/services.dart'; + +class VolumePlugin { + static const MethodChannel channel = MethodChannel('volume_channel'); + + static Future reduceVolumeOnTtsStart() async { + await channel.invokeMethod('tts_on'); + } + + static Future increaseVolumeOnTtsStop() async { + await channel.invokeMethod('tts_off'); + } +} diff --git a/test/models/tts_test.dart b/test/models/tts_test.dart index a63237dd7..da7c7331a 100644 --- a/test/models/tts_test.dart +++ b/test/models/tts_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:rtchat/tts_plugin.dart'; +import 'package:rtchat/volume_plugin.dart'; void main() { final ttsQueue = TTSQueue(); @@ -16,11 +17,24 @@ void main() { (MethodCall method) async { return null; }); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(VolumePlugin.channel, + (MethodCall method) async { + if (method.method == 'tts_on' || method.method == 'tts_off') { + return null; // Mock response for volume channel methods + } + throw MissingPluginException( + 'No implementation found for method ${method.method} on channel ${VolumePlugin.channel.name}'); + }); }); tearDown(() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(TextToSpeechPlugin.channel, null); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(VolumePlugin.channel, null); }); test('Speak adds elements to the queue', () async {