From 2fef7470e02cade9f5d950e0d5cb45065ddae094 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 14:58:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(player):=20=E4=BD=BF=E7=94=A8=E9=AB=98?= =?UTF-8?q?=E6=B8=85=E9=A6=96=E5=B8=A7=E5=B9=B6=E7=A8=B3=E5=AE=9A=E5=88=87?= =?UTF-8?q?=E9=9B=86=E5=8D=A0=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/data/models/drama.dart | 7 +- lib/core/data/models/play_ticket.dart | 3 + lib/pages/player/player_logic.dart | 1 + lib/pages/player/player_view.dart | 55 ++++++++-- .../player/widgets/player_opening_frame.dart | 102 ++++++++++++++---- .../player/widgets/player_video_surface.dart | 34 +++++- test/playback/sprite_thumbnail_axis_test.dart | 7 ++ test/player/player_fullscreen_test.dart | 6 ++ test/player/player_logic_test.dart | 29 +++++ test/player/player_opening_frame_test.dart | 47 ++++++++ 10 files changed, 258 insertions(+), 33 deletions(-) diff --git a/lib/core/data/models/drama.dart b/lib/core/data/models/drama.dart index 82679fc..0ca516e 100644 --- a/lib/core/data/models/drama.dart +++ b/lib/core/data/models/drama.dart @@ -21,6 +21,7 @@ class Episode { this.likeCount, this.commentCount, this.thumbnailsUrl, + this.posterUrl, }); final String id; @@ -37,12 +38,14 @@ class Episode { final int? likeCount; final int? commentCount; final String? thumbnailsUrl; + final String? posterUrl; Episode withVideoUrl( String url, { int? likeCount, int? commentCount, String? thumbnailsUrl, + String? posterUrl, }) => Episode( id: id, number: number, @@ -54,6 +57,7 @@ class Episode { likeCount: likeCount ?? this.likeCount, commentCount: commentCount ?? this.commentCount, thumbnailsUrl: thumbnailsUrl ?? this.thumbnailsUrl, + posterUrl: posterUrl ?? this.posterUrl, ); factory Episode.fromJson(Map json) { @@ -68,6 +72,7 @@ class Episode { likeCount: _optionalInt(json['likeCount']), commentCount: _optionalInt(json['commentCount']), thumbnailsUrl: _optionalString(json['thumbnailsUrl']), + posterUrl: _optionalString(json['posterUrl']), ); } } @@ -324,4 +329,4 @@ String _genreText(List> types, Object? fallback) { final joined = names.join(' / '); if (joined.isNotEmpty) return joined; return fallback is String ? fallback : ''; -} \ No newline at end of file +} diff --git a/lib/core/data/models/play_ticket.dart b/lib/core/data/models/play_ticket.dart index 0094ba4..353fa86 100644 --- a/lib/core/data/models/play_ticket.dart +++ b/lib/core/data/models/play_ticket.dart @@ -16,6 +16,7 @@ class PlayTicket { this.qualities = const [], this.tracks = const [], this.thumbnailsUrl, + this.posterUrl, this.likeCount, this.commentCount, }); @@ -46,6 +47,7 @@ class PlayTicket { /// 各档签名地址。AUTO 是主列表,点选其它档位时换成对应地址。 final List tracks; final String? thumbnailsUrl; + final String? posterUrl; final int? likeCount; final int? commentCount; @@ -70,6 +72,7 @@ class PlayTicket { .toList(), tracks: _tracks(json['tracks'], json['playUrl'] as String? ?? ''), thumbnailsUrl: _optionalString(json['thumbnailsUrl']), + posterUrl: _optionalString(json['posterUrl']), likeCount: json['likeCount'] == null ? null : jsonToInt(json['likeCount']), diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index a638617..fa56e6f 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -462,6 +462,7 @@ class PlayerLogic extends ChangeNotifier { likeCount: ticket.likeCount, commentCount: ticket.commentCount, thumbnailsUrl: ticket.thumbnailsUrl, + posterUrl: ticket.posterUrl, ); return true; } diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 02d56ce..8a001ce 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -127,6 +127,7 @@ class _PlayerPageState extends State DateTime? _lastDoubleTapAt; DateTime? _swipeStartedAt; bool _swipePrewarmCaptured = false; + final Set _precachedPosterUrls = {}; /// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。 final Map _favoriteDelta = {}; @@ -376,7 +377,7 @@ class _PlayerPageState extends State if (mounted) setState(() {}); } - void _prewarmAdjacentThumbnails() { + void _prewarmAdjacentFrames() { final current = logic.session.currentIndex; final indices = [ current, @@ -387,9 +388,24 @@ class _PlayerPageState extends State if (index == null || index < 0 || index >= logic.sessionEpisodes.length) { continue; } - final thumbnailsUrl = logic.sessionEpisodes[index].thumbnailsUrl; - if (thumbnailsUrl != null && - !logic.isEpisodeLocked(logic.sessionEpisodes[index])) { + final episode = logic.sessionEpisodes[index]; + if (logic.isEpisodeLocked(episode)) continue; + final posterUrl = episode.posterUrl?.trim() ?? ''; + if (posterUrl.isNotEmpty) { + final imageUrl = AppConfig.mediaUrl(posterUrl); + if (_precachedPosterUrls.add(imageUrl)) { + unawaited( + precacheImage(NetworkImage(imageUrl), context).catchError(( + Object _, + ) { + _precachedPosterUrls.remove(imageUrl); + }), + ); + } + continue; + } + final thumbnailsUrl = episode.thumbnailsUrl; + if (thumbnailsUrl != null && thumbnailsUrl.isNotEmpty) { unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero)); } } @@ -477,7 +493,7 @@ class _PlayerPageState extends State final content = AnimatedBuilder( animation: logic.session, builder: (context, _) { - _prewarmAdjacentThumbnails(); + _prewarmAdjacentFrames(); final pages = NotificationListener( onNotification: _handlePageScroll, child: PageView.builder( @@ -592,12 +608,17 @@ class _PlayerPageState extends State final displayed = widget.homeMode ? logic.displayEpisode(drama, sessionEpisode) : sessionEpisode; - final episode = + final needsSessionThumbnails = (displayed.thumbnailsUrl == null || displayed.thumbnailsUrl!.isEmpty) && - sessionEpisode.thumbnailsUrl != null + sessionEpisode.thumbnailsUrl != null; + final needsSessionPoster = + (displayed.posterUrl == null || displayed.posterUrl!.isEmpty) && + sessionEpisode.posterUrl != null; + final episode = needsSessionThumbnails || needsSessionPoster ? displayed.withVideoUrl( displayed.videoUrl, thumbnailsUrl: sessionEpisode.thumbnailsUrl, + posterUrl: sessionEpisode.posterUrl, ) : displayed; final selected = index == logic.session.currentIndex; @@ -621,12 +642,19 @@ class _PlayerPageState extends State final theater = logic.pip || logic.pipPreparing; final videoFit = theater ? BoxFit.contain : BoxFit.cover; final preparedFrameAvailable = - selected && !locked && logic.session.reusedWarmAt(index); + !locked && + videoReady && + (selected + ? logic.session.reusedWarmAt(index) + : logic.session.preparedPlayerAt(index) != null); + final warmSlotReused = logic.session.reusedWarmAt(index); final thumbnailsUrl = episode.thumbnailsUrl; + final posterUrl = episode.posterUrl; + final posterReady = posterUrl?.trim().isNotEmpty ?? false; final openingFrameReady = thumbnailsUrl != null && _spriteAxisService.cachedFrameAt(thumbnailsUrl, Duration.zero) != null; - if (thumbnailsUrl != null) { + if (!posterReady && thumbnailsUrl != null) { unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero)); } if (selected && @@ -654,7 +682,15 @@ class _PlayerPageState extends State videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl, theater: theater, warmFrameAvailable: preparedFrameAvailable, + warmSlotReused: warmSlotReused, thumbnailReady: openingFrameReady, + posterReady: posterReady, + fallback: handoffPort != null + ? PlayerOpeningFallback.handoff + : playerOpeningFallback( + posterUrl: posterUrl, + thumbnailsUrl: thumbnailsUrl, + ), waitForPlaybackFrame: logic.session.userWantsPlay, placeholder: locked ? null @@ -665,6 +701,7 @@ class _PlayerPageState extends State ) : playerOpeningFrame( thumbnailsUrl, + posterUrl: posterUrl, spriteSource: _spriteAxisService, fit: videoFit, ), diff --git a/lib/pages/player/widgets/player_opening_frame.dart b/lib/pages/player/widgets/player_opening_frame.dart index 529f7a4..b685c64 100644 --- a/lib/pages/player/widgets/player_opening_frame.dart +++ b/lib/pages/player/widgets/player_opening_frame.dart @@ -4,20 +4,42 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import '../../../core/config/app_config.dart'; import '../../../core/data/services/sprite_thumbnail_axis.dart'; import '../../../core/theme/app_colors.dart'; import 'scrub_preview_card.dart'; -/// 当前集雪碧图首帧在视频纹理未就绪时占位;空地址交还纯黑底。 +enum PlayerOpeningFallback { handoff, poster, thumbnails, black } + +PlayerOpeningFallback playerOpeningFallback({ + String? posterUrl, + String? thumbnailsUrl, +}) { + if (posterUrl?.trim().isNotEmpty ?? false) { + return PlayerOpeningFallback.poster; + } + if (thumbnailsUrl?.trim().isNotEmpty ?? false) { + return PlayerOpeningFallback.thumbnails; + } + return PlayerOpeningFallback.black; +} + +/// 当前集首帧在视频纹理未就绪时占位;空地址交还纯黑底。 Widget? playerOpeningFrame( String? thumbnailsUrl, { + String? posterUrl, required SpriteThumbnailAxisSource spriteSource, BoxFit fit = BoxFit.cover, }) { - final url = thumbnailsUrl?.trim() ?? ''; - if (url.isEmpty) return null; + final thumbnails = thumbnailsUrl?.trim() ?? ''; + final poster = posterUrl?.trim() ?? ''; + if (playerOpeningFallback(posterUrl: poster, thumbnailsUrl: thumbnails) == + PlayerOpeningFallback.black) { + return null; + } return PlayerOpeningFrame( - thumbnailsUrl: url, + thumbnailsUrl: thumbnails, + posterUrl: poster, spriteSource: spriteSource, fit: fit, ); @@ -29,9 +51,13 @@ class PlayerOpeningFrame extends StatefulWidget { super.key, required this.thumbnailsUrl, required this.spriteSource, + this.posterUrl, this.fit = BoxFit.cover, }); + /// 当前视频原始分辨率首帧地址,优先于雪碧图。 + final String? posterUrl; + /// 当前视频雪碧图 WebVTT 地址。 final String thumbnailsUrl; @@ -46,26 +72,29 @@ class PlayerOpeningFrame extends StatefulWidget { } class _PlayerOpeningFrameState extends State { + bool _posterFailed = false; + @override void initState() { super.initState(); widget.spriteSource.addListener(_handleSpriteChanged); - unawaited( - widget.spriteSource.prepareAt(widget.thumbnailsUrl, Duration.zero), - ); + _prepareSpriteIfNeeded(); } @override void didUpdateWidget(covariant PlayerOpeningFrame oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.posterUrl != widget.posterUrl) { + _posterFailed = false; + } if (oldWidget.spriteSource != widget.spriteSource) { oldWidget.spriteSource.removeListener(_handleSpriteChanged); widget.spriteSource.addListener(_handleSpriteChanged); + _prepareSpriteIfNeeded(); } - if (oldWidget.thumbnailsUrl != widget.thumbnailsUrl) { - unawaited( - widget.spriteSource.prepareAt(widget.thumbnailsUrl, Duration.zero), - ); + if (oldWidget.posterUrl != widget.posterUrl || + oldWidget.thumbnailsUrl != widget.thumbnailsUrl) { + _prepareSpriteIfNeeded(); } } @@ -79,19 +108,56 @@ class _PlayerOpeningFrameState extends State { if (mounted) setState(() {}); } + bool get _hasPoster => + !_posterFailed && (widget.posterUrl?.trim().isNotEmpty ?? false); + + void _prepareSpriteIfNeeded() { + if (_hasPoster || widget.thumbnailsUrl.trim().isEmpty) return; + unawaited( + widget.spriteSource.prepareAt(widget.thumbnailsUrl, Duration.zero), + ); + } + + Widget _handlePosterError() { + if (!_posterFailed) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || _posterFailed) return; + setState(() => _posterFailed = true); + _prepareSpriteIfNeeded(); + }); + } + return _spriteOrBlack(); + } + + Widget _spriteOrBlack() { + final frame = widget.spriteSource.cachedFrameAt( + widget.thumbnailsUrl, + Duration.zero, + ); + if (frame == null) return const SizedBox.expand(); + return SpriteCropPreview( + key: const Key('player-opening-sprite'), + image: frame.image, + sourceRect: frame.sourceRect, + fit: widget.fit, + ); + } + @override Widget build(BuildContext context) => Stack( fit: StackFit.expand, children: [ const ColoredBox(color: AppColors.mediaBlack), - if (widget.spriteSource.cachedFrameAt(widget.thumbnailsUrl, Duration.zero) - case final frame?) - SpriteCropPreview( - key: const Key('player-opening-sprite'), - image: frame.image, - sourceRect: frame.sourceRect, + if (_hasPoster) + Image.network( + AppConfig.mediaUrl(widget.posterUrl!.trim()), + key: const Key('player-opening-poster'), fit: widget.fit, - ), + gaplessPlayback: true, + errorBuilder: (_, _, _) => _handlePosterError(), + ) + else + _spriteOrBlack(), ], ); } diff --git a/lib/pages/player/widgets/player_video_surface.dart b/lib/pages/player/widgets/player_video_surface.dart index 2aebe3a..ec4ca86 100644 --- a/lib/pages/player/widgets/player_video_surface.dart +++ b/lib/pages/player/widgets/player_video_surface.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; import '../../../core/theme/app_colors.dart'; +import 'player_opening_frame.dart'; const Duration _crossFadeDuration = Duration(milliseconds: 180); const double _errorHorizontal = 32; @@ -20,6 +21,9 @@ class PlayerVideoSurface extends StatefulWidget { this.placeholder, this.warmFrameAvailable = false, this.thumbnailReady = false, + this.posterReady = false, + this.fallback = PlayerOpeningFallback.black, + this.warmSlotReused = false, this.waitForPlaybackFrame = false, }); @@ -34,6 +38,15 @@ class PlayerVideoSurface extends StatefulWidget { /// 当前集 t=0 雪碧图是否已命中缓存,仅用于调试判定链。 final bool thumbnailReady; + /// 当前集高清 poster 是否已下发,仅用于调试判定链。 + final bool posterReady; + + /// 首个视频帧确认前实际选择的唯一占位来源。 + final PlayerOpeningFallback fallback; + + /// 当前集是否为相邻集预热槽晋升而来。 + final bool warmSlotReused; + /// 自动播放中的新集等待 position 首次前进后再移交画面。 final bool waitForPlaybackFrame; final String videoUrl; @@ -90,6 +103,9 @@ class _PlayerVideoSurfaceState extends State _placeholderSnapshot = widget.placeholder; } if (oldWidget.thumbnailReady != widget.thumbnailReady || + oldWidget.posterReady != widget.posterReady || + oldWidget.fallback != widget.fallback || + oldWidget.warmSlotReused != widget.warmSlotReused || oldWidget.warmFrameAvailable != widget.warmFrameAvailable) { final value = widget.controller?.value; _updatePlaybackFlags(value); @@ -285,14 +301,12 @@ class _PlayerVideoSurfaceState extends State if (!kDebugMode) return; final fallback = phase == 'video' || phase == 'fade-end' ? 'video' - : _placeholderSnapshot == null - ? 'black' - : widget.thumbnailReady - ? 'thumbnails-first-frame' - : 'black'; + : _fallbackLabel(widget.fallback); final signature = 'phase=$phase warm=${widget.warmFrameAvailable ? 1 : 0} ' + 'reusedWarm=${widget.warmSlotReused ? 1 : 0} ' 'frame=${_frameConfirmed ? 1 : 0} ' + 'poster=${widget.posterReady ? 'ready' : 'none'} ' 'thumbnails=${widget.thumbnailReady ? 'ready' : 'pending'} ' 'fallback=$fallback ' 'init=${value?.isInitialized == true ? 1 : 0} ' @@ -305,6 +319,16 @@ class _PlayerVideoSurfaceState extends State '[player-layer] ts=${DateTime.now().microsecondsSinceEpoch} $signature', ); } + + String _fallbackLabel(PlayerOpeningFallback fallback) { + if (_placeholderSnapshot == null) return 'black'; + return switch (fallback) { + PlayerOpeningFallback.handoff => 'handoff', + PlayerOpeningFallback.poster => 'poster', + PlayerOpeningFallback.thumbnails => 'thumbnails-first-frame', + PlayerOpeningFallback.black => 'black', + }; + } } /// 将旧 controller 作为唯一占位,不额外叠加 handoff 层。 diff --git a/test/playback/sprite_thumbnail_axis_test.dart b/test/playback/sprite_thumbnail_axis_test.dart index 4e45521..90cba24 100644 --- a/test/playback/sprite_thumbnail_axis_test.dart +++ b/test/playback/sprite_thumbnail_axis_test.dart @@ -199,19 +199,26 @@ void main() { 'title': 'episode', 'isFree': true, 'thumbnailsUrl': 'https://cdn.test/thumbnails.vtt?e=1&s=x', + 'posterUrl': 'https://cdn.test/poster.jpg?e=1&s=x', }); final ticket = PlayTicket.fromJson(const { 'episodeId': '11', 'accessible': true, 'accessType': 'FREE', 'thumbnailsUrl': 'https://cdn.test/thumbnails.vtt?e=1&s=x', + 'posterUrl': 'https://cdn.test/poster.jpg?e=1&s=x', }); expect(episode.thumbnailsUrl, ticket.thumbnailsUrl); + expect(episode.posterUrl, ticket.posterUrl); expect( episode.withVideoUrl('file:///video.mp4').thumbnailsUrl, episode.thumbnailsUrl, ); + expect( + episode.withVideoUrl('file:///video.mp4').posterUrl, + episode.posterUrl, + ); }); } diff --git a/test/player/player_fullscreen_test.dart b/test/player/player_fullscreen_test.dart index 354f4da..7704bfc 100644 --- a/test/player/player_fullscreen_test.dart +++ b/test/player/player_fullscreen_test.dart @@ -1037,6 +1037,8 @@ void main() { accessible: false, accessType: PlayTicket.coinRequired, unlockCoin: 30, + posterUrl: '/files/locked-first-frame.jpg', + thumbnailsUrl: '/files/locked-thumbnails.vtt', ), ), ); @@ -1049,6 +1051,8 @@ void main() { expect(find.byKey(const Key('progress-inactive')), findsOneWidget); expect(find.byType(Slider), findsNothing); expect(find.byKey(const Key('player-single-placeholder')), findsNothing); + expect(find.byKey(const Key('player-opening-poster')), findsNothing); + expect(find.byKey(const Key('player-opening-sprite')), findsNothing); expect(find.byKey(const Key('player-video-crossfade')), findsNothing); expect(find.byKey(const Key('player-video-layer')), findsNothing); expect( @@ -1487,6 +1491,8 @@ const _lockedDrama = Drama( isFree: false, locked: true, videoUrl: 'https://example.test/locked-demo-1.mp4', + posterUrl: '/files/locked-first-frame.jpg', + thumbnailsUrl: '/files/locked-thumbnails.vtt', ), Episode( id: 'locked-demo-2', diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index 55c4a2b..3b7b661 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -393,6 +393,35 @@ void main() { expect(logic.sessionEpisodes.first.videoUrl, 'file:///local/signal-1.mp4'); }); + test('should_write_ticket_poster_url_into_session_episode', () async { + final logic = _logic( + FakeWatchHistory(), + playback: FakePlaybackRepository( + ticket: const PlayTicket( + episodeId: 'signal-1', + accessible: true, + accessType: PlayTicket.free, + playUrl: '/files/signal-1.mp4', + posterUrl: '/files/transcoded/signal-1/poster.jpg', + ), + ), + ); + logic.attach( + PlayerPage(drama: _drama('signal'), initialIndex: 0), + isMounted: () => true, + ); + logic.start(factory: _RecordingFactory().create, coordinator: PlaybackCoordinator()); + await _drain(logic); + + expect( + logic.sessionEpisodes.first.posterUrl, + '/files/transcoded/signal-1/poster.jpg', + ); + + logic.dispose(); + await _drain(logic); + }); + group('surface flags', () { test('should_set_immersive_when_picture_in_picture_callback_fires', () async { final factory = _RecordingFactory(); diff --git a/test/player/player_opening_frame_test.dart b/test/player/player_opening_frame_test.dart index 294e2d2..587b0e4 100644 --- a/test/player/player_opening_frame_test.dart +++ b/test/player/player_opening_frame_test.dart @@ -17,6 +17,53 @@ sprite_001.jpg#xywh=0,0,160,90 '''; void main() { + test('should_choose_poster_before_thumbnails_when_both_are_available', () { + final service = SpriteThumbnailAxisService(loader: _FakeSpriteLoader()); + addTearDown(service.dispose); + + final frame = + playerOpeningFrame( + 'https://cdn.test/thumbnails.vtt', + posterUrl: 'https://cdn.test/poster.jpg', + spriteSource: service, + fit: BoxFit.cover, + ) + as PlayerOpeningFrame; + + expect(frame.posterUrl, 'https://cdn.test/poster.jpg'); + expect(frame.thumbnailsUrl, 'https://cdn.test/thumbnails.vtt'); + expect(frame.fit, BoxFit.cover); + expect( + playerOpeningFallback( + posterUrl: frame.posterUrl, + thumbnailsUrl: frame.thumbnailsUrl, + ), + PlayerOpeningFallback.poster, + ); + }); + + testWidgets('should_use_poster_without_decoding_sprite_when_poster_exists', ( + tester, + ) async { + final loader = _FakeSpriteLoader(); + final service = SpriteThumbnailAxisService(loader: loader); + addTearDown(service.dispose); + + await tester.pumpWidget( + MaterialApp( + home: PlayerOpeningFrame( + posterUrl: 'https://cdn.test/poster.jpg', + thumbnailsUrl: 'https://cdn.test/thumbnails.vtt', + spriteSource: service, + ), + ), + ); + + expect(find.byKey(const Key('player-opening-poster')), findsOneWidget); + expect(find.byKey(const Key('player-opening-sprite')), findsNothing); + expect(loader.vttCalls, 0); + }); + testWidgets('should_return_no_opening_frame_when_thumbnails_is_empty', ( tester, ) async {