diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 3de180c..049c0a1 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1192,4 +1192,5 @@ $ flutter test - Slider 不再在 `onChangeStart` 清屏;首次真实移动后才进入 scrub,轨道 2/6、thumb 6/8。 - 推荐流拖动使用独立 opaque 手势与 pointer 兜底,end/cancel 同时恢复浮层和轨道。 -- `flutter test test/player/seek_scrub_visibility_test.dart`:tap、首次移动、松手恢复 3 项测试全部通过。 +- 推荐浮层改为 AnimatedOpacity + IgnorePointer,进度条节点始终留在树中;scrub setState 均加 mounted 守卫。 +- `flutter test test/player/seek_scrub_visibility_test.dart`:6 项测试全部通过。 diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 1628224..3c2b3ef 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -304,11 +304,13 @@ class _PlayerPageState extends State } void _setScrubbing(bool value) { + if (!mounted) return; if (_scrubbing == value) return; setState(() => _scrubbing = value); } void _finishFeedScrub(bool wasVisible) { + if (!mounted) return; setState(() { _scrubbing = false; _chromeVisible = wasVisible; @@ -322,6 +324,7 @@ class _PlayerPageState extends State } void _restoreFeedChrome() { + if (!mounted) return; setState(() { _scrubbing = false; _chromeVisible = true; diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index bd0433b..f3ed2e1 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -25,6 +25,7 @@ const double _railRight = 8; const double _railGap = 12; const double _bottomInfoInset = 32; const double _railBottomOffset = 60; +const Duration _overlayFadeDuration = Duration(milliseconds: 120); const List _boostTextShadows = [ Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)), ]; @@ -137,25 +138,46 @@ class _HomeFeedOverlayState extends State { ), ), ), - if (widget.boosting) - Positioned(left: 0, right: 0, bottom: 20, child: _boostHint()) - else if (widget.visible && !_scrubbing) - Positioned( - left: _pageHorizontal, - right: _railRight, - bottom: _bottomInfoInset, - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded(child: _bottomInfo()), - const SizedBox(width: _railGap), - Padding( - padding: const EdgeInsets.only(bottom: _railBottomOffset), - child: _rail(), - ), - ], + Positioned( + left: _pageHorizontal, + right: _railRight, + bottom: _bottomInfoInset, + child: AnimatedOpacity( + key: const Key('home-feed-content-opacity'), + duration: _overlayFadeDuration, + opacity: widget.visible && !_scrubbing && !widget.boosting + ? 1 + : 0, + child: IgnorePointer( + key: const Key('home-feed-content-ignore'), + ignoring: !widget.visible || _scrubbing || widget.boosting, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded(child: _bottomInfo()), + const SizedBox(width: _railGap), + Padding( + padding: const EdgeInsets.only(bottom: _railBottomOffset), + child: _rail(), + ), + ], + ), ), ), + ), + Positioned( + left: 0, + right: 0, + bottom: 20, + child: AnimatedOpacity( + duration: _overlayFadeDuration, + opacity: widget.boosting ? 1 : 0, + child: IgnorePointer( + ignoring: !widget.boosting, + child: _boostHint(), + ), + ), + ), Positioned( left: 0, right: 0, @@ -177,6 +199,7 @@ class _HomeFeedOverlayState extends State { } void _startScrub() { + if (!mounted) return; _wasVisibleBeforeScrub = widget.visible; if (!_scrubbing) setState(() => _scrubbing = true); widget.onScrubVisibilityChanged?.call(true); @@ -184,6 +207,7 @@ class _HomeFeedOverlayState extends State { } void _endScrub() { + if (!mounted) return; final wasVisible = _wasVisibleBeforeScrub; if (_scrubbing) setState(() => _scrubbing = false); widget.onScrubVisibilityChanged?.call(false); diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index 6bf6483..bd377df 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -256,7 +256,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { } void _startScrub(double fraction) { - if (_dragging) return; + if (!mounted || _dragging) return; setState(() { _dragging = true; _preview = fraction.clamp(0.0, 1.0).toDouble(); @@ -265,12 +265,12 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { } void _update(double fraction) { - if (!_dragging) return; + if (!mounted || !_dragging) return; setState(() => _preview = fraction.clamp(0.0, 1.0).toDouble()); } void _finishScrub() { - if (!_dragging) return; + if (!mounted || !_dragging) return; final fraction = _preview; setState(() { _dragging = false; diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 600bec6..b0af80e 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -723,8 +723,24 @@ void main() { await gesture.moveBy(const Offset(120, 0)); await tester.pump(); expect(scrubVisibility.last, isTrue); - expect(find.byKey(const Key('home-feed-title')), findsNothing); - expect(find.byKey(const Key('rail-save')), findsNothing); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect(find.byKey(const Key('rail-save')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('home-feed-content-opacity')), + ) + .opacity, + 0, + ); + expect( + tester + .widget( + find.byKey(const Key('home-feed-content-ignore')), + ) + .ignoring, + isTrue, + ); expect(find.byKey(const Key('scrub-preview-time-bubble')), findsOneWidget); final bubbleRect = tester.getRect( find.byKey(const Key('scrub-preview-time-bubble')), @@ -806,6 +822,14 @@ void main() { await tester.pumpAndSettle(); expect(scrubVisibility.last, isFalse); expect(find.byKey(const Key('home-feed-title')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('home-feed-content-opacity')), + ) + .opacity, + 1, + ); expect(seeked, isNotNull); expect(seeked!.inSeconds, greaterThan(0)); expect( diff --git a/test/player/seek_scrub_visibility_test.dart b/test/player/seek_scrub_visibility_test.dart index 8117f07..0497128 100644 --- a/test/player/seek_scrub_visibility_test.dart +++ b/test/player/seek_scrub_visibility_test.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart'; import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart'; import 'package:video_player/video_player.dart'; @@ -68,6 +71,86 @@ void main() { 6, ); }); + + testWidgets('feed progress tap does not clear overlay content', ( + tester, + ) async { + await _pumpFeedOverlay(tester); + + await tester.tap(find.byKey(const Key('home-feed-progress'))); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byKey(const Key('home-feed-progress')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('home-feed-content-opacity')), + ) + .opacity, + 1, + ); + }); + + testWidgets('feed progress drag then immediate release restores content', ( + tester, + ) async { + await _pumpFeedOverlay(tester); + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(const Key('home-feed-progress'))), + ); + + await gesture.moveBy(const Offset(80, 0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byKey(const Key('home-feed-progress')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('home-feed-content-opacity')), + ) + .opacity, + 1, + ); + expect( + tester.getSize(find.byKey(const Key('home-feed-progress-track'))).height, + 2, + ); + }); + + testWidgets('feed progress remains mounted and thick while dragging', ( + tester, + ) async { + await _pumpFeedOverlay(tester); + final progress = find.byKey(const Key('home-feed-progress')); + final gesture = await tester.startGesture(tester.getCenter(progress)); + + await gesture.moveBy(const Offset(80, 0)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 130)); + + expect(tester.takeException(), isNull); + expect(progress, findsOneWidget); + expect(find.byKey(const Key('scrub-preview-time-bubble')), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('home-feed-content-opacity')), + ) + .opacity, + 0, + ); + expect( + tester.getSize(find.byKey(const Key('home-feed-progress-track'))).height, + 6, + ); + + await gesture.up(); + await tester.pumpAndSettle(); + }); } Future<_HarnessState> _pumpHarness(WidgetTester tester) async { @@ -104,6 +187,64 @@ SliderThemeData _sliderTheme(WidgetTester tester) { .data; } +Future> _pumpFeedOverlay( + WidgetTester tester, +) async { + final controller = ValueNotifier( + const VideoPlayerValue( + duration: Duration(minutes: 2), + position: Duration(seconds: 20), + isInitialized: true, + ), + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Builder( + builder: (context) => Scaffold( + body: HomeFeedOverlay( + l10n: AppLocalizations.of(context)!, + drama: const Drama( + id: 'scrub-drama', + title: 'Scrub Drama', + synopsis: 'A short synopsis.', + posterUrl: '', + totalEpisodes: 8, + ), + episode: const Episode( + id: 'scrub-episode', + number: 1, + title: 'Scrub Episode', + isFree: true, + ), + controller: controller, + locked: false, + liked: false, + saved: false, + visible: true, + boosting: false, + onLike: () {}, + onSave: () {}, + onComments: () {}, + onShare: () {}, + onDetail: () {}, + onOpenFullPlayer: () {}, + onTogglePlay: () {}, + onBoostStart: () {}, + onBoostEnd: () {}, + onMore: () {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + return controller; +} + class _Harness extends StatefulWidget { const _Harness({super.key, required this.controller});