fix(player): 优化上下滑切集黑屏

This commit is contained in:
Zhou Weidong committed 2026-10-26 13:15:56 +08:00
1 parent b83b5799e3
commit 222bacdeeb
10 files changed
+625 -122

No files matched your search

@@ -0,0 +1,167 @@
// Author: fruit.com.cn
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import '../../../core/config/app_config.dart';
import '../../../core/data/services/sprite_thumbnail_asset_loader.dart';
import '../../../core/data/services/sprite_thumbnail_axis.dart';
import 'scrub_preview_card.dart';
/// 无雪碧图地址时不垫帧,交还给纯黑底。
Widget? playerOpeningFrame(String? thumbnailsUrl) {
final url = thumbnailsUrl?.trim() ?? '';
if (url.isEmpty) return null;
return PlayerOpeningFrame(thumbnailsUrl: url);
}
/// 解码出视频画面前,用本集雪碧图第一帧垫底。
class PlayerOpeningFrame extends StatefulWidget {
const PlayerOpeningFrame({
super.key,
required this.thumbnailsUrl,
this.loader,
this.cache,
});
final String thumbnailsUrl;
final SpriteThumbnailAssetLoader? loader;
final OpeningFrameCache? cache;
@override
State<PlayerOpeningFrame> createState() => _PlayerOpeningFrameState();
}
class _PlayerOpeningFrameState extends State<PlayerOpeningFrame> {
SpriteThumbnailFrame? _frame;
int _serial = 0;
@override
void initState() {
super.initState();
unawaited(_load());
}
@override
void didUpdateWidget(covariant PlayerOpeningFrame oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.thumbnailsUrl != widget.thumbnailsUrl) {
_frame = null;
unawaited(_load());
}
}
Future<void> _load() async {
final serial = ++_serial;
final frame = await (widget.cache ?? OpeningFrameCache.shared).load(
widget.thumbnailsUrl,
loader: widget.loader,
);
if (!mounted || serial != _serial) return;
setState(() => _frame = frame);
}
@override
Widget build(BuildContext context) {
final frame = _frame;
if (frame == null) return const SizedBox.expand();
return SpriteCropPreview(
key: const Key('player-opening-sprite'),
image: frame.image,
sourceRect: frame.sourceRect,
);
}
}
/// 相邻页共用的雪碧图首帧缓存,避免每页重复拉 VTT。
class OpeningFrameCache {
OpeningFrameCache({this.capacity = 6});
static final OpeningFrameCache shared = OpeningFrameCache();
final int capacity;
final Map<String, SpriteThumbnailFrame> _frames = {};
final Map<String, Future<SpriteThumbnailFrame?>> _loads = {};
/// 取 [thumbnailsUrl] 对应的第一帧;失败返回 null。
Future<SpriteThumbnailFrame?> load(
String? thumbnailsUrl, {
SpriteThumbnailAssetLoader? loader,
}) {
final url = _normalize(thumbnailsUrl);
if (url == null) return Future<SpriteThumbnailFrame?>.value();
final cached = _frames[url];
if (cached != null) return Future.value(cached);
return _loads.putIfAbsent(url, () => _fetch(url, loader));
}
Future<SpriteThumbnailFrame?> _fetch(
String url,
SpriteThumbnailAssetLoader? loader,
) async {
try {
final asset = loader ?? DioSpriteThumbnailAssetLoader();
final text = await asset.loadVtt(Uri.parse(url));
if (text == null || text.isEmpty) return null;
final axis = parseSpriteThumbnailVtt(text, vttUrl: url);
final cue = axis.cues.isEmpty ? null : axis.cues.first;
if (cue == null) return null;
final decoded = await asset.loadSprite(
axis.resolveSpriteUri(cue.spriteFile),
);
if (decoded == null) return null;
final frame = SpriteThumbnailFrame(
image: decoded.image,
sourceRect: _scaledRect(decoded, cue.sourceRect),
spriteUrl: axis.resolveSpriteUri(cue.spriteFile).toString(),
cue: cue,
);
_store(url, frame);
return frame;
} catch (_) {
return null;
} finally {
_loads.remove(url);
}
}
void _store(String url, SpriteThumbnailFrame frame) {
_frames.remove(url);
_frames[url] = frame;
while (_frames.length > capacity) {
final oldest = _frames.keys.first;
_frames.remove(oldest)?.image.dispose();
}
}
ui.Rect _scaledRect(SpriteThumbnailDecodedImage decoded, ui.Rect source) {
if (decoded.originalWidth <= 0 || decoded.originalHeight <= 0) {
return source;
}
final scaleX = decoded.image.width / decoded.originalWidth;
final scaleY = decoded.image.height / decoded.originalHeight;
return ui.Rect.fromLTWH(
source.left * scaleX,
source.top * scaleY,
source.width * scaleX,
source.height * scaleY,
);
}
String? _normalize(String? raw) {
final value = AppConfig.mediaUrl(raw?.trim() ?? '');
if (value.isEmpty) return null;
return value;
}
/// 测试收尾时清空缓存。
void clear() {
for (final frame in _frames.values) {
frame.image.dispose();
}
_frames.clear();
_loads.clear();
}
}
@@ -18,10 +18,14 @@ class PlayerVideoSurface extends StatefulWidget {
required this.videoUrl,
required this.theater,
this.handoff,
this.openingFrame,
});
final VideoPlayerController? controller;
final VideoPlayerController? handoff;
/// 解码出第一帧之前垫上的视频首帧,没有则为空。
final Widget? openingFrame;
final String videoUrl;
final bool theater;
@@ -60,6 +64,11 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
key: Key('player-fallback-background'),
color: AppColors.mediaBlack,
),
if (!reveal && widget.openingFrame != null)
KeyedSubtree(
key: const Key('player-opening-frame'),
child: widget.openingFrame!,
),
AnimatedOpacity(
key: const Key('player-video-opacity'),
opacity: reveal ? 1 : 0,
@@ -99,10 +108,9 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
}
bool _shouldReveal(VideoPlayerValue? value, bool covered) {
if (covered) return value?.isInitialized ?? false;
if (value == null || value.hasError || !value.isInitialized) return false;
if (value.size.width <= 0) return false;
if (covered) return true;
return value.position > Duration.zero || value.isPlaying;
return value.size.width > 0 && value.size.height > 0;
}
/// 只画会话仍持有的旧画面,控制器一旦被换掉就不再引用。