From 12b733d1114052a77d1393d8e14ddb09f8141542 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Thu, 22 Oct 2026 11:40:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(player):=20=E8=BF=9B=E5=BA=A6=E6=9D=A1?= =?UTF-8?q?=E6=8B=96=E5=8A=A8=E9=A2=84=E8=A7=88=E6=94=B9=E7=94=A8=E9=9B=AA?= =?UTF-8?q?=E7=A2=A7=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-10-22-sprite-scrub-preview.md | 46 ++ docs/plans/PROGRESS.md | 52 ++ lib/core/data/models/drama.dart | 37 +- lib/core/data/models/play_ticket.dart | 8 + .../sprite_thumbnail_asset_loader.dart | 128 +++++ .../data/services/sprite_thumbnail_axis.dart | 461 ++++++++++++++++++ lib/pages/player/player_logic.dart | 2 + lib/pages/player/player_view.dart | 21 +- .../player/widgets/home_feed_overlay.dart | 5 + .../widgets/home_feed_overlay_parts.dart | 6 + lib/pages/player/widgets/player_overlay.dart | 2 + .../player/widgets/player_overlay_models.dart | 3 + .../player/widgets/scrub_preview_card.dart | 161 +++++- .../player/widgets/seek_progress_bar.dart | 7 + test/playback/sprite_thumbnail_axis_test.dart | 233 +++++++++ test/player/scrub_preview_card_test.dart | 171 +++++++ 16 files changed, 1312 insertions(+), 31 deletions(-) create mode 100644 docs/plans/2026-10-22-sprite-scrub-preview.md create mode 100644 lib/core/data/services/sprite_thumbnail_asset_loader.dart create mode 100644 lib/core/data/services/sprite_thumbnail_axis.dart create mode 100644 test/playback/sprite_thumbnail_axis_test.dart create mode 100644 test/player/scrub_preview_card_test.dart diff --git a/docs/plans/2026-10-22-sprite-scrub-preview.md b/docs/plans/2026-10-22-sprite-scrub-preview.md new file mode 100644 index 0000000..860a2a2 --- /dev/null +++ b/docs/plans/2026-10-22-sprite-scrub-preview.md @@ -0,0 +1,46 @@ +# 雪碧图 VTT 拖动预览 + +## 目标 + +将进度条拖动预览主链路改为服务端雪碧图 + WebVTT 局部裁剪,原生取帧只作为降级: + +1. `Episode` 与 `PlayTicket` 解析 `thumbnailsUrl`。 +2. VTT 只拉取/解析一次,内存缓存 10 分钟。 +3. cue 使用起止时间 + 相对 sprite 文件名 + `xywh`,按 position 二分选择。 +4. sprite 图最多缓存 2 张 `ui.Image`,拖动帧只绘制 sourceRect。 +5. 无 VTT、空轴或图片失败时回退现有 `PlaybackThumbnailService`,再回退封面。 + +## 性能红线 + +- 拖动帧禁止网络请求与图片解码,只允许 cue 二分查找和 `RawImage.sourceRect` 绘制。 +- VTT 每个 URL 只解析一次;sprite 解码不在 build/build paint 中执行。 +- sprite ui.Image LRU 上限 2 张,页面 dispose 清空。 +- 解码 sprite 最长边超过 2048px 时按比例缩小后常驻。 +- 签名 `thumbnailsUrl` 不写入任何持久化缓存。 + +## 实施 + +- 新增 `SpriteThumbnailAxisService`: + - Dio 拉取 VTT,403 清缓存后重试一次; + - 解析和选 cue 为纯函数,解析失败返回空轴; + - 播放器就绪时预取当前 cue 及相邻 sprite; + - LRU2 sprite 缓存和 10 分钟 VTT 缓存。 +- `PlayerPage` 持有页面级服务,按当前选中集预取,dispose 时清空。 +- `ScrubPreviewCard` 优先读取已缓存 sprite frame,显示 `RawImage` 裁剪; + VTT 未就绪时暂显封面,轴为空/不可用后启动本地取帧,全部失败保留封面。 +- 首页 feed 和全屏播放器都把 `thumbnailsUrl` 传到预览卡片。 +- 不新增 pub 依赖、不改后端契约。 + +## 测试 + +- VTT 解析:时间、xywh、多 sprite 文件。 +- cue 选择:命中、越界最近邻。 +- sprite LRU 最多 2 张。 +- 回退状态判定。 +- Widget:sprite 裁剪且不触发本地取帧;无 URL 回退本地;全部失败显示封面。 +- 门禁:`dart analyze lib test`、`flutter test --no-pub`。 + +## 风险 + +- 真实网络 VTT/sprite 可访问性和签名过期由 PM 真机验收。 +- 本次不启动服务、不操作真机、不修改 Web/FruitServer。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 7b0e427..6fa020d 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -479,3 +479,55 @@ $ flutter test --no-pub 消失均通过;全量静态分析零告警,606 项测试全过。性能红线保持:主线程不做 图片解码/取帧、原生并发 1、预览最大宽 240px、缓存最多 24 帧、不增依赖、 不改后端。原生取帧真实画面与设备性能由 PM 真机验收。 + +## 2026-10-22 雪碧图 VTT 拖动预览 + +- 已从 `develop`(`89a94f8`)创建 `feature/sprite-scrub-preview`。 +- 计划文档:`docs/plans/2026-10-22-sprite-scrub-preview.md`。 +- 性能红线:拖动帧不发起网络/解码,只做 cue 二分与 sourceRect 绘制; + VTT 缓存 10 分钟且只解析一次;sprite ui.Image 最多 2 张;超过 2048px + 按比例缩放;签名地址不进入持久化缓存。 +- 回退链:雪碧图 -> 现有本地原生取帧 -> 集封面。 + +```text +$ git status --short --branch +## feature/sprite-scrub-preview + +$ git rev-parse --short HEAD +89a94f8 +``` + +解读:工作树从指定最新 develop 创建,开工前无未提交改动;主工作区现有 +`pubspec.lock` 未复制、未修改。 + +### 实现结果 + +- `Episode` / `PlayTicket` 增加 nullable `thumbnailsUrl`;票据写回和详情来源都保留, + 本地下载只覆盖视频地址,不丢 VTT;签名地址未进入持久化缓存。 +- 新增 `SpriteThumbnailAxisService`:Dio VTT 拉取、10 分钟缓存、403 清缓存重试一次、 + cue 解析与二分、sprite `ui.Image` LRU2、2048px 解码上限。 +- `PlayerPage` 在选中集就绪时预取当前及相邻 sprite;dispose 释放 VTT 与图片。 +- `ScrubPreviewCard` 优先用 `CustomPaint.drawImageRect` 绘制 `sourceRect`; + VTT 未就绪显示封面,轴为空/加载失败回退本地原生取帧,再失败保留封面。 +- 拖动帧只做缓存查询、二分、矩形裁剪与 drawImageRect,不发起网络或图片解码。 + +执行命令与原始输出: + +```text +$ flutter test --no-pub test/playback/sprite_thumbnail_axis_test.dart test/player/scrub_preview_card_test.dart +00:00 +11: All tests passed! + +$ flutter test --no-pub test/playback/seek_progress_bar_test.dart test/home/home_preview_layout_test.dart test/player/player_fullscreen_test.dart +00:10 +55: All tests passed! + +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:54 +617: All tests passed! +``` + +解读:VTT 时间/xywh/多 sprite、越界最近 cue、LRU2、403 重试、10 分钟 TTL、 +回退链、sprite 零本地取帧、无 URL 本地回退和最终封面兜底均通过;全量静态分析 +零告警,617 项测试全过。`pubspec.lock` 已还原,未启停服务,未操作真机。 diff --git a/lib/core/data/models/drama.dart b/lib/core/data/models/drama.dart index a2f024f..8b2f1b5 100644 --- a/lib/core/data/models/drama.dart +++ b/lib/core/data/models/drama.dart @@ -17,6 +17,7 @@ class Episode { this.locked = false, this.likeCount, this.commentCount, + this.thumbnailsUrl, }); final String id; @@ -32,19 +33,25 @@ class Episode { final bool locked; final int? likeCount; final int? commentCount; + final String? thumbnailsUrl; - Episode withVideoUrl(String url, {int? likeCount, int? commentCount}) => - Episode( - id: id, - number: number, - title: title, - isFree: isFree, - videoUrl: url, - durationSec: durationSec, - locked: locked, - likeCount: likeCount ?? this.likeCount, - commentCount: commentCount ?? this.commentCount, - ); + Episode withVideoUrl( + String url, { + int? likeCount, + int? commentCount, + String? thumbnailsUrl, + }) => Episode( + id: id, + number: number, + title: title, + isFree: isFree, + videoUrl: url, + durationSec: durationSec, + locked: locked, + likeCount: likeCount ?? this.likeCount, + commentCount: commentCount ?? this.commentCount, + thumbnailsUrl: thumbnailsUrl ?? this.thumbnailsUrl, + ); factory Episode.fromJson(Map json) { return Episode( @@ -57,6 +64,7 @@ class Episode { videoUrl: json['playUrl'] as String? ?? '', likeCount: _optionalInt(json['likeCount']), commentCount: _optionalInt(json['commentCount']), + thumbnailsUrl: _optionalString(json['thumbnailsUrl']), ); } } @@ -159,3 +167,8 @@ class Drama { } int? _optionalInt(dynamic value) => value == null ? null : jsonToInt(value); + +String? _optionalString(dynamic value) { + final text = value is String ? value.trim() : ''; + return text.isEmpty ? null : text; +} diff --git a/lib/core/data/models/play_ticket.dart b/lib/core/data/models/play_ticket.dart index b613e05..2a5ad1d 100644 --- a/lib/core/data/models/play_ticket.dart +++ b/lib/core/data/models/play_ticket.dart @@ -14,6 +14,7 @@ class PlayTicket { this.unlockCoin = 0, this.previewSeconds = 0, this.qualities = const [], + this.thumbnailsUrl, this.likeCount, this.commentCount, }); @@ -40,6 +41,7 @@ class PlayTicket { /// 后端下发的清晰度档位(如 AUTO / 720P / 1080P),HLS 由播放器自动切换。 final List qualities; + final String? thumbnailsUrl; final int? likeCount; final int? commentCount; @@ -62,6 +64,7 @@ class PlayTicket { .whereType() .where((quality) => quality.isNotEmpty) .toList(), + thumbnailsUrl: _optionalString(json['thumbnailsUrl']), likeCount: json['likeCount'] == null ? null : jsonToInt(json['likeCount']), @@ -71,3 +74,8 @@ class PlayTicket { ); } } + +String? _optionalString(dynamic value) { + final text = value is String ? value.trim() : ''; + return text.isEmpty ? null : text; +} diff --git a/lib/core/data/services/sprite_thumbnail_asset_loader.dart b/lib/core/data/services/sprite_thumbnail_asset_loader.dart new file mode 100644 index 0000000..c82648e --- /dev/null +++ b/lib/core/data/services/sprite_thumbnail_asset_loader.dart @@ -0,0 +1,128 @@ +// Author: fruit.com.cn + +import 'dart:convert'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:dio/dio.dart'; + +import '../../config/app_config.dart'; + +const int spriteThumbnailDecodeMaxDimension = 2048; + +/// VTT 资源拒绝访问,服务层据此清缓存并重试一次。 +class SpriteThumbnailForbiddenException implements Exception { + const SpriteThumbnailForbiddenException(); +} + +/// 已解码 sprite 与原图尺寸;cue 坐标会按解码缩放比例映射。 +class SpriteThumbnailDecodedImage { + const SpriteThumbnailDecodedImage({ + required this.image, + required this.originalWidth, + required this.originalHeight, + }); + + final ui.Image image; + final int originalWidth; + final int originalHeight; +} + +/// 可注入的资源加载接口,测试无需真实网络或图片。 +abstract interface class SpriteThumbnailAssetLoader { + Future loadVtt(Uri uri); + + Future loadSprite(Uri uri); +} + +/// 默认通过项目现有 Dio 拉取 VTT 和 sprite 图片。 +class DioSpriteThumbnailAssetLoader implements SpriteThumbnailAssetLoader { + DioSpriteThumbnailAssetLoader({Dio? dio}) + : _dio = + dio ?? + Dio( + BaseOptions( + connectTimeout: AppConfig.requestTimeout, + receiveTimeout: AppConfig.requestTimeout, + validateStatus: (_) => true, + ), + ); + + final Dio _dio; + + @override + Future loadVtt(Uri uri) async { + final response = await _request(uri); + if (response?.statusCode == 403) { + throw const SpriteThumbnailForbiddenException(); + } + if (response == null || !_isSuccess(response.statusCode)) return null; + final data = response.data; + if (data is! List) return null; + return utf8.decode(data, allowMalformed: true); + } + + @override + Future loadSprite(Uri uri) async { + final response = await _request(uri); + if (response == null || !_isSuccess(response.statusCode)) return null; + final data = response.data; + if (data is! List) return null; + return _decode(Uint8List.fromList(data)); + } + + Future>?> _request(Uri uri) async { + try { + return await _dio.get>( + uri.toString(), + options: Options(responseType: ResponseType.bytes), + ); + } catch (_) { + return null; + } + } + + bool _isSuccess(int? status) => + status != null && status >= 200 && status < 300; + + Future _decode(Uint8List bytes) async { + ui.ImmutableBuffer? buffer; + ui.ImageDescriptor? descriptor; + ui.Codec? codec; + try { + buffer = await ui.ImmutableBuffer.fromUint8List(bytes); + descriptor = await ui.ImageDescriptor.encoded(buffer); + final originalWidth = descriptor.width; + final originalHeight = descriptor.height; + final target = _targetSize(originalWidth, originalHeight); + codec = await descriptor.instantiateCodec( + targetWidth: target.$1, + targetHeight: target.$2, + ); + final frame = await codec.getNextFrame(); + return SpriteThumbnailDecodedImage( + image: frame.image, + originalWidth: originalWidth, + originalHeight: originalHeight, + ); + } catch (_) { + return null; + } finally { + codec?.dispose(); + descriptor?.dispose(); + buffer?.dispose(); + } + } + + (int?, int?) _targetSize(int width, int height) { + final longest = width > height ? width : height; + if (longest <= spriteThumbnailDecodeMaxDimension) { + return (null, null); + } + final scale = spriteThumbnailDecodeMaxDimension / longest; + return ( + (width * scale).round().clamp(1, spriteThumbnailDecodeMaxDimension), + (height * scale).round().clamp(1, spriteThumbnailDecodeMaxDimension), + ); + } +} diff --git a/lib/core/data/services/sprite_thumbnail_axis.dart b/lib/core/data/services/sprite_thumbnail_axis.dart new file mode 100644 index 0000000..3a63401 --- /dev/null +++ b/lib/core/data/services/sprite_thumbnail_axis.dart @@ -0,0 +1,461 @@ +// Author: fruit.com.cn + +import 'dart:collection'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; + +import '../../config/app_config.dart'; +import 'sprite_thumbnail_asset_loader.dart'; + +const Duration spriteVttCacheTtl = Duration(minutes: 10); +const int spriteImageCacheCapacity = 2; + +/// 单个 VTT cue 对应的 sprite 裁剪区域。 +class SpriteThumbnailCue { + const SpriteThumbnailCue({ + required this.start, + required this.end, + required this.spriteFile, + required this.sourceRect, + }); + + final Duration start; + final Duration end; + final String spriteFile; + final ui.Rect sourceRect; +} + +/// 已解析的 VTT 时间轴。 +class SpriteThumbnailAxis { + const SpriteThumbnailAxis({required this.vttUrl, required this.cues}); + + final String vttUrl; + final List cues; + + factory SpriteThumbnailAxis.empty(String vttUrl) => + SpriteThumbnailAxis(vttUrl: vttUrl, cues: const []); + + Uri resolveSpriteUri(String spriteFile) => + Uri.parse(vttUrl).resolve(spriteFile); +} + +/// 可直接交给 RawImage.sourceRect 的裁剪帧。 +class SpriteThumbnailFrame { + const SpriteThumbnailFrame({ + required this.image, + required this.sourceRect, + required this.spriteUrl, + required this.cue, + }); + + final ui.Image image; + final ui.Rect sourceRect; + final String spriteUrl; + final SpriteThumbnailCue cue; +} + +/// 预览卡片当前应展示的回退阶段。 +enum ScrubPreviewStage { sprite, waitingForAxis, localFrame, cover } + +/// 预览回退链的纯判定函数。 +ScrubPreviewStage decideScrubPreviewStage({ + required bool hasThumbnailsUrl, + required bool hasSpriteFrame, + required bool hasAxis, + required bool hasLocalFrame, +}) { + if (hasSpriteFrame) return ScrubPreviewStage.sprite; + if (hasThumbnailsUrl && !hasAxis) { + return ScrubPreviewStage.waitingForAxis; + } + if (hasLocalFrame) return ScrubPreviewStage.localFrame; + return ScrubPreviewStage.cover; +} + +/// 解析 WebVTT;任意异常都收敛为空轴。 +SpriteThumbnailAxis parseSpriteThumbnailVtt( + String source, { + required String vttUrl, +}) { + final cues = []; + try { + for (final block + in source.replaceAll('\r\n', '\n').split(RegExp(r'\n\s*\n'))) { + final cue = _parseCueBlock(block); + if (cue != null) cues.add(cue); + } + } catch (_) { + return SpriteThumbnailAxis.empty(vttUrl); + } + cues.sort((left, right) => left.start.compareTo(right.start)); + return SpriteThumbnailAxis(vttUrl: vttUrl, cues: cues); +} + +/// 按 position 二分选择 cue;边界外选择最近 cue。 +SpriteThumbnailCue? selectSpriteThumbnailCue( + List cues, + Duration position, +) { + if (cues.isEmpty) return null; + var low = 0; + var high = cues.length; + while (low < high) { + final middle = (low + high) >> 1; + if (cues[middle].start <= position) { + low = middle + 1; + } else { + high = middle; + } + } + if (low == 0) return cues.first; + final previous = cues[low - 1]; + if (low == cues.length || position <= previous.end) return previous; + final next = cues[low]; + final previousDistance = position - previous.end; + final nextDistance = next.start - position; + return previousDistance <= nextDistance ? previous : next; +} + +/// 只读缓存接口,便于 widget 测试注入。 +abstract interface class SpriteThumbnailAxisSource implements Listenable { + SpriteThumbnailFrame? cachedFrameAt(String? thumbnailsUrl, Duration position); + + bool hasAxis(String? thumbnailsUrl); +} + +/// VTT 时间轴、cue 二分与 sprite ui.Image LRU 缓存。 +class SpriteThumbnailAxisService extends ChangeNotifier + implements SpriteThumbnailAxisSource { + SpriteThumbnailAxisService({ + SpriteThumbnailAssetLoader? loader, + this.cacheTtl = spriteVttCacheTtl, + DateTime Function()? now, + }) : _loader = loader ?? DioSpriteThumbnailAssetLoader(), + _now = now ?? DateTime.now; + + final SpriteThumbnailAssetLoader _loader; + final Duration cacheTtl; + final DateTime Function() _now; + final Map _axisCache = {}; + final Map> _axisRequests = {}; + final LinkedHashMap _spriteCache = LinkedHashMap(); + final Map> _spriteRequests = {}; + final Set _failedSprites = {}; + String? _activeAxisUrl; + int _axisGeneration = 0; + bool _disposed = false; + + @visibleForTesting + int get spriteCacheSize => _spriteCache.length; + + @visibleForTesting + List get spriteCacheKeys => _spriteCache.keys.toList(); + + /// 播放器就绪时预取当前 cue 与相邻 sprite,拖动帧不触发网络或解码。 + Future prepareAt(String? thumbnailsUrl, Duration position) async { + final url = _normalizeUrl(thumbnailsUrl); + if (_disposed || url == null) return; + _activateAxis(url); + final axis = await _loadAxis(url); + if (_disposed || _activeAxisUrl != url) return; + for (final cue in _prefetchCues(axis, position)) { + await _ensureSprite(axis, cue); + } + } + + @override + bool hasAxis(String? thumbnailsUrl) { + final url = _normalizeUrl(thumbnailsUrl); + if (url == null || url != _activeAxisUrl) return false; + return _readAxis(url) != null; + } + + @override + SpriteThumbnailFrame? cachedFrameAt( + String? thumbnailsUrl, + Duration position, + ) { + final url = _normalizeUrl(thumbnailsUrl); + if (url == null || url != _activeAxisUrl) return null; + final axis = _readAxis(url); + if (axis == null) return null; + final cue = selectSpriteThumbnailCue(axis.cues, position); + if (cue == null) return null; + final cached = _readSprite(cue.spriteFile); + if (cached == null) return null; + final sourceRect = _scaledSourceRect(cached, cue.sourceRect); + if (sourceRect == null) return null; + return SpriteThumbnailFrame( + image: cached.decoded.image, + sourceRect: sourceRect, + spriteUrl: axis.resolveSpriteUri(cue.spriteFile).toString(), + cue: cue, + ); + } + + /// 页面销毁时释放全部 VTT 与 ui.Image 内存。 + @override + void dispose() { + if (_disposed) return; + _disposed = true; + _clearAll(); + super.dispose(); + } + + void _activateAxis(String url) { + if (_activeAxisUrl == url) return; + _activeAxisUrl = url; + _axisGeneration++; + _clearSprites(); + } + + Future _loadAxis(String url) { + final cached = _readAxis(url); + if (cached != null) return Future.value(cached); + final existing = _axisRequests[url]; + if (existing != null) return existing; + final request = _fetchAxis(url); + _axisRequests[url] = request; + return request.whenComplete(() { + _axisRequests.remove(url); + }); + } + + Future _fetchAxis(String url) async { + for (var attempt = 0; attempt < 2; attempt++) { + try { + final text = await _loader.loadVtt(Uri.parse(url)); + final axis = text == null + ? SpriteThumbnailAxis.empty(url) + : parseSpriteThumbnailVtt(text, vttUrl: url); + if (_disposed) return SpriteThumbnailAxis.empty(url); + _cacheAxis(axis); + notifyListeners(); + return axis; + } on SpriteThumbnailForbiddenException { + if (_disposed) return SpriteThumbnailAxis.empty(url); + _axisCache.remove(url); + if (_activeAxisUrl == url) _clearSprites(); + } catch (_) { + break; + } + } + final empty = SpriteThumbnailAxis.empty(url); + if (_disposed) return empty; + _cacheAxis(empty); + notifyListeners(); + return empty; + } + + void _cacheAxis(SpriteThumbnailAxis axis) { + _axisCache[axis.vttUrl] = _CachedAxis( + axis: axis, + expiresAt: _now().add(cacheTtl), + ); + } + + SpriteThumbnailAxis? _readAxis(String url) { + final cached = _axisCache[url]; + if (cached == null) return null; + if (cached.expiresAt.isAfter(_now())) return cached.axis; + _axisCache.remove(url); + return null; + } + + List _prefetchCues( + SpriteThumbnailAxis axis, + Duration position, + ) { + final current = selectSpriteThumbnailCue(axis.cues, position); + if (current == null) return const []; + final index = axis.cues.indexOf(current); + final candidates = [current]; + for (final direction in const [1, -1]) { + for (var step = 1; step < axis.cues.length; step++) { + final neighborIndex = index + direction * step; + if (neighborIndex < 0 || neighborIndex >= axis.cues.length) break; + final neighbor = axis.cues[neighborIndex]; + if (neighbor.spriteFile != current.spriteFile) { + candidates.add(neighbor); + break; + } + } + if (candidates.length > 1) break; + } + return candidates; + } + + Future<_CachedSprite?> _ensureSprite( + SpriteThumbnailAxis axis, + SpriteThumbnailCue cue, + ) { + final cached = _readSprite(cue.spriteFile); + if (cached != null) return Future.value(cached); + if (_failedSprites.contains(cue.spriteFile)) return Future.value(); + final existing = _spriteRequests[cue.spriteFile]; + if (existing != null) return existing; + final generation = _axisGeneration; + final request = _loadSprite(axis, cue, generation); + _spriteRequests[cue.spriteFile] = request; + return request.whenComplete(() { + _spriteRequests.remove(cue.spriteFile); + }); + } + + Future<_CachedSprite?> _loadSprite( + SpriteThumbnailAxis axis, + SpriteThumbnailCue cue, + int generation, + ) async { + SpriteThumbnailDecodedImage? decoded; + try { + decoded = await _loader.loadSprite(axis.resolveSpriteUri(cue.spriteFile)); + } catch (_) { + decoded = null; + } + if (decoded == null) { + _failedSprites.add(cue.spriteFile); + if (!_disposed) notifyListeners(); + return null; + } + if (_disposed || generation != _axisGeneration) { + decoded.image.dispose(); + return null; + } + final cached = _CachedSprite(decoded); + _putSprite(cue.spriteFile, cached); + notifyListeners(); + return cached; + } + + _CachedSprite? _readSprite(String key) { + final value = _spriteCache.remove(key); + if (value == null) return null; + _spriteCache[key] = value; + return value; + } + + void _putSprite(String key, _CachedSprite value) { + _spriteCache.remove(key)?.decoded.image.dispose(); + _spriteCache[key] = value; + while (_spriteCache.length > spriteImageCacheCapacity) { + final oldest = _spriteCache.remove(_spriteCache.keys.first); + oldest?.decoded.image.dispose(); + } + } + + ui.Rect? _scaledSourceRect(_CachedSprite cached, ui.Rect source) { + final scaleX = cached.decoded.image.width / cached.decoded.originalWidth; + final scaleY = cached.decoded.image.height / cached.decoded.originalHeight; + final scaled = + ui.Rect.fromLTWH( + source.left * scaleX, + source.top * scaleY, + source.width * scaleX, + source.height * scaleY, + ).intersect( + ui.Rect.fromLTWH( + 0, + 0, + cached.decoded.image.width.toDouble(), + cached.decoded.image.height.toDouble(), + ), + ); + return scaled.isEmpty ? null : scaled; + } + + void _clearSprites() { + for (final cached in _spriteCache.values) { + cached.decoded.image.dispose(); + } + _spriteCache.clear(); + _spriteRequests.clear(); + _failedSprites.clear(); + } + + void _clearAll() { + _axisGeneration++; + _activeAxisUrl = null; + _axisCache.clear(); + _axisRequests.clear(); + _clearSprites(); + } + + String? _normalizeUrl(String? url) { + final trimmed = url?.trim() ?? ''; + if (trimmed.isEmpty) return null; + return AppConfig.mediaUrl(trimmed); + } +} + +final RegExp _timingPattern = RegExp( + r'^(\d{2,}:\d{2}:\d{2}\.\d{3})\s*-->\s*(\d{2,}:\d{2}:\d{2}\.\d{3})', +); +final RegExp _cueLinkPattern = RegExp(r'^(\S+?)#xywh=(.+)$'); +final RegExp _xywhPattern = RegExp(r'^(\d+),(\d+),(\d+),(\d+)$'); + +SpriteThumbnailCue? _parseCueBlock(String block) { + final lines = block + .split('\n') + .map((line) => line.trim()) + .where((line) => line.isNotEmpty) + .toList(); + final timingIndex = lines.indexWhere(_timingPattern.hasMatch); + if (timingIndex < 0 || timingIndex + 1 >= lines.length) return null; + final timing = _timingPattern.firstMatch(lines[timingIndex]); + if (timing == null) return null; + final link = _cueLinkPattern.firstMatch(lines[timingIndex + 1]); + if (link == null) return null; + final start = _parseTimestamp(timing.group(1)!); + final end = _parseTimestamp(timing.group(2)!); + if (start == null || end == null || end <= start) return null; + final segments = Uri.tryParse(link.group(1)!)?.pathSegments; + final file = segments == null || segments.isEmpty ? null : segments.last; + final xywh = _xywhPattern.firstMatch(link.group(2)!); + if (file == null || file.isEmpty || xywh == null) return null; + final rect = ui.Rect.fromLTWH( + double.parse(xywh.group(1)!), + double.parse(xywh.group(2)!), + double.parse(xywh.group(3)!), + double.parse(xywh.group(4)!), + ); + if (rect.isEmpty) return null; + return SpriteThumbnailCue( + start: start, + end: end, + spriteFile: file, + sourceRect: rect, + ); +} + +Duration? _parseTimestamp(String value) { + final parts = value.split(':'); + if (parts.length != 3) return null; + final secondsParts = parts[2].split('.'); + if (secondsParts.length != 2) return null; + final hours = int.tryParse(parts[0]); + final minutes = int.tryParse(parts[1]); + final seconds = int.tryParse(secondsParts[0]); + final milliseconds = int.tryParse(secondsParts[1]); + if ([hours, minutes, seconds, milliseconds].contains(null)) return null; + return Duration( + hours: hours!, + minutes: minutes!, + seconds: seconds!, + milliseconds: milliseconds!, + ); +} + +class _CachedAxis { + const _CachedAxis({required this.axis, required this.expiresAt}); + + final SpriteThumbnailAxis axis; + final DateTime expiresAt; +} + +class _CachedSprite { + const _CachedSprite(this.decoded); + + final SpriteThumbnailDecodedImage decoded; +} diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 27046f7..15c1260 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -149,6 +149,7 @@ class PlayerLogic extends ChangeNotifier { Episode displayEpisode(Drama drama, Episode fallback) { final number = pickedEpisode[drama.id]; if (number == null) return fallback; + if (fallback.number == number) return fallback; for (final episode in drama.episodes) { if (episode.number == number) return episode; } @@ -438,6 +439,7 @@ class PlayerLogic extends ChangeNotifier { ticket.playUrl, likeCount: ticket.likeCount, commentCount: ticket.commentCount, + thumbnailsUrl: ticket.thumbnailsUrl, ); return true; } diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 1f6d8e7..db69db6 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -14,6 +14,7 @@ import '../../core/data/services/download_controller.dart'; import '../../core/data/services/interaction_controller.dart'; import '../../core/data/services/playback_position_cache.dart'; import '../../core/data/services/playback_session.dart'; +import '../../core/data/services/sprite_thumbnail_axis.dart'; import '../../core/data/services/playback_thumbnail_service.dart'; import '../../core/data/services/video_player_port.dart'; import '../../core/data/services/wallet_controller.dart'; @@ -106,6 +107,7 @@ class _PlayerPageState extends State with WidgetsBindingObserver, RouteAware { late final PlayerLogic logic; late final PlaybackThumbnailService _thumbnailService; + late final SpriteThumbnailAxisService _spriteAxisService; PageRoute? _subscribedRoute; bool _chromeVisible = true; double _playbackSpeed = 1.0; @@ -131,6 +133,7 @@ class _PlayerPageState extends State _thumbnailService = PlaybackThumbnailService( sourceResolver: _resolveThumbnailSource, ); + _spriteAxisService = SpriteThumbnailAxisService(); WidgetsBinding.instance.addObserver(this); logic.start( factory: context.read(), @@ -247,6 +250,7 @@ class _PlayerPageState extends State void dispose() { WidgetsBinding.instance.removeObserver(this); appRouteObserver.unsubscribe(this); + _spriteAxisService.dispose(); _thumbnailService.dispose(); logic.dispose(); super.dispose(); @@ -422,9 +426,12 @@ class _PlayerPageState extends State final l10n = AppLocalizations.of(context)!; final entry = logic.queue.entryAt(index); final drama = entry.drama; - final episode = widget.homeMode - ? logic.displayEpisode(drama, entry.episode) + final sessionEpisode = index >= 0 && index < logic.sessionEpisodes.length + ? logic.sessionEpisodes[index] : entry.episode; + final episode = widget.homeMode + ? logic.displayEpisode(drama, sessionEpisode) + : sessionEpisode; final selected = index == logic.session.currentIndex; // Selection changes before the prepared slot is promoted. Use its controller // throughout the handoff instead of briefly falling back to a placeholder. @@ -435,6 +442,14 @@ class _PlayerPageState extends State final ready = videoPort?.controller.value.isInitialized ?? false; final theater = logic.pip || logic.pipPreparing; final locked = logic.session.isLocked || episode.locked; + if (selected && ready) { + unawaited( + _spriteAxisService.prepareAt( + episode.thumbnailsUrl, + videoPort!.controller.value.position, + ), + ); + } return Stack( fit: StackFit.expand, @@ -606,6 +621,7 @@ class _PlayerPageState extends State ? (position) => logic.seekTo(context, videoPort!, position) : null, thumbnailService: _thumbnailService, + spriteAxisService: _spriteAxisService, ) else PlayerPlaybackOverlay( @@ -629,6 +645,7 @@ class _PlayerPageState extends State totalEpisodes: logic.visibleIndices.length, boosting: _boosting, thumbnailService: _thumbnailService, + spriteAxisService: _spriteAxisService, ), actions: PlayerOverlayActions( onBack: widget.embedded diff --git a/lib/pages/player/widgets/home_feed_overlay.dart b/lib/pages/player/widgets/home_feed_overlay.dart index e36a961..5dbf276 100644 --- a/lib/pages/player/widgets/home_feed_overlay.dart +++ b/lib/pages/player/widgets/home_feed_overlay.dart @@ -8,6 +8,7 @@ import 'package:video_player/video_player.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/data/services/playback_thumbnail_service.dart'; +import '../../../core/data/services/sprite_thumbnail_axis.dart'; import '../../../core/formatting/count_format.dart'; import '../../../core/formatting/localized_content.dart'; import '../../../core/theme/app_colors.dart'; @@ -63,6 +64,7 @@ class HomeFeedOverlay extends StatefulWidget { this.onScrubStart, this.onSeek, this.thumbnailService, + this.spriteAxisService, }); final AppLocalizations l10n; @@ -91,6 +93,7 @@ class HomeFeedOverlay extends StatefulWidget { final VoidCallback? onScrubStart; final ValueChanged? onSeek; final PlaybackThumbnailService? thumbnailService; + final SpriteThumbnailAxisService? spriteAxisService; @override State createState() => _HomeFeedOverlayState(); @@ -405,6 +408,8 @@ class _HomeFeedOverlayState extends State { onScrubStart: widget.onScrubStart, onSeek: widget.onSeek, thumbnailService: widget.thumbnailService, + spriteAxisService: widget.spriteAxisService, + thumbnailsUrl: widget.episode.thumbnailsUrl, episodeId: widget.episode.id, posterUrl: widget.drama.posterUrl, ), diff --git a/lib/pages/player/widgets/home_feed_overlay_parts.dart b/lib/pages/player/widgets/home_feed_overlay_parts.dart index cd08b58..bbf57a2 100644 --- a/lib/pages/player/widgets/home_feed_overlay_parts.dart +++ b/lib/pages/player/widgets/home_feed_overlay_parts.dart @@ -18,6 +18,8 @@ class _FeedProgressBar extends StatefulWidget { this.onScrubStart, this.onSeek, this.thumbnailService, + this.spriteAxisService, + this.thumbnailsUrl, required this.episodeId, required this.posterUrl, }); @@ -29,6 +31,8 @@ class _FeedProgressBar extends StatefulWidget { final VoidCallback? onScrubStart; final ValueChanged? onSeek; final PlaybackThumbnailService? thumbnailService; + final SpriteThumbnailAxisService? spriteAxisService; + final String? thumbnailsUrl; final String episodeId; final String posterUrl; @@ -148,6 +152,8 @@ class _FeedProgressBarState extends State<_FeedProgressBar> { bottom: widget.hitHeight + 8, child: ScrubPreviewCard( service: widget.thumbnailService!, + spriteAxisService: widget.spriteAxisService, + thumbnailsUrl: widget.thumbnailsUrl, episodeId: widget.episodeId, posterUrl: widget.posterUrl, position: previewPosition, diff --git a/lib/pages/player/widgets/player_overlay.dart b/lib/pages/player/widgets/player_overlay.dart index 79b7a1b..a401acf 100644 --- a/lib/pages/player/widgets/player_overlay.dart +++ b/lib/pages/player/widgets/player_overlay.dart @@ -266,6 +266,8 @@ class PlayerPlaybackOverlay extends StatelessWidget { compact: true, locked: data.locked, thumbnailService: data.thumbnailService, + spriteAxisService: data.spriteAxisService, + thumbnailsUrl: data.episode.thumbnailsUrl, episodeId: data.episode.id, posterUrl: data.drama.posterUrl, inactiveTrackColor: Colors.white24, diff --git a/lib/pages/player/widgets/player_overlay_models.dart b/lib/pages/player/widgets/player_overlay_models.dart index 75d19df..b811d4c 100644 --- a/lib/pages/player/widgets/player_overlay_models.dart +++ b/lib/pages/player/widgets/player_overlay_models.dart @@ -5,6 +5,7 @@ import 'package:video_player/video_player.dart'; import '../../../core/data/models/drama.dart'; import '../../../core/data/services/playback_thumbnail_service.dart'; +import '../../../core/data/services/sprite_thumbnail_axis.dart'; import '../../../l10n/app_localizations.dart'; /// 播放浮层展示数据。 @@ -26,6 +27,7 @@ class PlayerOverlayData { required this.boosting, required this.locked, this.thumbnailService, + this.spriteAxisService, this.authorNote = '', }); @@ -73,6 +75,7 @@ class PlayerOverlayData { /// 拖动进度时按当前集和时间点取帧。 final PlaybackThumbnailService? thumbnailService; + final SpriteThumbnailAxisService? spriteAxisService; /// 作者声明;没有后端字段时为空。 final String authorNote; diff --git a/lib/pages/player/widgets/scrub_preview_card.dart b/lib/pages/player/widgets/scrub_preview_card.dart index e38f8f1..89472a1 100644 --- a/lib/pages/player/widgets/scrub_preview_card.dart +++ b/lib/pages/player/widgets/scrub_preview_card.dart @@ -2,10 +2,12 @@ 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'; @@ -36,9 +38,13 @@ class ScrubPreviewCard extends StatefulWidget { 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; @@ -49,50 +55,109 @@ class ScrubPreviewCard extends StatefulWidget { } class _ScrubPreviewCardState extends State { - ByteData? _frame; + ByteData? _fallbackFrame; int _requestSerial = 0; int? _requestedBucket; + String? _requestedEpisodeId; @override void initState() { super.initState(); - _requestFrame(); + widget.spriteAxisService?.addListener(_handleSpriteAxisChanged); + _syncFallbackSource(); } @override void didUpdateWidget(covariant ScrubPreviewCard oldWidget) { super.didUpdateWidget(oldWidget); - final bucket = playbackThumbnailBucket(widget.position); + if (oldWidget.spriteAxisService != widget.spriteAxisService) { + oldWidget.spriteAxisService?.removeListener(_handleSpriteAxisChanged); + widget.spriteAxisService?.addListener(_handleSpriteAxisChanged); + } if (oldWidget.service != widget.service || oldWidget.episodeId != widget.episodeId || - _requestedBucket != bucket) { - _frame = null; - _requestFrame(); - } else { - // 同 bucket 内继续拖动时不重复取帧,但会重置 120ms 预取停顿计时。 + 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 _requestFrame() { + 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; - final future = widget.service.thumbnail(widget.episodeId, widget.position); - future.then((frame) { - if (!mounted || serial != _requestSerial) return; - setState(() => _frame = frame); + 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, @@ -135,11 +200,18 @@ class _ScrubPreviewCardState extends State { fit: BoxFit.cover, fallbackColor: AppColors.bar, ), - if (_frame case final frame?) + 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( - frame.buffer.asUint8List( - frame.offsetInBytes, - frame.lengthInBytes, + _fallbackFrame!.buffer.asUint8List( + _fallbackFrame!.offsetInBytes, + _fallbackFrame!.lengthInBytes, ), key: const Key('scrub-preview-frame'), fit: BoxFit.cover, @@ -157,6 +229,61 @@ class _ScrubPreviewCardState extends State { } } +/// 对已解码 sprite 做 sourceRect 裁剪,不触发再次解码。 +class SpriteCropPreview extends StatelessWidget { + const SpriteCropPreview({ + super.key, + required this.image, + required this.sourceRect, + }); + + final ui.Image image; + final ui.Rect sourceRect; + + @override + Widget build(BuildContext context) => CustomPaint( + painter: _SpriteCropPainter(image: image, sourceRect: sourceRect), + ); +} + +class _SpriteCropPainter extends CustomPainter { + const _SpriteCropPainter({required this.image, required this.sourceRect}); + + final ui.Image image; + final ui.Rect sourceRect; + + @override + void paint(Canvas canvas, Size size) { + final fitted = applyBoxFit(BoxFit.cover, 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; +} + String _format(Duration value) { final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0'); final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0'); diff --git a/lib/pages/player/widgets/seek_progress_bar.dart b/lib/pages/player/widgets/seek_progress_bar.dart index aa96945..ed0f53d 100644 --- a/lib/pages/player/widgets/seek_progress_bar.dart +++ b/lib/pages/player/widgets/seek_progress_bar.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; import '../../../core/data/services/playback_thumbnail_service.dart'; +import '../../../core/data/services/sprite_thumbnail_axis.dart'; import '../../../core/theme/app_colors.dart'; import 'loading_progress_track.dart'; import 'scrub_preview_card.dart'; @@ -35,8 +36,10 @@ class SeekProgressBar extends StatefulWidget { this.inactiveTrackColor = AppColors.border, this.locked = false, this.thumbnailService, + this.spriteAxisService, this.episodeId, this.posterUrl = '', + this.thumbnailsUrl, }); final ValueListenable? valueListenable; @@ -48,8 +51,10 @@ class SeekProgressBar extends StatefulWidget { final Color inactiveTrackColor; final bool locked; final PlaybackThumbnailService? thumbnailService; + final SpriteThumbnailAxisService? spriteAxisService; final String? episodeId; final String posterUrl; + final String? thumbnailsUrl; @override State createState() => _SeekProgressBarState(); @@ -299,6 +304,8 @@ class _SeekProgressBarState extends State { bottom: trackAreaHeight + 8, child: ScrubPreviewCard( service: widget.thumbnailService!, + spriteAxisService: widget.spriteAxisService, + thumbnailsUrl: widget.thumbnailsUrl, episodeId: widget.episodeId!, posterUrl: widget.posterUrl, position: previewPosition, diff --git a/test/playback/sprite_thumbnail_axis_test.dart b/test/playback/sprite_thumbnail_axis_test.dart new file mode 100644 index 0000000..663225b --- /dev/null +++ b/test/playback/sprite_thumbnail_axis_test.dart @@ -0,0 +1,233 @@ +// Author: fruit.com.cn + +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/play_ticket.dart'; +import 'package:fruitshorts/core/data/services/sprite_thumbnail_asset_loader.dart'; +import 'package:fruitshorts/core/data/services/sprite_thumbnail_axis.dart'; + +const String _spriteVtt = ''' +WEBVTT + +00:00:00.000 --> 00:00:01.000 +sprite_001.jpg#xywh=0,0,160,90 + +00:00:01.000 --> 00:00:02.000 +sprite_001.jpg#xywh=160,0,160,90 + +00:00:02.000 --> 00:00:03.000 +sprite_002.jpg#xywh=0,0,160,90 + +00:00:03.000 --> 00:00:04.000 +sprite_003.jpg#xywh=0,0,160,90 +'''; + +void main() { + test('should_parse_cue_times_xywh_and_multiple_sprite_files', () { + final axis = parseSpriteThumbnailVtt( + _spriteVtt, + vttUrl: 'https://cdn.test/transcoded/thumbnails.vtt?e=1&s=x', + ); + + expect(axis.cues, hasLength(4)); + expect(axis.cues.first.start, Duration.zero); + expect(axis.cues.first.end, const Duration(seconds: 1)); + expect(axis.cues.first.spriteFile, 'sprite_001.jpg'); + expect(axis.cues.first.sourceRect, const Rect.fromLTWH(0, 0, 160, 90)); + expect(axis.cues[1].sourceRect, const Rect.fromLTWH(160, 0, 160, 90)); + expect(axis.cues[2].spriteFile, 'sprite_002.jpg'); + expect(axis.cues[3].spriteFile, 'sprite_003.jpg'); + expect( + axis.resolveSpriteUri(axis.cues[2].spriteFile).toString(), + 'https://cdn.test/transcoded/sprite_002.jpg', + ); + }); + + test('should_select_cue_and_use_nearest_when_position_is_out_of_range', () { + final cues = parseSpriteThumbnailVtt( + _spriteVtt, + vttUrl: 'https://cdn.test/thumbnails.vtt', + ).cues; + + expect( + selectSpriteThumbnailCue(cues, const Duration(milliseconds: 1500)), + same(cues[1]), + ); + expect( + selectSpriteThumbnailCue(cues, const Duration(seconds: -1)), + same(cues[0]), + ); + expect( + selectSpriteThumbnailCue(cues, const Duration(seconds: 9)), + same(cues[3]), + ); + }); + + test('should_return_empty_axis_when_vtt_cannot_be_parsed', () { + final axis = parseSpriteThumbnailVtt( + 'not-a-valid-vtt', + vttUrl: 'https://cdn.test/broken.vtt', + ); + + expect(axis.cues, isEmpty); + }); + + testWidgets('should_keep_only_two_decoded_sprites_in_lru', (tester) async { + final loader = _FakeSpriteLoader(vtt: _spriteVtt); + final service = SpriteThumbnailAxisService( + loader: loader, + cacheTtl: const Duration(minutes: 10), + ); + addTearDown(service.dispose); + + await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero); + expect(service.spriteCacheSize, 2); + expect( + service.spriteCacheKeys, + containsAll(['sprite_001.jpg', 'sprite_002.jpg']), + ); + + await service.prepareAt( + 'https://cdn.test/thumbnails.vtt', + const Duration(milliseconds: 2500), + ); + + expect(service.spriteCacheSize, 2); + expect(service.spriteCacheKeys, contains('sprite_002.jpg')); + expect(service.spriteCacheKeys, isNot(contains('sprite_001.jpg'))); + }); + + testWidgets('should_cache_vtt_and_retry_once_after_403', (tester) async { + final loader = _FakeSpriteLoader( + vttResponses: const [SpriteThumbnailForbiddenException(), _spriteVtt], + ); + final service = SpriteThumbnailAxisService(loader: loader); + addTearDown(service.dispose); + + await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero); + await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero); + + expect(loader.vttCalls, 2); + expect(service.hasAxis('https://cdn.test/thumbnails.vtt'), isTrue); + }); + + testWidgets('should_refresh_vtt_after_ten_minute_ttl', (tester) async { + var now = DateTime(2026, 10, 22); + final loader = _FakeSpriteLoader(vtt: _spriteVtt); + final service = SpriteThumbnailAxisService(loader: loader, now: () => now); + addTearDown(service.dispose); + + await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero); + now = now.add(const Duration(minutes: 11)); + await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero); + + expect(loader.vttCalls, 2); + }); + + test('should_resolve_fallback_chain_in_priority_order', () { + expect( + decideScrubPreviewStage( + hasThumbnailsUrl: true, + hasSpriteFrame: true, + hasAxis: true, + hasLocalFrame: true, + ), + ScrubPreviewStage.sprite, + ); + expect( + decideScrubPreviewStage( + hasThumbnailsUrl: true, + hasSpriteFrame: false, + hasAxis: false, + hasLocalFrame: true, + ), + ScrubPreviewStage.waitingForAxis, + ); + expect( + decideScrubPreviewStage( + hasThumbnailsUrl: false, + hasSpriteFrame: false, + hasAxis: false, + hasLocalFrame: true, + ), + ScrubPreviewStage.localFrame, + ); + expect( + decideScrubPreviewStage( + hasThumbnailsUrl: true, + hasSpriteFrame: false, + hasAxis: true, + hasLocalFrame: false, + ), + ScrubPreviewStage.cover, + ); + }); + + test('should_parse_thumbnails_url_from_episode_and_ticket', () { + final episode = Episode.fromJson(const { + 'id': '11', + 'number': 1, + 'title': 'episode', + 'isFree': true, + 'thumbnailsUrl': 'https://cdn.test/thumbnails.vtt?e=1&s=x', + }); + final ticket = PlayTicket.fromJson(const { + 'episodeId': '11', + 'accessible': true, + 'accessType': 'FREE', + 'thumbnailsUrl': 'https://cdn.test/thumbnails.vtt?e=1&s=x', + }); + + expect(episode.thumbnailsUrl, ticket.thumbnailsUrl); + expect( + episode.withVideoUrl('file:///video.mp4').thumbnailsUrl, + episode.thumbnailsUrl, + ); + }); +} + +class _FakeSpriteLoader implements SpriteThumbnailAssetLoader { + _FakeSpriteLoader({this.vtt, this.vttResponses}); + + final String? vtt; + final List? vttResponses; + int vttCalls = 0; + final List spriteCalls = []; + + @override + Future loadVtt(Uri uri) async { + final index = vttCalls++; + final responses = vttResponses; + if (responses == null) return vtt; + final response = responses[index.clamp(0, responses.length - 1)]; + if (response is SpriteThumbnailForbiddenException) throw response; + return response as String; + } + + @override + Future loadSprite(Uri uri) async { + spriteCalls.add(uri.pathSegments.last); + final image = await _createImage(320, 180); + return SpriteThumbnailDecodedImage( + image: image, + originalWidth: 320, + originalHeight: 180, + ); + } +} + +Future _createImage(int width, int height) async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.drawRect( + Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), + Paint()..color = Colors.blue, + ); + final picture = recorder.endRecording(); + final image = await picture.toImage(width, height); + picture.dispose(); + return image; +} diff --git a/test/player/scrub_preview_card_test.dart b/test/player/scrub_preview_card_test.dart new file mode 100644 index 0000000..2503b48 --- /dev/null +++ b/test/player/scrub_preview_card_test.dart @@ -0,0 +1,171 @@ +// Author: fruit.com.cn + +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/services/playback_thumbnail_service.dart'; +import 'package:fruitshorts/core/data/services/sprite_thumbnail_axis.dart'; +import 'package:fruitshorts/pages/player/widgets/scrub_preview_card.dart'; + +void main() { + testWidgets('should_render_sprite_crop_without_requesting_local_frame', ( + tester, + ) async { + final image = await _createImage(320, 180); + final cue = const SpriteThumbnailCue( + start: Duration.zero, + end: Duration(seconds: 2), + spriteFile: 'sprite_001.jpg', + sourceRect: Rect.fromLTWH(0, 0, 160, 90), + ); + final axis = _FakeAxisSource( + axisReady: true, + frame: SpriteThumbnailFrame( + image: image, + sourceRect: const Rect.fromLTWH(0, 0, 160, 90), + spriteUrl: 'https://cdn.test/sprite_001.jpg', + cue: cue, + ), + ); + final loader = _CountingThumbnailLoader(); + final localService = _localService(loader); + addTearDown(image.dispose); + addTearDown(axis.dispose); + addTearDown(localService.dispose); + + await tester.pumpWidget( + _card( + localService: localService, + axis: axis, + thumbnailsUrl: 'https://cdn.test/thumbnails.vtt', + ), + ); + await tester.pump(); + + final preview = tester.widget( + find.byKey(const Key('scrub-preview-sprite')), + ); + expect(preview.sourceRect, const Rect.fromLTWH(0, 0, 160, 90)); + expect(find.byKey(const Key('scrub-preview-frame')), findsNothing); + expect(loader.calls, 0); + }); + + testWidgets('should_fallback_to_local_frame_when_thumbnails_url_is_missing', ( + tester, + ) async { + final loader = _CountingThumbnailLoader(); + final localService = _localService(loader); + final axis = _FakeAxisSource(axisReady: false); + addTearDown(axis.dispose); + addTearDown(localService.dispose); + + await tester.pumpWidget( + _card(localService: localService, axis: axis, thumbnailsUrl: null), + ); + await tester.pump(); + + expect(loader.calls, 1); + expect(find.byKey(const Key('scrub-preview-poster')), findsOneWidget); + expect(find.byKey(const Key('scrub-preview-sprite')), findsNothing); + }); + + testWidgets('should_keep_poster_when_axis_and_local_frame_both_fail', ( + tester, + ) async { + final loader = _CountingThumbnailLoader(); + final localService = _localService(loader); + final axis = _FakeAxisSource(axisReady: true); + addTearDown(axis.dispose); + addTearDown(localService.dispose); + + await tester.pumpWidget( + _card( + localService: localService, + axis: axis, + thumbnailsUrl: 'https://cdn.test/empty.vtt', + ), + ); + await tester.pump(); + + expect(loader.calls, 1); + expect(find.byKey(const Key('scrub-preview-poster')), findsOneWidget); + expect(find.byKey(const Key('scrub-preview-sprite')), findsNothing); + expect(find.byKey(const Key('scrub-preview-frame')), findsNothing); + }); +} + +Widget _card({ + required PlaybackThumbnailService localService, + required SpriteThumbnailAxisSource axis, + required String? thumbnailsUrl, +}) { + return MaterialApp( + home: Scaffold( + body: Center( + child: ScrubPreviewCard( + service: localService, + spriteAxisService: axis, + thumbnailsUrl: thumbnailsUrl, + episodeId: 'episode-1', + posterUrl: '', + position: const Duration(seconds: 1), + duration: const Duration(seconds: 10), + ), + ), + ), + ); +} + +PlaybackThumbnailService _localService(PlaybackThumbnailFrameLoader loader) { + return PlaybackThumbnailService( + frameLoader: loader, + sourceResolver: (_) => + const PlaybackThumbnailSource(remoteUrl: 'https://example/video.mp4'), + prefetchDelay: const Duration(hours: 1), + ); +} + +class _FakeAxisSource extends ChangeNotifier + implements SpriteThumbnailAxisSource { + _FakeAxisSource({required this.axisReady, this.frame}); + + final bool axisReady; + final SpriteThumbnailFrame? frame; + + @override + SpriteThumbnailFrame? cachedFrameAt( + String? thumbnailsUrl, + Duration position, + ) => frame; + + @override + bool hasAxis(String? thumbnailsUrl) => axisReady; +} + +class _CountingThumbnailLoader implements PlaybackThumbnailFrameLoader { + int calls = 0; + + @override + Future load(PlaybackThumbnailFrameRequest request) async { + calls++; + return null; + } + + @override + void dispose() {} +} + +Future _createImage(int width, int height) async { + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + canvas.drawRect( + Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), + Paint()..color = Colors.orange, + ); + final picture = recorder.endRecording(); + final image = await picture.toImage(width, height); + picture.dispose(); + return image; +}