// 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 '../../../core/widgets/remote_image.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 createState() => _PlayerOpeningFrameState(); } class _PlayerOpeningFrameState extends State { 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) LayoutBuilder( builder: (context, constraints) { final pixelRatio = MediaQuery.devicePixelRatioOf(context); return Image.network( AppConfig.mediaUrl(widget.posterUrl!.trim()), key: const Key('player-opening-poster'), fit: widget.fit, cacheWidth: resolveImageDecodePixels( constraints.maxWidth, pixelRatio, ), cacheHeight: resolveImageDecodePixels( constraints.maxHeight, pixelRatio, ), gaplessPlayback: true, errorBuilder: (_, _, _) => _handlePosterError(), ); }, ) else _spriteOrBlack(), ], ); }