fix(player): 预热直出并与视频统一封面布局
This commit is contained in:
1 parent
a3a79f7068
commit
48b4e67d34
9 files changed
+119
-104
No files matched your search
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user