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