@@ -26,6 +26,10 @@ import '../widgets/player_hotkey_scope.dart';
2626import '../widgets/practice/annotation_content_view.dart' ;
2727import 'sentence_detail_screen.dart' ;
2828
29+ const kPlayerSingleSentenceSwipeAreaKey = ValueKey (
30+ 'player-single-sentence-swipe-area' ,
31+ );
32+
2933class PlayerScreen extends ConsumerStatefulWidget {
3034 const PlayerScreen ({super .key});
3135
@@ -35,6 +39,12 @@ class PlayerScreen extends ConsumerStatefulWidget {
3539
3640class _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+
711846class _HotkeyTipsCarousel extends StatefulWidget {
712847 final AppLocalizations l10n;
713848
0 commit comments