From 516593499c13876fdc36fbe14dd9056f24b3c8bf Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 13:43:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(player):=20=E4=BF=AE=E5=A4=8D=E7=AB=96?= =?UTF-8?q?=E6=BB=91=E4=B8=8A=E4=B8=80=E9=9B=86=E4=B8=8D=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=92=AD=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/services/playback_neighbors.dart | 20 +++-- lib/core/data/services/playback_session.dart | 47 +++-------- .../services/playback_session_controls.dart | 54 +++++++++++++ lib/pages/player/player_logic.dart | 2 +- lib/pages/player/player_view.dart | 78 ++++++++++++++----- .../player/widgets/player_opening_frame.dart | 50 ++++++++---- test/playback/playback_preload_test.dart | 58 ++++++++++++++ test/playback/seek_progress_bar_test.dart | 2 +- test/player/player_fullscreen_test.dart | 6 +- test/player/player_opening_frame_test.dart | 27 ++++++- 10 files changed, 264 insertions(+), 80 deletions(-) create mode 100644 lib/core/data/services/playback_session_controls.dart diff --git a/lib/core/data/services/playback_neighbors.dart b/lib/core/data/services/playback_neighbors.dart index d1aad89..17d67d1 100644 --- a/lib/core/data/services/playback_neighbors.dart +++ b/lib/core/data/services/playback_neighbors.dart @@ -67,15 +67,19 @@ Future _placeNeighbor(PlaybackSession session, _PlayerSlot? slot) async { await session._retire(slot); } -/// 当前集已经出画后,静默准备上一集和下一集。 -void _warmNeighbors(PlaybackSession session) { +/// 当前集已经出画后,按手势方向静默准备上一集和下一集。 +void _warmNeighbors(PlaybackSession session, {bool preferredNext = true}) { final request = session._generation; - session._preloadTask = () async { - await Future.wait([ - _warmOne(session, request, session._nextIndex, next: true), - _warmOne(session, request, session._previousIndex, next: false), - ]); - }(); + final nextTask = _warmOne(session, request, session._nextIndex, next: true); + final previousTask = _warmOne( + session, + request, + session._previousIndex, + next: false, + ); + session._preloadTask = Future.wait( + preferredNext ? [nextTask, previousTask] : [previousTask, nextTask], + ).then((_) {}); } Future _warmOne( diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart index 04d9a0c..a7266e7 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -8,6 +8,7 @@ import '../models/player_port.dart'; import 'video_player_port.dart'; part 'playback_neighbors.dart'; +part 'playback_session_controls.dart'; /// 新纹理第一帧常是黑的,旧画面只盖住这一小段。 const Duration _qualityHandoffHold = Duration(milliseconds: 200); @@ -40,6 +41,7 @@ class PlaybackSession extends ChangeNotifier { final Future Function()? waitForFrame; final PlaybackCoordinator _coordinator; final Set<_PlayerSlot> _live = {}; + final Set _pausedEpisodeIds = {}; _PlayerSlot? _active; _PlayerSlot? _next; _PlayerSlot? _previous; @@ -106,15 +108,6 @@ class PlaybackSession extends ChangeNotifier { return null; } - /// 清晰度切换时盖住黑帧的旧播放器。只在仍由会话持有时交给界面。 - PlayerPort? handoffAt(int index) { - final slot = _handoff; - if (_closed || slot == null || slot.retiring || slot.index != index) { - return null; - } - return slot.player; - } - /// 队列重排后把当前仍在播放的 slot 映射到新索引,不重新创建播放器。 void remapCurrentIndex(int index, {int? preloadIndex, int? previousIndex}) { if (_closed || index < 0 || index >= episodes.length) return; @@ -214,10 +207,9 @@ class PlaybackSession extends ChangeNotifier { int? previousIndex, }) async { if (_closed || index < 0 || index >= episodes.length) return; - // 切到另一集时,上一集按下的暂停不跟着走:滑过去就应该自动播放。 - // 重复选中同一集(例如页面重新可见)不在这里恢复,尊重用户按下的暂停。 + // 新集默认起播;用户手动暂停过的同一集滑回时继续尊重暂停。 if (!_selected || index != _currentIndex) { - _userWantsPlay = true; + _userWantsPlay = !_pausedEpisodeIds.contains(episodes[index].id); } final request = ++_generation; _selected = true; @@ -256,7 +248,10 @@ class PlaybackSession extends ChangeNotifier { target = old; return; } - if (old != null) await old.player.pause(); + if (old != null) { + await old.player.pause(); + old.playing = false; + } final warmNext = _next; final warmPrevious = _previous; _active = null; @@ -369,7 +364,10 @@ class PlaybackSession extends ChangeNotifier { _handoff = previous; _active = slot; shown = true; - if (previous != null) await previous!.player.pause(); + if (previous != null) { + await previous!.player.pause(); + previous!.playing = false; + } _notify(); }); if (!shown || _active != slot) return null; @@ -422,27 +420,6 @@ class PlaybackSession extends ChangeNotifier { slot.playing = shouldPlay; } - void setAppResumed(bool value) { - _appResumed = value; - _syncPlayback(); - } - - void setRouteVisible(bool value) { - _routeVisible = value; - _syncPlayback(); - } - - void setOverlayOpen(bool value) { - _overlayOpen = value; - _syncPlayback(); - } - - void setUserWantsPlay(bool value) { - _userWantsPlay = value; - _syncPlayback(); - _notify(); - } - void _syncPlayback() { if (_closed) return; if (_visible && _selected && active == null) { diff --git a/lib/core/data/services/playback_session_controls.dart b/lib/core/data/services/playback_session_controls.dart new file mode 100644 index 0000000..dd9175b --- /dev/null +++ b/lib/core/data/services/playback_session_controls.dart @@ -0,0 +1,54 @@ +// Author: fruit.com.cn + +part of 'playback_session.dart'; + +/// 播放会话的可见性、用户意图与相邻集预热控制。 +extension PlaybackSessionControls on PlaybackSession { + /// 滑动手势开始时按方向优先准备相邻集,不改变当前集播放状态。 + void prewarmNeighbors({required bool forward}) { + if (!_visible || _overlayOpen || _active?.prepared != true) return; + _warmNeighbors(this, preferredNext: forward); + } + + /// 清晰度切换时盖住黑帧的旧播放器。只在仍由会话持有时交给界面。 + PlayerPort? handoffAt(int index) { + final slot = _handoff; + if (_closed || slot == null || slot.retiring || slot.index != index) { + return null; + } + return slot.player; + } + + /// 同步应用前台状态并恢复或暂停当前集。 + void setAppResumed(bool value) { + _appResumed = value; + _syncPlayback(); + } + + /// 同步播放器路由是否可见。 + void setRouteVisible(bool value) { + _routeVisible = value; + _syncPlayback(); + } + + /// 同步浮层是否遮挡播放器。 + void setOverlayOpen(bool value) { + _overlayOpen = value; + _syncPlayback(); + } + + /// 记录用户播放意图;手动暂停的剧集滑回时不再自动起播。 + void setUserWantsPlay(bool value) { + if (episodes.isNotEmpty) { + final episodeId = episodes[_currentIndex].id; + if (value) { + _pausedEpisodeIds.remove(episodeId); + } else { + _pausedEpisodeIds.add(episodeId); + } + } + _userWantsPlay = value; + _syncPlayback(); + _notify(); + } +} diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index b22aea0..07b450a 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -969,7 +969,7 @@ class PlayerLogic extends ChangeNotifier { !isMounted()) { return; } - if (!ready && ticketError != null) return; + if (!ready) return; await _selectVisible(index); } diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 96e5e00..7b07f04 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -2,6 +2,7 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -124,6 +125,8 @@ class _PlayerPageState extends State double? _speedBeforeBoost; bool? _chromeBeforeBoost; DateTime? _lastDoubleTapAt; + DateTime? _swipeStartedAt; + bool _swipePrewarmCaptured = false; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 final Map _favoriteDelta = {}; @@ -308,6 +311,34 @@ class _PlayerPageState extends State unawaited(logic.setPlaybackSpeed(speed)); } + bool _handlePageScroll(ScrollNotification notification) { + if (notification is ScrollStartNotification && + notification.dragDetails != null) { + _swipeStartedAt = DateTime.now(); + _swipePrewarmCaptured = false; + } else if (notification is ScrollUpdateNotification && + !_swipePrewarmCaptured && + notification.dragDetails != null) { + final delta = notification.scrollDelta ?? 0; + if (delta != 0) { + _swipePrewarmCaptured = true; + logic.session.prewarmNeighbors(forward: delta > 0); + } + } else if (notification is ScrollEndNotification) { + _swipePrewarmCaptured = false; + } + return false; + } + + void _logSwipeReveal() { + if (!kDebugMode) return; + final startedAt = _swipeStartedAt; + if (startedAt == null) return; + _swipeStartedAt = null; + final elapsed = DateTime.now().difference(startedAt).inMilliseconds; + debugPrint('[player] swipe reveal ${elapsed}ms'); + } + Future _refreshHomeFeed() async { final refresh = widget.onRefreshFeed; if (refresh == null) return; @@ -420,25 +451,28 @@ class _PlayerPageState extends State final content = AnimatedBuilder( animation: logic.session, builder: (context, _) { - final pages = PageView.builder( - controller: logic.pages, - scrollDirection: Axis.vertical, - allowImplicitScrolling: true, - physics: widget.onRefreshFeed == null - ? null - : const FeedRefreshPhysics(), - itemCount: indices.length, - onPageChanged: (position) { - final index = indices[position]; - widget.onDramaChanged?.call(logic.queue.entryAt(index).drama); - unawaited(logic.selectEpisode(index)); - if (position >= indices.length - 3) { - final loadMore = widget.onLoadMore; - if (loadMore != null) unawaited(loadMore()); - } - }, - itemBuilder: (context, position) => - _buildEpisode(context, indices[position], interactions), + final pages = NotificationListener( + onNotification: _handlePageScroll, + child: PageView.builder( + controller: logic.pages, + scrollDirection: Axis.vertical, + allowImplicitScrolling: true, + physics: widget.onRefreshFeed == null + ? null + : const FeedRefreshPhysics(), + itemCount: indices.length, + onPageChanged: (position) { + final index = indices[position]; + widget.onDramaChanged?.call(logic.queue.entryAt(index).drama); + unawaited(logic.selectEpisode(index)); + if (position >= indices.length - 3) { + final loadMore = widget.onLoadMore; + if (loadMore != null) unawaited(loadMore()); + } + }, + itemBuilder: (context, position) => + _buildEpisode(context, indices[position], interactions), + ), ); if (widget.onRefreshFeed == null) return pages; return FeedPullRefresh( @@ -550,6 +584,7 @@ class _PlayerPageState extends State final handoffPort = handoff is VideoPlayerPort ? handoff : null; // 测试播放器没有纹理。有播放器但不是视频控制器时视为已就绪,避免加载圈一直转。 final videoReady = videoPort?.controller.value.isInitialized ?? false; + if (selected && videoReady) _logSwipeReveal(); final ready = videoReady || port != null; final theater = logic.pip || logic.pipPreparing; final locked = logic.session.isLocked || episode.locked; @@ -577,7 +612,10 @@ class _PlayerPageState extends State handoff: handoffPort?.controller, videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, theater: theater, - openingFrame: playerOpeningFrame(episode.thumbnailsUrl), + openingFrame: playerOpeningFrame( + episode.thumbnailsUrl, + posterUrl: drama.posterUrl, + ), ), if (selected && !theater && diff --git a/lib/pages/player/widgets/player_opening_frame.dart b/lib/pages/player/widgets/player_opening_frame.dart index b445af1..594c456 100644 --- a/lib/pages/player/widgets/player_opening_frame.dart +++ b/lib/pages/player/widgets/player_opening_frame.dart @@ -8,25 +8,33 @@ import 'package:flutter/material.dart'; import '../../../core/config/app_config.dart'; import '../../../core/data/services/sprite_thumbnail_asset_loader.dart'; import '../../../core/data/services/sprite_thumbnail_axis.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/widgets/remote_image.dart'; import 'scrub_preview_card.dart'; -/// 无雪碧图地址时不垫帧,交还给纯黑底。 -Widget? playerOpeningFrame(String? thumbnailsUrl) { +/// 封面立即可见,雪碧图首帧解码完成后再覆盖封面。 +Widget? playerOpeningFrame(String? thumbnailsUrl, {String? posterUrl}) { final url = thumbnailsUrl?.trim() ?? ''; - if (url.isEmpty) return null; - return PlayerOpeningFrame(thumbnailsUrl: url); + final poster = posterUrl?.trim() ?? ''; + if (url.isEmpty && poster.isEmpty) return null; + return PlayerOpeningFrame( + thumbnailsUrl: url.isEmpty ? null : url, + posterUrl: poster.isEmpty ? null : poster, + ); } -/// 解码出视频画面前,用本集雪碧图第一帧垫底。 +/// 视频解码前依次使用封面和雪碧图首帧,最终由父层黑色兜底。 class PlayerOpeningFrame extends StatefulWidget { const PlayerOpeningFrame({ super.key, - required this.thumbnailsUrl, + this.thumbnailsUrl, + this.posterUrl, this.loader, this.cache, }); - final String thumbnailsUrl; + final String? thumbnailsUrl; + final String? posterUrl; final SpriteThumbnailAssetLoader? loader; final OpeningFrameCache? cache; @@ -55,8 +63,10 @@ class _PlayerOpeningFrameState extends State { Future _load() async { final serial = ++_serial; + final thumbnailsUrl = widget.thumbnailsUrl; + if (thumbnailsUrl == null) return; final frame = await (widget.cache ?? OpeningFrameCache.shared).load( - widget.thumbnailsUrl, + thumbnailsUrl, loader: widget.loader, ); if (!mounted || serial != _serial) return; @@ -66,11 +76,25 @@ class _PlayerOpeningFrameState extends State { @override Widget build(BuildContext context) { final frame = _frame; - if (frame == null) return const SizedBox.expand(); - return SpriteCropPreview( - key: const Key('player-opening-sprite'), - image: frame.image, - sourceRect: frame.sourceRect, + final posterUrl = widget.posterUrl; + return Stack( + fit: StackFit.expand, + children: [ + const ColoredBox(color: AppColors.mediaBlack), + if (posterUrl != null) + RemoteImage( + posterUrl, + key: const Key('player-opening-poster'), + fit: BoxFit.cover, + fallbackColor: AppColors.mediaBlack, + ), + if (frame != null) + SpriteCropPreview( + key: const Key('player-opening-sprite'), + image: frame.image, + sourceRect: frame.sourceRect, + ), + ], ); } } diff --git a/test/playback/playback_preload_test.dart b/test/playback/playback_preload_test.dart index 1c1dff7..5f9ab14 100644 --- a/test/playback/playback_preload_test.dart +++ b/test/playback/playback_preload_test.dart @@ -148,6 +148,64 @@ void main() { await session.close(); }); + test('should_play_previous_episode_when_swiping_back', () async { + final factory = _Factory(); + final session = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (_) => true, + ); + + await session.select(1, preloadIndex: 2, previousIndex: 0); + await session.preloadSettled; + await session.select(2, preloadIndex: 3, previousIndex: 1); + await session.preloadSettled; + expect(session.active, isA<_Player>().having((p) => p.id, 'id', 'ep-2')); + + await session.select(1, preloadIndex: 2, previousIndex: 0); + await session.preloadSettled; + + expect(session.active, isA<_Player>().having((p) => p.id, 'id', 'ep-1')); + expect( + factory.players.firstWhere((player) => player.id == 'ep-1').playing, + isTrue, + ); + expect(session.userWantsPlay, isTrue); + await session.close(); + }); + + test('should_keep_manual_pause_when_returning_to_same_episode', () async { + final factory = _Factory(); + final session = PlaybackSession( + episodes: episodes, + factory: factory.create, + canPlay: (_) => true, + ); + + await session.select(1, preloadIndex: 2, previousIndex: 0); + await session.preloadSettled; + session.setUserWantsPlay(false); + await session.playbackSettled; + expect(session.userWantsPlay, isFalse); + + await session.select(2, preloadIndex: 3, previousIndex: 1); + await session.preloadSettled; + expect(session.userWantsPlay, isTrue); + expect( + factory.players.firstWhere((player) => player.id == 'ep-2').playing, + isTrue, + ); + + await session.select(1, preloadIndex: 2, previousIndex: 0); + await session.preloadSettled; + expect(session.userWantsPlay, isFalse); + expect( + factory.players.firstWhere((player) => player.id == 'ep-1').playing, + isFalse, + ); + await session.close(); + }); + test('should_reuse_in_flight_preload_when_swiped_before_ready', () async { final gate = Completer(); final factory = _Factory(gates: {'ep-1': gate}); diff --git a/test/playback/seek_progress_bar_test.dart b/test/playback/seek_progress_bar_test.dart index d4a2f02..1571fda 100644 --- a/test/playback/seek_progress_bar_test.dart +++ b/test/playback/seek_progress_bar_test.dart @@ -30,7 +30,7 @@ void main() { scrubPreviewLeft(trackWidth: 300, fraction: 1), 300 - scrubPreviewWidth - scrubPreviewSafeMargin, ); - expect(scrubPreviewLeft(trackWidth: 100, fraction: 0.5), 0); + expect(scrubPreviewLeft(trackWidth: 100, fraction: 0.5), 2); }); testWidgets('should_show_inactive_track_when_video_is_not_initialized', ( diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 6eb8625..b10bc7c 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -50,7 +50,11 @@ void main() { .homeMode, isTrue, ); - expect(find.byType(PageView), findsOneWidget); + final pageViews = tester.widgetList(find.byType(PageView)); + expect( + pageViews.where((pageView) => pageView.scrollDirection == Axis.vertical), + hasLength(1), + ); expect(tester.takeException(), isNull); }); diff --git a/test/player/player_opening_frame_test.dart b/test/player/player_opening_frame_test.dart index fd166de..b8b3acb 100644 --- a/test/player/player_opening_frame_test.dart +++ b/test/player/player_opening_frame_test.dart @@ -16,10 +16,17 @@ sprite_001.jpg#xywh=0,0,160,90 void main() { test('should_return_no_opening_frame_when_thumbnails_url_is_empty', () { - expect(playerOpeningFrame(null), isNull); + expect(playerOpeningFrame(null, posterUrl: ' '), isNull); expect(playerOpeningFrame(' '), isNull); }); + test('should_keep_opening_frame_when_only_poster_is_available', () { + expect( + playerOpeningFrame(null, posterUrl: 'https://cdn.test/poster.jpg'), + isA(), + ); + }); + testWidgets('should_load_and_reuse_first_sprite_frame', (tester) async { final loader = _FakeSpriteLoader(); final cache = OpeningFrameCache(); @@ -58,6 +65,24 @@ void main() { expect(find.byKey(const Key('player-opening-sprite')), findsOneWidget); }); + + testWidgets('should_show_poster_while_sprite_is_loading', (tester) async { + final cache = OpeningFrameCache(); + addTearDown(cache.clear); + + await tester.pumpWidget( + MaterialApp( + home: PlayerOpeningFrame( + thumbnailsUrl: 'https://cdn.test/thumbnails.vtt', + posterUrl: 'https://cdn.test/poster.jpg', + loader: _FakeSpriteLoader(), + cache: cache, + ), + ), + ); + + expect(find.byKey(const Key('player-opening-poster')), findsOneWidget); + }); } class _FakeSpriteLoader implements SpriteThumbnailAssetLoader {