diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 8682c22..3de180c 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -1187,3 +1187,9 @@ $ flutter test - 进度条固定中心并动画到 6 高,命中高度 32,时间气泡限制在左右 12;end/cancel/pointer 兜底均恢复拖动前状态。 - 修复 `scrubCancelRequest` 误置于 `initState` 的编译错误,并清理推荐页未使用变量;`flutter analyze` 无问题。 - 观看全集按钮相对进度条上移 8,间距调整为 20;按钮改为屏幕宽度 70% 左对齐,`flutter analyze` 无问题。 + +## 2026-10-22 进度条按下与拖动彻底修复 + +- Slider 不再在 `onChangeStart` 清屏;首次真实移动后才进入 scrub,轨道 2/6、thumb 6/8。 +- 推荐流拖动使用独立 opaque 手势与 pointer 兜底,end/cancel 同时恢复浮层和轨道。 +- `flutter test test/player/seek_scrub_visibility_test.dart`:tap、首次移动、松手恢复 3 项测试全部通过。 diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index 9723933..00a9023 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -30,6 +30,7 @@ class SeekProgressBar extends StatefulWidget { required this.valueListenable, required this.semanticsLabel, this.onScrubStart, + this.onScrubEnd, this.onSeek, this.showTimes = true, this.compact = false, @@ -45,6 +46,7 @@ class SeekProgressBar extends StatefulWidget { final ValueListenable? valueListenable; final String semanticsLabel; final VoidCallback? onScrubStart; + final VoidCallback? onScrubEnd; final Future Function(Duration)? onSeek; final bool showTimes; final bool compact; @@ -66,6 +68,10 @@ class _SeekProgressBarState extends State { final _previewFraction = ValueNotifier(null); Timer? _bufferTimer; bool _buffering = false; + bool _dragging = false; + bool _pointerMoved = false; + Offset? _pointerStart; + double? _dragStartFraction; @override void initState() { @@ -165,10 +171,14 @@ class _SeekProgressBarState extends State { } Future _finish(double fraction, Duration duration) async { - if (widget.locked) return; - _previewFraction.value = fraction; + if (widget.locked || !_dragging) return; + setState(() => _dragging = false); + _pointerMoved = false; + _pointerStart = null; + _dragStartFraction = null; widget.thumbnailService?.cancelPending(); _previewFraction.value = null; + widget.onScrubEnd?.call(); await widget.onSeek?.call(seekPositionForFraction(fraction, duration)); } @@ -193,7 +203,7 @@ class _SeekProgressBarState extends State { Widget _buildTrack(BuildContext context, VideoPlayerValue video) { final total = video.duration.inMilliseconds; - final scrubbing = _previewFraction.value != null; + final scrubbing = _dragging; final loading = !widget.locked && !scrubbing && _buffering; final enabled = !widget.locked && @@ -212,9 +222,7 @@ class _SeekProgressBarState extends State { widget.showTimes && widget.thumbnailService != null && (widget.episodeId?.isNotEmpty ?? false); - final trackHeight = widget.compact - ? (scrubbing ? 3.0 : 1.5) - : (scrubbing ? 4.0 : 3.0); + final trackHeight = scrubbing ? 6.0 : 2.0; final trackAreaHeight = loading ? (widget.compact ? 20.0 : 44.0) : widget.compact @@ -258,9 +266,7 @@ class _SeekProgressBarState extends State { inactiveTrackColor: widget.inactiveTrackColor, thumbColor: AppColors.onPrimary, thumbShape: RoundSliderThumbShape( - enabledThumbRadius: widget.compact - ? (scrubbing ? 6 : 4) - : (scrubbing ? 8 : 6), + enabledThumbRadius: scrubbing ? 8 : 6, ), overlayShape: RoundSliderOverlayShape( overlayRadius: widget.compact ? 12 : 18, @@ -268,23 +274,57 @@ class _SeekProgressBarState extends State { ), child: Semantics( label: widget.semanticsLabel, - child: Slider( - value: fraction, - // 轨道与上方文案共用同一层水平内边距,避免 Slider 默认内缩。 - padding: EdgeInsets.zero, - onChangeStart: enabled - ? (value) { - _previewFraction.value = value; - widget.onScrubStart?.call(); - } - : null, - onChanged: enabled - ? (value) => _previewFraction.value = value - : null, - onChangeEnd: enabled - ? (value) => - unawaited(_finish(value, video.duration)) - : null, + child: Listener( + onPointerDown: (event) { + _pointerStart = event.position; + _pointerMoved = false; + }, + onPointerMove: (event) { + final start = _pointerStart; + if (start != null && + (event.position - start).distance > 0) { + _pointerMoved = true; + } + }, + onPointerUp: (_) { + if (!_dragging) { + _pointerMoved = false; + _pointerStart = null; + _dragStartFraction = null; + } + }, + onPointerCancel: (_) { + if (!_dragging) { + _pointerMoved = false; + _pointerStart = null; + _dragStartFraction = null; + } + }, + child: Slider( + value: fraction, + // 轨道与上方文案共用同一层水平内边距,避免 Slider 默认内缩。 + padding: EdgeInsets.zero, + onChangeStart: enabled + ? (value) { + _dragStartFraction = value; + _pointerMoved = false; + } + : null, + onChanged: enabled + ? (value) { + if (!_pointerMoved) return; + if (!_dragging) { + setState(() => _dragging = true); + widget.onScrubStart?.call(); + } + _previewFraction.value = value; + } + : null, + onChangeEnd: enabled + ? (value) => + unawaited(_finish(value, video.duration)) + : null, + ), ), ), ), diff --git a/test/player/seek_scrub_visibility_test.dart b/test/player/seek_scrub_visibility_test.dart new file mode 100644 index 0000000..8117f07 --- /dev/null +++ b/test/player/seek_scrub_visibility_test.dart @@ -0,0 +1,149 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart'; +import 'package:video_player/video_player.dart'; + +void main() { + testWidgets('tapping the progress bar does not hide the overlay', ( + tester, + ) async { + final harness = await _pumpHarness(tester); + + await tester.tap(find.byType(Slider)); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('scrub-overlay')), findsOneWidget); + expect(harness.scrubStarts, 0); + expect(harness.seeks, 0); + expect(_sliderTheme(tester).trackHeight, 2); + }); + + testWidgets('horizontal drag hides the overlay and thickens the track', ( + tester, + ) async { + final harness = await _pumpHarness(tester); + final slider = find.byType(Slider); + final gesture = await tester.startGesture(tester.getCenter(slider)); + + await gesture.moveBy(const Offset(24, 0)); + await tester.pump(); + expect(find.byKey(const Key('scrub-overlay')), findsNothing); + expect(harness.scrubStarts, 1); + expect(_sliderTheme(tester).trackHeight, 6); + expect( + (_sliderTheme(tester).thumbShape! as RoundSliderThumbShape) + .enabledThumbRadius, + 8, + ); + + await gesture.moveBy(const Offset(72, 0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('releasing restores the overlay and idle track height', ( + tester, + ) async { + final harness = await _pumpHarness(tester); + final gesture = await tester.startGesture( + tester.getCenter(find.byType(Slider)), + ); + await gesture.moveBy(const Offset(32, 0)); + await tester.pump(); + await gesture.moveBy(const Offset(64, 0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('scrub-overlay')), findsOneWidget); + expect(harness.scrubEnds, 1); + expect(harness.seeks, 1); + expect(_sliderTheme(tester).trackHeight, 2); + expect( + (_sliderTheme(tester).thumbShape! as RoundSliderThumbShape) + .enabledThumbRadius, + 6, + ); + }); +} + +Future<_HarnessState> _pumpHarness(WidgetTester tester) async { + final controller = ValueNotifier( + const VideoPlayerValue( + duration: Duration(minutes: 2), + position: Duration(seconds: 20), + isInitialized: true, + ), + ); + addTearDown(controller.dispose); + final key = GlobalKey<_HarnessState>(); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: _Harness(key: key, controller: controller), + ), + ), + ); + await tester.pumpAndSettle(); + return key.currentState!; +} + +SliderThemeData _sliderTheme(WidgetTester tester) { + return tester + .widget( + find + .ancestor( + of: find.byType(Slider), + matching: find.byType(SliderTheme), + ) + .first, + ) + .data; +} + +class _Harness extends StatefulWidget { + const _Harness({super.key, required this.controller}); + + final ValueNotifier controller; + + @override + State<_Harness> createState() => _HarnessState(); +} + +class _HarnessState extends State<_Harness> { + bool visible = true; + int scrubStarts = 0; + int scrubEnds = 0; + int seeks = 0; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + if (visible) const Text('Overlay', key: Key('scrub-overlay')), + SizedBox( + width: 320, + height: 80, + child: SeekProgressBar( + valueListenable: widget.controller, + semanticsLabel: 'Progress', + onScrubStart: () => setState(() { + visible = false; + scrubStarts++; + }), + onScrubEnd: () => setState(() { + visible = true; + scrubEnds++; + }), + onSeek: (_) async { + seeks++; + }, + ), + ), + ], + ); + } +}