From 62d5a7190feea11e92d2ebb5fe9306a894b433f1 Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Fri, 21 Feb 2025 12:19:51 -0500 Subject: [PATCH] 1846 word specific audio player not working (#1882) * feat: tie TTS enabled to target lang, show warning popup when disabled * fix: prevent top overflow for popups --- assets/l10n/intl_en.arb | 4 +- .../choreographer/widgets/choice_array.dart | 6 +- lib/pangea/common/utils/overlay.dart | 18 ++- .../instructions/instructions_enum.dart | 5 + .../pages/settings_learning.dart | 15 +- .../pages/settings_learning_view.dart | 97 +++++++------ .../toolbar/controllers/tts_controller.dart | 88 ++++++------ .../toolbar/widgets/message_audio_card.dart | 12 -- .../widgets/message_selection_overlay.dart | 2 +- .../multiple_choice_activity.dart | 1 - .../practice_activity/word_audio_button.dart | 77 ++++++----- .../word_text_with_audio_button.dart | 130 +++++++++--------- .../widgets/word_zoom/word_zoom_widget.dart | 1 - 13 files changed, 243 insertions(+), 213 deletions(-) diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index c186674ae..4c8ffa5af 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4824,5 +4824,7 @@ "whoIsAllowedToJoinThisChat": "Who is allowed to join this chat", "dontForgetPassword": "Don't forget your password!", "enableAutocorrectToolName": "Enable autocorrect", - "enableAutocorrectDescription": "Use your keyboard's built-in autocorrect when typing messages" + "enableAutocorrectDescription": "Use your keyboard's built-in autocorrect when typing messages", + "ttsDisbledTitle": "Text-to-speech disabled", + "ttsDisabledBody": "You can enable text-to-speech in your learning settings" } \ No newline at end of file diff --git a/lib/pangea/choreographer/widgets/choice_array.dart b/lib/pangea/choreographer/widgets/choice_array.dart index 492e8ed28..757596438 100644 --- a/lib/pangea/choreographer/widgets/choice_array.dart +++ b/lib/pangea/choreographer/widgets/choice_array.dart @@ -100,7 +100,11 @@ class ChoicesArrayState extends State { widget.onPressed(value, index); // TODO - what to pass here as eventID? if (widget.enableAudio && widget.tts != null) { - widget.tts?.tryToSpeak(value, context, null); + widget.tts?.tryToSpeak( + value, + context, + targetID: null, + ); } } : (String value, int index) { diff --git a/lib/pangea/common/utils/overlay.dart b/lib/pangea/common/utils/overlay.dart index ab3304f8f..81e8b3bf3 100644 --- a/lib/pangea/common/utils/overlay.dart +++ b/lib/pangea/common/utils/overlay.dart @@ -30,6 +30,8 @@ class OverlayUtil { OverlayPositionEnum position = OverlayPositionEnum.transform, Offset? offset, String? overlayKey, + Alignment? targetAnchor, + Alignment? followerAnchor, }) { try { if (closePrevOverlay) { @@ -56,8 +58,9 @@ class OverlayUtil { child: (position != OverlayPositionEnum.transform) ? child : CompositedTransformFollower( - targetAnchor: Alignment.topCenter, - followerAnchor: Alignment.bottomCenter, + targetAnchor: targetAnchor ?? Alignment.topCenter, + followerAnchor: + followerAnchor ?? Alignment.bottomCenter, link: MatrixState.pAnyState .layerLinkAndKey(transformTargetId) .link, @@ -110,6 +113,8 @@ class OverlayUtil { Offset offset = Offset.zero; final RenderBox? targetRenderBox = layerLinkAndKey.key.currentContext!.findRenderObject() as RenderBox?; + + bool hasTopOverflow = false; if (targetRenderBox != null && targetRenderBox.hasSize) { final Offset transformTargetOffset = (targetRenderBox).localToGlobal(Offset.zero); @@ -117,10 +122,15 @@ class OverlayUtil { final horizontalMidpoint = transformTargetOffset.dx + (transformTargetSize.width / 2); + final verticalMidpoint = + transformTargetOffset.dy + (transformTargetSize.height / 2); + debugPrint("vertical midpoint $verticalMidpoint"); + final halfMaxWidth = maxWidth / 2; final hasLeftOverflow = (horizontalMidpoint - halfMaxWidth) < 0; final hasRightOverflow = (horizontalMidpoint + halfMaxWidth) > MediaQuery.of(context).size.width; + hasTopOverflow = (verticalMidpoint - maxHeight) < 0; double xOffset = 0; @@ -156,6 +166,10 @@ class OverlayUtil { closePrevOverlay: closePrevOverlay, offset: offset, overlayKey: overlayKey, + targetAnchor: + hasTopOverflow ? Alignment.bottomCenter : Alignment.topCenter, + followerAnchor: + hasTopOverflow ? Alignment.topCenter : Alignment.bottomCenter, ); } catch (err, stack) { debugger(when: kDebugMode); diff --git a/lib/pangea/instructions/instructions_enum.dart b/lib/pangea/instructions/instructions_enum.dart index 8c1e9108c..e3030db21 100644 --- a/lib/pangea/instructions/instructions_enum.dart +++ b/lib/pangea/instructions/instructions_enum.dart @@ -22,6 +22,7 @@ enum InstructionsEnum { unlockedLanguageTools, lemmaMeaning, activityPlannerOverview, + ttsDisabled, } extension InstructionsEnumExtension on InstructionsEnum { @@ -37,6 +38,8 @@ extension InstructionsEnumExtension on InstructionsEnum { return l10n.tooltipInstructionsTitle; case InstructionsEnum.missingVoice: return l10n.missingVoiceTitle; + case InstructionsEnum.ttsDisabled: + return l10n.ttsDisbledTitle; case InstructionsEnum.activityPlannerOverview: case InstructionsEnum.clickAgainToDeselect: case InstructionsEnum.speechToText: @@ -87,6 +90,8 @@ extension InstructionsEnumExtension on InstructionsEnum { return l10n.lemmaMeaningInstructionsBody; case InstructionsEnum.activityPlannerOverview: return l10n.activityPlannerOverviewInstructionsBody; + case InstructionsEnum.ttsDisabled: + return l10n.ttsDisabledBody; } } diff --git a/lib/pangea/learning_settings/pages/settings_learning.dart b/lib/pangea/learning_settings/pages/settings_learning.dart index 7b879dcb5..7de0d90f2 100644 --- a/lib/pangea/learning_settings/pages/settings_learning.dart +++ b/lib/pangea/learning_settings/pages/settings_learning.dart @@ -42,6 +42,10 @@ class SettingsLearningController extends State { Future submit() async { if (formKey.currentState!.validate()) { + if (!isTTSSupported) { + updateToolSetting(ToolSetting.enableTTS, false); + } + await showFutureLoadingDialog( context: context, future: () async => pangeaController.userController.updateProfile( @@ -62,6 +66,9 @@ class SettingsLearningController extends State { } if (targetLanguage != null) { _profile.userSettings.targetLanguage = targetLanguage.langCode; + if (!_profile.toolSettings.enableTTS && isTTSSupported) { + updateToolSetting(ToolSetting.enableTTS, true); + } } if (mounted) setState(() {}); @@ -123,12 +130,18 @@ class SettingsLearningController extends State { case ToolSetting.autoIGC: return toolSettings.autoIGC; case ToolSetting.enableTTS: - return toolSettings.enableTTS; + return _profile.userSettings.targetLanguage != null && + tts.isLanguageSupported(_profile.userSettings.targetLanguage!) && + toolSettings.enableTTS; case ToolSetting.enableAutocorrect: return toolSettings.enableAutocorrect; } } + bool get isTTSSupported => + _profile.userSettings.targetLanguage != null && + tts.isLanguageSupported(_profile.userSettings.targetLanguage!); + LanguageModel? get selectedSourceLanguage { return userL1 ?? pangeaController.languageController.systemLanguage; } diff --git a/lib/pangea/learning_settings/pages/settings_learning_view.dart b/lib/pangea/learning_settings/pages/settings_learning_view.dart index 76aae6e65..2eb2ab30d 100644 --- a/lib/pangea/learning_settings/pages/settings_learning_view.dart +++ b/lib/pangea/learning_settings/pages/settings_learning_view.dart @@ -118,8 +118,7 @@ class SettingsLearningView extends StatelessWidget { title: toolSetting.toolName(context), subtitle: toolSetting == ToolSetting.enableTTS && - !controller - .tts.isLanguageFullySupported + !controller.isTTSSupported ? null : toolSetting .toolDescription(context), @@ -130,54 +129,64 @@ class SettingsLearningView extends StatelessWidget { ), enabled: toolSetting == ToolSetting.enableTTS - ? controller - .tts.isLanguageFullySupported + ? controller.isTTSSupported : true, ), if (toolSetting == ToolSetting.enableTTS && - !controller - .tts.isLanguageFullySupported) - ListTile( - trailing: const Padding( - padding: EdgeInsets.symmetric( - horizontal: 16.0, + !controller.isTTSSupported) + Row( + children: [ + Padding( + padding: const EdgeInsets.only( + right: 16.0, + ), + child: Icon( + Icons.info_outlined, + color: Theme.of(context) + .disabledColor, + ), ), - child: Icon(Icons.info_outlined), - ), - subtitle: RichText( - text: TextSpan( - text: L10n.of(context) - .couldNotFindTTS, - style: DefaultTextStyle.of(context) - .style, - children: [ - if (PlatformInfos.isWindows || - PlatformInfos.isAndroid) - TextSpan( - text: L10n.of(context) - .ttsInstructionsHyperlink, - style: const TextStyle( - color: Colors.blue, - fontWeight: FontWeight.bold, - decoration: TextDecoration - .underline, - ), - recognizer: - TapGestureRecognizer() - ..onTap = () { - launchUrlString( - PlatformInfos - .isWindows - ? AppConfig - .windowsTTSDownloadInstructions - : AppConfig - .androidTTSDownloadInstructions, - ); - }, + Flexible( + child: RichText( + text: TextSpan( + text: L10n.of(context) + .couldNotFindTTS, + style: TextStyle( + color: Theme.of(context) + .disabledColor, ), - ], + children: [ + if (PlatformInfos.isWindows || + PlatformInfos.isAndroid) + TextSpan( + text: L10n.of(context) + .ttsInstructionsHyperlink, + style: const TextStyle( + color: Colors.blue, + fontWeight: + FontWeight.bold, + decoration: + TextDecoration + .underline, + ), + recognizer: + TapGestureRecognizer() + ..onTap = () { + launchUrlString( + PlatformInfos + .isWindows + ? AppConfig + .windowsTTSDownloadInstructions + : AppConfig + .androidTTSDownloadInstructions, + ); + }, + ), + ], + ), + ), ), - ), + ], ), ], ), diff --git a/lib/pangea/toolbar/controllers/tts_controller.dart b/lib/pangea/toolbar/controllers/tts_controller.dart index 6201f8524..e72de188c 100644 --- a/lib/pangea/toolbar/controllers/tts_controller.dart +++ b/lib/pangea/toolbar/controllers/tts_controller.dart @@ -91,18 +91,6 @@ class TtsController { s: s, data: {}, ); - } finally { - debugPrint("availableLangCodes: $_availableLangCodes"); - final enableTTSSetting = userController.profile.toolSettings.enableTTS; - if (enableTTSSetting != isLanguageFullySupported) { - await userController.updateProfile( - (profile) { - profile.toolSettings.enableTTS = isLanguageFullySupported; - return profile; - }, - waitForDataInSync: true, - ); - } } } @@ -162,47 +150,49 @@ class TtsController { Future _showMissingVoicePopup( BuildContext context, - String eventID, - ) async { - await instructionsShowPopup( - context, - InstructionsEnum.missingVoice, - eventID, - showToggle: false, - customContent: const Padding( - padding: EdgeInsets.only(top: 12), - child: MissingVoiceButton(), - ), - forceShow: true, - ); - return; - } + String targetID, + ) async => + instructionsShowPopup( + context, + InstructionsEnum.missingVoice, + targetID, + showToggle: false, + customContent: const Padding( + padding: EdgeInsets.only(top: 12), + child: MissingVoiceButton(), + ), + forceShow: true, + ); + + Future _showTTSDisabledPopup( + BuildContext context, + String targetID, + ) async => + instructionsShowPopup( + context, + InstructionsEnum.ttsDisabled, + targetID, + showToggle: false, + forceShow: true, + ); /// A safer version of speak, that handles the case of /// the language not being supported by the TTS engine Future tryToSpeak( String text, - BuildContext context, - // TODO - make non-nullable again - String? eventID, - ) async { - if (!MatrixState - .pangeaController.userController.profile.toolSettings.enableTTS) { - return; - } + BuildContext context, { + // Target ID for where to show warning popup + String? targetID, + }) async { + final enableTTS = MatrixState + .pangeaController.userController.profile.toolSettings.enableTTS; - if (isLanguageFullySupported) { + if (_isL2FullySupported && enableTTS) { await _speak(text); - } else { - ErrorHandler.logError( - e: 'Language not supported by TTS engine', - data: { - 'targetLanguage': targetLanguage, - }, - ); - if (eventID != null) { - await _showMissingVoicePopup(context, eventID); - } + } else if (!_isL2FullySupported && targetID != null) { + await _showMissingVoicePopup(context, targetID); + } else if (!enableTTS && targetID != null) { + await _showTTSDisabledPopup(context, targetID); } } @@ -252,6 +242,8 @@ class TtsController { } } - bool get isLanguageFullySupported => - _availableLangCodes.contains(targetLanguage); + bool get _isL2FullySupported => _availableLangCodes.contains(targetLanguage); + + bool isLanguageSupported(String langCode) => + _availableLangCodes.contains(langCode); } diff --git a/lib/pangea/toolbar/widgets/message_audio_card.dart b/lib/pangea/toolbar/widgets/message_audio_card.dart index d5dcea7f1..827dca987 100644 --- a/lib/pangea/toolbar/widgets/message_audio_card.dart +++ b/lib/pangea/toolbar/widgets/message_audio_card.dart @@ -69,18 +69,6 @@ class MessageAudioCardState extends State { super.didUpdateWidget(oldWidget); } - Future playSelectionAudio() async { - if (widget.selection == null) return; - final PangeaTokenText selection = widget.selection!; - final tokenText = selection.content; - - await widget.tts.tryToSpeak( - tokenText, - context, - widget.messageEvent.eventId, - ); - } - void setSectionStartAndEnd(int? start, int? end) => mounted ? setState(() { sectionStartMS = start; diff --git a/lib/pangea/toolbar/widgets/message_selection_overlay.dart b/lib/pangea/toolbar/widgets/message_selection_overlay.dart index 93b3be657..6eadc1c64 100644 --- a/lib/pangea/toolbar/widgets/message_selection_overlay.dart +++ b/lib/pangea/toolbar/widgets/message_selection_overlay.dart @@ -109,7 +109,7 @@ class MessageOverlayController extends State widget.chatController.choreographer.tts.tryToSpeak( selectedSpan.content, context, - pangeaMessageEvent?.eventId, + targetID: null, ); } diff --git a/lib/pangea/toolbar/widgets/practice_activity/multiple_choice_activity.dart b/lib/pangea/toolbar/widgets/practice_activity/multiple_choice_activity.dart index 3e8884974..c424661cb 100644 --- a/lib/pangea/toolbar/widgets/practice_activity/multiple_choice_activity.dart +++ b/lib/pangea/toolbar/widgets/practice_activity/multiple_choice_activity.dart @@ -226,7 +226,6 @@ class MultipleChoiceActivityState extends State { WordAudioButton( text: practiceActivity.content.answers.first, ttsController: tts, - eventID: widget.event.eventId, ), if (practiceActivity.activityType == ActivityTypeEnum.hiddenWordListening) diff --git a/lib/pangea/toolbar/widgets/practice_activity/word_audio_button.dart b/lib/pangea/toolbar/widgets/practice_activity/word_audio_button.dart index ab49784b1..cd0ad9881 100644 --- a/lib/pangea/toolbar/widgets/practice_activity/word_audio_button.dart +++ b/lib/pangea/toolbar/widgets/practice_activity/word_audio_button.dart @@ -4,18 +4,17 @@ import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; +import 'package:fluffychat/widgets/matrix.dart'; class WordAudioButton extends StatefulWidget { final String text; final TtsController ttsController; - final String? eventID; final double size; const WordAudioButton({ super.key, required this.text, required this.ttsController, - this.eventID, this.size = 24, }); @@ -28,45 +27,49 @@ class WordAudioButtonState extends State { @override Widget build(BuildContext context) { - return IconButton( - icon: const Icon(Icons.play_arrow_outlined), - isSelected: _isPlaying, - selectedIcon: const Icon(Icons.pause_outlined), - color: _isPlaying ? Colors.white : null, - tooltip: _isPlaying ? L10n.of(context).stop : L10n.of(context).playAudio, - iconSize: widget.size, - onPressed: () async { - if (_isPlaying) { - await widget.ttsController.stop(); - if (mounted) { - setState(() => _isPlaying = false); - } - } else { - if (mounted) { - setState(() => _isPlaying = true); - } - try { - await widget.ttsController.tryToSpeak( - widget.text, - context, - widget.eventID, - ); - } catch (e, s) { - ErrorHandler.logError( - e: e, - s: s, - data: { - "text": widget.text, - "eventID": widget.eventID, - }, - ); - } finally { + return CompositedTransformTarget( + link: MatrixState.pAnyState.layerLinkAndKey('word-audio-button').link, + child: IconButton( + key: MatrixState.pAnyState.layerLinkAndKey('word-audio-button').key, + icon: const Icon(Icons.play_arrow_outlined), + isSelected: _isPlaying, + selectedIcon: const Icon(Icons.pause_outlined), + color: _isPlaying ? Colors.white : null, + tooltip: + _isPlaying ? L10n.of(context).stop : L10n.of(context).playAudio, + iconSize: widget.size, + onPressed: () async { + if (_isPlaying) { + await widget.ttsController.stop(); if (mounted) { setState(() => _isPlaying = false); } + } else { + if (mounted) { + setState(() => _isPlaying = true); + } + try { + await widget.ttsController.tryToSpeak( + widget.text, + context, + targetID: 'word-audio-button', + ); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + "text": widget.text, + }, + ); + } finally { + if (mounted) { + setState(() => _isPlaying = false); + } + } } - } - }, // Disable button if language isn't supported + }, // Disable button if language isn't supported + ), ); } } diff --git a/lib/pangea/toolbar/widgets/practice_activity/word_text_with_audio_button.dart b/lib/pangea/toolbar/widgets/practice_activity/word_text_with_audio_button.dart index be49fb7fc..4fe722f2f 100644 --- a/lib/pangea/toolbar/widgets/practice_activity/word_text_with_audio_button.dart +++ b/lib/pangea/toolbar/widgets/practice_activity/word_text_with_audio_button.dart @@ -2,17 +2,16 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; +import 'package:fluffychat/widgets/matrix.dart'; class WordTextWithAudioButton extends StatefulWidget { final String text; final TtsController ttsController; - final String eventID; const WordTextWithAudioButton({ super.key, required this.text, required this.ttsController, - required this.eventID, }); @override @@ -24,73 +23,76 @@ class WordAudioButtonState extends State { @override Widget build(BuildContext context) { - return MouseRegion( - cursor: SystemMouseCursors.click, - onEnter: (event) => setState(() {}), - onExit: (event) => setState(() {}), - child: GestureDetector( - onTap: () async { - if (_isPlaying) { - await widget.ttsController.stop(); - if (mounted) { - setState(() => _isPlaying = false); - } - } else { - if (mounted) { - setState(() => _isPlaying = true); - } - try { - await widget.ttsController.tryToSpeak( - widget.text, - context, - widget.eventID, - ); - } catch (e, s) { - ErrorHandler.logError( - e: e, - s: s, - data: { - "text": widget.text, - "eventID": widget.eventID, - }, - ); - } finally { + return CompositedTransformTarget( + link: MatrixState.pAnyState.layerLinkAndKey('text-audio-button').link, + child: MouseRegion( + key: MatrixState.pAnyState.layerLinkAndKey('text-audio-button').key, + cursor: SystemMouseCursors.click, + onEnter: (event) => setState(() {}), + onExit: (event) => setState(() {}), + child: GestureDetector( + onTap: () async { + if (_isPlaying) { + await widget.ttsController.stop(); if (mounted) { setState(() => _isPlaying = false); } - } - } - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - decoration: BoxDecoration( - color: Colors.transparent, - borderRadius: BorderRadius.circular(4), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 180), - child: Text( + } else { + if (mounted) { + setState(() => _isPlaying = true); + } + try { + await widget.ttsController.tryToSpeak( widget.text, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: _isPlaying - ? Theme.of(context).colorScheme.secondary - : null, - fontSize: - Theme.of(context).textTheme.titleLarge?.fontSize, - ), - overflow: TextOverflow.ellipsis, + context, + targetID: 'text-audio-button', + ); + } catch (e, s) { + ErrorHandler.logError( + e: e, + s: s, + data: { + "text": widget.text, + }, + ); + } finally { + if (mounted) { + setState(() => _isPlaying = false); + } + } + } + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 180), + child: Text( + widget.text, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: _isPlaying + ? Theme.of(context).colorScheme.secondary + : null, + fontSize: + Theme.of(context).textTheme.titleLarge?.fontSize, + ), + overflow: TextOverflow.ellipsis, + ), ), - ), - const SizedBox(width: 4), - Icon( - _isPlaying ? Icons.play_arrow : Icons.play_arrow_outlined, - size: Theme.of(context).textTheme.titleLarge?.fontSize, - ), - ], + const SizedBox(width: 4), + Icon( + _isPlaying ? Icons.play_arrow : Icons.play_arrow_outlined, + size: Theme.of(context).textTheme.titleLarge?.fontSize, + ), + ], + ), ), ), ), diff --git a/lib/pangea/toolbar/widgets/word_zoom/word_zoom_widget.dart b/lib/pangea/toolbar/widgets/word_zoom/word_zoom_widget.dart index bc5609556..b54406fcf 100644 --- a/lib/pangea/toolbar/widgets/word_zoom/word_zoom_widget.dart +++ b/lib/pangea/toolbar/widgets/word_zoom/word_zoom_widget.dart @@ -237,7 +237,6 @@ class WordZoomWidgetState extends State { WordTextWithAudioButton( text: widget.token.text.content, ttsController: widget.tts, - eventID: widget.messageEvent.eventId, ), // if _selectionType is null, we don't know if the lemma activity // can be shown yet, so we don't show the lemma definition