feat(player): 进度条拖动预览改用雪碧图
This commit is contained in:
1 parent
89a94f85c2
commit
12b733d111
16 files changed
+1312
-31
No files matched your search
@@ -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<Object>? vttResponses;
|
||||
int vttCalls = 0;
|
||||
final List<String> spriteCalls = [];
|
||||
|
||||
@override
|
||||
Future<String?> 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<SpriteThumbnailDecodedImage?> loadSprite(Uri uri) async {
|
||||
spriteCalls.add(uri.pathSegments.last);
|
||||
final image = await _createImage(320, 180);
|
||||
return SpriteThumbnailDecodedImage(
|
||||
image: image,
|
||||
originalWidth: 320,
|
||||
originalHeight: 180,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<ui.Image> _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;
|
||||
}
|
||||
Reference in new issue
Block a user