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;
|
||||
}
|
||||
@@ -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<SpriteCropPreview>(
|
||||
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<ByteData?> load(PlaybackThumbnailFrameRequest request) async {
|
||||
calls++;
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {}
|
||||
}
|
||||
|
||||
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.orange,
|
||||
);
|
||||
final picture = recorder.endRecording();
|
||||
final image = await picture.toImage(width, height);
|
||||
picture.dispose();
|
||||
return image;
|
||||
}
|
||||
Reference in new issue
Block a user