feat(player): 增加进度条拖动画面预览

This commit is contained in:
Zhou Weidong committed 2026-10-22 11:03:17 +08:00
1 parent d06dce9753
commit 51c56d884b
15 files changed
+1493 -77

No files matched your search

+96
View File
@@ -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() {}
}
+86
View File
@@ -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() {}
}