diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index c825021..b3c2fd3 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -1161,6 +1161,7 @@ class _PlayerPageState extends State : null, thumbnailService: _thumbnailService, spriteAxisService: _spriteAxisService, + suppressBufferLoading: !widget.embedded && !_loadingRemoved, ) else if (!widget.homeMode) _standardPlaybackOverlay(context, index, interactions), @@ -1284,6 +1285,7 @@ class _PlayerPageState extends State onBoostEnd: _endBoost, ), controlsVisible: _chromeVisible, + suppressProgressLoading: !widget.embedded && !_loadingRemoved, ); } diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index 15411ee..fdd4aac 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -69,6 +69,7 @@ class HomeFeedOverlay extends StatefulWidget { this.cancelScrubRequest = 0, this.thumbnailService, this.spriteAxisService, + this.suppressBufferLoading = false, }); final AppLocalizations l10n; @@ -104,6 +105,7 @@ class HomeFeedOverlay extends StatefulWidget { final int cancelScrubRequest; final PlaybackThumbnailService? thumbnailService; final SpriteThumbnailAxisService? spriteAxisService; + final bool suppressBufferLoading; @override State createState() => _HomeFeedOverlayState(); @@ -195,6 +197,7 @@ class _HomeFeedOverlayState extends State { spriteAxisService: widget.spriteAxisService, thumbnailsUrl: widget.episode.thumbnailsUrl, episodeId: widget.episode.id, + suppressBufferLoading: widget.suppressBufferLoading, onScrubStart: _startScrub, onScrubEnd: _endScrub, onSeek: widget.onSeek, diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index 8934d39..3f534cc 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -23,6 +23,7 @@ class _FeedProgressBar extends StatefulWidget { this.spriteAxisService, this.thumbnailsUrl, this.episodeId, + this.suppressBufferLoading = false, this.onScrubStart, this.onScrubEnd, this.onSeek, @@ -37,6 +38,7 @@ class _FeedProgressBar extends StatefulWidget { final SpriteThumbnailAxisSource? spriteAxisService; final String? thumbnailsUrl; final String? episodeId; + final bool suppressBufferLoading; final VoidCallback? onScrubStart; final VoidCallback? onScrubEnd; final ValueChanged? onSeek; @@ -52,6 +54,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { Timer? _bufferTimer; bool _buffering = false; bool _dragging = false; + Duration? _lastPosition; @override void initState() { @@ -67,6 +70,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { _bufferTimer?.cancel(); _bufferTimer = null; _buffering = false; + _lastPosition = null; _attachSource(); } if (oldWidget.cancelScrubRequest != widget.cancelScrubRequest) { @@ -77,6 +81,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { _bufferTimer?.cancel(); _bufferTimer = null; _buffering = false; + _lastPosition = null; if (widget.locked) { _dragging = false; _preview = null; @@ -84,6 +89,13 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { _syncBufferState(notify: false); } } + if (oldWidget.suppressBufferLoading != widget.suppressBufferLoading) { + _bufferTimer?.cancel(); + _bufferTimer = null; + _buffering = false; + _lastPosition = null; + _syncBufferState(notify: false); + } } @override @@ -100,7 +112,11 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { final width = constraints.maxWidth; final source = widget.controller; final scrubbing = _dragging; - final loading = !widget.locked && !scrubbing && _buffering; + final loading = + !widget.locked && + !widget.suppressBufferLoading && + !scrubbing && + _buffering; final track = widget.locked ? _inactiveTrack() : source == null @@ -199,14 +215,24 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { void _syncBufferState({bool notify = true}) { final source = widget.controller; - if (widget.locked || source == null) { + if (widget.locked || widget.suppressBufferLoading || source == null) { _bufferTimer?.cancel(); _bufferTimer = null; + _lastPosition = null; _setBuffering(false, notify: notify); return; } final value = source.value; if (!value.isInitialized) { + _bufferTimer?.cancel(); + _bufferTimer = null; + _lastPosition = null; + _setBuffering(false, notify: notify); + return; + } + final previousPosition = _lastPosition; + _lastPosition = value.position; + if (previousPosition != null && value.position > previousPosition) { _bufferTimer?.cancel(); _bufferTimer = null; _setBuffering(false, notify: notify); @@ -218,14 +244,22 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { _setBuffering(false, notify: notify); return; } + if (previousPosition == null && + value.isPlaying && + value.position > Duration.zero) { + _setBuffering(false, notify: notify); + return; + } if (_buffering || _bufferTimer != null) return; + final bufferingPosition = value.position; _bufferTimer = Timer(_bufferDebounce, () { _bufferTimer = null; final current = widget.controller; if (!mounted || current != source || !current!.value.isInitialized || - !current.value.isBuffering) { + !current.value.isBuffering || + current.value.position != bufferingPosition) { return; } _setBuffering(true); diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 11ff157..561f486 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -37,6 +37,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { required this.actions, required this.controlsVisible, required this.scrubbing, + this.suppressProgressLoading = false, }); /// 展示数据。 @@ -50,6 +51,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { /// 正在拖动进度条。拖动期间清屏,只留进度条。 final bool scrubbing; + final bool suppressProgressLoading; @override Widget build(BuildContext context) { @@ -306,6 +308,7 @@ class PlayerPlaybackOverlay extends StatelessWidget { episodeId: data.episode.id, inactiveTrackColor: AppColors.inactiveTrack, fallbackDuration: Duration(seconds: data.episode.durationSec), + suppressBufferLoading: suppressProgressLoading, onScrubStart: actions.onScrubStart, onScrubEnd: actions.onScrubEnd, onSeek: actions.onSeek, diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index 7e566f9..82e8be9 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -43,6 +43,7 @@ class SeekProgressBar extends StatefulWidget { this.episodeId, this.thumbnailsUrl, this.fallbackDuration = Duration.zero, + this.suppressBufferLoading = false, }); final ValueListenable? valueListenable; @@ -59,6 +60,7 @@ class SeekProgressBar extends StatefulWidget { final String? episodeId; final String? thumbnailsUrl; final Duration fallbackDuration; + final bool suppressBufferLoading; @override State createState() => _SeekProgressBarState(); @@ -74,6 +76,7 @@ class _SeekProgressBarState extends State { bool _pointerMoved = false; Offset? _pointerStart; double? _dragStartFraction; + Duration? _lastPosition; @override void initState() { @@ -90,13 +93,22 @@ class _SeekProgressBarState extends State { _bufferTimer?.cancel(); _bufferTimer = null; _buffering = false; + _lastPosition = null; widget.thumbnailService?.cancelPending(); _attachSource(); } + if (oldWidget.suppressBufferLoading != widget.suppressBufferLoading) { + _bufferTimer?.cancel(); + _bufferTimer = null; + _lastPosition = null; + _buffering = false; + _syncBufferState(notify: false); + } if (oldWidget.locked != widget.locked) { _bufferTimer?.cancel(); _bufferTimer = null; _buffering = false; + _lastPosition = null; if (widget.locked) { widget.thumbnailService?.cancelPending(); _previewFraction.value = null; @@ -130,14 +142,24 @@ class _SeekProgressBarState extends State { void _syncBufferState({bool notify = true}) { final source = widget.valueListenable; - if (widget.locked || source == null) { + if (widget.locked || widget.suppressBufferLoading || source == null) { _bufferTimer?.cancel(); _bufferTimer = null; + _lastPosition = null; _setBuffering(false, notify: notify); return; } final value = source.value; if (!value.isInitialized) { + _bufferTimer?.cancel(); + _bufferTimer = null; + _lastPosition = null; + _setBuffering(false, notify: notify); + return; + } + final previousPosition = _lastPosition; + _lastPosition = value.position; + if (previousPosition != null && value.position > previousPosition) { _bufferTimer?.cancel(); _bufferTimer = null; _setBuffering(false, notify: notify); @@ -149,14 +171,24 @@ class _SeekProgressBarState extends State { _setBuffering(false, notify: notify); return; } + // 首次看到“已播放且有画面”的 buffering 状态时,以 position 为基线, + // 不抢先显示 loading;后续 position 停滞超过阈值才视为真实卡顿。 + if (previousPosition == null && + value.isPlaying && + value.position > Duration.zero) { + _setBuffering(false, notify: notify); + return; + } if (_buffering || _bufferTimer != null) return; + final bufferingPosition = value.position; _bufferTimer = Timer(_bufferDebounce, () { _bufferTimer = null; final current = widget.valueListenable; if (!mounted || current != source || !current!.value.isInitialized || - !current.value.isBuffering) { + !current.value.isBuffering || + current.value.position != bufferingPosition) { return; } _setBuffering(true); @@ -227,7 +259,11 @@ class _SeekProgressBarState extends State { : widget.fallbackDuration; final total = duration.inMilliseconds; final scrubbing = _dragging; - final loading = !widget.locked && !scrubbing && _buffering; + final loading = + !widget.locked && + !widget.suppressBufferLoading && + !scrubbing && + _buffering; final enabled = !widget.locked && video.isInitialized && @@ -243,6 +279,8 @@ class _SeekProgressBarState extends State { final showPreview = scrubbing && widget.showTimes && + video.isInitialized && + total > 0 && widget.thumbnailService != null && (widget.episodeId?.isNotEmpty ?? false); final trackHeight = scrubbing ? 6.0 : 2.0; @@ -253,21 +291,6 @@ class _SeekProgressBarState extends State { final track = Column( mainAxisSize: MainAxisSize.min, children: [ - if (widget.showTimes && !video.isInitialized) - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Text( - '00:00 / ${_format(duration)}', - key: const Key('player-progress-uninitialized-time'), - textAlign: TextAlign.center, - style: const TextStyle( - color: AppColors.onPrimary, - fontSize: 12, - fontWeight: FontWeight.w600, - height: 1.2, - ), - ), - ), if (widget.showTimes && scrubbing && !showPreview) Padding( padding: const EdgeInsets.only(bottom: 2), diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 0c667ac..029414b 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -855,6 +855,16 @@ void main() { await tester.pump(); await gesture.up(); await tester.pumpAndSettle(); + + controller.value = const VideoPlayerValue( + duration: Duration(seconds: 100), + position: Duration(seconds: 40), + 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); }); testWidgets('should_show_refresh_spinner_until_refresh_finishes', ( diff --git a/test/playback/seek_progress_bar_test.dart b/test/playback/seek_progress_bar_test.dart index f584ef6..98cd2be 100644 --- a/test/playback/seek_progress_bar_test.dart +++ b/test/playback/seek_progress_bar_test.dart @@ -54,6 +54,11 @@ void main() { expect(find.byKey(const Key('progress-loading')), findsNothing); expect(find.byKey(const Key('progress-inactive')), findsOneWidget); expect(find.byType(Slider), findsNothing); + expect( + find.byKey(const Key('player-progress-uninitialized-time')), + findsNothing, + ); + expect(find.textContaining('00:00'), findsNothing); }); testWidgets('should_show_inactive_track_and_disable_seek_when_locked', ( @@ -144,6 +149,119 @@ void main() { expect(find.byType(Slider), findsOneWidget); }); + testWidgets( + 'should_hide_buffering_loading_immediately_when_position_advances', + (tester) async { + final video = ValueNotifier( + const VideoPlayerValue( + duration: Duration(seconds: 12), + position: Duration(seconds: 3), + isInitialized: true, + isPlaying: true, + isBuffering: true, + ), + ); + addTearDown(video.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SeekProgressBar( + valueListenable: video, + semanticsLabel: 'Seek video', + onSeek: (_) async {}, + ), + ), + ), + ); + + expect(find.byKey(const Key('progress-loading')), findsNothing); + await tester.pump(const Duration(milliseconds: 200)); + video.value = const VideoPlayerValue( + duration: Duration(seconds: 12), + position: Duration(seconds: 4), + isInitialized: true, + isBuffering: true, + ); + await tester.pump(); + 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: 4), + isInitialized: true, + isPlaying: true, + isBuffering: true, + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byKey(const Key('progress-loading')), findsOneWidget); + expect(find.byType(Slider), findsNothing); + }, + ); + + testWidgets('should_not_show_loading_for_prewarmed_episode', (tester) async { + final video = ValueNotifier( + const VideoPlayerValue( + duration: Duration(seconds: 12), + position: Duration(seconds: 5), + isInitialized: true, + isPlaying: true, + isBuffering: true, + ), + ); + addTearDown(video.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SeekProgressBar( + valueListenable: video, + semanticsLabel: 'Seek video', + onSeek: (_) async {}, + ), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byType(Slider), findsOneWidget); + }); + + testWidgets( + 'should_suppress_progress_loading_while_entry_loading_is_visible', + (tester) async { + final video = ValueNotifier( + const VideoPlayerValue( + duration: Duration(seconds: 12), + position: Duration.zero, + isInitialized: true, + isBuffering: true, + ), + ); + addTearDown(video.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SeekProgressBar( + valueListenable: video, + semanticsLabel: 'Seek video', + suppressBufferLoading: true, + onSeek: (_) async {}, + ), + ), + ), + ); + + await tester.pump(const Duration(milliseconds: 400)); + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byType(Slider), findsOneWidget); + }, + ); + testWidgets('should_keep_slider_while_scrubbing_during_buffering', ( tester, ) async { diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 784bb47..53d4b6f 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -361,7 +361,11 @@ void main() { findsOneWidget, ); expect(find.byKey(const Key('player-progress')), findsOneWidget); - expect(find.text('00:00 / 01:00'), findsOneWidget); + expect( + find.byKey(const Key('player-progress-uninitialized-time')), + findsNothing, + ); + expect(find.text('00:00 / 01:00'), findsNothing); // IgnorePointer 让加载中的播放暂停与上下滑穿透,滑动会关闭入口 loading。 expect(fullLogic.session.userWantsPlay, isTrue); @@ -515,6 +519,49 @@ void main() { expect(find.byKey(const Key('progress-inactive')), findsOneWidget); }); + testWidgets('should_only_show_progress_buffer_after_video_first_frame', ( + tester, + ) async { + final factory = _RouteVideoFactory(); + await _pumpApp(tester, playerFactory: factory.create); + await _openSeriesPlayer(tester); + + final videoIndex = factory.episodes.lastIndexWhere( + (episode) => episode.id == 'city-lights-demo-1', + ); + final video = factory.created[videoIndex]; + expect(video.controller.value.isInitialized, isTrue); + expect(find.byKey(const Key('player-entry-loading-overlay')), findsNothing); + + video.controller.value = const VideoPlayerValue( + duration: Duration(minutes: 1), + size: Size(1280, 720), + position: Duration(seconds: 5), + isInitialized: true, + isPlaying: true, + isBuffering: true, + ); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byKey(const Key('progress-loading')), findsOneWidget); + expect(find.byKey(const Key('player-entry-loading-overlay')), findsNothing); + expect(find.byKey(const Key('player-video-loading')), findsNothing); + expect(find.byKey(const Key('player-ticket-loading')), findsNothing); + + video.controller.value = const VideoPlayerValue( + duration: Duration(minutes: 1), + size: Size(1280, 720), + position: Duration(seconds: 6), + isInitialized: true, + isPlaying: true, + isBuffering: true, + ); + await tester.pump(); + + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byType(Slider), findsOneWidget); + }); + testWidgets('should_pause_feed_when_auth_page_is_pushed_and_resume_on_pop', ( tester, ) async {