feat(player): 增加进度条拖动画面预览
This commit is contained in:
1 parent
d06dce9753
commit
51c56d884b
15 files changed
+1493
-77
No files matched your search
@@ -1,6 +1,7 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -13,6 +14,7 @@ import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/playback_thumbnail_service.dart';
|
||||
import 'package:fruitshorts/core/theme/app_theme.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
import 'package:fruitshorts/pages/home/home_feed_channel.dart';
|
||||
@@ -609,6 +611,83 @@ void main() {
|
||||
expect(find.byIcon(Icons.refresh_rounded), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_show_scrub_preview_card_from_home_progress', (
|
||||
tester,
|
||||
) async {
|
||||
final controller = ValueNotifier<VideoPlayerValue>(
|
||||
const VideoPlayerValue(
|
||||
duration: Duration(seconds: 100),
|
||||
position: Duration(seconds: 10),
|
||||
isInitialized: true,
|
||||
),
|
||||
);
|
||||
final service = _nullThumbnailService();
|
||||
addTearDown(controller.dispose);
|
||||
addTearDown(service.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
locale: const Locale('en'),
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: HomeFeedOverlay(
|
||||
l10n: AppLocalizations.of(context)!,
|
||||
drama: const Drama(
|
||||
id: 'preview-drama',
|
||||
title: 'Preview Drama',
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '',
|
||||
totalEpisodes: 8,
|
||||
),
|
||||
episode: const Episode(
|
||||
id: 'preview-episode',
|
||||
number: 1,
|
||||
title: 'Episode 1',
|
||||
isFree: true,
|
||||
),
|
||||
controller: controller,
|
||||
locked: false,
|
||||
liked: false,
|
||||
saved: false,
|
||||
visible: true,
|
||||
boosting: false,
|
||||
thumbnailService: service,
|
||||
onTogglePlay: () {},
|
||||
onBoostStart: () {},
|
||||
onBoostEnd: () {},
|
||||
onLike: () {},
|
||||
onSave: () {},
|
||||
onComments: () {},
|
||||
onShare: () {},
|
||||
onDetail: () {},
|
||||
onOpenFullPlayer: () {},
|
||||
onMore: () {},
|
||||
onScrubStart: () {},
|
||||
onSeek: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byKey(const Key('home-feed-progress'))),
|
||||
);
|
||||
await gesture.moveBy(const Offset(60, 0));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(const Key('scrub-preview-card')), findsOneWidget);
|
||||
expect(find.byKey(const Key('scrub-preview-poster')), findsOneWidget);
|
||||
expect(find.textContaining('/ 01:40'), findsOneWidget);
|
||||
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
expect(find.byKey(const Key('scrub-preview-card')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_seek_from_series_bar_when_overlay_is_cleared', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -683,3 +762,20 @@ void main() {
|
||||
expect(seeked!.inSeconds, greaterThan(0));
|
||||
});
|
||||
}
|
||||
|
||||
PlaybackThumbnailService _nullThumbnailService() {
|
||||
return PlaybackThumbnailService(
|
||||
frameLoader: _NullThumbnailLoader(),
|
||||
sourceResolver: (_) =>
|
||||
const PlaybackThumbnailSource(remoteUrl: 'https://example/video.mp4'),
|
||||
prefetchDelay: const Duration(hours: 1),
|
||||
);
|
||||
}
|
||||
|
||||
class _NullThumbnailLoader implements PlaybackThumbnailFrameLoader {
|
||||
@override
|
||||
Future<ByteData?> load(PlaybackThumbnailFrameRequest request) async => null;
|
||||
|
||||
@override
|
||||
void dispose() {}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
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/theme/app_colors.dart';
|
||||
import 'package:fruitshorts/pages/player/widgets/scrub_preview_card.dart';
|
||||
import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
@@ -17,6 +21,18 @@ void main() {
|
||||
expect(seekPositionForFraction(1, duration), lessThan(duration));
|
||||
});
|
||||
|
||||
test('should_clamp_preview_card_inside_track_safe_margins', () {
|
||||
expect(
|
||||
scrubPreviewLeft(trackWidth: 300, fraction: 0),
|
||||
scrubPreviewSafeMargin,
|
||||
);
|
||||
expect(
|
||||
scrubPreviewLeft(trackWidth: 300, fraction: 1),
|
||||
300 - scrubPreviewWidth - scrubPreviewSafeMargin,
|
||||
);
|
||||
expect(scrubPreviewLeft(trackWidth: 100, fraction: 0.5), 0);
|
||||
});
|
||||
|
||||
testWidgets('should_show_inactive_track_when_video_is_not_initialized', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -233,6 +249,59 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('should_show_frame_card_with_cover_then_hide_on_release', (
|
||||
tester,
|
||||
) async {
|
||||
final video = ValueNotifier<VideoPlayerValue>(
|
||||
const VideoPlayerValue(
|
||||
duration: Duration(seconds: 12),
|
||||
position: Duration(seconds: 3),
|
||||
isInitialized: true,
|
||||
),
|
||||
);
|
||||
final service = _thumbnailService();
|
||||
addTearDown(video.dispose);
|
||||
addTearDown(service.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: SizedBox(
|
||||
width: 300,
|
||||
child: SeekProgressBar(
|
||||
valueListenable: video,
|
||||
semanticsLabel: 'Seek video',
|
||||
episodeId: 'episode-1',
|
||||
posterUrl: '',
|
||||
thumbnailService: service,
|
||||
onSeek: (_) async {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byType(Slider)),
|
||||
);
|
||||
await gesture.moveBy(const Offset(80, 0));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(const Key('scrub-preview-card')), findsOneWidget);
|
||||
expect(find.byKey(const Key('scrub-preview-poster')), findsOneWidget);
|
||||
expect(find.byKey(const Key('scrub-preview-frame')), findsNothing);
|
||||
expect(find.textContaining('/ 00:12'), findsOneWidget);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('scrub-preview-card'))).width,
|
||||
120,
|
||||
);
|
||||
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
expect(find.byKey(const Key('scrub-preview-card')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('should_show_time_only_while_dragging_when_seek_bar_is_compact', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -267,3 +336,20 @@ void main() {
|
||||
expect(find.textContaining('00:12'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
PlaybackThumbnailService _thumbnailService() {
|
||||
return PlaybackThumbnailService(
|
||||
frameLoader: _NullThumbnailLoader(),
|
||||
sourceResolver: (_) =>
|
||||
const PlaybackThumbnailSource(remoteUrl: 'https://example/video.mp4'),
|
||||
prefetchDelay: const Duration(hours: 1),
|
||||
);
|
||||
}
|
||||
|
||||
class _NullThumbnailLoader implements PlaybackThumbnailFrameLoader {
|
||||
@override
|
||||
Future<ByteData?> load(PlaybackThumbnailFrameRequest request) async => null;
|
||||
|
||||
@override
|
||||
void dispose() {}
|
||||
}
|
||||
Reference in new issue
Block a user