feat(player): 增加进度条拖动画面预览
This commit is contained in:
1 parent
d06dce9753
commit
51c56d884b
15 files changed
+1493
-77
No files matched your search
@@ -0,0 +1,227 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/services/playback_thumbnail_service.dart';
|
||||
|
||||
void main() {
|
||||
test('should_bucket_thumbnail_requests_by_whole_seconds', () {
|
||||
expect(playbackThumbnailBucket(Duration.zero), 0);
|
||||
expect(playbackThumbnailBucket(const Duration(milliseconds: 1999)), 1);
|
||||
expect(playbackThumbnailBucket(const Duration(seconds: 12)), 12);
|
||||
expect(playbackThumbnailBucket(const Duration(milliseconds: -300)), 0);
|
||||
});
|
||||
|
||||
test('should_return_injected_frame_from_loader', () async {
|
||||
final loader = _RecordingLoader((_) async => ByteData(1));
|
||||
final frame = await loader.load(
|
||||
const PlaybackThumbnailFrameRequest(
|
||||
episodeId: 'episode',
|
||||
position: Duration(seconds: 1),
|
||||
source: PlaybackThumbnailSource(remoteUrl: 'https://example/video.mp4'),
|
||||
),
|
||||
);
|
||||
expect(frame, isNotNull);
|
||||
});
|
||||
|
||||
test('should_evict_least_recently_used_frame_after_24_entries', () async {
|
||||
final loader = _RecordingLoader((_) async => ByteData(1));
|
||||
final service = _service(loader);
|
||||
addTearDown(service.dispose);
|
||||
|
||||
for (var second = 0; second < 25; second++) {
|
||||
final frame = await service.thumbnail(
|
||||
'episode',
|
||||
Duration(seconds: second),
|
||||
);
|
||||
expect(
|
||||
frame,
|
||||
isNotNull,
|
||||
reason: 'bucket $second loader=${loader.buckets}',
|
||||
);
|
||||
}
|
||||
expect(service.cacheSize, 24);
|
||||
expect(loader.buckets.first, 0);
|
||||
|
||||
await service.thumbnail('episode', const Duration(seconds: 1));
|
||||
expect(loader.buckets.where((bucket) => bucket == 1), hasLength(1));
|
||||
|
||||
await service.thumbnail('episode', Duration.zero);
|
||||
expect(loader.buckets.where((bucket) => bucket == 0), hasLength(2));
|
||||
});
|
||||
|
||||
test('should_coalesce_same_bucket_while_frame_is_in_flight', () async {
|
||||
final frame = Completer<ByteData?>();
|
||||
final loader = _RecordingLoader((_) => frame.future);
|
||||
final service = _service(loader);
|
||||
addTearDown(service.dispose);
|
||||
|
||||
final first = service.thumbnail('episode', const Duration(seconds: 8));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final second = service.thumbnail(
|
||||
'episode',
|
||||
const Duration(milliseconds: 8999),
|
||||
);
|
||||
|
||||
expect(identical(first, second), isTrue);
|
||||
expect(loader.buckets, [8]);
|
||||
frame.complete(ByteData(2));
|
||||
expect(await first, isNotNull);
|
||||
expect(await second, isNotNull);
|
||||
});
|
||||
|
||||
test('should_drop_superseded_request_and_start_latest_bucket', () async {
|
||||
final frames = <int, Completer<ByteData?>>{};
|
||||
final loader = _RecordingLoader((request) {
|
||||
final completer = Completer<ByteData?>();
|
||||
frames[request.position.inSeconds] = completer;
|
||||
return completer.future;
|
||||
});
|
||||
final service = _service(loader);
|
||||
addTearDown(service.dispose);
|
||||
|
||||
final stale = service.thumbnail('episode', const Duration(seconds: 4));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final latest = service.thumbnail('episode', const Duration(seconds: 9));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(loader.buckets, [4]);
|
||||
frames[4]!.complete(ByteData(4));
|
||||
expect(await stale, isNull);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(loader.buckets, [4, 9]);
|
||||
|
||||
frames[9]!.complete(ByteData(9));
|
||||
expect(await latest, isNotNull);
|
||||
});
|
||||
|
||||
test('should_cancel_unstarted_request_when_scrub_ends', () async {
|
||||
final firstFrame = Completer<ByteData?>();
|
||||
final loader = _RecordingLoader((request) {
|
||||
if (request.position.inSeconds == 3) return firstFrame.future;
|
||||
return Future<ByteData?>.value(ByteData(7));
|
||||
});
|
||||
final service = _service(loader);
|
||||
addTearDown(service.dispose);
|
||||
|
||||
final active = service.thumbnail('episode', const Duration(seconds: 3));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final pending = service.thumbnail('episode', const Duration(seconds: 7));
|
||||
service.cancelPending();
|
||||
|
||||
expect(await pending, isNull);
|
||||
firstFrame.complete(ByteData(3));
|
||||
expect(await active, isNull);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(loader.buckets, [3]);
|
||||
});
|
||||
|
||||
test('should_prefetch_one_neighbor_after_scrub_pause', () async {
|
||||
final loader = _RecordingLoader((_) async => ByteData(1));
|
||||
final service = _service(
|
||||
loader,
|
||||
prefetchDelay: const Duration(milliseconds: 10),
|
||||
);
|
||||
addTearDown(service.dispose);
|
||||
|
||||
await service.thumbnail('episode', const Duration(seconds: 5));
|
||||
expect(loader.buckets, [5]);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 5));
|
||||
expect(loader.buckets, [5]);
|
||||
|
||||
await Future<void>.delayed(const Duration(milliseconds: 12));
|
||||
expect(loader.buckets, [5, 6]);
|
||||
});
|
||||
|
||||
test('should_not_prefetch_over_newer_unstarted_bucket', () async {
|
||||
final frames = <int, Completer<ByteData?>>{};
|
||||
final loader = _RecordingLoader((request) {
|
||||
final completer = Completer<ByteData?>();
|
||||
frames[request.position.inSeconds] = completer;
|
||||
return completer.future;
|
||||
});
|
||||
final service = _service(
|
||||
loader,
|
||||
prefetchDelay: const Duration(milliseconds: 10),
|
||||
);
|
||||
addTearDown(service.dispose);
|
||||
|
||||
final first = service.thumbnail('episode', const Duration(seconds: 1));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final latest = service.thumbnail('episode', const Duration(seconds: 3));
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
|
||||
expect(loader.buckets, [1]);
|
||||
frames[1]!.complete(ByteData(1));
|
||||
expect(await first, isNull);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
expect(loader.buckets, [1, 3]);
|
||||
|
||||
frames[3]!.complete(ByteData(3));
|
||||
expect(await latest, isNotNull);
|
||||
});
|
||||
|
||||
test('should_return_null_once_then_negative_cache_failed_bucket', () async {
|
||||
final loader = _RecordingLoader((_) => Completer<ByteData?>().future);
|
||||
final service = _service(
|
||||
loader,
|
||||
requestTimeout: const Duration(milliseconds: 10),
|
||||
);
|
||||
addTearDown(service.dispose);
|
||||
|
||||
expect(
|
||||
await service.thumbnail('episode', const Duration(seconds: 2)),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
await service.thumbnail('episode', const Duration(seconds: 2)),
|
||||
isNull,
|
||||
);
|
||||
expect(loader.buckets, [2]);
|
||||
});
|
||||
|
||||
test('should_convert_loader_exception_to_null', () async {
|
||||
final loader = _RecordingLoader((_) async => throw StateError('decode'));
|
||||
final service = _service(loader);
|
||||
addTearDown(service.dispose);
|
||||
|
||||
expect(
|
||||
await service.thumbnail('episode', const Duration(seconds: 6)),
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
PlaybackThumbnailService _service(
|
||||
PlaybackThumbnailFrameLoader loader, {
|
||||
Duration requestTimeout = const Duration(hours: 1),
|
||||
Duration prefetchDelay = const Duration(hours: 1),
|
||||
}) {
|
||||
return PlaybackThumbnailService(
|
||||
frameLoader: loader,
|
||||
sourceResolver: (_) =>
|
||||
const PlaybackThumbnailSource(remoteUrl: 'https://example/video.mp4'),
|
||||
requestTimeout: requestTimeout,
|
||||
prefetchDelay: prefetchDelay,
|
||||
);
|
||||
}
|
||||
|
||||
class _RecordingLoader implements PlaybackThumbnailFrameLoader {
|
||||
_RecordingLoader(this._handler);
|
||||
|
||||
final Future<ByteData?> Function(PlaybackThumbnailFrameRequest request)
|
||||
_handler;
|
||||
final List<int> buckets = [];
|
||||
|
||||
@override
|
||||
Future<ByteData?> load(PlaybackThumbnailFrameRequest request) {
|
||||
buckets.add(request.position.inSeconds);
|
||||
return _handler(request);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {}
|
||||
}
|
||||
Reference in new issue
Block a user