fix(player): 使用高清首帧并稳定切集占位
This commit is contained in:
1 parent
0cb0ab0613
commit
2fef7470e0
10 files changed
+258
-33
No files matched your search
@@ -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<String, dynamic> 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<Map<String, String>> types, Object? fallback) {
|
||||
final joined = names.join(' / ');
|
||||
if (joined.isNotEmpty) return joined;
|
||||
return fallback is String ? fallback : '';
|
||||
}
|
||||
}
|
||||
@@ -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<PlayQualityTrack> 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']),
|
||||
|
||||
@@ -462,6 +462,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
likeCount: ticket.likeCount,
|
||||
commentCount: ticket.commentCount,
|
||||
thumbnailsUrl: ticket.thumbnailsUrl,
|
||||
posterUrl: ticket.posterUrl,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
DateTime? _lastDoubleTapAt;
|
||||
DateTime? _swipeStartedAt;
|
||||
bool _swipePrewarmCaptured = false;
|
||||
final Set<String> _precachedPosterUrls = {};
|
||||
|
||||
/// 收藏切换后的本地增量:基准是详情快照,切换成功按服务端状态 ±1。
|
||||
final Map<String, int> _favoriteDelta = {};
|
||||
@@ -376,7 +377,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _prewarmAdjacentThumbnails() {
|
||||
void _prewarmAdjacentFrames() {
|
||||
final current = logic.session.currentIndex;
|
||||
final indices = <int?>[
|
||||
current,
|
||||
@@ -387,9 +388,24 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
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<PlayerPage>
|
||||
final content = AnimatedBuilder(
|
||||
animation: logic.session,
|
||||
builder: (context, _) {
|
||||
_prewarmAdjacentThumbnails();
|
||||
_prewarmAdjacentFrames();
|
||||
final pages = NotificationListener<ScrollNotification>(
|
||||
onNotification: _handlePageScroll,
|
||||
child: PageView.builder(
|
||||
@@ -592,12 +608,17 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
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<PlayerPage>
|
||||
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<PlayerPage>
|
||||
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<PlayerPage>
|
||||
)
|
||||
: playerOpeningFrame(
|
||||
thumbnailsUrl,
|
||||
posterUrl: posterUrl,
|
||||
spriteSource: _spriteAxisService,
|
||||
fit: videoFit,
|
||||
),
|
||||
|
||||
@@ -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<PlayerOpeningFrame> {
|
||||
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<PlayerOpeningFrame> {
|
||||
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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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<PlayerVideoSurface>
|
||||
_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<PlayerVideoSurface>
|
||||
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<PlayerVideoSurface>
|
||||
'[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 层。
|
||||
|
||||
Reference in new issue
Block a user