// 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, BoxFit fit = BoxFit.cover, }) { final url = thumbnailsUrl?.trim() ?? ''; final poster = posterUrl?.trim() ?? ''; if (url.isEmpty && poster.isEmpty) return null; return PlayerOpeningFrame( thumbnailsUrl: url.isEmpty ? null : url, posterUrl: poster.isEmpty ? null : poster, fit: fit, ); } /// 视频解码前依次使用封面和雪碧图首帧,最终由父层黑色兜底。 class PlayerOpeningFrame extends StatefulWidget { const PlayerOpeningFrame({ super.key, this.thumbnailsUrl, this.posterUrl, this.fit = BoxFit.cover, this.loader, this.cache, }); final String? thumbnailsUrl; final String? posterUrl; final BoxFit fit; final SpriteThumbnailAssetLoader? loader; final OpeningFrameCache? cache; @override State createState() => _PlayerOpeningFrameState(); } class _PlayerOpeningFrameState extends State { SpriteThumbnailFrame? _frame; int _serial = 0; @override void initState() { super.initState(); unawaited(_load()); } @override void didUpdateWidget(covariant PlayerOpeningFrame oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.thumbnailsUrl != widget.thumbnailsUrl) { _frame = null; unawaited(_load()); } } Future _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 _frames = {}; final Map> _loads = {}; /// 取 [thumbnailsUrl] 对应的第一帧;失败返回 null。 Future load( String? thumbnailsUrl, { SpriteThumbnailAssetLoader? loader, }) { final url = _normalize(thumbnailsUrl); if (url == null) return Future.value(); final cached = _frames[url]; if (cached != null) return Future.value(cached); return _loads.putIfAbsent(url, () => _fetch(url, loader)); } Future _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(); } }