fix(player): 预热直出并与视频统一封面布局

This commit is contained in:
Zhou Weidong committed 2026-10-26 14:12:42 +08:00
1 parent a3a79f7068
commit 48b4e67d34
9 files changed
+119 -104

No files matched your search

+5
View File
@@ -587,6 +587,9 @@ class _PlayerPageState extends State<PlayerPage>
if (selected && videoReady) _logSwipeReveal();
final ready = videoReady || port != null;
final theater = logic.pip || logic.pipPreparing;
final videoFit = theater ? BoxFit.contain : BoxFit.cover;
final preparedFrameAvailable =
selected && logic.session.reusedWarmAt(index);
final locked = logic.session.isLocked || episode.locked;
if (selected &&
videoPort != null &&
@@ -613,9 +616,11 @@ class _PlayerPageState extends State<PlayerPage>
videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl,
theater: theater,
holdOpeningUntilFirstFrame: logic.session.userWantsPlay,
preparedFrameAvailable: preparedFrameAvailable,
openingFrame: playerOpeningFrame(
episode.thumbnailsUrl,
posterUrl: drama.posterUrl,
fit: videoFit,
),
),
if (selected &&
@@ -12,17 +12,19 @@ import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/remote_image.dart';
import 'scrub_preview_card.dart';
const double _posterAspectRatio = 3 / 4;
const double _posterBlurSigma = 18;
/// 封面立即可见,雪碧图首帧解码完成后再覆盖封面。
Widget? playerOpeningFrame(String? thumbnailsUrl, {String? posterUrl}) {
Widget? playerOpeningFrame(
String? thumbnailsUrl, {
String? posterUrl,
BoxFit fit = BoxFit.cover,
}) {
final url = thumbnailsUrl?.trim() ?? '';
final poster = posterUrl?.trim() ?? '';
if (url.isEmpty && poster.isEmpty) return null;
return PlayerOpeningFrame(
thumbnailsUrl: url.isEmpty ? null : url,
posterUrl: poster.isEmpty ? null : poster,
fit: fit,
);
}
@@ -32,12 +34,14 @@ class PlayerOpeningFrame extends StatefulWidget {
super.key,
this.thumbnailsUrl,
this.posterUrl,
this.fit = BoxFit.cover,
this.loader,
this.cache,
});
final String? thumbnailsUrl;
final String? posterUrl;
final BoxFit fit;
final SpriteThumbnailAssetLoader? loader;
final OpeningFrameCache? cache;
@@ -85,39 +89,18 @@ class _PlayerOpeningFrameState extends State<PlayerOpeningFrame> {
children: [
const ColoredBox(color: AppColors.mediaBlack),
if (posterUrl != null)
Positioned.fill(
child: ImageFiltered(
key: const Key('player-opening-poster-blur'),
imageFilter: ui.ImageFilter.blur(
sigmaX: _posterBlurSigma,
sigmaY: _posterBlurSigma,
),
child: RemoteImage(
posterUrl,
key: const Key('player-opening-poster-backdrop'),
fit: BoxFit.cover,
fallbackColor: AppColors.mediaBlack,
),
),
),
if (posterUrl != null)
Center(
child: AspectRatio(
key: const Key('player-opening-poster-stage'),
aspectRatio: _posterAspectRatio,
child: RemoteImage(
posterUrl,
key: const Key('player-opening-poster'),
fit: BoxFit.cover,
fallbackColor: AppColors.mediaBlack,
),
),
RemoteImage(
posterUrl,
key: const Key('player-opening-poster'),
fit: widget.fit,
fallbackColor: AppColors.mediaBlack,
),
if (frame != null)
SpriteCropPreview(
key: const Key('player-opening-sprite'),
image: frame.image,
sourceRect: frame.sourceRect,
fit: widget.fit,
),
],
);
@@ -6,7 +6,6 @@ import 'package:video_player/video_player.dart';
import '../../../core/theme/app_colors.dart';
const Duration _videoFadeDuration = Duration(milliseconds: 200);
const double _errorHorizontal = 32;
const int _errorMaxLines = 3;
@@ -20,6 +19,7 @@ class PlayerVideoSurface extends StatefulWidget {
this.handoff,
this.openingFrame,
this.holdOpeningUntilFirstFrame = false,
this.preparedFrameAvailable = false,
});
final VideoPlayerController? controller;
@@ -30,6 +30,9 @@ class PlayerVideoSurface extends StatefulWidget {
/// 播放中的新片源在位置首次前进前继续显示封面,避免混入未出帧纹理。
final bool holdOpeningUntilFirstFrame;
/// 相邻集预热槽已持有可用画面,进入页面时不需要封面兜底。
final bool preparedFrameAvailable;
final String videoUrl;
final bool theater;
@@ -69,15 +72,27 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
Widget _frame(VideoPlayerValue? value) {
final controller = widget.controller;
final handoff = _liveHandoff(controller);
final firstFrameReady = _trackFrameState(controller, value);
final reveal = _shouldReveal(value, handoff != null) &&
final firstFrameSeen = _trackFrameState(controller, value);
final firstFrameReady =
widget.preparedFrameAvailable || firstFrameSeen;
final textureReady = _shouldReveal(value, handoff != null);
final reveal = textureReady &&
(!widget.holdOpeningUntilFirstFrame ||
widget.preparedFrameAvailable ||
firstFrameReady ||
_playbackStarted &&
value?.isPlaying == false &&
value?.isBuffering != true);
final showOpening =
widget.openingFrame != null && handoff == null && !reveal;
_logStateChange(value);
_logLayerState(value, handoff, reveal, firstFrameReady);
_logLayerState(
value,
handoff,
reveal,
firstFrameReady,
showOpening,
);
return Stack(
fit: StackFit.expand,
children: [
@@ -85,19 +100,15 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
key: Key('player-fallback-background'),
color: AppColors.mediaBlack,
),
if (widget.openingFrame != null)
AnimatedOpacity(
if (showOpening)
KeyedSubtree(
key: const Key('player-opening-frame'),
opacity: reveal ? 0 : 1,
duration: handoff != null ? Duration.zero : _videoFadeDuration,
curve: Curves.easeOut,
child: widget.openingFrame!,
),
AnimatedOpacity(
key: const Key('player-video-opacity'),
opacity: reveal ? 1 : 0,
duration: handoff != null ? Duration.zero : _videoFadeDuration,
curve: Curves.easeOut,
duration: Duration.zero,
child: reveal && controller != null
? _VideoLayer(
controller: controller,
@@ -180,20 +191,24 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
VideoPlayerController? handoff,
bool reveal,
bool firstFrameReady,
bool showOpening,
) {
if (!kDebugMode) return;
final reason = handoff != null
? 'handoff'
: !_shouldReveal(value, handoff != null)
? 'uninitialized'
: widget.preparedFrameAvailable
? 'warm-frame'
: !firstFrameReady && widget.holdOpeningUntilFirstFrame
? 'awaiting-first-frame'
: 'reveal';
final signature =
'opening=${widget.openingFrame == null ? 0 : reveal ? 0 : 1} '
'warm=${widget.preparedFrameAvailable ? 1 : 0} '
'frame=${firstFrameReady ? 1 : 0} '
'cover=${showOpening ? 1 : 0} '
'video=${reveal ? 1 : 0} handoff=${handoff == null ? 0 : 1} '
'init=${value?.isInitialized == true ? 1 : 0} '
'frame=${firstFrameReady ? 1 : 0} '
'playing=${value?.isPlaying == true ? 1 : 0} '
'buffering=${value?.isBuffering == true ? 1 : 0} '
'reason=$reason';
@@ -256,26 +256,37 @@ class SpriteCropPreview extends StatelessWidget {
super.key,
required this.image,
required this.sourceRect,
this.fit = BoxFit.cover,
});
final ui.Image image;
final ui.Rect sourceRect;
final BoxFit fit;
@override
Widget build(BuildContext context) => CustomPaint(
painter: _SpriteCropPainter(image: image, sourceRect: sourceRect),
painter: _SpriteCropPainter(
image: image,
sourceRect: sourceRect,
fit: fit,
),
);
}
class _SpriteCropPainter extends CustomPainter {
const _SpriteCropPainter({required this.image, required this.sourceRect});
const _SpriteCropPainter({
required this.image,
required this.sourceRect,
required this.fit,
});
final ui.Image image;
final ui.Rect sourceRect;
final BoxFit fit;
@override
void paint(Canvas canvas, Size size) {
final fitted = applyBoxFit(BoxFit.cover, sourceRect.size, size);
final fitted = applyBoxFit(fit, sourceRect.size, size);
final sourceOffset = Alignment.center.inscribe(
fitted.source,
Offset.zero & sourceRect.size,
@@ -302,7 +313,9 @@ class _SpriteCropPainter extends CustomPainter {
@override
bool shouldRepaint(covariant _SpriteCropPainter oldDelegate) =>
oldDelegate.image != image || oldDelegate.sourceRect != sourceRect;
oldDelegate.image != image ||
oldDelegate.sourceRect != sourceRect ||
oldDelegate.fit != fit;
}
String _format(Duration value) {