fix(player): 使用雪碧图预热首帧兜底
This commit is contained in:
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();
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user