Skip to content

Commit 187fd4a

Browse files
committed
FEAT: 调整播放器横向手势语义
1 parent 843e3d7 commit 187fd4a

12 files changed

Lines changed: 447 additions & 71 deletions

TASKS.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
11
# Echo Loop 任务清单
22

3-
> 最后更新:2026-06-22(学习版通知提示收敛到逐句精听完成
3+
> 最后更新:2026-06-22(Free Player 禁止 tab swipe,逐句精听支持左右滑动切句
44
> 当前焦点:Android 结束录音闪退(离线 ASR / Silero VAD)——**仍未解决**
55
6+
## 已完成:Free Player 禁止 tab swipe,逐句精听支持左右滑动切句
7+
8+
此前 Free Player 的 `全文 / 收藏` 使用 `TabBarView` 默认横向切页,和产品想要的“横向手势优先用于学习态切句”语义冲突;逐句精听则只有底部上下句按钮,没有对应的左右滑动切句入口。现统一调整为:Free Player 只允许点击 tab 切换,逐句精听支持左右 swipe 切换句子。
9+
10+
- [x] `player_screen.dart``TabBarView` 显式禁用横向 swipe,仅保留点击 `TabBar` 切换。
11+
- [x] `intensive_listen_player_screen.dart`:在主体内容区新增横向手势,左滑下一句、右滑上一句,并补边界防越界处理。
12+
- [x] 测试:`player_screen_test.dart` 增加“横向滑动不会切 tab”回归;`intensive_listen_player_screen_test.dart` 增加左右滑动切句与首句防越界回归。
13+
14+
**完成时间**: 2026-06-22 22:09:26 +0800
15+
616
## 已完成:学习版通知提示收敛到逐句精听完成
717

818
此前“开启通知提醒”的学习版 pre-prompt 同时挂在逐句精听、跟读、盲听、难句补练、复述多个完成页上;任务顺序调整后,用户可能在错误的任务完成点看到提示。现收敛为只在逐句精听完成后直接弹出,其它任务完成页不再主动弹学习版通知提示。

lib/l10n/app_en.arb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1484,7 +1484,7 @@
14841484
"guideFavoritesSentencesReviewDescription": "Tap here to review every saved sentence at once.",
14851485
"guideFavoritesVocabularyListDescription": "Your saved words and phrases. Expand a card to see definitions and hear how they sound in the original sentences.",
14861486
"guideFavoritesFlashcardDescription": "Tap here to enter flashcard mode and review every saved word. Seeing the word and hearing it in context makes memory stick.",
1487-
"guideIntensiveListenCantUnderstandDescription": "Tap here when a sentence is hard to follow. It will be auto-marked as difficult and you'll enter single-sentence analysis mode.",
1487+
"guideIntensiveListenCantUnderstandDescription": "Tap here when a sentence is hard to follow. It will be auto-marked as difficult and you'll enter explanation mode.",
14881488
"guideIntensiveListenAnnotationContinueDescription": "Once you understand this sentence, tap Continue to move on to the next one.",
14891489
"guideIntensiveListenAnnotationPlayDescription": "Tap the play button to play the current sentence.",
14901490
"guideSentenceTileNumberDescription": "Tap the number to play from this sentence.",

lib/l10n/app_localizations.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5045,7 +5045,7 @@ abstract class AppLocalizations {
50455045
/// No description provided for @guideIntensiveListenCantUnderstandDescription.
50465046
///
50475047
/// In en, this message translates to:
5048-
/// **'Tap here when a sentence is hard to follow. It will be auto-marked as difficult and you\'ll enter single-sentence analysis mode.'**
5048+
/// **'Tap here when a sentence is hard to follow. It will be auto-marked as difficult and you\'ll enter explanation mode.'**
50495049
String get guideIntensiveListenCantUnderstandDescription;
50505050

50515051
/// No description provided for @guideIntensiveListenAnnotationContinueDescription.

lib/l10n/app_localizations_en.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2844,7 +2844,7 @@ class AppLocalizationsEn extends AppLocalizations {
28442844

28452845
@override
28462846
String get guideIntensiveListenCantUnderstandDescription =>
2847-
'Tap here when a sentence is hard to follow. It will be auto-marked as difficult and you\'ll enter single-sentence analysis mode.';
2847+
'Tap here when a sentence is hard to follow. It will be auto-marked as difficult and you\'ll enter explanation mode.';
28482848

28492849
@override
28502850
String get guideIntensiveListenAnnotationContinueDescription =>

lib/l10n/app_localizations_zh.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2743,7 +2743,7 @@ class AppLocalizationsZh extends AppLocalizations {
27432743

27442744
@override
27452745
String get guideIntensiveListenCantUnderstandDescription =>
2746-
'遇到听不懂的句子时点这里,会自动标记为难句并进入单句解析模式。';
2746+
'遇到听不懂的句子时点这里,会自动标记为难句并进入讲解模式。';
27472747

27482748
@override
27492749
String get guideIntensiveListenAnnotationContinueDescription =>

lib/l10n/app_zh.arb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1100,7 +1100,7 @@
11001100
"guideFavoritesSentencesReviewDescription": "点击这里,一键复习所有收藏的句子。",
11011101
"guideFavoritesVocabularyListDescription": "这里是你收藏的单词和意群。展开后可查看释义,也可以收听它们在原句中的发音。",
11021102
"guideFavoritesFlashcardDescription": "点击这里进入闪卡模式,复习所有收藏的词汇。通过看单词、听原句,可以更高效地巩固记忆。",
1103-
"guideIntensiveListenCantUnderstandDescription": "遇到听不懂的句子时点这里,会自动标记为难句并进入单句解析模式。",
1103+
"guideIntensiveListenCantUnderstandDescription": "遇到听不懂的句子时点这里,会自动标记为难句并进入讲解模式。",
11041104
"guideIntensiveListenAnnotationContinueDescription": "搞懂这句后点「继续」进入下一句。",
11051105
"guideIntensiveListenAnnotationPlayDescription": "点击播放按钮,播放当前句。",
11061106
"guideSentenceTileNumberDescription": "点击编号从这句开始播放。",

lib/screens/intensive_listen_player_screen.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -887,6 +887,10 @@ bool _isIntensiveMainPlaybackActive(IntensiveListenState state) {
887887

888888
extension on _IntensiveListenPlayerScreenState {
889889
void _handlePrevious() {
890+
final playerState = ref.read(intensiveListenPlayerProvider);
891+
if (playerState.currentSentenceIndex <= 0) {
892+
return;
893+
}
890894
final player = ref.read(intensiveListenPlayerProvider.notifier);
891895
unawaited(player.goToPrevious());
892896
}

lib/screens/player_screen.dart

Lines changed: 195 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ import '../widgets/player_hotkey_scope.dart';
2626
import '../widgets/practice/annotation_content_view.dart';
2727
import 'sentence_detail_screen.dart';
2828

29+
const kPlayerSingleSentenceSwipeAreaKey = ValueKey(
30+
'player-single-sentence-swipe-area',
31+
);
32+
2933
class PlayerScreen extends ConsumerStatefulWidget {
3034
const PlayerScreen({super.key});
3135

@@ -35,6 +39,12 @@ class PlayerScreen extends ConsumerStatefulWidget {
3539

3640
class _PlayerScreenState extends ConsumerState<PlayerScreen>
3741
with SingleTickerProviderStateMixin {
42+
static const double _singleSentenceSwipeVelocityThreshold = 250;
43+
String? _lastSingleSentenceContentId;
44+
PlaylistMode? _lastSingleSentencePlaylistMode;
45+
int? _lastSingleSentenceIndex;
46+
int _singleSentenceTransitionDirection = 0;
47+
3848
late TabController _tabController;
3949
int _previousTabIndex = 0;
4050
Duration? _seekPreviewPosition;
@@ -213,6 +223,8 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen>
213223
Expanded(
214224
child: TabBarView(
215225
controller: _tabController,
226+
// Free Player 的横向手势保留给学习态切句,这里只允许点 tab 切换。
227+
physics: const NeverScrollableScrollPhysics(),
216228
children: [
217229
_buildFullTextTab(playerState, controller),
218230
_buildBookmarkedTab(playerState, controller),
@@ -398,78 +410,187 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen>
398410
if (audioItem == null) {
399411
return const SizedBox.shrink();
400412
}
413+
final contentId = '${playerState.playlistMode.name}-$index';
414+
if (_lastSingleSentenceContentId != contentId) {
415+
if (_lastSingleSentencePlaylistMode == playerState.playlistMode &&
416+
_lastSingleSentenceIndex != null) {
417+
final previousIndex = _lastSingleSentenceIndex!;
418+
_singleSentenceTransitionDirection = index > previousIndex
419+
? 1
420+
: index < previousIndex
421+
? -1
422+
: 0;
423+
} else {
424+
_singleSentenceTransitionDirection = 0;
425+
}
426+
_lastSingleSentenceContentId = contentId;
427+
_lastSingleSentencePlaylistMode = playerState.playlistMode;
428+
_lastSingleSentenceIndex = index;
429+
}
401430

402-
return Padding(
403-
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.l),
404-
child: Column(
405-
crossAxisAlignment: CrossAxisAlignment.stretch,
406-
children: [
407-
// 序号 + 时间区间(弱化辅助信息)
408-
Padding(
409-
padding: const EdgeInsets.only(
410-
top: AppSpacing.m,
411-
bottom: AppSpacing.s,
431+
return GestureDetector(
432+
key: kPlayerSingleSentenceSwipeAreaKey,
433+
behavior: HitTestBehavior.translucent,
434+
onHorizontalDragEnd: (details) =>
435+
_handleSingleSentenceSwipe(details, playerState, controller),
436+
child: Padding(
437+
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.l),
438+
child: Column(
439+
crossAxisAlignment: CrossAxisAlignment.stretch,
440+
children: [
441+
// 序号 + 时间区间(弱化辅助信息)
442+
Padding(
443+
padding: const EdgeInsets.only(
444+
top: AppSpacing.m,
445+
bottom: AppSpacing.s,
446+
),
447+
child: Row(
448+
children: [
449+
Text(
450+
'#${currentSentence.index + 1}',
451+
style: AppTextStyles.caption(context),
452+
),
453+
const SizedBox(width: AppSpacing.l),
454+
Text(
455+
'${SubtitleParser.formatDuration(currentSentence.startTime)} - ${SubtitleParser.formatDuration(currentSentence.endTime)}',
456+
style: AppTextStyles.caption(context),
457+
),
458+
],
459+
),
412460
),
413-
child: Row(
414-
children: [
415-
Text(
416-
'#${currentSentence.index + 1}',
417-
style: AppTextStyles.caption(context),
418-
),
419-
const SizedBox(width: AppSpacing.l),
420-
Text(
421-
'${SubtitleParser.formatDuration(currentSentence.startTime)} - ${SubtitleParser.formatDuration(currentSentence.endTime)}',
422-
style: AppTextStyles.caption(context),
423-
),
424-
],
461+
// 难句标记行(复用精听)—— 不被遮蔽,盲听时仍可标记
462+
BookmarkToggleRow(
463+
isDifficult: isBookmarked,
464+
onTap: () => controller.toggleBookmark(currentSentence.index),
425465
),
426-
),
427-
// 难句标记行(复用精听)—— 不被遮蔽,盲听时仍可标记
428-
BookmarkToggleRow(
429-
isDifficult: isBookmarked,
430-
onTap: () => controller.toggleBookmark(currentSentence.index),
431-
),
432-
const SizedBox(height: AppSpacing.m),
433-
// 精听解析内容 + 隐藏字幕遮罩
434-
Expanded(
435-
child: Stack(
436-
children: [
437-
AnnotationContentView(
438-
// 切句时重建,确保 AnnotationContentView 内部意群等状态重置
439-
key: ValueKey(currentSentence.index),
440-
text: currentSentence.text,
441-
aiNotifier: ref.read(sentenceAiNotifierProvider),
442-
audioItemId: audioItem.id,
443-
sentenceIndex: currentSentence.index,
444-
sentenceStartMs: currentSentence.startTime.inMilliseconds,
445-
sentenceEndMs: currentSentence.endTime.inMilliseconds,
446-
// 意群试听与主播放共用引擎,播放前先暂停主播放
447-
onStopMainPlayer: () => controller.pause(),
466+
const SizedBox(height: AppSpacing.m),
467+
// 精听解析内容 + 隐藏字幕遮罩。切句时做轻量滑动过渡,降低跳变感。
468+
Expanded(
469+
child: AnimatedSwitcher(
470+
duration: const Duration(milliseconds: 220),
471+
switchInCurve: Curves.easeOutCubic,
472+
switchOutCurve: Curves.easeOutCubic,
473+
layoutBuilder: (currentChild, previousChildren) => Stack(
474+
fit: StackFit.expand,
475+
children: [
476+
...previousChildren,
477+
if (currentChild != null) currentChild,
478+
],
448479
),
449-
// 隐藏字幕遮罩:覆盖整个内容区(含工具栏),模糊且不可点击
450-
if (!settings.showTranscript)
451-
Positioned.fill(
452-
child: IgnorePointer(
453-
child: ClipRRect(
454-
child: BackdropFilter(
455-
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
456-
child: Container(
457-
color: Theme.of(
458-
context,
459-
).colorScheme.onSurface.withValues(alpha: 0.05),
480+
transitionBuilder: (child, animation) {
481+
final page = child is _SingleSentenceAnimatedPage
482+
? child
483+
: _SingleSentenceAnimatedPage(
484+
direction: 0,
485+
child: child,
486+
);
487+
final beginOffset = switch (page.direction) {
488+
1 => const Offset(0.1, 0),
489+
-1 => const Offset(-0.1, 0),
490+
_ => const Offset(0, 0),
491+
};
492+
return FadeTransition(
493+
opacity: animation,
494+
child: SlideTransition(
495+
position:
496+
Tween<Offset>(
497+
begin: beginOffset,
498+
end: Offset.zero,
499+
).animate(
500+
CurvedAnimation(
501+
parent: animation,
502+
curve: Curves.easeOutCubic,
503+
),
460504
),
461-
),
462-
),
505+
child: page.child,
463506
),
507+
);
508+
},
509+
child: _SingleSentenceAnimatedPage(
510+
key: ValueKey(contentId),
511+
direction: _singleSentenceTransitionDirection,
512+
child: Stack(
513+
children: [
514+
AnnotationContentView(
515+
// 切句时重建,确保 AnnotationContentView 内部意群等状态重置
516+
key: ValueKey(currentSentence.index),
517+
text: currentSentence.text,
518+
aiNotifier: ref.read(sentenceAiNotifierProvider),
519+
audioItemId: audioItem.id,
520+
sentenceIndex: currentSentence.index,
521+
sentenceStartMs: currentSentence.startTime.inMilliseconds,
522+
sentenceEndMs: currentSentence.endTime.inMilliseconds,
523+
// 意群试听与主播放共用引擎,播放前先暂停主播放
524+
onStopMainPlayer: () => controller.pause(),
525+
),
526+
// 隐藏字幕遮罩:覆盖整个内容区(含工具栏),模糊且不可点击
527+
if (!settings.showTranscript)
528+
Positioned.fill(
529+
child: IgnorePointer(
530+
child: ClipRRect(
531+
child: BackdropFilter(
532+
filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5),
533+
child: Container(
534+
color: Theme.of(context).colorScheme.onSurface
535+
.withValues(alpha: 0.05),
536+
),
537+
),
538+
),
539+
),
540+
),
541+
],
464542
),
465-
],
543+
),
544+
),
466545
),
467-
),
468-
],
546+
],
547+
),
469548
),
470549
);
471550
}
472551

552+
void _handleSingleSentenceSwipe(
553+
DragEndDetails details,
554+
ListeningPracticeState playerState,
555+
ListeningPractice controller,
556+
) {
557+
final velocity = details.primaryVelocity ?? 0;
558+
if (velocity.abs() < _singleSentenceSwipeVelocityThreshold) {
559+
return;
560+
}
561+
if (velocity < 0) {
562+
if (playerState.playlistMode == PlaylistMode.bookmarks) {
563+
final currentIndex = playerState.currentBookmarkIndex;
564+
final bookmarked = playerState.bookmarkedSentences;
565+
final currentPos = currentIndex == null
566+
? -1
567+
: bookmarked.indexWhere((s) => s.index == currentIndex);
568+
if (currentPos >= bookmarked.length - 1) {
569+
return;
570+
}
571+
} else if ((playerState.currentFullIndex ?? 0) >=
572+
playerState.sentences.length - 1) {
573+
return;
574+
}
575+
unawaited(controller.nextSentence());
576+
return;
577+
}
578+
579+
if (playerState.playlistMode == PlaylistMode.bookmarks) {
580+
final currentIndex = playerState.currentBookmarkIndex;
581+
final bookmarked = playerState.bookmarkedSentences;
582+
final currentPos = currentIndex == null
583+
? -1
584+
: bookmarked.indexWhere((s) => s.index == currentIndex);
585+
if (currentPos <= 0) {
586+
return;
587+
}
588+
} else if ((playerState.currentFullIndex ?? 0) <= 0) {
589+
return;
590+
}
591+
unawaited(controller.previousSentence());
592+
}
593+
473594
/// 点击句子主体 → 暂停播放 → 进入句子讲解页
474595
///
475596
/// 与盲听任务行为一致:导航前停止音频,返回后同步收藏状态。
@@ -708,6 +829,20 @@ class _PlayerScreenState extends ConsumerState<PlayerScreen>
708829
}
709830
}
710831

832+
class _SingleSentenceAnimatedPage extends StatelessWidget {
833+
final int direction;
834+
final Widget child;
835+
836+
const _SingleSentenceAnimatedPage({
837+
super.key,
838+
required this.direction,
839+
required this.child,
840+
});
841+
842+
@override
843+
Widget build(BuildContext context) => child;
844+
}
845+
711846
class _HotkeyTipsCarousel extends StatefulWidget {
712847
final AppLocalizations l10n;
713848

0 commit comments

Comments
 (0)