fix(player): 使用雪碧图预热首帧兜底

This commit is contained in:
Zhou Weidong committed 2026-10-26 14:21:52 +08:00
1 parent 48b4e67d34
commit 70d8e0f982
13 files changed
+207 -322

No files matched your search

@@ -132,36 +132,37 @@ class SpriteThumbnailAxisService extends ChangeNotifier
SpriteThumbnailAxisService({ SpriteThumbnailAxisService({
SpriteThumbnailAssetLoader? loader, SpriteThumbnailAssetLoader? loader,
this.cacheTtl = spriteVttCacheTtl, this.cacheTtl = spriteVttCacheTtl,
this.spriteCacheCapacity = spriteImageCacheCapacity,
DateTime Function()? now, DateTime Function()? now,
}) : _loader = loader ?? DioSpriteThumbnailAssetLoader(), }) : _loader = loader ?? DioSpriteThumbnailAssetLoader(),
_now = now ?? DateTime.now; _now = now ?? DateTime.now;
final SpriteThumbnailAssetLoader _loader; final SpriteThumbnailAssetLoader _loader;
final Duration cacheTtl; final Duration cacheTtl;
final int spriteCacheCapacity;
final DateTime Function() _now; final DateTime Function() _now;
final Map<String, _CachedAxis> _axisCache = {}; final Map<String, _CachedAxis> _axisCache = {};
final Map<String, Future<SpriteThumbnailAxis>> _axisRequests = {}; final Map<String, Future<SpriteThumbnailAxis>> _axisRequests = {};
final LinkedHashMap<String, _CachedSprite> _spriteCache = LinkedHashMap(); final LinkedHashMap<String, _CachedSprite> _spriteCache = LinkedHashMap();
final Map<String, Future<_CachedSprite?>> _spriteRequests = {}; final Map<String, Future<_CachedSprite?>> _spriteRequests = {};
final Set<String> _failedSprites = {}; final Set<String> _failedSprites = {};
String? _activeAxisUrl;
int _axisGeneration = 0;
bool _disposed = false; bool _disposed = false;
@visibleForTesting @visibleForTesting
int get spriteCacheSize => _spriteCache.length; int get spriteCacheSize => _spriteCache.length;
@visibleForTesting @visibleForTesting
List<String> get spriteCacheKeys => _spriteCache.keys.toList(); List<String> get spriteCacheKeys => _spriteCache.keys
.map((url) => Uri.tryParse(url)?.pathSegments.lastOrNull ?? url)
.toList();
/// 播放器就绪时预取当前 cue 与相邻 sprite,拖动帧不触发网络或解码。 /// 播放器就绪时预取当前 cue 与相邻 sprite,拖动帧不触发网络或解码。
@override @override
Future<void> prepareAt(String? thumbnailsUrl, Duration position) async { Future<void> prepareAt(String? thumbnailsUrl, Duration position) async {
final url = _normalizeUrl(thumbnailsUrl); final url = _normalizeUrl(thumbnailsUrl);
if (_disposed || url == null) return; if (_disposed || url == null) return;
_activateAxis(url);
final axis = await _loadAxis(url); final axis = await _loadAxis(url);
if (_disposed || _activeAxisUrl != url) return; if (_disposed) return;
for (final cue in _prefetchCues(axis, position)) { for (final cue in _prefetchCues(axis, position)) {
await _ensureSprite(axis, cue); await _ensureSprite(axis, cue);
} }
@@ -170,7 +171,7 @@ class SpriteThumbnailAxisService extends ChangeNotifier
@override @override
bool hasAxis(String? thumbnailsUrl) { bool hasAxis(String? thumbnailsUrl) {
final url = _normalizeUrl(thumbnailsUrl); final url = _normalizeUrl(thumbnailsUrl);
if (url == null || url != _activeAxisUrl) return false; if (url == null) return false;
return _readAxis(url) != null; return _readAxis(url) != null;
} }
@@ -180,12 +181,14 @@ class SpriteThumbnailAxisService extends ChangeNotifier
Duration position, Duration position,
) { ) {
final url = _normalizeUrl(thumbnailsUrl); final url = _normalizeUrl(thumbnailsUrl);
if (url == null || url != _activeAxisUrl) return null; if (url == null) return null;
final axis = _readAxis(url); final axis = _readAxis(url);
if (axis == null) return null; if (axis == null) return null;
final cue = selectSpriteThumbnailCue(axis.cues, position); final cue = selectSpriteThumbnailCue(axis.cues, position);
if (cue == null) return null; if (cue == null) return null;
final cached = _readSprite(cue.spriteFile); final cached = _readSprite(
axis.resolveSpriteUri(cue.spriteFile).toString(),
);
if (cached == null) return null; if (cached == null) return null;
final sourceRect = _scaledSourceRect(cached, cue.sourceRect); final sourceRect = _scaledSourceRect(cached, cue.sourceRect);
if (sourceRect == null) return null; if (sourceRect == null) return null;
@@ -206,13 +209,6 @@ class SpriteThumbnailAxisService extends ChangeNotifier
super.dispose(); super.dispose();
} }
void _activateAxis(String url) {
if (_activeAxisUrl == url) return;
_activeAxisUrl = url;
_axisGeneration++;
_clearSprites();
}
Future<SpriteThumbnailAxis> _loadAxis(String url) { Future<SpriteThumbnailAxis> _loadAxis(String url) {
final cached = _readAxis(url); final cached = _readAxis(url);
if (cached != null) return Future.value(cached); if (cached != null) return Future.value(cached);
@@ -239,7 +235,6 @@ class SpriteThumbnailAxisService extends ChangeNotifier
} on SpriteThumbnailForbiddenException { } on SpriteThumbnailForbiddenException {
if (_disposed) return SpriteThumbnailAxis.empty(url); if (_disposed) return SpriteThumbnailAxis.empty(url);
_axisCache.remove(url); _axisCache.remove(url);
if (_activeAxisUrl == url) _clearSprites();
} catch (_) { } catch (_) {
break; break;
} }
@@ -293,23 +288,23 @@ class SpriteThumbnailAxisService extends ChangeNotifier
SpriteThumbnailAxis axis, SpriteThumbnailAxis axis,
SpriteThumbnailCue cue, SpriteThumbnailCue cue,
) { ) {
final cached = _readSprite(cue.spriteFile); final spriteUrl = axis.resolveSpriteUri(cue.spriteFile).toString();
final cached = _readSprite(spriteUrl);
if (cached != null) return Future.value(cached); if (cached != null) return Future.value(cached);
if (_failedSprites.contains(cue.spriteFile)) return Future.value(); if (_failedSprites.contains(spriteUrl)) return Future.value();
final existing = _spriteRequests[cue.spriteFile]; final existing = _spriteRequests[spriteUrl];
if (existing != null) return existing; if (existing != null) return existing;
final generation = _axisGeneration; final request = _loadSprite(axis, cue, spriteUrl);
final request = _loadSprite(axis, cue, generation); _spriteRequests[spriteUrl] = request;
_spriteRequests[cue.spriteFile] = request;
return request.whenComplete(() { return request.whenComplete(() {
_spriteRequests.remove(cue.spriteFile); _spriteRequests.remove(spriteUrl);
}); });
} }
Future<_CachedSprite?> _loadSprite( Future<_CachedSprite?> _loadSprite(
SpriteThumbnailAxis axis, SpriteThumbnailAxis axis,
SpriteThumbnailCue cue, SpriteThumbnailCue cue,
int generation, String spriteUrl,
) async { ) async {
SpriteThumbnailDecodedImage? decoded; SpriteThumbnailDecodedImage? decoded;
try { try {
@@ -318,16 +313,16 @@ class SpriteThumbnailAxisService extends ChangeNotifier
decoded = null; decoded = null;
} }
if (decoded == null) { if (decoded == null) {
_failedSprites.add(cue.spriteFile); _failedSprites.add(spriteUrl);
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
return null; return null;
} }
if (_disposed || generation != _axisGeneration) { if (_disposed) {
decoded.image.dispose(); decoded.image.dispose();
return null; return null;
} }
final cached = _CachedSprite(decoded); final cached = _CachedSprite(decoded);
_putSprite(cue.spriteFile, cached); _putSprite(spriteUrl, cached);
notifyListeners(); notifyListeners();
return cached; return cached;
} }
@@ -342,7 +337,7 @@ class SpriteThumbnailAxisService extends ChangeNotifier
void _putSprite(String key, _CachedSprite value) { void _putSprite(String key, _CachedSprite value) {
_spriteCache.remove(key)?.decoded.image.dispose(); _spriteCache.remove(key)?.decoded.image.dispose();
_spriteCache[key] = value; _spriteCache[key] = value;
while (_spriteCache.length > spriteImageCacheCapacity) { while (_spriteCache.length > spriteCacheCapacity) {
final oldest = _spriteCache.remove(_spriteCache.keys.first); final oldest = _spriteCache.remove(_spriteCache.keys.first);
oldest?.decoded.image.dispose(); oldest?.decoded.image.dispose();
} }
@@ -378,8 +373,6 @@ class SpriteThumbnailAxisService extends ChangeNotifier
} }
void _clearAll() { void _clearAll() {
_axisGeneration++;
_activeAxisUrl = null;
_axisCache.clear(); _axisCache.clear();
_axisRequests.clear(); _axisRequests.clear();
_clearSprites(); _clearSprites();
+43 -4
View File
@@ -146,7 +146,9 @@ class _PlayerPageState extends State<PlayerPage>
_thumbnailService = PlaybackThumbnailService( _thumbnailService = PlaybackThumbnailService(
sourceResolver: _resolveThumbnailSource, sourceResolver: _resolveThumbnailSource,
); );
_spriteAxisService = SpriteThumbnailAxisService(); _spriteAxisService = SpriteThumbnailAxisService(
spriteCacheCapacity: 8,
)..addListener(_handleSpriteFrameChanged);
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
logic.start( logic.start(
factory: context.read<PlayerFactory>(), factory: context.read<PlayerFactory>(),
@@ -266,7 +268,9 @@ class _PlayerPageState extends State<PlayerPage>
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
appRouteObserver.unsubscribe(this); appRouteObserver.unsubscribe(this);
_spriteAxisService.dispose(); _spriteAxisService
..removeListener(_handleSpriteFrameChanged)
..dispose();
_thumbnailService.dispose(); _thumbnailService.dispose();
logic.dispose(); logic.dispose();
super.dispose(); super.dispose();
@@ -369,6 +373,30 @@ class _PlayerPageState extends State<PlayerPage>
_chromeVisible = true; _chromeVisible = true;
} }
void _handleSpriteFrameChanged() {
if (mounted) setState(() {});
}
void _prewarmAdjacentThumbnails() {
final current = logic.session.currentIndex;
final indices = <int?>[
current,
logic.previousVisibleIndex(current),
logic.nextVisibleIndex(current),
];
for (final index in indices) {
if (index == null || index < 0 || index >= logic.sessionEpisodes.length) {
continue;
}
final thumbnailsUrl = logic.sessionEpisodes[index].thumbnailsUrl;
if (thumbnailsUrl != null) {
unawaited(
_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero),
);
}
}
}
void _restoreFeedChrome() { void _restoreFeedChrome() {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -451,6 +479,7 @@ class _PlayerPageState extends State<PlayerPage>
final content = AnimatedBuilder( final content = AnimatedBuilder(
animation: logic.session, animation: logic.session,
builder: (context, _) { builder: (context, _) {
_prewarmAdjacentThumbnails();
final pages = NotificationListener<ScrollNotification>( final pages = NotificationListener<ScrollNotification>(
onNotification: _handlePageScroll, onNotification: _handlePageScroll,
child: PageView.builder( child: PageView.builder(
@@ -590,6 +619,15 @@ class _PlayerPageState extends State<PlayerPage>
final videoFit = theater ? BoxFit.contain : BoxFit.cover; final videoFit = theater ? BoxFit.contain : BoxFit.cover;
final preparedFrameAvailable = final preparedFrameAvailable =
selected && logic.session.reusedWarmAt(index); selected && logic.session.reusedWarmAt(index);
final thumbnailsUrl = episode.thumbnailsUrl;
final openingFrameReady =
thumbnailsUrl != null &&
_spriteAxisService.cachedFrameAt(thumbnailsUrl, Duration.zero) != null;
if (thumbnailsUrl != null) {
unawaited(
_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero),
);
}
final locked = logic.session.isLocked || episode.locked; final locked = logic.session.isLocked || episode.locked;
if (selected && if (selected &&
videoPort != null && videoPort != null &&
@@ -617,9 +655,10 @@ class _PlayerPageState extends State<PlayerPage>
theater: theater, theater: theater,
holdOpeningUntilFirstFrame: logic.session.userWantsPlay, holdOpeningUntilFirstFrame: logic.session.userWantsPlay,
preparedFrameAvailable: preparedFrameAvailable, preparedFrameAvailable: preparedFrameAvailable,
openingFrameReady: openingFrameReady,
openingFrame: playerOpeningFrame( openingFrame: playerOpeningFrame(
episode.thumbnailsUrl, thumbnailsUrl,
posterUrl: drama.posterUrl, spriteSource: _spriteAxisService,
fit: videoFit, fit: videoFit,
), ),
), ),
@@ -217,7 +217,6 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
spriteAxisService: widget.spriteAxisService, spriteAxisService: widget.spriteAxisService,
thumbnailsUrl: widget.episode.thumbnailsUrl, thumbnailsUrl: widget.episode.thumbnailsUrl,
episodeId: widget.episode.id, episodeId: widget.episode.id,
posterUrl: widget.drama.posterUrl,
onScrubStart: _startScrub, onScrubStart: _startScrub,
onScrubEnd: _endScrub, onScrubEnd: _endScrub,
onSeek: widget.onSeek, onSeek: widget.onSeek,
@@ -23,7 +23,6 @@ class _FeedProgressBar extends StatefulWidget {
this.spriteAxisService, this.spriteAxisService,
this.thumbnailsUrl, this.thumbnailsUrl,
this.episodeId, this.episodeId,
this.posterUrl = '',
this.onScrubStart, this.onScrubStart,
this.onScrubEnd, this.onScrubEnd,
this.onSeek, this.onSeek,
@@ -38,7 +37,6 @@ class _FeedProgressBar extends StatefulWidget {
final SpriteThumbnailAxisSource? spriteAxisService; final SpriteThumbnailAxisSource? spriteAxisService;
final String? thumbnailsUrl; final String? thumbnailsUrl;
final String? episodeId; final String? episodeId;
final String posterUrl;
final VoidCallback? onScrubStart; final VoidCallback? onScrubStart;
final VoidCallback? onScrubEnd; final VoidCallback? onScrubEnd;
final ValueChanged<Duration>? onSeek; final ValueChanged<Duration>? onSeek;
@@ -166,7 +164,6 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
spriteAxisService: widget.spriteAxisService, spriteAxisService: widget.spriteAxisService,
thumbnailsUrl: widget.thumbnailsUrl, thumbnailsUrl: widget.thumbnailsUrl,
episodeId: widget.episodeId!, episodeId: widget.episodeId!,
posterUrl: widget.posterUrl,
position: previewPosition, position: previewPosition,
duration: video!.duration, duration: video!.duration,
), ),
@@ -1,199 +1,97 @@
// Author: fruit.com.cn // Author: fruit.com.cn
import 'dart:async'; import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart'; 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 '../../../core/data/services/sprite_thumbnail_axis.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/remote_image.dart';
import 'scrub_preview_card.dart'; import 'scrub_preview_card.dart';
/// 封面立即可见,雪碧图首帧解码完成后再覆盖封面。 /// 当前集雪碧图首帧在视频纹理未就绪时占位;空地址交还纯黑底。
Widget? playerOpeningFrame( Widget? playerOpeningFrame(
String? thumbnailsUrl, { String? thumbnailsUrl, {
String? posterUrl, required SpriteThumbnailAxisSource spriteSource,
BoxFit fit = BoxFit.cover, BoxFit fit = BoxFit.cover,
}) { }) {
final url = thumbnailsUrl?.trim() ?? ''; final url = thumbnailsUrl?.trim() ?? '';
final poster = posterUrl?.trim() ?? ''; if (url.isEmpty) return null;
if (url.isEmpty && poster.isEmpty) return null;
return PlayerOpeningFrame( return PlayerOpeningFrame(
thumbnailsUrl: url.isEmpty ? null : url, thumbnailsUrl: url,
posterUrl: poster.isEmpty ? null : poster, spriteSource: spriteSource,
fit: fit, fit: fit,
); );
} }
/// 视频解码前依次使用封面和雪碧图首帧,最终由父层黑色兜底。 /// 与视频共用全屏父约束和 BoxFit,切换时保持相同可见区域。
class PlayerOpeningFrame extends StatefulWidget { class PlayerOpeningFrame extends StatefulWidget {
const PlayerOpeningFrame({ const PlayerOpeningFrame({
super.key, super.key,
this.thumbnailsUrl, required this.thumbnailsUrl,
this.posterUrl, required this.spriteSource,
this.fit = BoxFit.cover, this.fit = BoxFit.cover,
this.loader,
this.cache,
}); });
final String? thumbnailsUrl; /// 当前视频雪碧图 WebVTT 地址。
final String? posterUrl; final String thumbnailsUrl;
/// 进度条拖动预览共用的雪碧图时间轴与解码缓存。
final SpriteThumbnailAxisSource spriteSource;
/// 必须与同页视频层使用相同适配规则。
final BoxFit fit; final BoxFit fit;
final SpriteThumbnailAssetLoader? loader;
final OpeningFrameCache? cache;
@override @override
State<PlayerOpeningFrame> createState() => _PlayerOpeningFrameState(); State<PlayerOpeningFrame> createState() => _PlayerOpeningFrameState();
} }
class _PlayerOpeningFrameState extends State<PlayerOpeningFrame> { class _PlayerOpeningFrameState extends State<PlayerOpeningFrame> {
SpriteThumbnailFrame? _frame;
int _serial = 0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
unawaited(_load()); widget.spriteSource.addListener(_handleSpriteChanged);
unawaited(
widget.spriteSource.prepareAt(widget.thumbnailsUrl, Duration.zero),
);
} }
@override @override
void didUpdateWidget(covariant PlayerOpeningFrame oldWidget) { void didUpdateWidget(covariant PlayerOpeningFrame oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.spriteSource != widget.spriteSource) {
oldWidget.spriteSource.removeListener(_handleSpriteChanged);
widget.spriteSource.addListener(_handleSpriteChanged);
}
if (oldWidget.thumbnailsUrl != widget.thumbnailsUrl) { if (oldWidget.thumbnailsUrl != widget.thumbnailsUrl) {
_frame = null; unawaited(
unawaited(_load()); widget.spriteSource.prepareAt(widget.thumbnailsUrl, Duration.zero),
);
} }
}
Future<void> _load() async {
final serial = ++_serial;
final thumbnailsUrl = widget.thumbnailsUrl;
if (thumbnailsUrl == null) return;
final frame = await (widget.cache ?? OpeningFrameCache.shared).load(
thumbnailsUrl,
loader: widget.loader,
);
if (!mounted || serial != _serial) return;
setState(() => _frame = frame);
} }
@override @override
Widget build(BuildContext context) { void dispose() {
final frame = _frame; widget.spriteSource.removeListener(_handleSpriteChanged);
final posterUrl = widget.posterUrl; super.dispose();
return Stack(
fit: StackFit.expand,
children: [
const ColoredBox(color: AppColors.mediaBlack),
if (posterUrl != null)
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,
),
],
);
}
}
/// 相邻页共用的雪碧图首帧缓存,避免每页重复拉 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();
} }
void _handleSpriteChanged() {
if (mounted) setState(() {});
}
@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,
fit: widget.fit,
),
],
);
} }
@@ -303,7 +303,6 @@ class PlayerPlaybackOverlay extends StatelessWidget {
spriteAxisService: data.spriteAxisService, spriteAxisService: data.spriteAxisService,
thumbnailsUrl: data.episode.thumbnailsUrl, thumbnailsUrl: data.episode.thumbnailsUrl,
episodeId: data.episode.id, episodeId: data.episode.id,
posterUrl: data.drama.posterUrl,
inactiveTrackColor: AppColors.inactiveTrack, inactiveTrackColor: AppColors.inactiveTrack,
onScrubStart: actions.onScrubStart, onScrubStart: actions.onScrubStart,
onScrubEnd: actions.onScrubEnd, onScrubEnd: actions.onScrubEnd,
@@ -18,6 +18,7 @@ class PlayerVideoSurface extends StatefulWidget {
required this.theater, required this.theater,
this.handoff, this.handoff,
this.openingFrame, this.openingFrame,
this.openingFrameReady = false,
this.holdOpeningUntilFirstFrame = false, this.holdOpeningUntilFirstFrame = false,
this.preparedFrameAvailable = false, this.preparedFrameAvailable = false,
}); });
@@ -28,6 +29,9 @@ class PlayerVideoSurface extends StatefulWidget {
/// 解码出第一帧之前垫上的视频首帧,没有则为空。 /// 解码出第一帧之前垫上的视频首帧,没有则为空。
final Widget? openingFrame; final Widget? openingFrame;
/// 当前集雪碧图 t=0 是否已在共享缓存中可用。
final bool openingFrameReady;
/// 播放中的新片源在位置首次前进前继续显示封面,避免混入未出帧纹理。 /// 播放中的新片源在位置首次前进前继续显示封面,避免混入未出帧纹理。
final bool holdOpeningUntilFirstFrame; final bool holdOpeningUntilFirstFrame;
@@ -73,10 +77,10 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
final controller = widget.controller; final controller = widget.controller;
final handoff = _liveHandoff(controller); final handoff = _liveHandoff(controller);
final firstFrameSeen = _trackFrameState(controller, value); final firstFrameSeen = _trackFrameState(controller, value);
final firstFrameReady = final firstFrameReady = widget.preparedFrameAvailable || firstFrameSeen;
widget.preparedFrameAvailable || firstFrameSeen;
final textureReady = _shouldReveal(value, handoff != null); final textureReady = _shouldReveal(value, handoff != null);
final reveal = textureReady && final reveal =
textureReady &&
(!widget.holdOpeningUntilFirstFrame || (!widget.holdOpeningUntilFirstFrame ||
widget.preparedFrameAvailable || widget.preparedFrameAvailable ||
firstFrameReady || firstFrameReady ||
@@ -86,13 +90,7 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
final showOpening = final showOpening =
widget.openingFrame != null && handoff == null && !reveal; widget.openingFrame != null && handoff == null && !reveal;
_logStateChange(value); _logStateChange(value);
_logLayerState( _logLayerState(value, handoff, reveal, firstFrameReady, showOpening);
value,
handoff,
reveal,
firstFrameReady,
showOpening,
);
return Stack( return Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
@@ -203,10 +201,20 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
: !firstFrameReady && widget.holdOpeningUntilFirstFrame : !firstFrameReady && widget.holdOpeningUntilFirstFrame
? 'awaiting-first-frame' ? 'awaiting-first-frame'
: 'reveal'; : 'reveal';
final fallback = handoff != null
? 'handoff'
: showOpening
? widget.openingFrameReady
? 'thumbnails-first-frame'
: 'black'
: reveal
? 'video'
: 'black';
final signature = final signature =
'warm=${widget.preparedFrameAvailable ? 1 : 0} ' 'warm=${widget.preparedFrameAvailable ? 1 : 0} '
'frame=${firstFrameReady ? 1 : 0} ' 'frame=${firstFrameReady ? 1 : 0} '
'cover=${showOpening ? 1 : 0} ' 'thumbnails=${widget.openingFrameReady ? 'ready' : 'pending'} '
'fallback=$fallback '
'video=${reveal ? 1 : 0} handoff=${handoff == null ? 0 : 1} ' 'video=${reveal ? 1 : 0} handoff=${handoff == null ? 0 : 1} '
'init=${value?.isInitialized == true ? 1 : 0} ' 'init=${value?.isInitialized == true ? 1 : 0} '
'playing=${value?.isPlaying == true ? 1 : 0} ' 'playing=${value?.isPlaying == true ? 1 : 0} '
@@ -9,7 +9,6 @@ import 'package:flutter/material.dart';
import '../../../core/data/services/playback_thumbnail_service.dart'; import '../../../core/data/services/playback_thumbnail_service.dart';
import '../../../core/data/services/sprite_thumbnail_axis.dart'; import '../../../core/data/services/sprite_thumbnail_axis.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/remote_image.dart';
const double scrubPreviewWidth = 96; const double scrubPreviewWidth = 96;
const double scrubPreviewSafeMargin = 12; const double scrubPreviewSafeMargin = 12;
@@ -36,7 +35,6 @@ class ScrubPreviewCard extends StatefulWidget {
super.key, super.key,
required this.service, required this.service,
required this.episodeId, required this.episodeId,
required this.posterUrl,
required this.position, required this.position,
required this.duration, required this.duration,
this.spriteAxisService, this.spriteAxisService,
@@ -47,7 +45,6 @@ class ScrubPreviewCard extends StatefulWidget {
final SpriteThumbnailAxisSource? spriteAxisService; final SpriteThumbnailAxisSource? spriteAxisService;
final String? thumbnailsUrl; final String? thumbnailsUrl;
final String episodeId; final String episodeId;
final String posterUrl;
final Duration position; final Duration position;
final Duration duration; final Duration duration;
@@ -207,11 +204,9 @@ class _ScrubPreviewCardState extends State<ScrubPreviewCard> {
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
RemoteImage( const ColoredBox(
widget.posterUrl, key: Key('scrub-preview-background'),
key: const Key('scrub-preview-poster'), color: AppColors.bar,
fit: BoxFit.cover,
fallbackColor: AppColors.bar,
), ),
if (stage == ScrubPreviewStage.sprite && spriteFrame != null) if (stage == ScrubPreviewStage.sprite && spriteFrame != null)
SpriteCropPreview( SpriteCropPreview(
@@ -265,11 +260,7 @@ class SpriteCropPreview extends StatelessWidget {
@override @override
Widget build(BuildContext context) => CustomPaint( Widget build(BuildContext context) => CustomPaint(
painter: _SpriteCropPainter( painter: _SpriteCropPainter(image: image, sourceRect: sourceRect, fit: fit),
image: image,
sourceRect: sourceRect,
fit: fit,
),
); );
} }
@@ -41,7 +41,6 @@ class SeekProgressBar extends StatefulWidget {
this.thumbnailService, this.thumbnailService,
this.spriteAxisService, this.spriteAxisService,
this.episodeId, this.episodeId,
this.posterUrl = '',
this.thumbnailsUrl, this.thumbnailsUrl,
}); });
@@ -57,7 +56,6 @@ class SeekProgressBar extends StatefulWidget {
final PlaybackThumbnailService? thumbnailService; final PlaybackThumbnailService? thumbnailService;
final SpriteThumbnailAxisService? spriteAxisService; final SpriteThumbnailAxisService? spriteAxisService;
final String? episodeId; final String? episodeId;
final String posterUrl;
final String? thumbnailsUrl; final String? thumbnailsUrl;
@override @override
@@ -351,7 +349,6 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
spriteAxisService: widget.spriteAxisService, spriteAxisService: widget.spriteAxisService,
thumbnailsUrl: widget.thumbnailsUrl, thumbnailsUrl: widget.thumbnailsUrl,
episodeId: widget.episodeId!, episodeId: widget.episodeId!,
posterUrl: widget.posterUrl,
position: previewPosition, position: previewPosition,
duration: video.duration, duration: video.duration,
), ),
+2 -5
View File
@@ -249,9 +249,7 @@ void main() {
}, },
); );
testWidgets('should_show_frame_card_with_cover_then_hide_on_release', ( testWidgets('should_show_frame_card_then_hide_on_release', (tester) async {
tester,
) async {
final video = ValueNotifier<VideoPlayerValue>( final video = ValueNotifier<VideoPlayerValue>(
const VideoPlayerValue( const VideoPlayerValue(
duration: Duration(seconds: 12), duration: Duration(seconds: 12),
@@ -272,7 +270,6 @@ void main() {
valueListenable: video, valueListenable: video,
semanticsLabel: 'Seek video', semanticsLabel: 'Seek video',
episodeId: 'episode-1', episodeId: 'episode-1',
posterUrl: '',
thumbnailService: service, thumbnailService: service,
onSeek: (_) async {}, onSeek: (_) async {},
), ),
@@ -289,7 +286,7 @@ void main() {
await tester.pump(); await tester.pump();
expect(find.byKey(const Key('scrub-preview-card')), findsOneWidget); expect(find.byKey(const Key('scrub-preview-card')), findsOneWidget);
expect(find.byKey(const Key('scrub-preview-poster')), findsOneWidget); expect(find.byKey(const Key('scrub-preview-background')), findsOneWidget);
expect(find.byKey(const Key('scrub-preview-frame')), findsNothing); expect(find.byKey(const Key('scrub-preview-frame')), findsNothing);
expect(find.textContaining('/ 00:12'), findsOneWidget); expect(find.textContaining('/ 00:12'), findsOneWidget);
expect( expect(
@@ -114,6 +114,32 @@ void main() {
expect(service.hasAxis('https://cdn.test/thumbnails.vtt'), isTrue); expect(service.hasAxis('https://cdn.test/thumbnails.vtt'), isTrue);
}); });
testWidgets('should_keep_zero_frames_ready_across_neighbor_episodes', (
tester,
) async {
final loader = _FakeSpriteLoader(vtt: _spriteVtt);
final service = SpriteThumbnailAxisService(
loader: loader,
spriteCacheCapacity: 6,
);
addTearDown(service.dispose);
const current = 'https://cdn.test/episode-1/thumbnails.vtt';
const previous = 'https://cdn.test/episode-0/thumbnails.vtt';
const next = 'https://cdn.test/episode-2/thumbnails.vtt';
await Future.wait([
service.prepareAt(previous, Duration.zero),
service.prepareAt(current, Duration.zero),
service.prepareAt(next, Duration.zero),
]);
for (final url in [previous, current, next]) {
expect(service.hasAxis(url), isTrue);
expect(service.cachedFrameAt(url, Duration.zero), isNotNull);
}
expect(loader.spriteCalls, hasLength(6));
});
testWidgets('should_refresh_vtt_after_ten_minute_ttl', (tester) async { testWidgets('should_refresh_vtt_after_ten_minute_ttl', (tester) async {
var now = DateTime(2026, 10, 22); var now = DateTime(2026, 10, 22);
final loader = _FakeSpriteLoader(vtt: _spriteVtt); final loader = _FakeSpriteLoader(vtt: _spriteVtt);
+38 -94
View File
@@ -5,8 +5,9 @@ import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/services/sprite_thumbnail_asset_loader.dart'; import 'package:fruitshorts/core/data/services/sprite_thumbnail_asset_loader.dart';
import 'package:fruitshorts/core/widgets/remote_image.dart'; import 'package:fruitshorts/core/data/services/sprite_thumbnail_axis.dart';
import 'package:fruitshorts/pages/player/widgets/player_opening_frame.dart'; import 'package:fruitshorts/pages/player/widgets/player_opening_frame.dart';
import 'package:fruitshorts/pages/player/widgets/scrub_preview_card.dart';
const String _spriteVtt = ''' const String _spriteVtt = '''
WEBVTT WEBVTT
@@ -16,49 +17,46 @@ sprite_001.jpg#xywh=0,0,160,90
'''; ''';
void main() { void main() {
test('should_return_no_opening_frame_when_thumbnails_url_is_empty', () { testWidgets('should_return_no_opening_frame_when_thumbnails_is_empty', (
expect(playerOpeningFrame(null, posterUrl: ' '), isNull); tester,
expect(playerOpeningFrame(' '), isNull); ) async {
final service = SpriteThumbnailAxisService(loader: _FakeSpriteLoader());
addTearDown(service.dispose);
expect(playerOpeningFrame(null, spriteSource: service), isNull);
expect(playerOpeningFrame(' ', spriteSource: service), isNull);
}); });
test('should_keep_opening_frame_when_only_poster_is_available', () { testWidgets('should_render_prewarmed_zero_frame_from_shared_cache', (
expect( tester,
playerOpeningFrame(null, posterUrl: 'https://cdn.test/poster.jpg'), ) async {
isA<PlayerOpeningFrame>(),
);
});
testWidgets('should_load_and_reuse_first_sprite_frame', (tester) async {
final loader = _FakeSpriteLoader(); final loader = _FakeSpriteLoader();
final cache = OpeningFrameCache(); final service = SpriteThumbnailAxisService(loader: loader);
addTearDown(cache.clear); addTearDown(service.dispose);
const url = 'https://cdn.test/thumbnails.vtt';
await service.prepareAt(url, Duration.zero);
final first = await cache.load( await tester.pumpWidget(
'https://cdn.test/thumbnails.vtt', MaterialApp(
loader: loader, home: PlayerOpeningFrame(thumbnailsUrl: url, spriteSource: service),
); ),
final second = await cache.load(
'https://cdn.test/thumbnails.vtt',
loader: loader,
); );
await tester.pump();
expect(first, isNotNull); expect(find.byKey(const Key('player-opening-sprite')), findsOneWidget);
expect(second, same(first));
expect(loader.vttCalls, 1); expect(loader.vttCalls, 1);
expect(loader.spriteCalls, ['sprite_001.jpg']); expect(loader.spriteCalls, ['sprite_001.jpg']);
}); });
testWidgets('should_render_decoded_opening_frame', (tester) async { testWidgets('should_load_sprite_when_not_prewarmed', (tester) async {
final loader = _FakeSpriteLoader(); final service = SpriteThumbnailAxisService(loader: _FakeSpriteLoader());
final cache = OpeningFrameCache(); addTearDown(service.dispose);
addTearDown(cache.clear);
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: PlayerOpeningFrame( home: PlayerOpeningFrame(
thumbnailsUrl: 'https://cdn.test/thumbnails.vtt', thumbnailsUrl: 'https://cdn.test/thumbnails.vtt',
loader: loader, spriteSource: service,
cache: cache,
), ),
), ),
); );
@@ -67,80 +65,26 @@ void main() {
expect(find.byKey(const Key('player-opening-sprite')), findsOneWidget); expect(find.byKey(const Key('player-opening-sprite')), findsOneWidget);
}); });
testWidgets('should_show_poster_while_sprite_is_loading', (tester) async { testWidgets('should_use_same_fit_for_sprite_and_video', (tester) async {
final cache = OpeningFrameCache(); final service = SpriteThumbnailAxisService(loader: _FakeSpriteLoader());
addTearDown(cache.clear); addTearDown(service.dispose);
const url = 'https://cdn.test/thumbnails.vtt';
await service.prepareAt(url, Duration.zero);
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
home: PlayerOpeningFrame( home: PlayerOpeningFrame(
thumbnailsUrl: 'https://cdn.test/thumbnails.vtt', thumbnailsUrl: url,
posterUrl: 'https://cdn.test/poster.jpg', spriteSource: service,
loader: _FakeSpriteLoader(), fit: BoxFit.contain,
cache: cache,
), ),
), ),
); );
expect(find.byKey(const Key('player-opening-poster')), findsOneWidget); final preview = tester.widget<SpriteCropPreview>(
}); find.byKey(const Key('player-opening-sprite')),
testWidgets('should_use_surface_fit_without_extra_frame_constraint', (
tester,
) async {
await tester.pumpWidget(
const MaterialApp(
home: PlayerOpeningFrame(
posterUrl: 'https://cdn.test/poster.jpg',
),
),
); );
expect(preview.fit, BoxFit.contain);
final poster = tester.widget<RemoteImage>(
find.byKey(const Key('player-opening-poster')),
);
expect(poster.fit, BoxFit.cover);
expect(find.byType(AspectRatio), findsNothing);
});
test('should_forward_video_fit_to_opening_frame', () {
final frame = playerOpeningFrame(
null,
posterUrl: 'https://cdn.test/poster.jpg',
fit: BoxFit.contain,
);
expect(frame, isA<PlayerOpeningFrame>());
expect((frame! as PlayerOpeningFrame).fit, BoxFit.contain);
});
testWidgets('should_reuse_decoded_sprite_when_parent_rebuilds', (
tester,
) async {
final loader = _FakeSpriteLoader();
final cache = OpeningFrameCache();
addTearDown(cache.clear);
Widget build() => MaterialApp(
home: PlayerOpeningFrame(
thumbnailsUrl: 'https://cdn.test/thumbnails.vtt',
posterUrl: 'https://cdn.test/poster.jpg',
loader: loader,
cache: cache,
),
);
await tester.pumpWidget(build());
await tester.pumpAndSettle();
expect(loader.vttCalls, 1);
expect(loader.spriteCalls, hasLength(1));
await tester.pumpWidget(build());
await tester.pumpAndSettle();
expect(loader.vttCalls, 1);
expect(loader.spriteCalls, hasLength(1));
expect(find.byKey(const Key('player-opening-sprite')), findsOneWidget);
}); });
} }
+3 -6
View File
@@ -67,13 +67,11 @@ void main() {
await tester.pump(); await tester.pump();
expect(loader.calls, 1); expect(loader.calls, 1);
expect(find.byKey(const Key('scrub-preview-poster')), findsOneWidget); expect(find.byKey(const Key('scrub-preview-background')), findsOneWidget);
expect(find.byKey(const Key('scrub-preview-sprite')), findsNothing); expect(find.byKey(const Key('scrub-preview-sprite')), findsNothing);
}); });
testWidgets('should_keep_poster_when_axis_and_local_frame_both_fail', ( testWidgets('should_keep_background_when_frames_both_fail', (tester) async {
tester,
) async {
final loader = _CountingThumbnailLoader(); final loader = _CountingThumbnailLoader();
final localService = _localService(loader); final localService = _localService(loader);
final axis = _FakeAxisSource(axisReady: true); final axis = _FakeAxisSource(axisReady: true);
@@ -90,7 +88,7 @@ void main() {
await tester.pump(); await tester.pump();
expect(loader.calls, 1); expect(loader.calls, 1);
expect(find.byKey(const Key('scrub-preview-poster')), findsOneWidget); expect(find.byKey(const Key('scrub-preview-background')), findsOneWidget);
expect(find.byKey(const Key('scrub-preview-sprite')), findsNothing); expect(find.byKey(const Key('scrub-preview-sprite')), findsNothing);
expect(find.byKey(const Key('scrub-preview-frame')), findsNothing); expect(find.byKey(const Key('scrub-preview-frame')), findsNothing);
}); });
@@ -109,7 +107,6 @@ Widget _card({
spriteAxisService: axis, spriteAxisService: axis,
thumbnailsUrl: thumbnailsUrl, thumbnailsUrl: thumbnailsUrl,
episodeId: 'episode-1', episodeId: 'episode-1',
posterUrl: '',
position: const Duration(seconds: 1), position: const Duration(seconds: 1),
duration: const Duration(seconds: 10), duration: const Duration(seconds: 10),
), ),