From 7ad4681049cdc294fdfc52dfe1ad6b9156e9523e Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 10:47:16 +0800 Subject: [PATCH] =?UTF-8?q?fix(player):=20=E6=94=B6=E6=95=9B=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E5=85=A5=E5=8F=A3=E7=BC=93=E5=86=B2=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-10-22-playback-entry-no-buffer.md | 21 ++++++ docs/plans/PROGRESS.md | 35 ++++++++++ lib/pages/player/player_logic.dart | 2 +- lib/pages/player/player_view.dart | 11 ---- .../widgets/home_feed_overlay_parts.dart | 17 ++--- .../player/widgets/seek_progress_bar.dart | 7 +- test/home/home_preview_layout_test.dart | 65 +++++++++++++++++- test/playback/seek_progress_bar_test.dart | 11 +++- test/player/player_fullscreen_test.dart | 19 +++--- test/player/player_logic_test.dart | 66 +++++++++++++++++-- test/player/player_open_detail_test.dart | 3 + test/widget_test.dart | 3 +- 12 files changed, 218 insertions(+), 42 deletions(-) create mode 100644 docs/plans/2026-10-22-playback-entry-no-buffer.md diff --git a/docs/plans/2026-10-22-playback-entry-no-buffer.md b/docs/plans/2026-10-22-playback-entry-no-buffer.md new file mode 100644 index 0000000..8d2c409 --- /dev/null +++ b/docs/plans/2026-10-22-playback-entry-no-buffer.md @@ -0,0 +1,21 @@ +# 播放入口无缓冲提示 + +## 目标 + +- 未初始化进度条显示静态 inactive 轨道,不出现流动加载高光。 +- 仅真实 buffering 持续超过 350ms 时显示白色高光。 +- 起播定位阶段不叠加 session 居中 loading;只有 ticket loading 居中显示。 +- 同集返回复用 controller;重建时 initialize→seek→play。 + +## 实施步骤 + +1. 调整 SeekProgressBar 与 _FeedProgressBar 的初始化/缓冲状态判断。 +2. 删除 PlayerView 的 session loading 居中圈,仅保留 ticket loading。 +3. preparePlayer 统一通过 VideoPlayerPort.seekTo 定位缓存位置。 +4. 更新进度条、全屏入口、详情返回和重建顺序测试。 +5. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。 + +## 风险 / 待确认 + +- 真实持续网络卡顿仍保留缓冲高光。 +- 锁定集继续沿用静态空轨道和禁用 seek 行为。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 4ab0457..2674012 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -391,3 +391,38 @@ $ flutter test --no-pub 解读:seek 完成后缓存与上报使用实际位置,越界目标被 clamp;seek 后立即 进详情并返回仍保持目标位置。全量静态分析零告警,591 项测试全过。 `pubspec.lock` 已还原,未启停服务,未操作真机。 + +## 2026-10-22 播放入口无缓冲提示 + +- 已从 Seek 修复合并后的 `develop`(`f302b39`)创建 `feature/playback-entry-no-buffer`。 +- 未初始化进度条改为静态 inactive 轨道,不显示流动高光且不可拖动。 +- 仅 initialized 且 isBuffering 持续超过 350ms 时显示白色缓冲高光。 +- PlayerView 删除 session 居中 loading,只保留 ticket loading。 +- preparePlayer 改为初始化完成后先 seek 缓存位置再 play;详情返回复用原 controller。 + +执行命令与原始输出: + +```text +$ flutter test --no-pub test/playback/seek_progress_bar_test.dart +00:00 +7: All tests passed! + +$ flutter test --no-pub test/home/home_preview_layout_test.dart +00:01 +8: All tests passed! + +$ flutter test --no-pub test/player/player_logic_test.dart +00:00 +40: All tests passed! + +$ flutter test --no-pub test/player/player_fullscreen_test.dart +00:09 +37: All tests passed! + +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:49 +593: All tests passed! +``` + +解读:短缓冲不闪、持续缓冲仍显示白色高光;未初始化/入口/返回/重建路径 +均无多余 loading,重建顺序为 initialize→seek→play。全量静态分析零告警, +593 项测试全过。`pubspec.lock` 已还原,未启停服务,未操作真机。 diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 71ffa67..27046f7 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -677,7 +677,7 @@ class PlayerLogic extends ChangeNotifier { } final saved = resumePositionFor(episode.id); if (saved != null) { - await player.controller.seekTo( + await player.seekTo( clampResumePosition(saved, player.controller.value.duration), ); } diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 27f036f..20b0e49 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -442,17 +442,6 @@ class _PlayerPageState extends State ), ), ), - if (selected && logic.session.loading) - const Center( - child: SizedBox.square( - dimension: _playerLoadingSize, - child: CircularProgressIndicator( - key: Key('player-session-loading'), - color: AppColors.blue, - strokeWidth: _playerLoadingStrokeWidth, - ), - ), - ), if (selected && !ready && logic.session.error != null) Center( child: Padding( diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index 37b3370..ebf4578 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -79,18 +79,16 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { final width = constraints.maxWidth; final source = widget.controller; final scrubbing = _preview != null; - final loading = - !widget.locked && - (source == null || - (!scrubbing && (!source.value.isInitialized || _buffering))); + final loading = !widget.locked && !scrubbing && _buffering; final track = widget.locked ? _inactiveTrack() : source == null - ? _loadingTrack() + ? _inactiveTrack() : ValueListenableBuilder( valueListenable: source, builder: (context, value, _) { - if (!scrubbing && (!value.isInitialized || _buffering)) { + if (!value.isInitialized) return _inactiveTrack(); + if (!scrubbing && _buffering) { return _loadingTrack(); } final total = value.duration.inMilliseconds; @@ -102,7 +100,10 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { return _track(_preview ?? played, width); }, ); - final interactive = !widget.locked && !loading; + final interactive = + !widget.locked && + !loading && + (source?.value.isInitialized ?? false); return GestureDetector( key: const Key('home-feed-progress'), behavior: HitTestBehavior.opaque, @@ -148,7 +149,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { if (!value.isInitialized) { _bufferTimer?.cancel(); _bufferTimer = null; - _setBuffering(true, notify: notify); + _setBuffering(false, notify: notify); return; } if (!value.isBuffering) { diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index 553462a..bc58c53 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -114,7 +114,7 @@ class _SeekProgressBarState extends State { if (!value.isInitialized) { _bufferTimer?.cancel(); _bufferTimer = null; - _setBuffering(true, notify: notify); + _setBuffering(false, notify: notify); return; } if (!value.isBuffering) { @@ -181,8 +181,7 @@ class _SeekProgressBarState extends State { Widget _buildTrack(BuildContext context, VideoPlayerValue video) { final total = video.duration.inMilliseconds; final scrubbing = _previewFraction.value != null; - final loading = - !widget.locked && !scrubbing && (!video.isInitialized || _buffering); + final loading = !widget.locked && !scrubbing && _buffering; final enabled = !widget.locked && video.isInitialized && @@ -223,7 +222,7 @@ class _SeekProgressBarState extends State { ), SizedBox( height: trackAreaHeight, - child: widget.locked + child: widget.locked || !video.isInitialized ? _inactiveTrack(trackHeight) : loading ? Center( diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index bf2d5fb..275759f 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -180,7 +180,11 @@ void main() { .colorBlendMode, BlendMode.srcIn, ); - expect(find.byKey(const Key('progress-loading')), findsOneWidget); + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect( + find.byKey(const Key('home-feed-progress-inactive')), + findsOneWidget, + ); expect(find.byKey(const Key('home-feed-progress-thumb')), findsNothing); }); @@ -258,6 +262,65 @@ void main() { expect(seeks, 0); }); + testWidgets('should_show_static_track_when_feed_video_is_uninitialized', ( + tester, + ) async { + final controller = ValueNotifier( + const VideoPlayerValue.uninitialized(), + ); + 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: 'loading-feed', + title: 'Loading Feed', + synopsis: 'synopsis', + posterUrl: '', + ), + episode: const Episode( + id: 'loading-feed-1', + number: 1, + title: 'Loading', + isFree: true, + ), + controller: controller, + locked: false, + liked: false, + saved: false, + visible: true, + boosting: false, + onTogglePlay: () {}, + onBoostStart: () {}, + onBoostEnd: () {}, + onLike: () {}, + onSave: () {}, + onComments: () {}, + onShare: () {}, + onDetail: () {}, + onOpenFullPlayer: () {}, + onMore: () {}, + onSeek: (_) {}, + ), + ), + ), + ), + ); + + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect( + find.byKey(const Key('home-feed-progress-inactive')), + findsOneWidget, + ); + }); + testWidgets('should_seek_and_open_player_from_feed_actions', (tester) async { var opened = 0; Duration? seeked; diff --git a/test/playback/seek_progress_bar_test.dart b/test/playback/seek_progress_bar_test.dart index 5682333..04f0fae 100644 --- a/test/playback/seek_progress_bar_test.dart +++ b/test/playback/seek_progress_bar_test.dart @@ -17,21 +17,26 @@ void main() { expect(seekPositionForFraction(1, duration), lessThan(duration)); }); - testWidgets('should_show_loading_track_when_controller_is_missing', ( + testWidgets('should_show_inactive_track_when_video_is_not_initialized', ( tester, ) async { + final video = ValueNotifier( + const VideoPlayerValue.uninitialized(), + ); + addTearDown(video.dispose); await tester.pumpWidget( MaterialApp( home: Scaffold( body: SeekProgressBar( - valueListenable: null, + valueListenable: video, semanticsLabel: 'Seek video', ), ), ), ); - expect(find.byKey(const Key('progress-loading')), findsOneWidget); + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byKey(const Key('progress-inactive')), findsOneWidget); expect(find.byType(Slider), findsNothing); }); diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 854ee69..9bfd278 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -99,13 +99,8 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); - final sessionLoading = find.byKey(const Key('player-session-loading')); - expect(sessionLoading, findsOneWidget); - expect(tester.getSize(sessionLoading), const Size(24, 24)); - expect( - tester.widget(sessionLoading).strokeWidth, - 2, - ); + expect(find.byKey(const Key('player-session-loading')), findsNothing); + expect(find.byKey(const Key('progress-loading')), findsNothing); initialization.complete(); await tester.pumpWidget(const SizedBox.shrink()); @@ -127,6 +122,8 @@ void main() { await _openSeriesPlayer(tester); final player = players.firstWhere((item) => item.playing); expect(player.playing, isTrue); + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byKey(const Key('progress-inactive')), findsOneWidget); final navigator = tester.state( find.byType(Navigator).first, @@ -147,6 +144,8 @@ void main() { await tester.pumpAndSettle(); expect(player.playing, isTrue); + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byKey(const Key('progress-inactive')), findsOneWidget); }); testWidgets('should_pause_feed_when_auth_page_is_pushed_and_resume_on_pop', ( @@ -571,7 +570,8 @@ void main() { tester.getSize(find.byKey(const Key('player-bottom-clear-screen'))), const Size(40, 40), ); - expect(find.byKey(const Key('progress-loading')), findsOneWidget); + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byKey(const Key('progress-inactive')), findsOneWidget); expect(find.byType(Slider), findsNothing); expect(bottom.height, lessThanOrEqualTo(200)); @@ -1301,7 +1301,8 @@ void _expectFullscreenOverlay(WidgetTester tester) { expect(find.byKey(const Key('player-pip')), findsNothing); expect(find.byKey(const Key('player-rail-comments')), findsOneWidget); expect(find.byKey(const Key('player-fullscreen-toggle')), findsNothing); - expect(find.byKey(const Key('progress-loading')), findsOneWidget); + 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-title')), findsNothing); expect(find.byKey(const Key('player-subtitle')), findsNothing); diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index d16ee8d..a600ce7 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:video_player/video_player.dart'; +// ignore: depend_on_referenced_packages +import 'package:video_player_platform_interface/video_player_platform_interface.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/play_ticket.dart'; import 'package:fruitshorts/core/data/models/player_port.dart'; @@ -287,6 +289,44 @@ void main() { await tester.pump(); }, ); + + testWidgets('should_seek_cached_position_before_play_when_rebuilding', ( + tester, + ) async { + final previousPlatform = VideoPlayerPlatform.instance; + VideoPlayerPlatform.instance = _VideoPlayerPlatformStub(); + addTearDown(() => VideoPlayerPlatform.instance = previousPlatform); + final cache = PlaybackPositionCache(); + cache.save( + episodeId: 'signal-1', + dramaId: 'signal', + position: const Duration(seconds: 42), + duration: const Duration(minutes: 1), + ); + final factory = _VideoRecordingFactory(); + late PlayerLogic logic; + await tester.runAsync(() async { + logic = _logic(FakeWatchHistory(), positionCache: cache); + logic.attach( + PlayerPage(drama: _drama('signal'), initialIndex: 0), + isMounted: () => true, + ); + logic.start( + factory: factory.create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + await pumpEventQueue(); + await logic.session.playbackSettled; + await logic.session.preloadSettled; + }); + + final player = logic.session.active! as _VideoRecordingPlayer; + expect(player.events, containsAllInOrder(['initialize', 'seek', 'play'])); + expect(player.controller.value.position, const Duration(seconds: 42)); + logic.dispose(); + await tester.pump(); + }); }); test('should_play_local_file_without_ticket_when_episode_is_downloaded', @@ -1297,21 +1337,38 @@ class _SessionVideoSurface extends StatelessWidget { class _VideoRecordingFactory { final List<_VideoRecordingPlayer> created = []; + final List events = []; PlayerPort create(Episode episode) { - final player = _VideoRecordingPlayer(episode); + final player = _VideoRecordingPlayer(episode, events); created.add(player); return player; } } +class _VideoPlayerPlatformStub extends VideoPlayerPlatform { + @override + Future init() async {} + + @override + Future dispose(int playerId) async {} + + @override + Future setVolume(int playerId, double volume) async {} + + @override + Future setPlaybackSpeed(int playerId, double speed) async {} +} + class _VideoRecordingPlayer extends VideoPlayerPort { - _VideoRecordingPlayer(super.episode); + _VideoRecordingPlayer(super.episode, this.events); bool disposed = false; + final List events; @override Future initialize() async { + events.add('initialize'); controller.value = const VideoPlayerValue( duration: Duration(minutes: 1), size: Size(1280, 720), @@ -1321,13 +1378,14 @@ class _VideoRecordingPlayer extends VideoPlayerPort { } @override - Future play() async {} + Future play() async => events.add('play'); @override - Future pause() async {} + Future pause() async => events.add('pause'); @override Future seekTo(Duration position) async { + events.add('seek'); final current = controller.value; controller.value = VideoPlayerValue( duration: current.duration, diff --git a/test/player/player_open_detail_test.dart b/test/player/player_open_detail_test.dart index 3bbaa1e..a4e3176 100644 --- a/test/player/player_open_detail_test.dart +++ b/test/player/player_open_detail_test.dart @@ -59,6 +59,7 @@ void main() { final active = logic.session.active; final index = logic.session.currentIndex; final player = factory.created.first; + final createdBeforeDetail = factory.created.length; logic ..trackedPlayer = player ..trackedEpisode = drama.episodes.first @@ -106,6 +107,7 @@ void main() { expect(logic.session.active, same(active)); expect(logic.session.currentIndex, index); expect(logic.session.userWantsPlay, isTrue); + expect(factory.created, hasLength(createdBeforeDetail)); expect( player.controller.value.position, const Duration(minutes: 1, seconds: 22), @@ -125,6 +127,7 @@ void main() { expect(logic.session.active, same(active)); expect(logic.session.currentIndex, index); expect(logic.session.userWantsPlay, isTrue); + expect(factory.created, hasLength(createdBeforeDetail)); expect( player.controller.value.position, const Duration(minutes: 1, seconds: 22), diff --git a/test/widget_test.dart b/test/widget_test.dart index 43556a7..26fb796 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -185,7 +185,8 @@ void main() { await tester.pumpAndSettle(); await _openTheaterPlayer(tester); - expect(find.byKey(const Key('progress-loading')), findsOneWidget); + expect(find.byKey(const Key('progress-loading')), findsNothing); + expect(find.byKey(const Key('progress-inactive')), findsOneWidget); expect(find.byType(Slider), findsNothing); await tester.drag(find.byType(PageView), const Offset(0, -500)); await tester.pumpAndSettle();