fix(player): 重构单层过渡并阻止锁定集串播

This commit is contained in:
Zhou Weidong committed 2026-10-26 14:41:18 +08:00
1 parent 70d8e0f982
commit 0cb0ab0613
8 files changed
+657 -335

No files matched your search

+11
View File
@@ -1031,6 +1031,14 @@ void main() {
await _pumpApp(
tester,
catalog: FakeCatalogRepository(dramas: const [_lockedDrama]),
playback: FakePlaybackRepository(
ticket: const PlayTicket(
episodeId: 'locked-demo-1',
accessible: false,
accessType: PlayTicket.coinRequired,
unlockCoin: 30,
),
),
);
await _openSeriesPlayer(
tester,
@@ -1040,6 +1048,9 @@ void main() {
expect(find.byKey(const Key('progress-loading-highlight')), findsNothing);
expect(find.byKey(const Key('progress-inactive')), findsOneWidget);
expect(find.byType(Slider), findsNothing);
expect(find.byKey(const Key('player-single-placeholder')), findsNothing);
expect(find.byKey(const Key('player-video-crossfade')), findsNothing);
expect(find.byKey(const Key('player-video-layer')), findsNothing);
expect(
tester
.widget<SeekProgressBar>(find.byKey(const Key('player-progress')))
+138
View File
@@ -11,12 +11,15 @@ import 'package:fruitshorts/core/data/models/watch_progress.dart';
import 'package:fruitshorts/core/data/models/download_license.dart';
import 'package:fruitshorts/core/data/models/download_policy.dart';
import 'package:fruitshorts/core/data/repositories/download_repository.dart';
import 'package:fruitshorts/core/data/repositories/commerce_repository.dart';
import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart';
import 'package:fruitshorts/core/data/services/download_controller.dart';
import 'package:fruitshorts/core/data/services/download_storage.dart';
import 'package:fruitshorts/core/data/services/playback_session.dart';
import 'package:fruitshorts/core/data/services/playback_position_cache.dart';
import 'package:fruitshorts/core/data/services/video_player_port.dart';
import 'package:fruitshorts/core/data/services/wallet_controller.dart';
import 'package:fruitshorts/core/data/models/wallet.dart';
import 'package:fruitshorts/pages/player/player_logic.dart';
import 'package:fruitshorts/pages/player/player_view.dart';
import 'package:fruitshorts/pages/player/widgets/player_video_surface.dart';
@@ -46,6 +49,37 @@ Drama _drama(
],
);
Drama _gateDrama() => const Drama(
id: 'gate',
title: 'Gate',
synopsis: 'Gate synopsis',
posterUrl: '/files/gate.png',
episodes: [
Episode(
id: 'gate-1',
number: 1,
title: 'Free',
isFree: true,
videoUrl: 'https://example.test/gate-1.mp4',
),
Episode(
id: 'gate-2',
number: 2,
title: 'Locked',
isFree: false,
locked: true,
videoUrl: 'https://example.test/gate-2.mp4',
),
Episode(
id: 'gate-3',
number: 3,
title: 'Next',
isFree: true,
videoUrl: 'https://example.test/gate-3.mp4',
),
],
);
AndroidPictureInPicture _pip() =>
AndroidPictureInPicture(channel: const MethodChannel('test/player_logic/pip'));
@@ -53,11 +87,13 @@ PlayerLogic _logic(
FakeWatchHistory history, {
PlaybackRepository? playback,
PlaybackPositionCache? positionCache,
WalletController? wallet,
Future<void> Function()? waitForFrame,
}) => PlayerLogic(
history: history,
playback: playback ?? FakePlaybackRepository(),
pictureInPicture: _pip(),
wallet: wallet,
positionCache: positionCache,
waitForFrame: waitForFrame ?? () async {},
);
@@ -581,6 +617,88 @@ void main() {
await _drain(logic);
});
test('should_pause_and_release_when_swiping_to_locked_episode', () async {
final factory = _RecordingFactory();
final playback = FakePlaybackRepository();
final logic = _logic(FakeWatchHistory(), playback: playback);
logic.attach(
PlayerPage(drama: _gateDrama(), initialIndex: 0),
isMounted: () => true,
);
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
await _drain(logic);
final previous = factory.created.single;
playback.ticket = const PlayTicket(
episodeId: 'gate-2',
accessible: false,
accessType: PlayTicket.coinRequired,
unlockCoin: 30,
);
await logic.selectEpisode(1);
await _drain(logic);
expect(previous.pauses, greaterThan(0));
expect(previous.disposed, isTrue);
expect(logic.session.active, isNull);
expect(factory.live, 0);
expect(factory.created, hasLength(1));
expect(logic.blockedEpisodeId, 'gate-2');
playback.ticket = null;
await logic.selectEpisode(2);
await _drain(logic);
expect(logic.session.active, isNotNull);
expect(factory.created.last.plays, greaterThan(0));
logic.dispose();
await _drain(logic);
});
test('should_start_locked_episode_after_unlock_succeeds', () async {
final factory = _RecordingFactory();
final playback = FakePlaybackRepository();
final wallet = WalletController(_UnlockCommerceRepository());
final logic = _logic(
FakeWatchHistory(),
playback: playback,
wallet: wallet,
);
logic.attach(
PlayerPage(drama: _gateDrama(), initialIndex: 0),
isMounted: () => true,
);
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
await _drain(logic);
playback.ticket = const PlayTicket(
episodeId: 'gate-2',
accessible: false,
accessType: PlayTicket.coinRequired,
unlockCoin: 30,
);
await logic.selectEpisode(1);
await _drain(logic);
expect(logic.session.active, isNull);
playback.ticket = const PlayTicket(
episodeId: 'gate-2',
accessible: true,
accessType: PlayTicket.coinRequired,
playUrl: '/files/gate-2.mp4',
);
final unlocked = await logic.unlockEpisode(1);
await _drain(logic);
expect(unlocked, isTrue);
expect(logic.isEpisodeLocked(logic.sessionEpisodes[1]), isFalse);
final active = logic.session.active;
expect(active, isA<_RecordingPlayer>());
expect((active! as _RecordingPlayer).plays, greaterThan(0));
logic.dispose();
await _drain(logic);
});
test('should_preload_only_what_follows_when_starting_from_a_later_index', () async {
final factory = _RecordingFactory();
final drama = _drama('signal');
@@ -1403,3 +1521,23 @@ class _VideoRecordingPlayer extends VideoPlayerPort {
await controller.dispose();
}
}
class _UnlockCommerceRepository implements CommerceRepository {
@override
Future<WalletSnapshot> fetchWallet() async => const WalletSnapshot(
wallet: CoinWallet(
coinBalance: 100,
totalRechargedCoin: 0,
totalConsumedCoin: 0,
totalRefundedCoin: 0,
),
);
@override
Future<UnlockResult> unlock(String episodeId) async => const UnlockResult(
unlocked: true,
accessType: PlayTicket.coinRequired,
coinCost: 30,
coinBalance: 70,
);
}
+90 -137
View File
@@ -7,179 +7,130 @@ import 'package:video_player/video_player.dart';
import 'package:fruitshorts/pages/player/widgets/player_video_surface.dart';
void main() {
testWidgets('should_keep_fallback_visible_before_video_is_ready', (
testWidgets('should_show_black_without_placeholder_before_video', (
tester,
) async {
await _pumpSurface(tester);
expect(_fallbackColor(tester), Colors.black);
expect(find.byKey(const Key('player-opening-frame')), findsNothing);
expect(_opacity(tester), 0);
expect(find.byKey(const Key('player-single-placeholder')), findsNothing);
expect(find.byKey(const Key('player-video-layer')), findsNothing);
});
testWidgets('should_show_opening_frame_before_decode_is_ready', (
testWidgets('should_show_exactly_one_placeholder_before_first_frame', (
tester,
) async {
await _pumpSurface(tester, placeholder: _placeholder('opening'));
expect(find.byKey(const Key('player-single-placeholder')), findsOneWidget);
expect(find.byKey(const Key('opening')), findsOneWidget);
expect(find.byKey(const Key('player-video-crossfade')), findsNothing);
expect(find.byKey(const Key('player-video-layer')), findsNothing);
});
testWidgets('should_skip_placeholder_for_warm_controller', (tester) async {
await _pumpSurface(
tester,
openingFrame: const ColoredBox(
key: Key('fake-opening'),
color: Colors.red,
),
placeholder: _placeholder('opening'),
warmFrameAvailable: true,
thumbnailReady: true,
value: _initializedValue,
);
expect(find.byKey(const Key('player-opening-frame')), findsOneWidget);
expect(find.byKey(const Key('fake-opening')), findsOneWidget);
expect(_opacity(tester), 0);
expect(find.byKey(const Key('player-single-placeholder')), findsNothing);
expect(find.byKey(const Key('player-video-layer')), findsOneWidget);
expect(find.byKey(const Key('player-video-crossfade')), findsNothing);
});
testWidgets('should_reveal_paused_frame_when_initialized', (tester) async {
final controller = await _pumpSurface(tester);
controller.value = const VideoPlayerValue(
duration: Duration(minutes: 1),
size: Size(1280, 720),
position: Duration.zero,
isInitialized: true,
isPlaying: false,
);
await tester.pump();
expect(_opacity(tester), 1);
expect(find.byKey(const Key('player-opening-frame')), findsNothing);
});
testWidgets('should_show_cover_until_first_playback_frame', (
testWidgets('should_cross_fade_once_after_first_frame_is_confirmed', (
tester,
) async {
final controller = await _pumpSurface(
tester,
openingFrame: const ColoredBox(color: Colors.red),
holdOpeningUntilFirstFrame: true,
);
expect(_opacity(tester), 0);
controller.value = const VideoPlayerValue(
duration: Duration(minutes: 1),
size: Size(1280, 720),
position: Duration.zero,
isInitialized: true,
isPlaying: true,
placeholder: _placeholder('opening'),
thumbnailReady: true,
waitForPlaybackFrame: true,
);
controller.value = _readyValue(position: Duration.zero, playing: true);
await tester.pump();
expect(_opacity(tester), 0);
expect(find.byKey(const Key('player-opening-frame')), findsOneWidget);
expect(find.byKey(const Key('player-single-placeholder')), findsOneWidget);
expect(find.byKey(const Key('player-video-crossfade')), findsNothing);
controller.value = const VideoPlayerValue(
duration: Duration(minutes: 1),
size: Size(1280, 720),
position: Duration(milliseconds: 1),
isInitialized: true,
isPlaying: true,
controller.value = _readyValue(
position: const Duration(milliseconds: 1),
playing: true,
);
await tester.pump();
await tester.pump();
await tester.pump();
expect(_opacity(tester), 1);
expect(find.byKey(const Key('player-opening-frame')), findsNothing);
expect(
tester
.widget<AnimatedOpacity>(
find.byKey(const Key('player-video-opacity')),
)
.duration,
Duration.zero,
expect(find.byKey(const Key('player-single-placeholder')), findsOneWidget);
expect(find.byKey(const Key('player-video-crossfade')), findsOneWidget);
expect(find.byKey(const Key('player-video-layer')), findsNothing);
expect(_crossFadeValue(tester), 0);
await tester.pump(const Duration(milliseconds: 90));
expect(find.byKey(const Key('player-single-placeholder')), findsOneWidget);
expect(find.byKey(const Key('player-video-crossfade')), findsOneWidget);
expect(_crossFadeValue(tester), greaterThan(0));
expect(_crossFadeValue(tester), lessThan(1));
await tester.pump(const Duration(milliseconds: 100));
expect(find.byKey(const Key('player-single-placeholder')), findsNothing);
expect(find.byKey(const Key('player-video-crossfade')), findsNothing);
expect(find.byKey(const Key('player-video-layer')), findsOneWidget);
controller.value = _readyValue(
position: const Duration(seconds: 1),
playing: true,
);
await tester.pump();
expect(find.byKey(const Key('player-video-crossfade')), findsNothing);
expect(find.byKey(const Key('player-video-layer')), findsOneWidget);
});
testWidgets('should_skip_cover_when_prewarmed_frame_is_available', (
tester,
) async {
await _pumpSurface(
tester,
openingFrame: const ColoredBox(color: Colors.red),
holdOpeningUntilFirstFrame: true,
preparedFrameAvailable: true,
value: const VideoPlayerValue(
duration: Duration(minutes: 1),
size: Size(1280, 720),
position: Duration.zero,
isInitialized: true,
isPlaying: false,
),
);
expect(find.byKey(const Key('player-opening-frame')), findsNothing);
expect(_opacity(tester), 1);
});
testWidgets('should_show_video_error_above_retained_fallback', (
tester,
) async {
testWidgets('should_keep_placeholder_above_video_error', (tester) async {
await _pumpSurface(
tester,
placeholder: _placeholder('opening'),
value: const VideoPlayerValue.erroneous('No route to host'),
);
expect(find.byKey(const Key('opening')), findsOneWidget);
expect(find.byKey(const Key('player-video-error')), findsOneWidget);
expect(find.text('No route to host'), findsOneWidget);
expect(_fallbackColor(tester), Colors.black);
expect(_opacity(tester), 0);
});
testWidgets('should_cover_new_video_only_while_handoff_is_held', (
tester,
) async {
final current = VideoPlayerController.networkUrl(
Uri.parse('http://192.168.31.187:8081/files/720.m3u8'),
);
final previous = VideoPlayerController.networkUrl(
Uri.parse('http://192.168.31.187:8081/files/360.m3u8'),
);
addTearDown(current.dispose);
addTearDown(previous.dispose);
const ready = VideoPlayerValue(
duration: Duration(minutes: 1),
size: Size(1280, 720),
position: Duration(seconds: 3),
isInitialized: true,
isPlaying: true,
);
current.value = ready;
previous.value = ready;
Future<void> pump(VideoPlayerController? handoff) {
return tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: PlayerVideoSurface(
controller: current,
handoff: handoff,
videoUrl: current.dataSource,
theater: false,
openingFrame: const ColoredBox(color: Colors.red),
),
),
),
);
}
await pump(previous);
expect(find.byKey(const Key('player-video-handoff')), findsOneWidget);
expect(find.byKey(const Key('player-opening-frame')), findsNothing);
expect(_opacity(tester), 1);
await pump(null);
expect(find.byKey(const Key('player-video-handoff')), findsNothing);
expect(find.byType(VideoPlayer), findsOneWidget);
});
}
Widget _placeholder(String key) => ColoredBox(key: Key(key), color: Colors.red);
const VideoPlayerValue _initializedValue = VideoPlayerValue(
duration: Duration(minutes: 1),
size: Size(1280, 720),
position: Duration.zero,
isInitialized: true,
isPlaying: false,
);
VideoPlayerValue _readyValue({
required Duration position,
required bool playing,
}) => VideoPlayerValue(
duration: const Duration(minutes: 1),
size: const Size(1280, 720),
position: position,
isInitialized: true,
isPlaying: playing,
);
Future<VideoPlayerController> _pumpSurface(
WidgetTester tester, {
VideoPlayerValue? value,
Widget? openingFrame,
bool holdOpeningUntilFirstFrame = false,
bool preparedFrameAvailable = false,
Widget? placeholder,
bool warmFrameAvailable = false,
bool thumbnailReady = false,
bool waitForPlaybackFrame = false,
}) async {
final controller = VideoPlayerController.networkUrl(
Uri.parse('http://192.168.31.187:8081/files/video.mp4'),
@@ -193,9 +144,10 @@ Future<VideoPlayerController> _pumpSurface(
controller: controller,
videoUrl: controller.dataSource,
theater: false,
openingFrame: openingFrame,
holdOpeningUntilFirstFrame: holdOpeningUntilFirstFrame,
preparedFrameAvailable: preparedFrameAvailable,
placeholder: placeholder,
warmFrameAvailable: warmFrameAvailable,
thumbnailReady: thumbnailReady,
waitForPlaybackFrame: waitForPlaybackFrame,
),
),
),
@@ -210,8 +162,9 @@ Color _fallbackColor(WidgetTester tester) {
.color;
}
double _opacity(WidgetTester tester) {
double _crossFadeValue(WidgetTester tester) {
return tester
.widget<AnimatedOpacity>(find.byKey(const Key('player-video-opacity')))
.opacity;
.widget<FadeTransition>(find.byKey(const Key('player-video-crossfade')))
.opacity
.value;
}