164 lines
4.4 KiB
Dart
164 lines
4.4 KiB
Dart
// Author: fruit.com.cn
|
|
|
|
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../../../core/config/app_config.dart';
|
|
import '../../../core/data/services/sprite_thumbnail_axis.dart';
|
|
import '../../../core/theme/app_colors.dart';
|
|
import 'scrub_preview_card.dart';
|
|
|
|
enum PlayerOpeningFallback { handoff, poster, thumbnails, black }
|
|
|
|
PlayerOpeningFallback playerOpeningFallback({
|
|
String? posterUrl,
|
|
String? thumbnailsUrl,
|
|
}) {
|
|
if (posterUrl?.trim().isNotEmpty ?? false) {
|
|
return PlayerOpeningFallback.poster;
|
|
}
|
|
if (thumbnailsUrl?.trim().isNotEmpty ?? false) {
|
|
return PlayerOpeningFallback.thumbnails;
|
|
}
|
|
return PlayerOpeningFallback.black;
|
|
}
|
|
|
|
/// 当前集首帧在视频纹理未就绪时占位;空地址交还纯黑底。
|
|
Widget? playerOpeningFrame(
|
|
String? thumbnailsUrl, {
|
|
String? posterUrl,
|
|
required SpriteThumbnailAxisSource spriteSource,
|
|
BoxFit fit = BoxFit.cover,
|
|
}) {
|
|
final thumbnails = thumbnailsUrl?.trim() ?? '';
|
|
final poster = posterUrl?.trim() ?? '';
|
|
if (playerOpeningFallback(posterUrl: poster, thumbnailsUrl: thumbnails) ==
|
|
PlayerOpeningFallback.black) {
|
|
return null;
|
|
}
|
|
return PlayerOpeningFrame(
|
|
thumbnailsUrl: thumbnails,
|
|
posterUrl: poster,
|
|
spriteSource: spriteSource,
|
|
fit: fit,
|
|
);
|
|
}
|
|
|
|
/// 与视频共用全屏父约束和 BoxFit,切换时保持相同可见区域。
|
|
class PlayerOpeningFrame extends StatefulWidget {
|
|
const PlayerOpeningFrame({
|
|
super.key,
|
|
required this.thumbnailsUrl,
|
|
required this.spriteSource,
|
|
this.posterUrl,
|
|
this.fit = BoxFit.cover,
|
|
});
|
|
|
|
/// 当前视频原始分辨率首帧地址,优先于雪碧图。
|
|
final String? posterUrl;
|
|
|
|
/// 当前视频雪碧图 WebVTT 地址。
|
|
final String thumbnailsUrl;
|
|
|
|
/// 进度条拖动预览共用的雪碧图时间轴与解码缓存。
|
|
final SpriteThumbnailAxisSource spriteSource;
|
|
|
|
/// 必须与同页视频层使用相同适配规则。
|
|
final BoxFit fit;
|
|
|
|
@override
|
|
State<PlayerOpeningFrame> createState() => _PlayerOpeningFrameState();
|
|
}
|
|
|
|
class _PlayerOpeningFrameState extends State<PlayerOpeningFrame> {
|
|
bool _posterFailed = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
widget.spriteSource.addListener(_handleSpriteChanged);
|
|
_prepareSpriteIfNeeded();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant PlayerOpeningFrame oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.posterUrl != widget.posterUrl) {
|
|
_posterFailed = false;
|
|
}
|
|
if (oldWidget.spriteSource != widget.spriteSource) {
|
|
oldWidget.spriteSource.removeListener(_handleSpriteChanged);
|
|
widget.spriteSource.addListener(_handleSpriteChanged);
|
|
_prepareSpriteIfNeeded();
|
|
}
|
|
if (oldWidget.posterUrl != widget.posterUrl ||
|
|
oldWidget.thumbnailsUrl != widget.thumbnailsUrl) {
|
|
_prepareSpriteIfNeeded();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
widget.spriteSource.removeListener(_handleSpriteChanged);
|
|
super.dispose();
|
|
}
|
|
|
|
void _handleSpriteChanged() {
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
bool get _hasPoster =>
|
|
!_posterFailed && (widget.posterUrl?.trim().isNotEmpty ?? false);
|
|
|
|
void _prepareSpriteIfNeeded() {
|
|
if (_hasPoster || widget.thumbnailsUrl.trim().isEmpty) return;
|
|
unawaited(
|
|
widget.spriteSource.prepareAt(widget.thumbnailsUrl, Duration.zero),
|
|
);
|
|
}
|
|
|
|
Widget _handlePosterError() {
|
|
if (!_posterFailed) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted || _posterFailed) return;
|
|
setState(() => _posterFailed = true);
|
|
_prepareSpriteIfNeeded();
|
|
});
|
|
}
|
|
return _spriteOrBlack();
|
|
}
|
|
|
|
Widget _spriteOrBlack() {
|
|
final frame = widget.spriteSource.cachedFrameAt(
|
|
widget.thumbnailsUrl,
|
|
Duration.zero,
|
|
);
|
|
if (frame == null) return const SizedBox.expand();
|
|
return SpriteCropPreview(
|
|
key: const Key('player-opening-sprite'),
|
|
image: frame.image,
|
|
sourceRect: frame.sourceRect,
|
|
fit: widget.fit,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
const ColoredBox(color: AppColors.mediaBlack),
|
|
if (_hasPoster)
|
|
Image.network(
|
|
AppConfig.mediaUrl(widget.posterUrl!.trim()),
|
|
key: const Key('player-opening-poster'),
|
|
fit: widget.fit,
|
|
gaplessPlayback: true,
|
|
errorBuilder: (_, _, _) => _handlePosterError(),
|
|
)
|
|
else
|
|
_spriteOrBlack(),
|
|
],
|
|
);
|
|
}
|