200 lines
5.6 KiB
Dart
200 lines
5.6 KiB
Dart
// 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<PlayerOpeningFrame> createState() => _PlayerOpeningFrameState();
|
|
}
|
|
|
|
class _PlayerOpeningFrameState extends State<PlayerOpeningFrame> {
|
|
SpriteThumbnailFrame? _frame;
|
|
int _serial = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
unawaited(_load());
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant PlayerOpeningFrame oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.thumbnailsUrl != widget.thumbnailsUrl) {
|
|
_frame = null;
|
|
unawaited(_load());
|
|
}
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
final serial = ++_serial;
|
|
final 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();
|
|
}
|
|
}
|