fix(player): 使用高清首帧并稳定切集占位

This commit is contained in:
Zhou Weidong committed 2026-10-26 14:58:00 +08:00
1 parent 0cb0ab0613
commit 2fef7470e0
10 files changed
+258 -33

No files matched your search

@@ -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(),
],
);
}