Commit 819ae43b6372c182f6ab91a6313e226000746817
1 parent
081fbff7
feat:体验优化-练习题目取消阻塞,支持任何条件下的点击,增加体验流畅感
Showing
8 changed files
with
125 additions
and
148 deletions
lib/common/utils/click_with_music_controller.dart
... | ... | @@ -11,6 +11,8 @@ class ClickWithMusicController { |
11 | 11 | |
12 | 12 | static ClickWithMusicController? _instance; |
13 | 13 | |
14 | + static String TAG = 'ClickWithMusicController'; | |
15 | + | |
14 | 16 | ClickWithMusicController._privateConstructor(); |
15 | 17 | |
16 | 18 | static ClickWithMusicController get instance => _instance ??= ClickWithMusicController._privateConstructor(); |
... | ... | @@ -21,11 +23,14 @@ class ClickWithMusicController { |
21 | 23 | ///@param action 可以是同步函数也可以是异步函数 |
22 | 24 | Future<void> playMusicAndPerformAction(BuildContext? context, |
23 | 25 | AudioPlayerUtilType audioType, FutureOr<void> Function() action) async { |
26 | + Log.d("$TAG playMusicAndPerformAction _isPlaying=$_isPlaying"); | |
27 | + ///todo 是否需要考虑打断覆盖能力 | |
24 | 28 | if (_isPlaying) return; |
25 | 29 | |
26 | 30 | _isPlaying = true; |
27 | - Log.d("WQF playMusicAndPerformAction playAudio begin"); | |
31 | + Log.d("$TAG playMusicAndPerformAction playAudio begin"); | |
28 | 32 | |
33 | + await AudioPlayerUtil.getInstance().pause(); | |
29 | 34 | // Play the music |
30 | 35 | await AudioPlayerUtil.getInstance() |
31 | 36 | .playAudio(audioType); |
... | ... | @@ -33,15 +38,16 @@ class ClickWithMusicController { |
33 | 38 | try { |
34 | 39 | await Future.sync(action); |
35 | 40 | } catch (e) { |
36 | - Log.d('WQF playMusicAndPerformAction exception $e'); | |
41 | + Log.d('$TAG playMusicAndPerformAction exception $e'); | |
37 | 42 | } finally { |
38 | - Log.d("WQF playMusicAndPerformAction playAudio end"); | |
43 | + Log.d("$TAG playMusicAndPerformAction playAudio end"); | |
39 | 44 | _isPlaying = false; |
40 | 45 | } |
41 | 46 | } |
42 | 47 | |
43 | - void reset() { | |
48 | + Future<void> reset() async { | |
44 | 49 | _isPlaying = false; |
50 | + await AudioPlayerUtil.getInstance().stop(); | |
45 | 51 | } |
46 | 52 | |
47 | 53 | // void dispose() { | ... | ... |
lib/common/widgets/cheer_reward_widget.dart
... | ... | @@ -54,13 +54,13 @@ class _CheerRewardWidgetState extends State<CheerRewardWidget> |
54 | 54 | } |
55 | 55 | |
56 | 56 | void _startAnimation() { |
57 | - Log.d("$TAG _startAnimation"); | |
57 | + Log.d("$TAG ${identityHashCode(this)} _startAnimation"); | |
58 | 58 | setState(() { |
59 | 59 | _isVisible = true; |
60 | 60 | }); |
61 | 61 | |
62 | 62 | _futureComposition.then((composition) { |
63 | - Log.d("$TAG _futureComposition.then duration=${composition.duration}"); | |
63 | + Log.d("$TAG ${identityHashCode(this)} _futureComposition.then duration=${composition.duration}"); | |
64 | 64 | _controller.duration = composition.duration; |
65 | 65 | _controller.forward().whenCompleteOrCancel(() { |
66 | 66 | if (mounted) { | ... | ... |
lib/common/widgets/recorder_widget.dart
lib/common/widgets/speaker_widget.dart
... | ... | @@ -52,7 +52,10 @@ class _SpeakerWidgetState extends State<SpeakerWidget> |
52 | 52 | Log.d( |
53 | 53 | "$TAG ${identityHashCode(this)} initState widget=${widget.isPlaying} _isPlaying=$_isPlaying _controller=${identityHashCode(_controller)}"); |
54 | 54 | if (widget.isPlaying) { |
55 | - _startAnimation(); | |
55 | + ///fixme 增加200毫秒延迟,避免一进入就已经开始播了,效果有待观察 | |
56 | + Future.delayed(const Duration(milliseconds: 200), () { | |
57 | + _startAnimation(); | |
58 | + }); | |
56 | 59 | } |
57 | 60 | } |
58 | 61 | ... | ... |
lib/models/voice_result_type.dart
0 → 100644
1 | +import 'package:wow_english/utils/audio_player_util.dart'; | |
2 | + | |
3 | +/// 语音评测结果聚合类 | |
4 | +class VoiceResultType { | |
5 | + /// lottie动画文件路径 | |
6 | + final String? lottieFilePath; | |
7 | + /// 音效 | |
8 | + final AudioPlayerUtilType audioType; | |
9 | + /// 得分范围最小值 | |
10 | + final int minScore; | |
11 | + /// 得分范围最大值 | |
12 | + final int maxScore; | |
13 | + | |
14 | + const VoiceResultType._(this.lottieFilePath, this.audioType, this.minScore, this.maxScore); | |
15 | + | |
16 | + static const VoiceResultType level1 = VoiceResultType._('assets/lotties/excellent.zip', AudioPlayerUtilType.excellent, 90, 100); | |
17 | + static const VoiceResultType level2 = VoiceResultType._('assets/lotties/great.zip', AudioPlayerUtilType.great, 70, 89); | |
18 | + static const VoiceResultType level3 = VoiceResultType._('assets/lotties/good.zip', AudioPlayerUtilType.good, 50, 69); | |
19 | + static const VoiceResultType level4 = VoiceResultType._(null, AudioPlayerUtilType.tryAgain, 0, 49); | |
20 | + | |
21 | + static List<VoiceResultType> get values => [level1, level2, level3, level4]; | |
22 | + | |
23 | + static VoiceResultType fromScore(int score) { | |
24 | + return values.firstWhere( | |
25 | + (type) => score >= type.minScore && score <= type.maxScore, | |
26 | + orElse: () => level4, // 默认返回level4 | |
27 | + ); | |
28 | + } | |
29 | +} | |
0 | 30 | \ No newline at end of file | ... | ... |
lib/pages/practice/bloc/topic_picture_bloc.dart
... | ... | @@ -20,8 +20,8 @@ import 'package:wow_english/utils/toast_util.dart'; |
20 | 20 | import '../../../common/permission/permissionRequester.dart'; |
21 | 21 | import '../../../common/utils/click_with_music_controller.dart'; |
22 | 22 | import '../../../common/utils/show_star_reward_dialog.dart'; |
23 | +import '../../../models/voice_result_type.dart'; | |
23 | 24 | import '../../../route/route.dart'; |
24 | -import '../../../utils/log_util.dart'; | |
25 | 25 | |
26 | 26 | part 'topic_picture_event.dart'; |
27 | 27 | |
... | ... | @@ -49,7 +49,7 @@ class TopicPictureBloc |
49 | 49 | |
50 | 50 | int _currentPage = 0; |
51 | 51 | |
52 | - int _selectItem = -1; | |
52 | + int _optionSelectItem = -1; | |
53 | 53 | |
54 | 54 | CourseProcessEntity? _entity; |
55 | 55 | |
... | ... | @@ -61,19 +61,10 @@ class TopicPictureBloc |
61 | 61 | ///正在播放音频 |
62 | 62 | VoicePlayState _voicePlayState = VoicePlayState.unKnow; |
63 | 63 | |
64 | - // 是否是回答(选择)结果音效 | |
65 | - bool _isResultSoundPlaying = false; | |
66 | - | |
67 | - bool get isResultSoundPlaying => _isResultSoundPlaying; | |
68 | - | |
69 | - // 答对播放音效时禁止任何点击(选择)操作 | |
70 | - bool _forbiddenWhenCorrect = false; | |
71 | - | |
72 | - bool get forbiddenWhenCorrect => _forbiddenWhenCorrect; | |
73 | - | |
74 | 64 | int get currentPage => _currentPage + 1; |
75 | 65 | |
76 | - int get selectItem => _selectItem; | |
66 | + /// 选择题选中项 | |
67 | + int get optionSelectItem => _optionSelectItem; | |
77 | 68 | |
78 | 69 | bool get isRecording => _isRecording; |
79 | 70 | |
... | ... | @@ -103,46 +94,24 @@ class TopicPictureBloc |
103 | 94 | //音频播放器 |
104 | 95 | audioPlayer = AudioPlayer(); |
105 | 96 | audioPlayer.onPlayerStateChanged.listen((event) async { |
106 | - debugPrint( | |
107 | - '播放状态变化 _voicePlayState=$_voicePlayState event=$event _isResultSoundPlaying=$_isResultSoundPlaying _forbiddenWhenCorrect=$_forbiddenWhenCorrect'); | |
108 | - if (_isResultSoundPlaying) { | |
109 | - if (event != PlayerState.playing) { | |
110 | - _isResultSoundPlaying = false; | |
111 | - if (_forbiddenWhenCorrect) { | |
112 | - _forbiddenWhenCorrect = false; | |
113 | - debugPrint('播放完成后解除禁止'); | |
114 | - if (event == PlayerState.completed) { | |
115 | - if (isLastPage()) { | |
116 | - showStepPage(); | |
117 | - } else { | |
118 | - // 答对后且播放完自动翻页 | |
119 | - pageController.nextPage( | |
120 | - duration: const Duration(milliseconds: 250), | |
121 | - curve: Curves.ease, | |
122 | - ); | |
123 | - } | |
124 | - } | |
125 | - } | |
126 | - } | |
127 | - } else { | |
128 | - if (event == PlayerState.completed) { | |
129 | - debugPrint('播放完成'); | |
130 | - _voicePlayState = VoicePlayState.completed; | |
131 | - } | |
132 | - if (event == PlayerState.stopped) { | |
133 | - debugPrint('播放结束'); | |
134 | - _voicePlayState = VoicePlayState.stop; | |
135 | - } | |
97 | + debugPrint('播放状态变化 _voicePlayState=$_voicePlayState event=$event'); | |
98 | + if (event == PlayerState.completed) { | |
99 | + debugPrint('播放完成'); | |
100 | + _voicePlayState = VoicePlayState.completed; | |
101 | + } | |
102 | + if (event == PlayerState.stopped) { | |
103 | + debugPrint('播放结束'); | |
104 | + _voicePlayState = VoicePlayState.stop; | |
105 | + } | |
136 | 106 | |
137 | - if (event == PlayerState.playing) { | |
138 | - debugPrint('正在播放中'); | |
139 | - _voicePlayState = VoicePlayState.playing; | |
140 | - } | |
141 | - if (isClosed) { | |
142 | - return; | |
143 | - } | |
144 | - add(VoicePlayStateChangeEvent()); | |
107 | + if (event == PlayerState.playing) { | |
108 | + debugPrint('正在播放中'); | |
109 | + _voicePlayState = VoicePlayState.playing; | |
145 | 110 | } |
111 | + if (isClosed) { | |
112 | + return; | |
113 | + } | |
114 | + add(VoicePlayStateChangeEvent()); | |
146 | 115 | }); |
147 | 116 | |
148 | 117 | methodChannel = |
... | ... | @@ -191,8 +160,6 @@ class TopicPictureBloc |
191 | 160 | pageController.dispose(); |
192 | 161 | audioPlayer.release(); |
193 | 162 | audioPlayer.dispose(); |
194 | - _isResultSoundPlaying = false; | |
195 | - _forbiddenWhenCorrect = false; | |
196 | 163 | _voiceXsCancel(); |
197 | 164 | return super.close(); |
198 | 165 | } |
... | ... | @@ -213,7 +180,7 @@ class TopicPictureBloc |
213 | 180 | ///页面切换 |
214 | 181 | void _pageControllerChange(CurrentPageIndexChangeEvent event, |
215 | 182 | Emitter<TopicPictureState> emitter) async { |
216 | - await closePlayerResource(); | |
183 | + await pageResetIfNeed(); | |
217 | 184 | debugPrint('翻页 $_currentPage->${event.pageIndex}'); |
218 | 185 | if (_currentPage == _entity?.topics?.length) { |
219 | 186 | return; |
... | ... | @@ -229,26 +196,22 @@ class TopicPictureBloc |
229 | 196 | } |
230 | 197 | } |
231 | 198 | } |
232 | - _selectItem = -1; | |
233 | 199 | emitter(CurrentPageIndexState()); |
234 | 200 | } |
235 | 201 | |
236 | 202 | ///选择 |
237 | 203 | void _selectItemLoad( |
238 | 204 | SelectItemEvent event, Emitter<TopicPictureState> emitter) async { |
239 | - if (_forbiddenWhenCorrect) { | |
240 | - return; | |
241 | - } | |
242 | - _selectItem = event.selectIndex; | |
243 | - if (checkAnswerRight(_selectItem) == true) { | |
244 | - _playResultSound(true); | |
205 | + _optionSelectItem = event.selectIndex; | |
206 | + emitter(SelectItemChangeState()); | |
207 | + if (checkAnswerRight(_optionSelectItem) == true) { | |
208 | + /// 如果选择题答(选)对后题目没播完,则暂停播放题目。答错的话继续播放体验也不错 | |
209 | + await closePlayerResource(); | |
245 | 210 | showStarRewardDialog(context); |
246 | - // showToast('恭喜你,答对啦!',duration: const Duration(seconds: 2)); | |
211 | + await _playResultSound(true); | |
247 | 212 | } else { |
248 | - _playResultSound(false); | |
249 | - // showToast('继续加油哦',duration: const Duration(seconds: 2)); | |
213 | + await _playResultSound(false); | |
250 | 214 | } |
251 | - emitter(SelectItemChangeState()); | |
252 | 215 | } |
253 | 216 | |
254 | 217 | ///为空则数据异常,用于是否晃动时需要 |
... | ... | @@ -290,69 +253,37 @@ class TopicPictureBloc |
290 | 253 | } |
291 | 254 | |
292 | 255 | ///终止评测 |
293 | - void _voiceXsStop( | |
256 | + Future<void> _voiceXsStop( | |
294 | 257 | XSVoiceStopEvent event, Emitter<TopicPictureState> emitter) async { |
295 | 258 | methodChannel.invokeMethod('stopVoice'); |
296 | 259 | } |
297 | 260 | |
298 | 261 | ///取消评测(用于处理退出页面后录音未停止等异常情况的保护操作) |
299 | - void _voiceXsCancel() { | |
300 | - methodChannel.invokeMethod('cancelVoice'); | |
262 | + Future<void> _voiceXsCancel({bool force = false}) async { | |
263 | + if (_isRecording || force) { | |
264 | + methodChannel.invokeMethod('cancelVoice'); | |
265 | + } | |
301 | 266 | } |
302 | 267 | |
303 | 268 | ///先声评测结果 |
304 | 269 | void _voiceXsResult( |
305 | 270 | XSVoiceResultEvent event, Emitter<TopicPictureState> emitter) async { |
271 | + _isRecording = false; | |
272 | + emitter(XSVoiceTestState()); | |
306 | 273 | final Map args = event.message as Map; |
307 | 274 | final result = args['result'] as Map; |
308 | 275 | final overall = result['overall'].toString(); |
309 | 276 | int score = int.parse(overall); |
310 | - AudioPlayerUtilType audioPlayerUtilType; | |
311 | - String? lottieFile; | |
312 | - if (score > 90) { | |
313 | - audioPlayerUtilType = AudioPlayerUtilType.excellent; | |
314 | - lottieFile = 'assets/lotties/excellent.zip'; | |
315 | - } else if (score > 70) { | |
316 | - audioPlayerUtilType = AudioPlayerUtilType.great; | |
317 | - lottieFile = 'assets/lotties/great.zip'; | |
318 | - } else if (score > 50) { | |
319 | - audioPlayerUtilType = AudioPlayerUtilType.good; | |
320 | - lottieFile = 'assets/lotties/good.zip'; | |
321 | - } else { | |
322 | - audioPlayerUtilType = AudioPlayerUtilType.tryAgain; | |
323 | - } | |
324 | - if (lottieFile != null) { | |
325 | - showCheerRewardDialog(context, lottieFile: lottieFile); | |
326 | - } | |
327 | - Log.d("WQF audioPlayerUtilType=$audioPlayerUtilType lottieFile=$lottieFile"); | |
328 | - ClickWithMusicController.instance | |
329 | - .playMusicAndPerformAction(context, audioPlayerUtilType, () => { | |
330 | - ///todo 是否需要自动翻页 | |
331 | - }); | |
332 | - // showToast('测评成功,分数是$overall', duration: const Duration(seconds: 5)); | |
333 | - _isRecording = false; | |
334 | - emitter(XSVoiceTestState()); | |
335 | - if (isLastPage()) { | |
336 | - showStepPage(); | |
337 | - } | |
338 | - } | |
339 | - | |
340 | - /// 根据得分计算星星数 | |
341 | - int _evaluateScore(String scoreStr) { | |
342 | - try { | |
343 | - int score = int.parse(scoreStr); | |
344 | - if (score > 80) { | |
345 | - return 3; | |
346 | - } else if (score > 60) { | |
347 | - return 2; | |
348 | - } else { | |
349 | - return 1; | |
350 | - } | |
351 | - } catch (e) { | |
352 | - // 如果转换失败,可以返回一个默认值或抛出异常 | |
353 | - print('Error parsing score: $e'); | |
354 | - return 1; // 返回一个默认值表示错误 | |
277 | + final voiceResult = VoiceResultType.fromScore(score); | |
278 | + if (voiceResult.lottieFilePath != null) { | |
279 | + showCheerRewardDialog(context, lottieFile: voiceResult.lottieFilePath!); | |
355 | 280 | } |
281 | + await ClickWithMusicController.instance.playMusicAndPerformAction( | |
282 | + context, | |
283 | + voiceResult.audioType, | |
284 | + () { | |
285 | + if (isLastPage()) {showStepPage();}; | |
286 | + }); | |
356 | 287 | } |
357 | 288 | |
358 | 289 | // 暂时没用上 |
... | ... | @@ -364,38 +295,46 @@ class TopicPictureBloc |
364 | 295 | // 题目音频播放 |
365 | 296 | void _questionVoicePlay( |
366 | 297 | VoicePlayEvent event, Emitter<TopicPictureState> emitter) async { |
367 | - if (_forbiddenWhenCorrect) { | |
368 | - return; | |
369 | - } | |
370 | - _forbiddenWhenCorrect = false; | |
371 | - await closePlayerResource(); | |
298 | + await pageResetIfNeed(); | |
372 | 299 | final topics = _entity?.topics?[_currentPage]; |
373 | 300 | final urlStr = topics?.audioUrl ?? ''; |
374 | 301 | await audioPlayer.play(UrlSource(urlStr), |
375 | 302 | balance: 0.0, ctx: AudioContext()); |
376 | 303 | } |
377 | 304 | |
305 | + /// 重置状态,音频播放、录音以及一些变量等。用于翻页,打断等场景 | |
306 | + Future<void> pageResetIfNeed() async { | |
307 | + _optionSelectItem = -1; | |
308 | + _isRecording = false; | |
309 | + _voicePlayState = VoicePlayState.stop; | |
310 | + | |
311 | + await closePlayerResource(); | |
312 | + await _voiceXsCancel(); | |
313 | + } | |
314 | + | |
378 | 315 | Future<void> closePlayerResource() async { |
379 | - if (voicePlayState == VoicePlayState.playing || _isResultSoundPlaying) { | |
316 | + if (voicePlayState == VoicePlayState.playing) { | |
380 | 317 | await audioPlayer.stop(); |
381 | 318 | } |
319 | + await ClickWithMusicController.instance.reset(); | |
382 | 320 | } |
383 | 321 | |
384 | 322 | ///播放选择结果音效 |
385 | - void _playResultSound(bool isCorrect) async { | |
386 | - // await audioPlayer.stop(); | |
387 | - if (audioPlayer.state == PlayerState.playing && | |
388 | - _isResultSoundPlaying == false) { | |
389 | - _voicePlayState = VoicePlayState.stop; | |
390 | - } | |
391 | - debugPrint("_playResultSound isCorrect=$isCorrect"); | |
392 | - _isResultSoundPlaying = true; | |
393 | - _forbiddenWhenCorrect = isCorrect; | |
394 | - if (isCorrect) { | |
395 | - await audioPlayer.play(AssetSource('right'.assetMp3)); | |
396 | - } else { | |
397 | - await audioPlayer.play(AssetSource('wrong'.assetMp3)); | |
398 | - } | |
323 | + Future<void> _playResultSound(bool isCorrect) async { | |
324 | + await ClickWithMusicController.instance.playMusicAndPerformAction(context, | |
325 | + isCorrect ? AudioPlayerUtilType.right : AudioPlayerUtilType.wrong, () { | |
326 | + if (isCorrect) { | |
327 | + if (isLastPage()) { | |
328 | + showStepPage(); | |
329 | + } else { | |
330 | + // 答对后且播放完自动翻页 | |
331 | + pageController.nextPage( | |
332 | + duration: const Duration(milliseconds: 250), | |
333 | + curve: Curves.ease, | |
334 | + ); | |
335 | + } | |
336 | + } | |
337 | + }); | |
399 | 338 | } |
400 | 339 | |
401 | 340 | ///是否是最后一页 | ... | ... |
lib/pages/practice/topic_picture_page.dart
... | ... | @@ -173,7 +173,7 @@ class _TopicPicturePage extends StatelessWidget { |
173 | 173 | buildWhen: (_, s) => s is SelectItemChangeState, |
174 | 174 | builder: (context, state) { |
175 | 175 | final bloc = BlocProvider.of<TopicPictureBloc>(context); |
176 | - final isAnswerOption = bloc.selectItem == index; | |
176 | + final isAnswerOption = bloc.optionSelectItem == index; | |
177 | 177 | final answerCorrect = |
178 | 178 | isAnswerOption && bloc.checkAnswerRight(index) == true; |
179 | 179 | return Container( |
... | ... | @@ -243,7 +243,7 @@ class _TopicPicturePage extends StatelessWidget { |
243 | 243 | buildWhen: (_, s) => s is SelectItemChangeState, |
244 | 244 | builder: (context, state) { |
245 | 245 | final bloc = BlocProvider.of<TopicPictureBloc>(context); |
246 | - final isAnswerOption = bloc.selectItem == index; | |
246 | + final isAnswerOption = bloc.optionSelectItem == index; | |
247 | 247 | final answerCorrect = |
248 | 248 | isAnswerOption && bloc.checkAnswerRight(index) == true; |
249 | 249 | return Container( |
... | ... | @@ -349,7 +349,7 @@ class _TopicPicturePage extends StatelessWidget { |
349 | 349 | buildWhen: (_, s) => s is SelectItemChangeState, |
350 | 350 | builder: (context, state) { |
351 | 351 | final bloc = BlocProvider.of<TopicPictureBloc>(context); |
352 | - final isAnswerOption = bloc.selectItem == index; | |
352 | + final isAnswerOption = bloc.optionSelectItem == index; | |
353 | 353 | final answerCorrect = |
354 | 354 | isAnswerOption && bloc.checkAnswerRight(index) == true; |
355 | 355 | return OptionWidget( |
... | ... | @@ -423,7 +423,7 @@ class _TopicPicturePage extends StatelessWidget { |
423 | 423 | buildWhen: (_, s) => s is SelectItemChangeState, |
424 | 424 | builder: (context, state) { |
425 | 425 | final bloc = BlocProvider.of<TopicPictureBloc>(context); |
426 | - final isAnswerOption = bloc.selectItem == index; | |
426 | + final isAnswerOption = bloc.optionSelectItem == index; | |
427 | 427 | final answerCorrect = |
428 | 428 | isAnswerOption && bloc.checkAnswerRight(index) == true; |
429 | 429 | return OptionWidget( |
... | ... | @@ -522,8 +522,6 @@ class _TopicPicturePage extends StatelessWidget { |
522 | 522 | 70.verticalSpace, |
523 | 523 | RecorderWidget( |
524 | 524 | isPlaying: bloc.isRecording, |
525 | - isClickable: | |
526 | - bloc.voicePlayState != VoicePlayState.playing, | |
527 | 525 | width: 72.w, |
528 | 526 | height: 72.w, |
529 | 527 | onTap: () { | ... | ... |
lib/utils/audio_player_util.dart