// Author: fruit.com.cn import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; import 'package:fruitshorts/core/data/models/episode_stats.dart'; import 'package:fruitshorts/core/data/models/interaction.dart'; import 'package:fruitshorts/core/data/models/play_ticket.dart'; import 'package:fruitshorts/core/data/models/player_port.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/interaction_controller.dart'; import 'package:fruitshorts/core/data/services/playback_session.dart'; import 'package:fruitshorts/core/data/services/video_player_port.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/pages/auth/auth_view.dart'; import 'package:fruitshorts/pages/player/open_player.dart'; import 'package:fruitshorts/pages/player/player_logic.dart'; import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:fruitshorts/pages/player/widgets/player_center_toggle.dart'; import 'package:fruitshorts/pages/player/widgets/player_video_surface.dart'; import 'package:fruitshorts/pages/player/widgets/seek_progress_bar.dart'; import 'package:video_player/video_player.dart'; import '../support/fake_catalog.dart'; import '../support/fake_category.dart'; import '../support/fake_haptics.dart'; import '../support/fake_interaction_repositories.dart'; import '../support/fake_notice_repository.dart'; import '../support/widget_player.dart'; void main() { setUp(() { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.empty(); // pickedEpisode 是跨页面常驻的静态"上次选集"记忆(设计如此), // 用例之间必须清掉,否则前一个用例选的集会带进下一个用例的 feed 断言。 PlayerLogic.pickedEpisode.clear(); }); testWidgets('should_render_immersive_overlay_in_recommend_feed', ( tester, ) async { await _pumpApp(tester); await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-feed-player')), findsOneWidget); expect( tester .widget(find.byKey(const Key('recommend-feed-player'))) .homeMode, isTrue, ); final pageViews = tester.widgetList(find.byType(PageView)); expect( pageViews.where((pageView) => pageView.scrollDirection == Axis.vertical), hasLength(1), ); expect(find.byKey(const Key('player-render-stack')), findsOneWidget); final renderStackPageAncestors = tester.widgetList( find.ancestor( of: find.byKey(const Key('player-render-stack')), matching: find.byType(PageView), ), ); expect( renderStackPageAncestors.where( (pageView) => pageView.scrollDirection == Axis.vertical, ), isEmpty, ); expect(tester.takeException(), isNull); }); testWidgets('should_use_black_system_navigation_bar_on_feed_and_player', ( tester, ) async { await _pumpApp(tester); await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); await _openSeriesPlayer(tester); _expectPlayerNavigationBar(tester); }); testWidgets('should_keep_feed_session_unchanged_when_switching_episode', ( tester, ) async { final factory = _RouteVideoFactory(); final playback = FakePlaybackRepository(); await _pumpApp(tester, playerFactory: factory.create, playback: playback); await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); final feedPage = find .descendant( of: find.byKey(const Key('recommend-feed-player')), matching: find.byType(PageView), ) .first; await tester.drag(feedPage, const Offset(0, -620)); await tester.pumpAndSettle(); await tester.drag(feedPage, const Offset(0, -620)); await tester.pumpAndSettle(); final recommendPlayer = find.byKey(const Key('recommend-feed-player')); final sourceState = tester.state(recommendPlayer); final sourceLogic = _pageLogic(tester, recommendPlayer); await sourceLogic.session.preloadSettled; await tester.pumpAndSettle(); final sourceIndex = sourceLogic.session.currentIndex; final sourceDramaId = sourceLogic.currentDramaId; final sourceEpisodeId = sourceLogic.session.currentEpisode.id; final feedPlayer = sourceLogic.session.active! as _RouteVideoPlayer; final feedPlayCalls = feedPlayer.playCalls; final feedPositionBefore = feedPlayer.controller.value.position; final sourceLiveBefore = sourceLogic.session.livePlayerCount; final sourceCurrentSurface = find.descendant( of: recommendPlayer, matching: find.byKey(const ValueKey('player-render-slot-current')), ); final sourceSurfaceState = tester.state(sourceCurrentSurface); final sourceSurfaceElement = tester.element(sourceCurrentSurface); expect(sourceDramaId, 'vengeance-demo'); expect(sourceIndex, 2); expect(sourceLiveBefore, greaterThan(1)); await _tapFullSeries(tester); await _pumpEntry(tester, settle: false); final fullPlayer = _fullPlayerFinder(); final fullLogic = _pageLogic(tester, fullPlayer); expect(fullLogic.session, isNot(same(sourceLogic.session))); final fullPlayerPort = fullLogic.session.active! as VideoPlayerPort; await _advanceFakeVideo(tester, fullPlayerPort.controller); expect(fullPlayerPort.controller, isNot(same(feedPlayer.controller))); expect(feedPlayer.disposed, isFalse); expect(feedPlayer.playing, isFalse); final fullCurrentSurface = find.descendant( of: fullPlayer, matching: find.byKey(const ValueKey('player-render-slot-current')), ); final fullCurrentSurfaceState = tester.state(fullCurrentSurface); final loadingOverlay = find.byKey( const Key('player-entry-loading-overlay'), ); expect(loadingOverlay, findsNothing); final fullPlayerPage = find .descendant( of: _fullPlayerFinder(), matching: find.byWidgetPredicate( (widget) => widget is PageView && widget.scrollDirection == Axis.vertical, ), ) .first; for (var index = 0; index < 4; index++) { await tester.drag(fullPlayerPage, const Offset(0, -560)); await tester.pump(); await tester.pump(const Duration(milliseconds: 500)); expect(loadingOverlay, findsNothing); } expect(fullLogic.session.currentEpisode.number, 5); expect(tester.state(fullCurrentSurface), same(fullCurrentSurfaceState)); expect( fullLogic.queue.entryAt(fullLogic.session.currentIndex).drama.id, sourceDramaId, ); expect(fullLogic.session.userWantsPlay, isTrue); expect(sourceLogic.session.currentIndex, sourceIndex); expect(sourceLogic.currentDramaId, sourceDramaId); expect(sourceLogic.session.currentEpisode.id, sourceEpisodeId); expect(sourceLogic.session.active, same(feedPlayer)); expect(feedPlayer.disposed, isFalse); final requestsBeforeReturn = playback.requests.length; await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('player-back')).hitTestable()); await tester.pumpAndSettle(); await sourceLogic.session.preloadSettled; await tester.pumpAndSettle(); final currentSurface = find.descendant( of: recommendPlayer, matching: find.byKey(const ValueKey('player-render-slot-current')), ); expect(find.byKey(const Key('recommend-page')), findsOneWidget); expect(tester.state(recommendPlayer), same(sourceState)); expect(sourceLogic.session.currentIndex, sourceIndex); expect(sourceLogic.currentDramaId, sourceDramaId); expect(sourceLogic.session.currentEpisode.id, sourceEpisodeId); expect(sourceLogic.session.active, same(feedPlayer)); expect(feedPlayer.disposed, isFalse); expect(sourceLogic.active, isTrue); expect(sourceLogic.session.userWantsPlay, isTrue); expect(sourceLogic.session.canResumeAfterBackground, isTrue); expect(sourceLogic.session.canAutoAdvance, isTrue); expect(sourceLogic.session.isLocked, isFalse); expect(sourceLogic.session.loading, isFalse); expect(sourceLogic.session.activeSlotActivated, isTrue); expect(sourceLogic.session.activeSlotPlaying, isTrue); expect(feedPlayer.seekCalls, 0); expect(feedPlayer.playCalls, greaterThan(feedPlayCalls)); expect(playback.requests.length, requestsBeforeReturn); expect(feedPlayer.controller.value.position, feedPositionBefore); expect(feedPlayer.playing, isTrue); expect(sourceLogic.session.livePlayerCount, sourceLiveBefore); expect(sourceLogic.session.livePlayerCount, greaterThan(1)); expect(tester.state(currentSurface), same(sourceSurfaceState)); expect(tester.element(currentSurface), same(sourceSurfaceElement)); expect( find.descendant( of: currentSurface, matching: find.byKey(const Key('player-single-placeholder')), ), findsNothing, ); expect( tester.widget(currentSurface).controller, same(feedPlayer.controller), ); expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); expect(find.byKey(const Key('recommend-top-nav')), findsOneWidget); }); testWidgets('should_autoplay_full_player_and_restore_paused_feed', ( tester, ) async { final factory = _RouteVideoFactory(); await _pumpApp(tester, playerFactory: factory.create); await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('home-feed-gesture'))); await tester.pump(const Duration(milliseconds: 400)); expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); await _tapFullSeries(tester); await _pumpEntry(tester, settle: false); expect(_fullPlayerFinder(), findsOneWidget); final fullLogic = _pageLogic(tester, _fullPlayerFinder()); expect(fullLogic.session.userWantsPlay, isTrue); expect(find.byKey(const Key('player-play-toggle')), findsNothing); expect( find.byKey(const Key('home-feed-center-play')).hitTestable(), findsNothing, ); expect(find.byKey(const Key('home-tabs-bar')).hitTestable(), findsNothing); expect( find.byKey(const Key('recommend-top-nav')).hitTestable(), findsNothing, ); await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('home-feed-center-play')), findsOneWidget); expect(find.byKey(const Key('home-tabs-bar')), findsOneWidget); }); testWidgets('should_show_non_blocking_loading_when_opening_series', ( tester, ) async { final initialization = Completer(); final routeFactory = _RouteVideoFactory(); await _pumpApp( tester, playerFactory: (episode) => episode.id == 'the-signal-demo-2' ? _GatedVideoPlayer(episode, initialization.future) : routeFactory.create(episode), ); await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); // 首页 feed 不再有选集入口:用目录入口把全屏页直接开在 gated 的第 2 集。 await _openFullPlayerWithWallet( tester, demoDramas.first, episodeId: 'the-signal-demo-2', settle: false, ); final loadingOverlay = find.byKey( const Key('player-entry-loading-overlay'), ); final fullPlayer = find.byType(PlayerPage).last; final fullLogic = _pageLogic(tester, fullPlayer); final fullCurrentSurface = find.descendant( of: fullPlayer, matching: find.byKey(const ValueKey('player-render-slot-current')), ); final fullCurrentSurfaceState = tester.state(fullCurrentSurface); final fullPlayerPage = find .descendant( of: fullPlayer, matching: find.byWidgetPredicate( (widget) => widget is PageView && widget.scrollDirection == Axis.vertical, ), ) .first; expect(loadingOverlay, findsOneWidget); expect(tester.widget(loadingOverlay).ignoring, isTrue); expect(tester.getSize(loadingOverlay), const Size(56, 36)); expect( tester .widget( find.descendant( of: loadingOverlay, matching: find.byType(AnimatedOpacity), ), ) .duration, const Duration(milliseconds: 140), ); expect( tester.getSize(find.byKey(const Key('player-loading-surface'))), const Size(56, 36), ); expect( tester.getSize(find.byKey(const Key('player-entry-loading'))), const Size(48, 29), ); expect( find.descendant( of: loadingOverlay, matching: find.byType(CircularProgressIndicator), ), findsNothing, ); expect(find.text('Loading'), findsNothing); expect(find.byKey(const Key('player-entry-loading-track')), findsNothing); expect( find.byKey(const Key('player-entry-loading-highlight')), findsNothing, ); final loadingSurface = tester.widget( find.byKey(const Key('player-loading-surface')), ); expect(loadingSurface.decoration, isNull); expect( find.descendant(of: loadingOverlay, matching: find.byType(ColoredBox)), findsNothing, ); expect(find.byKey(const Key('player-back')).hitTestable(), findsOneWidget); expect( find.byKey(const Key('player-episode-heading')).hitTestable(), findsOneWidget, ); expect( find.byKey(const Key('player-drama-title')).hitTestable(), findsOneWidget, ); expect( find .descendant( of: find.byKey(const Key('player-rail-like')), matching: find.byType(IconButton), ) .hitTestable(), findsOneWidget, ); expect(find.byKey(const Key('player-progress')), findsOneWidget); expect( find.byKey(const Key('player-progress-uninitialized-time')), findsNothing, ); expect(find.text('00:00 / 01:00'), findsNothing); // IgnorePointer 让加载中的播放暂停与上下滑穿透,滑动会关闭入口 loading。 expect(fullLogic.session.userWantsPlay, isTrue); await tester.tap(find.byKey(const Key('player-video-tap')).hitTestable()); await tester.pump(const Duration(milliseconds: 350)); expect(fullLogic.session.userWantsPlay, isFalse); final indexBeforeSwipe = fullLogic.session.currentIndex; await tester.drag(fullPlayerPage, const Offset(0, -560)); await tester.pump(); await tester.pump(const Duration(milliseconds: 500)); expect(fullLogic.session.currentIndex, isNot(indexBeforeSwipe)); expect(tester.state(fullCurrentSurface), same(fullCurrentSurfaceState)); // 切集完成后返回按钮仍可正常点击。 await tester.pumpAndSettle(); expect(loadingOverlay, findsNothing); await tester.tap(find.byKey(const Key('player-back')).hitTestable()); await tester.pumpAndSettle(); expect(find.byKey(const Key('recommend-page')), findsOneWidget); initialization.complete(); await tester.pump(); }); testWidgets('should_keep_drama_and_episode_indices_separate', (tester) async { final factory = _RouteVideoFactory(); await _pumpApp(tester, playerFactory: factory.create); await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); final feedPage = find .descendant( of: find.byKey(const Key('recommend-feed-player')), matching: find.byType(PageView), ) .first; await tester.drag(feedPage, const Offset(0, -620)); await tester.pumpAndSettle(); final recommendPlayer = find.byKey(const Key('recommend-feed-player')); final sourceLogic = _pageLogic(tester, recommendPlayer); final feedIndex = sourceLogic.session.currentIndex; final feedEpisodeId = sourceLogic.session.currentEpisode.id; final feedDramaId = sourceLogic.currentDramaId; expect(feedIndex, 1); await _tapFullSeries(tester); await _pumpEntry(tester, settle: false); final fullLogic = _pageLogic(tester, _fullPlayerFinder()); await tester.drag( find.byKey(const Key('player-video-tap')), const Offset(0, -560), ); await tester.pumpAndSettle(); expect(fullLogic.session.currentIndex, 1); expect(fullLogic.session.currentEpisode.number, 2); expect(fullLogic.queue.entryAt(1).drama.id, feedDramaId); expect(sourceLogic.session.currentIndex, feedIndex); expect(sourceLogic.session.currentEpisode.id, feedEpisodeId); expect(sourceLogic.currentDramaId, feedDramaId); await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); expect(sourceLogic.session.currentIndex, feedIndex); expect(sourceLogic.session.currentEpisode.id, feedEpisodeId); }); testWidgets('should_render_player_loading_indicators_at_compact_size', ( tester, ) async { final playback = _PendingPlaybackRepository(); final initialization = Completer(); await _pumpApp( tester, playback: playback, playerFactory: (_) => _GatedPlayer(initialization.future), settle: false, ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); await tester.tap(find.byKey(const Key('tab-home'))); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); final ticketLoading = find.byKey(const Key('player-ticket-loading')); expect(ticketLoading, findsOneWidget); expect(tester.getSize(ticketLoading), const Size(24, 24)); expect( tester.widget(ticketLoading).strokeWidth, 2, ); playback.ticket.complete( const PlayTicket( episodeId: 'city-lights-demo-1', accessible: true, accessType: PlayTicket.free, playUrl: '/files/episode/fake-city.mp4?e=1&s=abc', qualities: ['AUTO'], ), ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); expect(find.byKey(const Key('player-session-loading')), findsNothing); expect(find.byKey(const Key('progress-loading')), findsNothing); initialization.complete(); await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); }); testWidgets('should_pause_and_resume_player_when_page_route_covers_it', ( tester, ) async { final players = <_RecordingPlayer>[]; await _pumpApp( tester, playerFactory: (_) { final player = _RecordingPlayer(); players.add(player); return player; }, ); await _openSeriesPlayer(tester); final player = players.firstWhere((item) => item.playing); expect(player.playing, isTrue); expect(find.byKey(const Key('progress-loading')), findsNothing); expect(find.byKey(const Key('progress-inactive')), findsOneWidget); final navigator = tester.state( find.byType(Navigator).first, ); unawaited( navigator.push( MaterialPageRoute( builder: (_) => const Scaffold(key: Key('test-covering-route')), ), ), ); await tester.pumpAndSettle(); expect(find.byKey(const Key('test-covering-route')), findsOneWidget); expect(player.playing, isFalse); navigator.pop(); await tester.pumpAndSettle(); expect(player.playing, isTrue); expect(find.byKey(const Key('progress-loading')), findsNothing); expect(find.byKey(const Key('progress-inactive')), findsOneWidget); }); testWidgets('should_auto_play_full_player_when_feed_is_paused', ( tester, ) async { await _pumpApp(tester, playerFactory: (_) => _RecordingPlayer()); await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); final feedPage = find.byKey(const Key('recommend-feed-player')); final feedLogic = _pageLogic(tester, feedPage); await feedLogic.session.preloadSettled; feedLogic.session.setUserWantsPlay(false); await feedLogic.session.playbackSettled; await tester.pump(); expect(feedLogic.session.userWantsPlay, isFalse); await _tapFullSeries(tester); await tester.pumpAndSettle(); final fullLogic = _pageLogic(tester, _fullPlayerFinder()); final fullPlayer = fullLogic.session.active! as _RecordingPlayer; expect(fullLogic.session, isNot(same(feedLogic.session))); expect(fullLogic.session.userWantsPlay, isTrue); expect(fullPlayer.playCalls, greaterThan(0)); expect(fullPlayer.playing, isTrue); await tester.tap(find.byKey(const Key('player-back')).hitTestable()); await tester.pumpAndSettle(); expect(feedLogic.session.userWantsPlay, isFalse); expect(feedLogic.session.activeSlotPlaying, isFalse); }); testWidgets('should_only_show_progress_buffer_after_video_first_frame', ( tester, ) async { final factory = _RouteVideoFactory(); await _pumpApp(tester, playerFactory: factory.create); await _openSeriesPlayer(tester, settle: false); final videoIndex = factory.episodes.lastIndexWhere( (episode) => episode.id == 'city-lights-demo-1', ); final video = factory.created[videoIndex]; await _advanceFakeVideo(tester, video.controller); expect(video.controller.value.isInitialized, isTrue); expect(find.byKey(const Key('player-entry-loading-overlay')), findsNothing); video.controller.value = const VideoPlayerValue( duration: Duration(minutes: 1), size: Size(1280, 720), position: Duration(seconds: 5), isInitialized: true, isPlaying: true, isBuffering: true, ); await tester.pump(const Duration(milliseconds: 400)); expect(find.byKey(const Key('progress-loading')), findsOneWidget); expect(find.byKey(const Key('player-entry-loading-overlay')), findsNothing); expect(find.byKey(const Key('player-video-loading')), findsNothing); expect(find.byKey(const Key('player-ticket-loading')), findsNothing); video.controller.value = const VideoPlayerValue( duration: Duration(minutes: 1), size: Size(1280, 720), position: Duration(seconds: 6), isInitialized: true, isPlaying: true, isBuffering: true, ); await tester.pump(); expect(find.byKey(const Key('progress-loading')), findsNothing); expect(find.byType(Slider), findsOneWidget); }); testWidgets('should_keep_feed_playing_when_guest_saves', (tester) async { final players = <_RecordingPlayer>[]; await _pumpApp( tester, playerFactory: (_) { final player = _RecordingPlayer(); players.add(player); return player; }, ); await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); final player = players.firstWhere((item) => item.playing); expect(player.playing, isTrue); await tester.tap(find.byKey(const Key('rail-save'))); await tester.pumpAndSettle(); expect(find.byType(AuthPage), findsNothing); expect(player.playing, isTrue); }); testWidgets('should_like_without_login_from_recommend_feed', (tester) async { final interactions = FakeInteractionRepository(); await _pumpApp(tester, interactions: interactions); await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('rail-like'))); await tester.pumpAndSettle(); expect(find.byType(AuthPage), findsNothing); expect(interactions.likeTargets, ['EPISODE:the-signal-demo-1']); }); testWidgets('should_keep_favorite_count_when_player_saves_and_returns', ( tester, ) async { await _pumpApp(tester); await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); final initialCount = _railLabel(tester, const Key('rail-save')); await tester.tap(find.byKey(const Key('home-feed-series-name'))); await tester.pumpAndSettle(); expect(_railLabel(tester, const Key('player-rail-save')), initialCount); await tester.tap(find.byKey(const Key('player-rail-save'))); await tester.pumpAndSettle(); final savedCount = _railLabel(tester, const Key('player-rail-save')); expect(savedCount, isNot(initialCount)); await tester.tap(find.byKey(const Key('player-back'))); await tester.pumpAndSettle(); expect(find.byType(AuthPage), findsNothing); expect(_railLabel(tester, const Key('rail-save')), savedCount); }); testWidgets('should_keep_favorite_state_when_recommend_opens_player', ( tester, ) async { await _pumpApp(tester); await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('rail-save'))); await tester.pumpAndSettle(); final savedCount = _railLabel(tester, const Key('rail-save')); await tester.tap(find.byKey(const Key('home-feed-series-name'))); await tester.pumpAndSettle(); expect(_railLabel(tester, const Key('player-rail-save')), savedCount); final saveImage = tester.widget( find.descendant( of: find.byKey(const Key('player-rail-save')), matching: find.byType(Image), ), ); expect(saveImage.color, AppColors.blue); }); testWidgets('should_keep_video_surface_background_black', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); expect( tester .widget( find.byKey(const Key('player-fallback-background')), ) .color, Colors.black, ); expect(find.byKey(const Key('player-fallback-cover')), findsNothing); expect(find.byKey(const Key('player-fallback-scrim')), findsNothing); }); testWidgets('should_open_comments_without_login', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-rail-comments'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-comments-sheet')), findsOneWidget); expect(find.byType(AuthPage), findsNothing); }); testWidgets('should_open_feed_comments_when_signed_in', (tester) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.withData({ 'auth.accessToken': 'access', 'auth.refreshToken': 'refresh', 'auth.user': '{"uid":"9","nickname":"Viewer","avatarUrl":""}', }); await _pumpApp(tester); await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-rail-comments'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-comments-sheet')), findsOneWidget); }); testWidgets('should_refresh_comment_count_after_comment_sheet_closes', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.withData({ 'auth.accessToken': 'access', 'auth.refreshToken': 'refresh', 'auth.user': '{"uid":"9","nickname":"Viewer","avatarUrl":""}', }); final comments = FakeCommentRepository(countResult: 97); await _pumpApp(tester, commentRepository: comments); await _openSeriesPlayer(tester); expect( tester .widget(find.byKey(const Key('player-rail-comments-count'))) .data, '96', ); await tester.tap(find.byKey(const Key('player-rail-comments'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-comments-sheet')), findsOneWidget); expect(find.text('97 comments'), findsOneWidget); await tester.binding.handlePopRoute(); await tester.pumpAndSettle(); expect(comments.countCalls, ['city-lights-demo', 'city-lights-demo']); expect( tester .widget(find.byKey(const Key('player-rail-comments-count'))) .data, '97', ); }); testWidgets('should_show_center_toggle_only_when_paused', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); expect(find.byKey(const Key('player-play-toggle')), findsNothing); await tester.tapAt(const Offset(80, 300)); await tester.pump(const Duration(milliseconds: 350)); expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); expect( tester .widget( find.byKey(const Key('player-play-toggle')), ) .playing, isFalse, ); expect( find.descendant( of: find.byKey(const Key('player-play-toggle')), matching: find.byIcon(Icons.play_arrow_rounded), ), findsOneWidget, ); await tester.tap(find.byKey(const Key('player-play-toggle'))); await tester.pump(); expect(find.byKey(const Key('player-play-toggle')), findsNothing); }); testWidgets('should_like_once_on_first_home_feed_double_tap', (tester) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.withData({ 'auth.accessToken': 'test-access', 'auth.refreshToken': 'test-refresh', 'auth.user': '{"uid":"9","nickname":"Me","avatarUrl":""}', }); final interactions = FakeInteractionRepository( nextLikeResult: const LikeToggleResult(liked: true, count: 23001), ); await _pumpApp(tester, interactions: interactions); await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); final page = find.byKey(const Key('recommend-feed-player')); final logic = _pageLogic(tester, page); final episode = logic.queue.entryAt(logic.session.currentIndex).episode; final gesture = find.byKey(const Key('home-feed-gesture')).hitTestable(); final position = tester.getCenter(gesture); final interactionController = Provider.of( tester.element(gesture), listen: false, ); await tester.tapAt(position); await tester.tapAt(position); await tester.pump(); await tester.pump(); expect(interactionController.isEpisodeLiked(episode.id), isTrue); expect(interactionController.episodeLikeCount(episode.id), 23001); expect(_railLabel(tester, const Key('rail-like')), '2.3万'); expect(logic.session.userWantsPlay, isTrue); expect(interactions.likeTargets, ['EPISODE:${episode.id}']); await _waitForDoubleTapWindow(tester); await tester.tapAt(position); await tester.tapAt(position); await tester.pump(); await tester.pump(); expect(interactions.likeTargets, hasLength(1)); expect(interactionController.episodeLikeCount(episode.id), 23001); expect(logic.session.userWantsPlay, isTrue); await _waitForHeartCompletion(tester); }); testWidgets('should_like_once_on_first_double_tap_without_pausing', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.withData({ 'auth.accessToken': 'test-access', 'auth.refreshToken': 'test-refresh', 'auth.user': '{"uid":"9","nickname":"Me","avatarUrl":""}', }); final interactions = FakeInteractionRepository( nextLikeResult: const LikeToggleResult(liked: true, count: 23001), ); await _pumpApp(tester, interactions: interactions); await _openSeriesPlayer(tester); final logic = _pageLogic(tester, find.byType(PlayerPage).last); final gesture = find.byKey(const Key('player-video-tap')).hitTestable(); final position = tester.getCenter(gesture); final interactionController = Provider.of( tester.element(gesture), listen: false, ); expect(logic.session.userWantsPlay, isTrue); await tester.tapAt(position); await tester.tapAt(position); await tester.pump(); await tester.pump(); expect(logic.session.userWantsPlay, isTrue); expect(find.byKey(const Key('player-play-toggle')), findsNothing); expect(interactions.likeTargets, ['EPISODE:city-lights-demo-1']); expect(interactionController.isEpisodeLiked('city-lights-demo-1'), isTrue); expect(interactionController.episodeLikeCount('city-lights-demo-1'), 23001); expect(_railLabel(tester, const Key('player-rail-like')), '2.3万'); await _waitForBufferedHeartFrames(tester); for (var index = 0; index < 3; index++) { expect(find.byKey(Key('double-tap-heart-$index')), findsOneWidget); } expect( tester .widget( find.descendant( of: find.byKey(const Key('player-rail-like')), matching: find.byType(Image), ), ) .color, AppColors.brandRed, ); await _waitForDoubleTapWindow(tester); await tester.tapAt(position); await tester.tapAt(position); await tester.pump(); await tester.pump(); expect(interactions.likeTargets, hasLength(1)); expect(interactionController.episodeLikeCount('city-lights-demo-1'), 23001); expect(logic.session.userWantsPlay, isTrue); await _waitForHeartCompletion(tester); expect(find.byKey(const Key('double-tap-heart-0')), findsNothing); expect(logic.session.userWantsPlay, isTrue); }); testWidgets('should_double_tap_like_in_clear_screen', (tester) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.withData({ 'auth.accessToken': 'test-access', 'auth.refreshToken': 'test-refresh', 'auth.user': '{"uid":"9","nickname":"Me","avatarUrl":""}', }); final interactions = FakeInteractionRepository(); await _pumpApp(tester, interactions: interactions); await _openSeriesPlayer(tester); final logic = _pageLogic(tester, find.byType(PlayerPage).last); await _clearFromMore(tester); expect(find.byKey(const Key('player-controls-bottom')), findsNothing); await tester.tapAt(const Offset(80, 300)); await tester.tapAt(const Offset(80, 300)); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); await _waitForBufferedHeartFrames(tester); expect(logic.session.userWantsPlay, isTrue); expect(interactions.likeTargets, hasLength(1)); expect(find.byKey(const Key('double-tap-heart-0')), findsOneWidget); expect(find.byKey(const Key('double-tap-heart-1')), findsOneWidget); expect(find.byKey(const Key('double-tap-heart-2')), findsOneWidget); }); testWidgets('should_keep_controls_visible_until_clear_screen_is_tapped', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); expect(find.byType(PlayerCenterToggle), findsNothing); await tester.tapAt(const Offset(80, 300)); await tester.pump(const Duration(milliseconds: 350)); expect(find.byType(PlayerCenterToggle), findsOneWidget); expect( tester .widget( find.byKey(const Key('player-play-toggle')), ) .playing, isFalse, ); await tester.pump(const Duration(seconds: 5)); await tester.pump(); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); expect(find.byType(PlayerCenterToggle), findsOneWidget); await _clearFromMore(tester); await tester.pump(); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsNothing); expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget); expect(find.byKey(const Key('player-progress')), findsOneWidget); expect(find.byKey(const Key('player-interaction-rail')), findsNothing); expect(find.byType(PlayerCenterToggle), findsOneWidget); expect( tester .widget( find.byKey(const Key('player-play-toggle')), ) .playing, isFalse, ); expect(find.byKey(const Key('player-video-tap')), findsOneWidget); expect(find.byKey(const Key('player-bottom-clear-screen')), findsOneWidget); expect(find.byTooltip('Exit clear screen'), findsOneWidget); // 清屏后左侧选集条一起收起,只留右下角的退出按钮。 expect(find.byKey(const Key('player-episodes-pill')), findsNothing); expect( tester.getSize(find.byKey(const Key('player-bottom-clear-screen'))), const Size(40, 34), ); await tester.tapAt(const Offset(80, 300)); await tester.pump(const Duration(milliseconds: 350)); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget); expect(find.byType(PlayerCenterToggle), findsNothing); await tester.tapAt(const Offset(80, 300)); await tester.pump(const Duration(milliseconds: 350)); expect(find.byType(PlayerCenterToggle), findsOneWidget); await tester.tap(find.byKey(const Key('player-bottom-clear-screen'))); await tester.pump(); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); expect(find.byType(PlayerCenterToggle), findsOneWidget); expect(find.byTooltip('Clear screen'), findsOneWidget); }); testWidgets('should_open_more_sheet_on_center_long_press', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); expect(find.byKey(const Key('player-play-toggle')), findsNothing); final gesture = await tester.startGesture(const Offset(195, 300)); await tester.pump(const Duration(milliseconds: 700)); await tester.pump(); await gesture.up(); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-more-sheet')), findsOneWidget); await tester.tapAt(const Offset(10, 10)); await tester.pumpAndSettle(); // 长按弹更多不改播放状态,仍处于播放中。 expect(find.byKey(const Key('player-play-toggle')), findsNothing); expect(tester.takeException(), isNull); }); testWidgets('should_boost_speed_on_side_long_press_and_restore', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); final gesture = await tester.startGesture(const Offset(60, 300)); await tester.pump(const Duration(milliseconds: 700)); await tester.pump(); expect(find.byKey(const Key('player-speed-boost-hint')), findsOneWidget); expect(find.byKey(const Key('player-progress')), findsOneWidget); expect(find.byKey(const Key('player-episodes-pill')), findsNothing); expect(find.byKey(const Key('player-bottom-clear-screen')), findsNothing); expect(find.byKey(const Key('player-interaction-rail')), findsNothing); expect(find.byKey(const Key('player-controls-bottom')), findsNothing); expect( find.descendant( of: find.byKey(const Key('player-speed-entry')), matching: find.text('2.0x'), ), findsOneWidget, ); // 加速与清屏都不改变播放状态。 expect(find.byKey(const Key('player-play-toggle')), findsNothing); await gesture.up(); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-speed-boost-hint')), findsNothing); expect(find.byKey(const Key('player-episodes-pill')), findsOneWidget); expect(find.byKey(const Key('player-bottom-clear-screen')), findsOneWidget); expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); expect( find.descendant( of: find.byKey(const Key('player-speed-entry')), matching: find.text('1.0x'), ), findsOneWidget, ); expect(find.byKey(const Key('player-play-toggle')), findsNothing); // 右侧 1/3 同样触发加速。 final rightSide = await tester.startGesture(const Offset(330, 300)); await tester.pump(const Duration(milliseconds: 700)); await tester.pump(); expect(find.byKey(const Key('player-speed-boost-hint')), findsOneWidget); await rightSide.up(); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-speed-boost-hint')), findsNothing); }); testWidgets('should_keep_previous_clear_state_after_boost', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-bottom-clear-screen'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-interaction-rail')), findsNothing); final gesture = await tester.startGesture(const Offset(60, 300)); await tester.pump(const Duration(milliseconds: 700)); await tester.pump(); expect(find.byKey(const Key('player-speed-boost-hint')), findsOneWidget); expect(find.byKey(const Key('player-episodes-pill')), findsNothing); expect(find.byKey(const Key('player-bottom-clear-screen')), findsNothing); await gesture.up(); await tester.pumpAndSettle(); // 本来已清屏,松手后仍保持清屏,只恢复倍速与提示。 expect(find.byKey(const Key('player-speed-boost-hint')), findsNothing); expect(find.byKey(const Key('player-interaction-rail')), findsNothing); expect(find.byTooltip('Exit clear screen'), findsOneWidget); // 清屏态只恢复右下退出按钮;左侧选集条在清屏下一直不显示。 expect(find.byKey(const Key('player-episodes-pill')), findsNothing); expect(find.byKey(const Key('player-bottom-clear-screen')), findsOneWidget); expect( find.descendant( of: find.byKey(const Key('player-speed-entry')), matching: find.text('1.0x'), ), findsOneWidget, ); }); testWidgets('should_fit_player_header_on_320_width', (tester) async { await _pumpApp(tester, size: const Size(320, 568)); await _openSeriesPlayer(tester); expect( tester.getSize(find.byKey(const Key('player-back'))), const Size(40, 40), ); expect(tester.takeException(), isNull); }); testWidgets('should_keep_compact_bottom_panel_and_lower_rail', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); final screenHeight = tester.view.physicalSize.height; final bottom = tester.getRect( find.byKey(const Key('player-controls-bottom')), ); final rail = tester.getRect( find.byKey(const Key('player-interaction-rail')), ); expect(find.byKey(const Key('player-background')), findsNothing); final fallback = find.byKey(const Key('player-fallback-background')); expect(fallback, findsOneWidget); expect(tester.widget(fallback).color, Colors.black); expect(find.byKey(const Key('player-fallback-cover')), findsNothing); expect(find.byKey(const Key('player-fallback-scrim')), findsNothing); expect( tester .widget(find.byKey(const Key('player-scaffold'))) .backgroundColor, Colors.transparent, ); expect( tester .widget(find.byKey(const Key('player-controls-top'))) .decoration, isNull, ); expect( tester .widget(find.byKey(const Key('player-controls-bottom'))) .child, isNotNull, ); // 选集条给一层浅底色,标出可点区域。 expect( tester .widget(find.byKey(const Key('player-episodes-pill'))) .color, AppColors.textPrimary.withValues(alpha: 0.14), ); expect( tester .widget( find .descendant( of: find.byKey(const Key('player-episodes-pill')), matching: find.byType(Text), ) .first, ) .style ?.fontSize, 13.5, ); // 清屏按钮与左侧选集条等高。 expect( tester.getSize(find.byKey(const Key('player-bottom-clear-screen'))), const Size(40, 34), ); expect( tester.getSize(find.byKey(const Key('player-episodes-pill'))).height, 34, ); expect(find.byKey(const Key('progress-loading')), findsNothing); expect(find.byKey(const Key('progress-inactive')), findsOneWidget); expect(find.byType(Slider), findsNothing); expect(bottom.height, lessThanOrEqualTo(200)); expect(bottom.top, greaterThanOrEqualTo(screenHeight * 0.78)); expect(rail.top, greaterThanOrEqualTo(screenHeight * 0.40)); // 右侧 rail 改成底部锚定 120,不再按屏高百分比,允许压住底栏顶部。 expect(rail.bottom, screenHeight - 120); expect(find.byKey(const Key('player-author-note')), findsNothing); expect( tester .getSize( find.descendant( of: find.byKey(const Key('player-rail-comments')), matching: find.byType(Image), ), ) .width, 36, ); final bottomPanel = tester.getRect( find.byKey(const Key('player-controls-bottom')), ); expect(bottomPanel.height, lessThanOrEqualTo(330)); expect(bottomPanel.top, greaterThanOrEqualTo(screenHeight * 0.5)); expect(tester.takeException(), isNull); }); testWidgets( 'should_keep_fullscreen_player_position_when_keyboard_insets_change', (tester) async { addTearDown(tester.view.resetViewInsets); await _pumpApp(tester); await _openSeriesPlayer(tester); final rail = find.byKey(const Key('player-interaction-rail')); final before = tester.getRect(rail); tester.view.viewInsets = const FakeViewPadding(bottom: 300); await tester.pump(); expect( tester .widget(find.byKey(const Key('player-scaffold'))) .resizeToAvoidBottomInset, isFalse, ); expect(tester.getRect(rail), before); }, ); testWidgets('should_keep_series_clear_state_when_swiping_next_episode', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); await _clearFromMore(tester); await tester.pump(); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); await tester.drag(find.byType(PageView), const Offset(0, -550)); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsNothing); expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget); expect(find.byKey(const Key('player-progress')), findsOneWidget); expect(find.byKey(const Key('player-interaction-rail')), findsNothing); expect(find.byKey(const Key('player-bottom-clear-screen')), findsOneWidget); await tester.tap(find.byKey(const Key('player-bottom-clear-screen'))); await tester.pump(); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); }); testWidgets('should_keep_episode_entry_regular_when_player_is_open', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); final entry = tester.widget( find.descendant( of: find.byKey(const Key('player-episodes-pill')), matching: find.byType(Text), ), ); expect(entry.style?.fontWeight, FontWeight.w400); expect(entry.style?.fontSize, 13.5); expect(entry.data, contains(' · ')); }); testWidgets('should_push_episodes_arrow_to_pill_right', (tester) async { // 宽屏下文案不会撑满胶囊,才能验证箭头是否被推到最右侧。 await _pumpApp(tester, size: const Size(900, 844)); await _openSeriesPlayer(tester); final pill = tester.getRect(find.byKey(const Key('player-episodes-pill'))); final label = tester.getRect( find .descendant( of: find.byKey(const Key('player-episodes-pill')), matching: find.byType(Text), ) .first, ); final arrow = tester.getRect( find.descendant( of: find.byKey(const Key('player-episodes-pill')), matching: find.byIcon(Icons.keyboard_arrow_up_rounded), ), ); // 选集条左右各留 10pt 内边距,箭头不再贴边。 expect(pill.right - arrow.right, closeTo(10, 0.5)); expect(arrow.left - label.right, closeTo(8, 0.5)); }); testWidgets('should_show_rail_counts_from_detail_and_follow_server_results', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.withData({ 'auth.accessToken': 'test-access', 'auth.refreshToken': 'test-refresh', 'auth.user': '{"uid":"9","nickname":"Me","avatarUrl":""}', }); final interactions = FakeInteractionRepository( nextLikeResult: const LikeToggleResult(liked: true, count: 23000), ); await _pumpApp(tester, interactions: interactions); await _openSeriesPlayer(tester); String railLabel(Key key) => tester .widget( find.descendant(of: find.byKey(key), matching: find.byType(Text)), ) .data!; expect(railLabel(const Key('player-rail-like')), '1.2万'); expect(railLabel(const Key('player-rail-save')), '3200'); expect(railLabel(const Key('player-rail-comments')), '96'); // 分享没有数量,仍是文字标签。 expect(railLabel(const Key('player-rail-share')), 'Share'); await tester.tap(find.byKey(const Key('player-rail-like'))); await tester.pumpAndSettle(); expect(railLabel(const Key('player-rail-like')), '2.3万'); expect(interactions.likeTargets, contains('EPISODE:city-lights-demo-1')); await tester.tap(find.byKey(const Key('player-rail-save'))); await tester.pumpAndSettle(); expect(railLabel(const Key('player-rail-save')), '3201'); }); testWidgets('should_render_reference_rail_icons_without_background', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); expect( _railAssetName(tester, const Key('player-rail-save')), 'assets/icon/rail/rail_save.png', ); expect( _railAssetName(tester, const Key('player-rail-comments')), 'assets/icon/rail/rail_comment.png', ); expect( _railAssetName(tester, const Key('player-rail-like')), 'assets/icon/rail/rail_like.png', ); expect( _railAssetName(tester, const Key('player-rail-share')), 'assets/icon/rail/rail_share.png', ); final shareImage = tester.widget( find.descendant( of: find.byKey(const Key('player-rail-share')), matching: find.byType(Image), ), ); expect(shareImage.width, 36); expect(shareImage.height, 36); expect(shareImage.colorBlendMode, BlendMode.srcIn); final saveRect = tester.getRect(find.byKey(const Key('player-rail-save'))); final commentsRect = tester.getRect( find.byKey(const Key('player-rail-comments')), ); final likeRect = tester.getRect(find.byKey(const Key('player-rail-like'))); final shareRect = tester.getRect( find.byKey(const Key('player-rail-share')), ); expect(likeRect.top - saveRect.bottom, closeTo(6, 0.1)); expect(commentsRect.top - likeRect.bottom, closeTo(6, 0.1)); expect(shareRect.top - commentsRect.bottom, closeTo(6, 0.1)); final saveButton = tester.widget( find.descendant( of: find.byKey(const Key('player-rail-save')), matching: find.byType(IconButton), ), ); expect(saveButton.style?.backgroundColor, isNull); expect( tester.getSize( find.descendant( of: find.byKey(const Key('player-rail-save')), matching: find.byType(IconButton), ), ), const Size(48, 48), ); expect( tester .widget( find.descendant( of: find.byKey(const Key('player-rail-like')), matching: find.byType(Text), ), ) .style ?.fontSize, 15, ); }); testWidgets('should_toggle_synopsis_at_fixed_corner_with_feed_motion', ( tester, ) async { final longCopy = Drama( id: 'long-copy-demo', title: 'Long Title', synopsis: 'A very long synopsis that keeps describing the story far beyond ' 'the two lines available under the title in the fullscreen player.', posterUrl: '/files/long-copy-demo.png', genre: 'Drama', detailLoaded: true, episodes: const [ Episode( id: 'long-copy-demo-1', number: 1, title: 'Episode 1', isFree: true, videoUrl: 'https://example.test/long-demo-1.mp4', ), ], ); await _pumpApp(tester, catalog: FakeCatalogRepository(dramas: [longCopy])); await _openSeriesPlayer(tester, feedIndex: 0); final toggle = find.byKey(const Key('player-synopsis-more')); final synopsis = find.byKey(const Key('player-synopsis')); expect( find.descendant(of: toggle, matching: find.text('More')), findsOneWidget, ); expect( tester .widget( find.descendant(of: toggle, matching: find.text('More')), ) .style ?.color, AppColors.textPrimary, ); expect(tester.widget(synopsis).maxLines, 2); expect(tester.widget(synopsis).overflow, TextOverflow.ellipsis); final motion = tester.widget( find.byKey(const Key('player-synopsis-size')), ); expect(motion.duration, const Duration(milliseconds: 240)); expect(motion.curve, Curves.easeInOutCubic); final blockFinder = find.byKey(const Key('player-episode-synopsis')); final collapsedHeight = tester.getSize(blockFinder).height; final synopsisRect = tester.getRect(synopsis); final toggleRect = tester.getRect(toggle); expect(toggleRect.left, greaterThan(synopsisRect.left)); expect( toggleRect.center.dy, greaterThan(synopsisRect.top + synopsisRect.height * 0.45), ); await tester.tap(toggle); await tester.pump(); await tester.pump(const Duration(milliseconds: 80)); final growingHeight = tester.getSize(blockFinder).height; expect(growingHeight, greaterThan(collapsedHeight)); expect(tester.getRect(toggle), toggleRect); await tester.pumpAndSettle(); expect( find.descendant(of: toggle, matching: find.text('Less')), findsOneWidget, ); expect( tester .widget(find.byKey(const Key('player-synopsis-expanded'))) .maxLines, isNull, ); expect(tester.getRect(toggle), toggleRect); expect(tester.getSize(blockFinder).height, greaterThan(growingHeight)); await tester.tap(toggle); await tester.pumpAndSettle(); expect( find.descendant(of: toggle, matching: find.text('More')), findsOneWidget, ); expect(tester.widget(synopsis).maxLines, 2); }); testWidgets('should_match_feed_synopsis_without_episode_prefix', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); final line = tester.widget(find.byKey(const Key('player-synopsis'))); final style = line.style ?? const TextStyle(); expect(style.fontSize, 15); expect(style.fontWeight, FontWeight.w400); expect(style.color, AppColors.homeFeedVideoCopy); expect(style.shadows ?? [], isEmpty); expect(line.textSpan!.toPlainText(), isNot(startsWith('Episode '))); expect(line.textSpan!.toPlainText(), isNot(contains(' | '))); expect(find.byKey(const Key('player-episode-heading')), findsOneWidget); }); testWidgets('should_fit_long_title_and_synopsis_at_320_width', ( tester, ) async { final longCopy = Drama( id: 'long-copy-demo', title: 'An Extremely Long Drama Title That Keeps Going Beyond Any ' 'Sensible Width On A Narrow Screen', synopsis: 'A very long synopsis that keeps describing the story far beyond ' 'the single line space available in the compact bottom panel of ' 'the fullscreen player.', posterUrl: '/files/long-copy-demo.png', genre: 'Drama', detailLoaded: true, episodes: const [ Episode( id: 'long-copy-demo-1', number: 1, title: 'Episode 1', isFree: true, videoUrl: 'https://example.test/long-demo-1.mp4', ), ], ); await _pumpApp( tester, size: const Size(320, 568), catalog: FakeCatalogRepository(dramas: [longCopy]), ); await _openSeriesPlayer(tester, feedIndex: 0); expect(tester.takeException(), isNull); final title = tester.getRect(find.byKey(const Key('player-drama-title'))); final chevron = tester.getRect( find.descendant( of: find.byKey(const Key('player-open-detail')), matching: find.byIcon(Icons.chevron_right_rounded), ), ); expect(chevron.left - title.right, lessThanOrEqualTo(6)); // 剧名和简介一样收在互动栏左侧,不再铺到屏幕最右边。 expect(chevron.right, lessThanOrEqualTo(320 - 14 - 54)); final synopsis = tester.widget( find.byKey(const Key('player-synopsis')), ); expect( tester.getRect(find.byKey(const Key('player-synopsis'))).right, lessThanOrEqualTo(320 - 14 - 54), ); expect(synopsis.maxLines, 2); expect(synopsis.overflow, TextOverflow.ellipsis); expect( tester.getRect(find.byKey(const Key('player-episodes-pill'))).right, lessThanOrEqualTo(320 - 14 - 40 - 8), ); expect(tester.takeException(), isNull); }); testWidgets('should_open_speed_sheet_and_apply_selected_speed', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-speed-entry'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-speed-sheet')), findsOneWidget); for (final value in ['0.75', '1.0', '1.25', '1.5', '2.0', '3.0']) { expect(find.byKey(Key('player-speed-$value')), findsOneWidget); } await tester.tap(find.byKey(const Key('player-speed-1.5'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-speed-sheet')), findsNothing); await tester.tap(find.byKey(const Key('player-speed-entry'))); await tester.pumpAndSettle(); expect( find.descendant( of: find.byKey(const Key('player-speed-entry')), matching: find.text('1.5x'), ), findsOneWidget, ); }); testWidgets('should_hide_speed_entry_in_clear_screen_and_keep_speed', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-speed-entry'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('player-speed-1.5'))); await tester.pumpAndSettle(); await _clearFromMore(tester); await tester.pump(); expect(find.byKey(const Key('player-more')), findsOneWidget); await tester.tap(find.byKey(const Key('player-bottom-clear-screen'))); await tester.pump(); await tester.drag(find.byType(PageView), const Offset(0, -550)); await tester.pumpAndSettle(); expect( find.descendant( of: find.byKey(const Key('player-speed-entry')), matching: find.text('1.5x'), ), findsOneWidget, ); }); testWidgets('should_enable_drag_time_preview_on_player_progress', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); final bar = tester.widget( find.byKey(const Key('player-progress')), ); expect(bar.showTimes, isTrue); }); testWidgets('should_render_k28_player_structure_and_episode_grid', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); _expectFullscreenOverlay(tester); await tester.tap(find.byKey(const Key('player-episodes'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('drama-info-sheet')), findsOneWidget); expect(find.byKey(const Key('drama-info-close')), findsOneWidget); final grid = tester.widget( find.descendant( of: find.byKey(const Key('drama-info-sheet')), matching: find.byType(GridView), ), ); final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; expect(delegate.crossAxisCount, 6); final overlay = tester.widget>( find.byKey(const Key('drama-info-system-overlay')), ); expect(overlay.value.systemNavigationBarColor, AppColors.card); expect(overlay.value.systemNavigationBarContrastEnforced, isFalse); final safeArea = tester.widget( find .descendant( of: find.byKey(const Key('drama-info-sheet')), matching: find.byType(SafeArea), ) .first, ); expect(safeArea.bottom, isTrue); expect(tester.takeException(), isNull); }); testWidgets('should_show_locked_episode_in_episode_picker', (tester) async { await _pumpApp( tester, catalog: FakeCatalogRepository(dramas: const [_lockedDrama]), playback: FakePlaybackRepository( ticket: const PlayTicket( episodeId: 'locked-demo-1', accessible: false, accessType: PlayTicket.coinRequired, unlockCoin: 30, posterUrl: '/files/locked-first-frame.jpg', thumbnailsUrl: '/files/locked-thumbnails.vtt', ), ), ); await _openSeriesPlayer(tester, feedIndex: 0); expect(find.byKey(const Key('progress-loading')), findsNothing); 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-opening-poster')), findsNothing); expect(find.byKey(const Key('player-opening-sprite')), findsNothing); expect(find.byKey(const Key('player-video-crossfade')), findsNothing); expect(find.byKey(const Key('player-video-layer')), findsNothing); expect( tester .widget(find.byKey(const Key('player-progress'))) .locked, isTrue, ); await tester.tap(find.byKey(const Key('player-episodes'))); await tester.pumpAndSettle(); expect( find.byKey(const ValueKey('episode-lock-locked-demo-1')), findsOneWidget, ); expect(find.byKey(const Key('player-episode-2')), findsOneWidget); }); testWidgets('should_open_compact_more_actions', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-more'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-more-sheet')), findsOneWidget); expect(find.byKey(const Key('player-more-speed')), findsOneWidget); expect(find.byKey(const Key('player-more-quality-AUTO')), findsOneWidget); expect(find.byKey(const Key('player-more-offline')), findsOneWidget); expect(find.byKey(const Key('player-more-clear')), findsOneWidget); expect(find.byKey(const Key('player-more-drag-handle')), findsOneWidget); expect(find.byKey(const Key('player-more-close')), findsNothing); for (final removed in const [ Key('player-more-not-interested'), Key('player-more-danmaku'), Key('player-more-danmaku-settings'), Key('player-more-text-size'), Key('player-more-cast'), Key('player-more-timer'), Key('player-more-report'), ]) { expect(find.byKey(removed), findsNothing); } expect( tester.getSize(find.byKey(const Key('player-more-speed-1.0'))).height, 26, ); expect( tester.getSize(find.byKey(const Key('player-more-speed-track'))).height, 34, ); expect( tester.getSize(find.byKey(const Key('player-more-speed-row'))).height, 56, ); expect( tester.getSize(find.byKey(const Key('player-more-quality-row'))).height, 56, ); expect( tester.getSize(find.byKey(const Key('player-more-clear'))).height, 56, ); expect( tester.getSize(find.byKey(const Key('player-more-offline'))).height, 56, ); expect( tester .widget( find.byKey(const Key('player-more-speed')), ) .scrollDirection, Axis.horizontal, ); expect( tester .widget( find.byKey(const Key('player-more-quality')), ) .scrollDirection, Axis.horizontal, ); expect( tester .widget( find.descendant( of: find.byKey(const Key('player-more-speed-1.0')), matching: find.text('1.0x'), ), ) .style ?.fontSize, 13, ); await tester.ensureVisible(find.byKey(const Key('player-more-speed-1.5'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('player-more-speed-1.5'))); await tester.pumpAndSettle(); expect( tester .widget( find.descendant( of: find.byKey(const Key('player-more-speed-1.5')), matching: find.text('1.5x'), ), ) .style ?.fontWeight, FontWeight.w600, ); expect( tester .widget( find.descendant( of: find.byKey(const Key('player-more-speed-1.0')), matching: find.text('1.0x'), ), ) .style ?.fontWeight, FontWeight.w400, ); }); testWidgets('should_disable_quality_not_supplied_by_ticket', (tester) async { await _pumpApp( tester, playback: FakePlaybackRepository( ticket: const PlayTicket( episodeId: 'city-lights-demo-1', accessible: true, accessType: PlayTicket.free, playUrl: '/files/city-lights-demo-1.mp4', qualities: ['AUTO'], ), ), ); await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-more'))); await tester.pumpAndSettle(); expect( tester .widget( find.descendant( of: find.byKey(const Key('player-more-quality-AUTO')), matching: find.byType(InkWell), ), ) .onTap, isNotNull, ); expect( tester .widget( find.descendant( of: find.byKey(const Key('player-more-quality-720P')), matching: find.byType(InkWell), ), ) .onTap, isNull, ); expect(find.text('720P · Unavailable'), findsOneWidget); }); testWidgets('should_show_pip_action_when_platform_supported', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-more'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-more-pip')), findsOneWidget); expect(find.byKey(const Key('player-more-clear')), findsOneWidget); expect(find.byKey(const Key('player-more-offline')), findsOneWidget); }, variant: TargetPlatformVariant.only(TargetPlatform.android)); testWidgets('should_hide_pip_action_when_platform_unsupported', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-more'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-more-pip')), findsNothing); expect(find.byKey(const Key('player-more-clear')), findsOneWidget); expect(find.byKey(const Key('player-more-offline')), findsOneWidget); }, variant: TargetPlatformVariant.only(TargetPlatform.macOS)); testWidgets('should_enter_clear_screen_from_more_panel', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-more'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('player-more-clear'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsNothing); expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget); expect(find.byKey(const Key('player-progress')), findsOneWidget); expect(find.byKey(const Key('player-interaction-rail')), findsNothing); expect(find.byTooltip('Exit clear screen'), findsOneWidget); }); testWidgets('should_enter_clear_screen_from_bottom_button', (tester) async { await _pumpApp(tester); await _openSeriesPlayer(tester); expect(find.byKey(const Key('player-bottom-clear-screen')), findsOneWidget); expect(find.byKey(const Key('player-pip')), findsNothing); await tester.tap(find.byKey(const Key('player-bottom-clear-screen'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-controls-top')), findsOneWidget); expect(find.byKey(const Key('player-controls-bottom')), findsNothing); expect(find.byKey(const Key('player-bottom-actions')), findsOneWidget); expect(find.byKey(const Key('player-progress')), findsOneWidget); expect(find.byKey(const Key('player-interaction-rail')), findsNothing); expect(find.byType(PlayerCenterToggle), findsNothing); expect(find.byTooltip('Exit clear screen'), findsOneWidget); await tester.tap(find.byKey(const Key('player-bottom-clear-screen'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-controls-bottom')), findsOneWidget); expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); expect(find.byType(PlayerCenterToggle), findsNothing); expect(find.byTooltip('Clear screen'), findsOneWidget); }); testWidgets('should_open_unlock_panel_with_honest_method_states', ( tester, ) async { SharedPreferencesAsyncPlatform.instance = InMemorySharedPreferencesAsync.withData({ 'auth.accessToken': 'access', 'auth.refreshToken': 'refresh', 'auth.user': '{"uid":"9","nickname":"Viewer","avatarUrl":""}', }); final interactions = FakeInteractionRepository(); await _pumpApp( tester, interactions: interactions, playback: FakePlaybackRepository( ticket: const PlayTicket( episodeId: 'city-lights-demo-1', accessible: false, accessType: PlayTicket.coinRequired, unlockCoin: 30, ), ), ); // 首页 feed 的内嵌播放器没有拿到 wallet,经它打开的全屏页解锁入口不可用 //(真实缺陷,见报告),这里改走带 wallet 的目录入口打开全屏页。 await _openFullPlayerWithWallet(tester, demoDramas[1]); expect(find.byKey(const Key('player-unlock-notice')), findsOneWidget); await tester.tapAt(const Offset(80, 300)); await tester.tapAt(const Offset(80, 300)); await tester.pump(const Duration(milliseconds: 350)); expect(find.byKey(const Key('double-tap-heart-0')), findsNothing); expect(interactions.likeTargets, isEmpty); await tester.tap(find.byKey(const Key('player-open-unlock'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('player-unlock-panel')), findsOneWidget); expect(find.byKey(const Key('player-unlock-coins')), findsOneWidget); expect(find.byKey(const Key('player-unlock-ad')), findsOneWidget); expect(find.byKey(const Key('player-unlock-vip')), findsOneWidget); expect( find.text('Available unlock methods vary by title.'), findsOneWidget, ); }); testWidgets('should_require_login_before_opening_unlock_flow', ( tester, ) async { await _pumpApp( tester, playback: FakePlaybackRepository( ticket: const PlayTicket( episodeId: 'city-lights-demo-1', accessible: false, accessType: PlayTicket.coinRequired, unlockCoin: 30, ), ), ); await _openFullPlayerWithWallet(tester, demoDramas[1]); await tester.tap(find.byKey(const Key('player-open-unlock'))); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); expect(find.byKey(const Key('player-unlock-panel')), findsNothing); final signIn = find.widgetWithText(FilledButton, 'Sign In'); await tester.ensureVisible(signIn); await tester.tap(signIn); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); expect(find.byType(AuthPage), findsOneWidget); }); } String _railAssetName(WidgetTester tester, Key itemKey) { final image = tester.widget( find.descendant(of: find.byKey(itemKey), matching: find.byType(Image)), ); return (image.image as AssetImage).assetName; } void _expectPlayerNavigationBar(WidgetTester tester) { final overlay = tester.widget>( find.byKey(const Key('player-system-overlay')).last, ); expect(overlay.value.systemNavigationBarColor, Colors.black); expect(overlay.value.systemNavigationBarDividerColor, Colors.transparent); expect(overlay.value.systemNavigationBarContrastEnforced, isFalse); expect(overlay.value.systemNavigationBarIconBrightness, Brightness.light); } void _expectFullscreenOverlay(WidgetTester tester) { expect(find.byKey(const Key('player-back')), findsOneWidget); expect(find.byKey(const Key('player-episode-heading')), findsOneWidget); expect(find.byKey(const Key('player-speed-entry')), findsOneWidget); expect(find.byKey(const Key('player-drama-title')), findsOneWidget); expect(find.byKey(const Key('player-synopsis')), findsOneWidget); expect(find.byKey(const Key('player-more')), findsOneWidget); for (final key in const [Key('player-back'), Key('player-more')]) { expect(tester.getSize(find.byKey(key)), const Size(40, 40)); } expect(find.byKey(const Key('player-play-toggle')), findsNothing); expect(find.byKey(const Key('player-episodes')), findsOneWidget); expect(find.byKey(const Key('player-bottom-clear-screen')), findsOneWidget); expect(find.byKey(const Key('player-pip')), findsNothing); expect(find.byKey(const Key('player-rail-comments')), findsOneWidget); expect(find.byKey(const Key('player-fullscreen-toggle')), findsNothing); expect(find.byKey(const Key('progress-loading')), findsNothing); expect(find.byKey(const Key('progress-inactive')), findsOneWidget); expect(find.byType(Slider), findsNothing); expect(find.byKey(const Key('player-title')), findsNothing); expect(find.byKey(const Key('player-subtitle')), findsNothing); expect(find.byKey(const Key('player-genre-chip')), findsNothing); expect(find.byKey(const Key('player-page-dots')), findsNothing); expect(find.byKey(const Key('player-clear-screen')), findsNothing); expect(find.byKey(const Key('player-next')), findsNothing); expect(find.byKey(const Key('player-interaction-rail')), findsOneWidget); expect(find.byKey(const Key('player-rail-like')), findsOneWidget); expect(find.byKey(const Key('player-rail-save')), findsOneWidget); expect(find.byKey(const Key('player-rail-share')), findsOneWidget); } Future _clearFromMore(WidgetTester tester) async { await tester.tap(find.byKey(const Key('player-more'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('player-more-clear'))); await tester.pumpAndSettle(); } Future _waitForBufferedHeartFrames(WidgetTester tester) async { await tester.runAsync( () => Future.delayed(const Duration(milliseconds: 120)), ); await tester.pump(const Duration(milliseconds: 120)); } Future _waitForDoubleTapWindow(WidgetTester tester) async { await tester.runAsync( () => Future.delayed(const Duration(milliseconds: 360)), ); await tester.pump(); } Future _waitForHeartCompletion(WidgetTester tester) async { await tester.runAsync( () => Future.delayed(const Duration(milliseconds: 1700)), ); await tester.pump(const Duration(milliseconds: 16)); } String _railLabel(WidgetTester tester, Key key) => tester .widget( find.descendant(of: find.byKey(key), matching: find.byType(Text)), ) .data!; Future _openSeriesPlayer( WidgetTester tester, { int feedIndex = 1, bool settle = true, }) async { await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); final feedPage = find .descendant( of: find.byKey(const Key('recommend-feed-player')), matching: find.byType(PageView), ) .first; for (var index = 0; index < feedIndex; index++) { await tester.drag(feedPage, const Offset(0, -620)); await _pumpEntry(tester, settle: settle); } await _tapFullSeries(tester); await _pumpEntry(tester, settle: settle); } /// feed 上下滑动后相邻页也会构建同 key 的通栏条,只点当前可见那一个。 Future _tapFullSeries(WidgetTester tester) async { final bars = find.byKey(const Key('home-feed-full-series')); final screenHeight = tester.view.physicalSize.height / tester.view.devicePixelRatio; var target = bars; for (var index = 0; index < bars.evaluate().length; index++) { final candidate = bars.at(index); final center = tester.getRect(candidate).center.dy; if (center >= 0 && center <= screenHeight) { target = candidate; break; } } await tester.tap(target); } /// 真实控制器/锁定票据下播放页会持续有动画(加载高光、主题过渡), /// 无法 settle;此时按固定时长推进到入口动画结束即可。 Future _pumpEntry(WidgetTester tester, {required bool settle}) async { if (settle) { await tester.pumpAndSettle(); return; } for (var frame = 0; frame < 30; frame++) { await tester.pump(const Duration(milliseconds: 100)); } } /// 目录卡片入口:与 feed 内嵌播放器不同,这里会把 wallet 一起带进播放页。 Future _openFullPlayerWithWallet( WidgetTester tester, Drama drama, { String? episodeId, bool settle = true, }) async { await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); final context = tester.element( find.byKey(const Key('recommend-feed-player')), ); unawaited(playDramaFromCatalog(context, drama, episodeId: episodeId)); await _pumpEntry(tester, settle: settle); } /// 假播放器不会自己推进 position,播放页要看到进度前进才会确认首帧。 Future _advanceFakeVideo( WidgetTester tester, VideoPlayerController controller, ) async { controller.value = controller.value.copyWith( position: controller.value.position + const Duration(seconds: 1), ); await _pumpEntry(tester, settle: false); } Future _pumpApp( WidgetTester tester, { Size size = const Size(390, 844), FakeCatalogRepository? catalog, PlaybackRepository? playback, FakeInteractionRepository? interactions, FakeCommentRepository? commentRepository, PlayerFactory? playerFactory, bool settle = true, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final repository = catalog ?? FakeCatalogRepository(); await tester.pumpWidget( LumaDramaApp( playerFactory: playerFactory ?? createWidgetPlayer, catalogRepository: repository, categoryRepository: FakeCategoryRepository(catalog: repository), watchHistoryRepository: InMemoryWatchHistoryRepository(), favoriteRepository: InMemoryFavoriteRepository(cards: repository.dramas), playbackRepository: playback ?? FakePlaybackRepository(), commentRepository: commentRepository ?? FakeCommentRepository(), interactionRepository: interactions ?? FakeInteractionRepository(), noticeRepository: FakeNoticeRepository(), haptics: FakeHaptics(), ), ); if (settle) await tester.pumpAndSettle(); } PlayerLogic _pageLogic(WidgetTester tester, Finder page) { final dynamic state = tester.state(page); return state.logic as PlayerLogic; } Finder _fullPlayerFinder() { return find.byWidgetPredicate( (widget) => widget is PlayerPage && widget.handoff != null, ); } class _RouteVideoFactory { final List<_RouteVideoPlayer> created = []; final List episodes = []; PlayerPort create(Episode episode) { final player = _RouteVideoPlayer(episode); created.add(player); episodes.add(episode); return player; } } class _RouteVideoPlayer extends VideoPlayerPort { _RouteVideoPlayer(super.episode); bool disposed = false; int seekCalls = 0; int playCalls = 0; bool get playing => controller.value.isPlaying; @override Future initialize() async { controller.value = const VideoPlayerValue( duration: Duration(minutes: 1), size: Size(1280, 720), position: Duration(seconds: 5), isInitialized: true, isPlaying: true, ); } @override Future play() async { playCalls++; final value = controller.value; controller.value = VideoPlayerValue( duration: value.duration, size: value.size, position: value.position, isInitialized: value.isInitialized, isPlaying: true, ); } @override Future pause() async { final value = controller.value; controller.value = VideoPlayerValue( duration: value.duration, size: value.size, position: value.position, isInitialized: value.isInitialized, isPlaying: false, ); } @override Future seekTo(Duration position) async { seekCalls++; final value = controller.value; controller.value = VideoPlayerValue( duration: value.duration, size: value.size, position: position, isInitialized: value.isInitialized, isPlaying: value.isPlaying, ); } @override Future dispose() async { disposed = true; await super.dispose(); } } class _RecordingPlayer implements PlayerPort { bool playing = false; int playCalls = 0; @override Future initialize() async {} @override Future play() async { playCalls++; playing = true; } @override Future pause() async { playing = false; } @override Future dispose() async {} } class _PendingPlaybackRepository implements PlaybackRepository { final Completer ticket = Completer(); final Completer stats = Completer(); @override Future ticketFor(String episodeId, {bool forceRefresh = false}) => ticket.future; @override Future episodeStats(String episodeId) => stats.future; @override bool isFresh(String episodeId) => false; } class _GatedPlayer implements PlayerPort { _GatedPlayer(this.initialization); final Future initialization; @override Future initialize() => initialization; @override Future play() async {} @override Future pause() async {} @override Future dispose() async {} } class _GatedVideoPlayer extends VideoPlayerPort { _GatedVideoPlayer(super.episode, this.initialization); final Future initialization; @override Future initialize() => initialization; } const _lockedDrama = Drama( id: 'locked-demo', title: 'Locked Demo', synopsis: 'Locked demo synopsis', posterUrl: '/files/locked-demo.png', genre: 'Suspense', detailLoaded: true, episodes: [ Episode( id: 'locked-demo-1', number: 1, title: 'Locked Episode', isFree: false, locked: true, videoUrl: 'https://example.test/locked-demo-1.mp4', posterUrl: '/files/locked-first-frame.jpg', thumbnailsUrl: '/files/locked-thumbnails.vtt', ), Episode( id: 'locked-demo-2', number: 2, title: 'Open Episode', isFree: true, videoUrl: 'https://example.test/locked-demo-2.mp4', ), ], );