326 lines
9.8 KiB
Dart
326 lines
9.8 KiB
Dart
// Author: fruit.com.cn
|
|
|
|
import 'dart:async';
|
|
import 'dart:typed_data';
|
|
import 'dart:ui' as ui;
|
|
|
|
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;
|
|
const double _portraitFrameAspect = 9 / 16;
|
|
|
|
/// 把预览卡片限制在进度条可用宽度内。
|
|
double scrubPreviewLeft({
|
|
required double trackWidth,
|
|
required double fraction,
|
|
double cardWidth = scrubPreviewWidth,
|
|
double safeMargin = scrubPreviewSafeMargin,
|
|
}) {
|
|
final maxLeft = trackWidth - cardWidth - safeMargin;
|
|
if (maxLeft < safeMargin) {
|
|
return ((trackWidth - cardWidth) / 2).clamp(0.0, trackWidth).toDouble();
|
|
}
|
|
final requested = trackWidth * fraction.clamp(0.0, 1.0) - cardWidth / 2;
|
|
return requested.clamp(safeMargin, maxLeft).toDouble();
|
|
}
|
|
|
|
/// 拖动进度时覆盖在轨道上方的单帧预览。
|
|
class ScrubPreviewCard extends StatefulWidget {
|
|
const ScrubPreviewCard({
|
|
super.key,
|
|
required this.service,
|
|
required this.episodeId,
|
|
required this.posterUrl,
|
|
required this.position,
|
|
required this.duration,
|
|
this.spriteAxisService,
|
|
this.thumbnailsUrl,
|
|
});
|
|
|
|
final PlaybackThumbnailService service;
|
|
final SpriteThumbnailAxisSource? spriteAxisService;
|
|
final String? thumbnailsUrl;
|
|
final String episodeId;
|
|
final String posterUrl;
|
|
final Duration position;
|
|
final Duration duration;
|
|
|
|
@override
|
|
State<ScrubPreviewCard> createState() => _ScrubPreviewCardState();
|
|
}
|
|
|
|
class _ScrubPreviewCardState extends State<ScrubPreviewCard> {
|
|
ByteData? _fallbackFrame;
|
|
int _requestSerial = 0;
|
|
int? _requestedBucket;
|
|
String? _requestedEpisodeId;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
widget.spriteAxisService?.addListener(_handleSpriteAxisChanged);
|
|
_prepareSprite();
|
|
_syncFallbackSource();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant ScrubPreviewCard oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.spriteAxisService != widget.spriteAxisService) {
|
|
oldWidget.spriteAxisService?.removeListener(_handleSpriteAxisChanged);
|
|
widget.spriteAxisService?.addListener(_handleSpriteAxisChanged);
|
|
}
|
|
if (oldWidget.thumbnailsUrl != widget.thumbnailsUrl ||
|
|
oldWidget.position != widget.position) {
|
|
_prepareSprite();
|
|
}
|
|
if (oldWidget.service != widget.service ||
|
|
oldWidget.episodeId != widget.episodeId ||
|
|
oldWidget.thumbnailsUrl != widget.thumbnailsUrl) {
|
|
_resetFallback();
|
|
_syncFallbackSource();
|
|
return;
|
|
}
|
|
if (_requestedBucket != playbackThumbnailBucket(widget.position)) {
|
|
_fallbackFrame = null;
|
|
_syncFallbackSource();
|
|
} else if (_fallbackFrame != null || _shouldUseLocalFallback()) {
|
|
unawaited(widget.service.thumbnail(widget.episodeId, widget.position));
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
widget.spriteAxisService?.removeListener(_handleSpriteAxisChanged);
|
|
_requestSerial++;
|
|
widget.service.cancelPending();
|
|
super.dispose();
|
|
}
|
|
|
|
void _prepareSprite() {
|
|
final service = widget.spriteAxisService;
|
|
if (service == null) return;
|
|
unawaited(service.prepareAt(widget.thumbnailsUrl, widget.position));
|
|
}
|
|
|
|
void _handleSpriteAxisChanged() {
|
|
if (!mounted) return;
|
|
if (_spriteFrame != null) {
|
|
widget.service.cancelPending();
|
|
}
|
|
setState(() {});
|
|
_syncFallbackSource();
|
|
}
|
|
|
|
SpriteThumbnailFrame? get _spriteFrame => widget.spriteAxisService
|
|
?.cachedFrameAt(widget.thumbnailsUrl, widget.position);
|
|
|
|
bool _shouldUseLocalFallback() {
|
|
if (_spriteFrame != null) return false;
|
|
final url = widget.thumbnailsUrl?.trim() ?? '';
|
|
if (url.isEmpty || widget.spriteAxisService == null) return true;
|
|
return widget.spriteAxisService!.hasAxis(widget.thumbnailsUrl);
|
|
}
|
|
|
|
void _syncFallbackSource() {
|
|
if (_spriteFrame != null) {
|
|
widget.service.cancelPending();
|
|
return;
|
|
}
|
|
if (!_shouldUseLocalFallback()) return;
|
|
_requestFallbackFrame();
|
|
}
|
|
|
|
void _requestFallbackFrame() {
|
|
final bucket = playbackThumbnailBucket(widget.position);
|
|
if (_requestedBucket == bucket &&
|
|
_requestedEpisodeId == widget.episodeId &&
|
|
(_fallbackFrame != null || _requestSerial > 0)) {
|
|
return;
|
|
}
|
|
_requestedBucket = bucket;
|
|
_requestedEpisodeId = widget.episodeId;
|
|
final serial = ++_requestSerial;
|
|
widget.service.thumbnail(widget.episodeId, widget.position).then((frame) {
|
|
if (!mounted || serial != _requestSerial || _spriteFrame != null) return;
|
|
setState(() => _fallbackFrame = frame);
|
|
});
|
|
}
|
|
|
|
void _resetFallback() {
|
|
widget.service.cancelPending();
|
|
_fallbackFrame = null;
|
|
_requestedBucket = null;
|
|
_requestedEpisodeId = null;
|
|
_requestSerial++;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final spriteFrame = _spriteFrame;
|
|
final stage = decideScrubPreviewStage(
|
|
hasThumbnailsUrl: (widget.thumbnailsUrl?.trim().isNotEmpty ?? false),
|
|
hasSpriteFrame: spriteFrame != null,
|
|
hasAxis: widget.spriteAxisService?.hasAxis(widget.thumbnailsUrl) ?? false,
|
|
hasLocalFrame: _fallbackFrame != null,
|
|
);
|
|
return Container(
|
|
key: const Key('scrub-preview-card'),
|
|
width: scrubPreviewWidth,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.card,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: AppColors.onPrimary, width: 1),
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(7),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
|
color: AppColors.scrubPreviewScrim,
|
|
child: Text(
|
|
'${_format(widget.position)} / ${_format(widget.duration)}',
|
|
key: const Key('scrub-preview-time'),
|
|
textAlign: TextAlign.center,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: AppColors.onPrimary,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
height: 1.2,
|
|
),
|
|
),
|
|
),
|
|
AspectRatio(
|
|
key: const Key('scrub-preview-frame-box'),
|
|
aspectRatio: _frameAspect(spriteFrame),
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
RemoteImage(
|
|
widget.posterUrl,
|
|
key: const Key('scrub-preview-poster'),
|
|
fit: BoxFit.cover,
|
|
fallbackColor: AppColors.bar,
|
|
),
|
|
if (stage == ScrubPreviewStage.sprite && spriteFrame != null)
|
|
SpriteCropPreview(
|
|
key: const Key('scrub-preview-sprite'),
|
|
image: spriteFrame.image,
|
|
sourceRect: spriteFrame.sourceRect,
|
|
),
|
|
if (stage == ScrubPreviewStage.localFrame &&
|
|
_fallbackFrame != null)
|
|
Image.memory(
|
|
_fallbackFrame!.buffer.asUint8List(
|
|
_fallbackFrame!.offsetInBytes,
|
|
_fallbackFrame!.lengthInBytes,
|
|
),
|
|
key: const Key('scrub-preview-frame'),
|
|
fit: BoxFit.cover,
|
|
cacheWidth: 240,
|
|
gaplessPlayback: true,
|
|
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
double _frameAspect(SpriteThumbnailFrame? frame) {
|
|
final rect = frame?.sourceRect;
|
|
if (rect == null || rect.width <= 0 || rect.height <= 0) {
|
|
return _portraitFrameAspect;
|
|
}
|
|
return rect.width / rect.height;
|
|
}
|
|
|
|
/// 对已解码 sprite 做 sourceRect 裁剪,不触发再次解码。
|
|
class SpriteCropPreview extends StatelessWidget {
|
|
const SpriteCropPreview({
|
|
super.key,
|
|
required this.image,
|
|
required this.sourceRect,
|
|
this.fit = BoxFit.cover,
|
|
});
|
|
|
|
final ui.Image image;
|
|
final ui.Rect sourceRect;
|
|
final BoxFit fit;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => CustomPaint(
|
|
painter: _SpriteCropPainter(
|
|
image: image,
|
|
sourceRect: sourceRect,
|
|
fit: fit,
|
|
),
|
|
);
|
|
}
|
|
|
|
class _SpriteCropPainter extends CustomPainter {
|
|
const _SpriteCropPainter({
|
|
required this.image,
|
|
required this.sourceRect,
|
|
required this.fit,
|
|
});
|
|
|
|
final ui.Image image;
|
|
final ui.Rect sourceRect;
|
|
final BoxFit fit;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final fitted = applyBoxFit(fit, sourceRect.size, size);
|
|
final sourceOffset = Alignment.center.inscribe(
|
|
fitted.source,
|
|
Offset.zero & sourceRect.size,
|
|
);
|
|
final source = Rect.fromLTWH(
|
|
sourceRect.left + sourceOffset.left,
|
|
sourceRect.top + sourceOffset.top,
|
|
fitted.source.width,
|
|
fitted.source.height,
|
|
);
|
|
final destination = Alignment.center.inscribe(
|
|
fitted.destination,
|
|
Offset.zero & size,
|
|
);
|
|
canvas.drawImageRect(
|
|
image,
|
|
source,
|
|
destination,
|
|
Paint()
|
|
..filterQuality = FilterQuality.low
|
|
..isAntiAlias = false,
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant _SpriteCropPainter oldDelegate) =>
|
|
oldDelegate.image != image ||
|
|
oldDelegate.sourceRect != sourceRect ||
|
|
oldDelegate.fit != fit;
|
|
}
|
|
|
|
String _format(Duration value) {
|
|
final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
|
|
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
|
|
return '$minutes:$seconds';
|
|
}
|