diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index a4f2d75..fb71a6b 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -1,5 +1,7 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index c7b8709..690876b 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -28,7 +28,36 @@ class _FeedProgressBar extends StatefulWidget { } class _FeedProgressBarState extends State<_FeedProgressBar> { + static const _bufferDebounce = Duration(milliseconds: 350); + double? _preview; + Timer? _bufferTimer; + bool _buffering = false; + + @override + void initState() { + super.initState(); + _attachSource(); + } + + @override + void didUpdateWidget(covariant _FeedProgressBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _detachSource(oldWidget.controller); + _bufferTimer?.cancel(); + _bufferTimer = null; + _buffering = false; + _attachSource(); + } + } + + @override + void dispose() { + _detachSource(widget.controller); + _bufferTimer?.cancel(); + super.dispose(); + } @override Widget build(BuildContext context) { @@ -36,12 +65,16 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { builder: (context, constraints) { final width = constraints.maxWidth; final source = widget.controller; + final scrubbing = _preview != null; + final loading = + source == null || + (!scrubbing && (!source.value.isInitialized || _buffering)); final track = source == null ? _loadingTrack() : ValueListenableBuilder( valueListenable: source, builder: (context, value, _) { - if (!value.isInitialized || value.isBuffering) { + if (!scrubbing && (!value.isInitialized || _buffering)) { return _loadingTrack(); } final total = value.duration.inMilliseconds; @@ -56,20 +89,80 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { return GestureDetector( key: const Key('home-feed-progress'), behavior: HitTestBehavior.opaque, - onHorizontalDragStart: (details) { - widget.onScrubStart?.call(); - _update(details.localPosition.dx / width); - }, - onHorizontalDragUpdate: (details) => - _update(details.localPosition.dx / width), - onHorizontalDragEnd: (_) => _commit(_preview), - onHorizontalDragCancel: () => setState(() => _preview = null), + onHorizontalDragStart: loading + ? null + : (details) { + widget.onScrubStart?.call(); + _update(details.localPosition.dx / width); + }, + onHorizontalDragUpdate: loading + ? null + : (details) => _update(details.localPosition.dx / width), + onHorizontalDragEnd: loading ? null : (_) => _commit(_preview), + onHorizontalDragCancel: loading + ? null + : () => setState(() => _preview = null), child: track, ); }, ); } + void _attachSource() { + widget.controller?.addListener(_handleSourceChanged); + _syncBufferState(notify: false); + } + + void _detachSource(ValueListenable? source) { + source?.removeListener(_handleSourceChanged); + } + + void _handleSourceChanged() => _syncBufferState(); + + void _syncBufferState({bool notify = true}) { + final source = widget.controller; + if (source == null) { + _bufferTimer?.cancel(); + _bufferTimer = null; + _setBuffering(false, notify: notify); + return; + } + final value = source.value; + if (!value.isInitialized) { + _bufferTimer?.cancel(); + _bufferTimer = null; + _setBuffering(true, notify: notify); + return; + } + if (!value.isBuffering) { + _bufferTimer?.cancel(); + _bufferTimer = null; + _setBuffering(false, notify: notify); + return; + } + if (_buffering || _bufferTimer != null) return; + _bufferTimer = Timer(_bufferDebounce, () { + _bufferTimer = null; + final current = widget.controller; + if (!mounted || + current != source || + !current!.value.isInitialized || + !current.value.isBuffering) { + return; + } + _setBuffering(true); + }); + } + + void _setBuffering(bool buffering, {bool notify = true}) { + if (_buffering == buffering) return; + if (!notify || !mounted) { + _buffering = buffering; + return; + } + setState(() => _buffering = buffering); + } + Widget _loadingTrack() { final bottom = _lineCenterFromBottom - widget.height / 2; return SizedBox( diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index fa7e8bd..a4fcbb0 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -44,22 +44,94 @@ class SeekProgressBar extends StatefulWidget { } class _SeekProgressBarState extends State { + static const _bufferDebounce = Duration(milliseconds: 350); + final _previewFraction = ValueNotifier(null); + Timer? _bufferTimer; + bool _buffering = false; + + @override + void initState() { + super.initState(); + _attachSource(); + } @override void didUpdateWidget(covariant SeekProgressBar oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.valueListenable != widget.valueListenable) { + _detachSource(oldWidget.valueListenable); _previewFraction.value = null; + _bufferTimer?.cancel(); + _bufferTimer = null; + _buffering = false; + _attachSource(); } } @override void dispose() { + _detachSource(widget.valueListenable); + _bufferTimer?.cancel(); _previewFraction.dispose(); super.dispose(); } + void _attachSource() { + widget.valueListenable?.addListener(_handleSourceChanged); + _syncBufferState(notify: false); + } + + void _detachSource(ValueListenable? source) { + source?.removeListener(_handleSourceChanged); + } + + void _handleSourceChanged() => _syncBufferState(); + + void _syncBufferState({bool notify = true}) { + final source = widget.valueListenable; + if (source == null) { + _bufferTimer?.cancel(); + _bufferTimer = null; + _setBuffering(false, notify: notify); + return; + } + final value = source.value; + if (!value.isInitialized) { + _bufferTimer?.cancel(); + _bufferTimer = null; + _setBuffering(true, notify: notify); + return; + } + if (!value.isBuffering) { + _bufferTimer?.cancel(); + _bufferTimer = null; + _setBuffering(false, notify: notify); + return; + } + if (_buffering || _bufferTimer != null) return; + _bufferTimer = Timer(_bufferDebounce, () { + _bufferTimer = null; + final current = widget.valueListenable; + if (!mounted || + current != source || + !current!.value.isInitialized || + !current.value.isBuffering) { + return; + } + _setBuffering(true); + }); + } + + void _setBuffering(bool buffering, {bool notify = true}) { + if (_buffering == buffering) return; + if (!notify || !mounted) { + _buffering = buffering; + return; + } + setState(() => _buffering = buffering); + } + Future _finish(double fraction, Duration duration) async { final source = widget.valueListenable; _previewFraction.value = fraction; @@ -93,7 +165,8 @@ class _SeekProgressBarState extends State { Widget _buildTrack(BuildContext context, VideoPlayerValue video) { final total = video.duration.inMilliseconds; - final loading = !video.isInitialized || video.isBuffering; + final scrubbing = _previewFraction.value != null; + final loading = !scrubbing && (!video.isInitialized || _buffering); final enabled = video.isInitialized && total > 0 && widget.onSeek != null; final played = total > 0 ? (video.position.inMilliseconds / total).clamp(0.0, 1.0) @@ -102,7 +175,6 @@ class _SeekProgressBarState extends State { final previewPosition = _previewFraction.value == null ? video.position : seekPositionForFraction(fraction, video.duration); - final scrubbing = !loading && _previewFraction.value != null; final trackHeight = widget.compact ? (scrubbing ? 3.0 : 1.5) : (scrubbing ? 4.0 : 3.0); diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 704dc0d..5059184 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -291,6 +291,111 @@ void main() { expect(opened, 1); }); + testWidgets('should_debounce_feed_loading_and_keep_track_while_scrubbing', ( + tester, + ) async { + final controller = ValueNotifier( + const VideoPlayerValue( + duration: Duration(seconds: 100), + position: Duration(seconds: 10), + isInitialized: true, + ), + ); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + 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: 'debounce-drama', + title: 'Debounce Drama', + synopsis: 'synopsis', + posterUrl: '', + totalEpisodes: 8, + ), + episode: const Episode( + id: 'debounce-episode', + number: 1, + title: 'Episode 1', + isFree: true, + ), + controller: controller, + liked: false, + saved: false, + visible: true, + boosting: false, + onTogglePlay: () {}, + onBoostStart: () {}, + onBoostEnd: () {}, + onLike: () {}, + onSave: () {}, + onComments: () {}, + onShare: () {}, + onDetail: () {}, + onOpenFullPlayer: () {}, + onMore: () {}, + onSeek: (_) {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + controller.value = const VideoPlayerValue( + duration: Duration(seconds: 100), + position: Duration(seconds: 10), + isInitialized: true, + isBuffering: true, + ); + await tester.pump(const Duration(milliseconds: 200)); + expect(find.byKey(const Key('home-feed-progress-thumb')), findsOneWidget); + expect(find.byKey(const Key('progress-loading')), findsNothing); + + await tester.pump(const Duration(milliseconds: 200)); + expect(find.byKey(const Key('home-feed-progress-thumb')), findsNothing); + expect(find.byKey(const Key('progress-loading')), findsOneWidget); + + controller.value = const VideoPlayerValue( + duration: Duration(seconds: 100), + position: Duration(seconds: 10), + isInitialized: true, + ); + await tester.pump(); + expect(find.byKey(const Key('home-feed-progress-thumb')), findsOneWidget); + + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(const Key('home-feed-progress'))), + ); + await gesture.moveBy(const Offset(60, 0)); + await tester.pump(); + controller.value = const VideoPlayerValue( + duration: Duration(seconds: 100), + position: Duration(seconds: 10), + isInitialized: true, + isBuffering: true, + ); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byKey(const Key('home-feed-progress-thumb')), findsOneWidget); + expect(find.byKey(const Key('progress-loading')), findsNothing); + + controller.value = const VideoPlayerValue( + duration: Duration(seconds: 100), + position: Duration(seconds: 10), + isInitialized: true, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + }); + testWidgets('should_show_refresh_spinner_until_refresh_finishes', ( tester, ) async { diff --git a/test/playback/seek_progress_bar_test.dart b/test/playback/seek_progress_bar_test.dart index eb8524f..860423f 100644 --- a/test/playback/seek_progress_bar_test.dart +++ b/test/playback/seek_progress_bar_test.dart @@ -34,7 +34,9 @@ void main() { expect(find.byType(Slider), findsNothing); }); - testWidgets('should_switch_between_loading_track_and_slider', (tester) async { + testWidgets('should_debounce_buffering_before_showing_loading_track', ( + tester, + ) async { final video = ValueNotifier( const VideoPlayerValue( duration: Duration(seconds: 12), @@ -56,9 +58,17 @@ void main() { ), ); + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byType(Slider), findsOneWidget); + expect(find.textContaining('00:12'), findsNothing); + + await tester.pump(const Duration(milliseconds: 200)); + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byType(Slider), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 200)); expect(find.byKey(const Key('progress-loading')), findsOneWidget); expect(find.byType(Slider), findsNothing); - expect(find.textContaining('00:12'), findsNothing); video.value = const VideoPlayerValue( duration: Duration(seconds: 12), @@ -71,6 +81,61 @@ void main() { expect(find.byType(Slider), findsOneWidget); }); + testWidgets('should_keep_slider_while_scrubbing_during_buffering', ( + tester, + ) async { + final video = ValueNotifier( + const VideoPlayerValue( + duration: Duration(seconds: 12), + position: Duration(seconds: 3), + isInitialized: true, + ), + ); + addTearDown(video.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 300, + child: SeekProgressBar( + valueListenable: video, + semanticsLabel: 'Seek video', + onSeek: (_) async {}, + ), + ), + ), + ), + ), + ); + + final gesture = await tester.startGesture( + tester.getCenter(find.byType(Slider)), + ); + await gesture.moveBy(const Offset(60, 0)); + await tester.pump(); + + video.value = const VideoPlayerValue( + duration: Duration(seconds: 12), + position: Duration(seconds: 5), + isInitialized: true, + isBuffering: true, + ); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byType(Slider), findsOneWidget); + + video.value = const VideoPlayerValue( + duration: Duration(seconds: 12), + position: Duration(seconds: 5), + isInitialized: true, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + }); + testWidgets( 'should_preview_while_dragging_and_seek_on_release_when_slider_is_horizontal', (tester) async {