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
This commit is contained in:
ggurdin 2025-02-21 12:19:51 -05:00 committed by GitHub
parent 0255a71929
commit 62d5a7190f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 243 additions and 213 deletions

View file

@ -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"
}

View file

@ -100,7 +100,11 @@ class ChoicesArrayState extends State<ChoicesArray> {
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) {

View file

@ -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);

View file

@ -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;
}
}

View file

@ -42,6 +42,10 @@ class SettingsLearningController extends State<SettingsLearning> {
Future<void> 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<SettingsLearning> {
}
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<SettingsLearning> {
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;
}

View file

@ -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,
);
},
),
],
),
),
),
),
],
),
],
),

View file

@ -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<void> _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<void> _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<void> 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);
}

View file

@ -69,18 +69,6 @@ class MessageAudioCardState extends State<MessageAudioCard> {
super.didUpdateWidget(oldWidget);
}
Future<void> 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;

View file

@ -109,7 +109,7 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
widget.chatController.choreographer.tts.tryToSpeak(
selectedSpan.content,
context,
pangeaMessageEvent?.eventId,
targetID: null,
);
}

View file

@ -226,7 +226,6 @@ class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
WordAudioButton(
text: practiceActivity.content.answers.first,
ttsController: tts,
eventID: widget.event.eventId,
),
if (practiceActivity.activityType ==
ActivityTypeEnum.hiddenWordListening)

View file

@ -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<WordAudioButton> {
@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
),
);
}
}

View file

@ -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<WordTextWithAudioButton> {
@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,
),
],
),
),
),
),

View file

@ -237,7 +237,6 @@ class WordZoomWidgetState extends State<WordZoomWidget> {
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