// 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: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/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/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/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(); }); testWidgets('should_render_immersive_overlay_in_recommend_feed', ( tester, ) async { await _pumpApp(tester); await tester.tap(find.byKey(const Key('tab-recommend'))); 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-recommend'))); 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-recommend'))); 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 tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); final fullPlayer = _fullPlayerFinder(); final fullLogic = _pageLogic(tester, fullPlayer); expect(fullLogic.session, isNot(same(sourceLogic.session))); final fullPlayerPort = fullLogic.session.active! as VideoPlayerPort; 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_show_one_paused_overlay_when_entering_full_player', ( tester, ) async { final factory = _RouteVideoFactory(); await _pumpApp(tester, playerFactory: factory.create); await tester.tap(find.byKey(const Key('tab-recommend'))); 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 tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); expect(_fullPlayerFinder(), findsOneWidget); expect(find.byKey(const Key('player-play-toggle')), findsOneWidget); 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-recommend'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('rail-episodes'))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('player-episode-2'))); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); 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(40, 40)); 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(40, 40), ); expect( tester.getSize(find.byKey(const Key('player-entry-loading'))), const Size(20, 20), ); expect( tester .widget( find.byKey(const Key('player-entry-loading')), ) .color, Colors.white, ); final loadingSurface = tester.widget( find.byKey(const Key('player-loading-surface')), ); final decoration = loadingSurface.decoration! as BoxDecoration; expect(decoration.color, AppColors.refreshIndicatorSurface); expect(decoration.borderRadius, BorderRadius.circular(20)); 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.text('00:00 / 01:00'), findsOneWidget); // 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-recommend'))); 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 tester.tap(find.byKey(const Key('home-feed-full-series'))); await tester.pumpAndSettle(); 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-recommend'))); 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_pause_feed_when_auth_page_is_pushed_and_resume_on_pop', ( 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-recommend'))); 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(); await tester.tap(find.text('Sign In')); await tester.pumpAndSettle(); expect(find.byType(AuthPage), findsOneWidget); expect(player.playing, isFalse); await tester.pageBack(); await tester.pumpAndSettle(); expect(player.playing, isTrue); }); 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_gate_comments_before_opening_sheet_when_signed_out', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); await tester.tap(find.byKey(const Key('player-rail-comments'))); await tester.pumpAndSettle(); expect(find.text('Sign in to continue'), findsOneWidget); expect(find.byKey(const Key('player-comments-sheet')), 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_double_tap_like_once_without_pausing_full_player', ( 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); final logic = _pageLogic(tester, find.byType(PlayerPage).last); final gesture = find.byKey(const Key('player-video-tap')).hitTestable(); final position = tester.getCenter(gesture); expect(logic.session.userWantsPlay, isTrue); for (var index = 0; index < 4; index++) { await tester.tapAt(position); } await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); expect(logic.session.userWantsPlay, isTrue); expect(find.byKey(const Key('player-play-toggle')), findsNothing); expect(interactions.likeTargets, ['EPISODE:city-lights-demo-1']); 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.doubleTapHeartRed, ); await tester.pump(const Duration(milliseconds: 2300)); 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)); 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( tester.getSize(find.byKey(const Key('player-bottom-clear-screen'))), const Size(40, 40), ); 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')), findsOneWidget); 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(36, 36), ); 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.playerOverlayPill, ); expect( tester .widget(find.byKey(const Key('player-episodes-pill'))) .borderRadius, BorderRadius.circular(7), ); 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, 40), ); 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)); expect(rail.bottom, lessThanOrEqualTo(bottom.top)); 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, 32, ); 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_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), ), ); expect(pill.right - arrow.right, closeTo(14, 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, 32); expect(shareImage.height, 32); expect(shareImage.colorBlendMode, BlendMode.srcIn); 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(44, 44), ); expect( tester .widget( find.descendant( of: find.byKey(const Key('player-rail-like')), matching: find.byType(Text), ), ) .style ?.fontSize, 12, ); }); testWidgets('should_toggle_synopsis_with_inline_text_button', (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, cardKey: const Key('home-feed-card-long-copy-demo'), ); final toggle = find.byKey(const Key('player-synopsis-toggle')); 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(find.byKey(const Key('player-synopsis-gap'))) .padding, const EdgeInsets.only(left: 6), ); 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.pumpAndSettle(); expect( find.descendant(of: toggle, matching: find.text('Less')), findsOneWidget, ); expect( tester.widget(find.byKey(const Key('player-synopsis'))).maxLines, isNull, ); final lessRect = tester.getRect(toggle); final textRect = tester.getRect(find.byKey(const Key('player-synopsis'))); final block = tester.getRect( find.byKey(const Key('player-episode-synopsis')), ); expect(lessRect.top, greaterThanOrEqualTo(textRect.bottom - 1)); expect(block.right - lessRect.right, lessThanOrEqualTo(2)); 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_share_one_muted_style_across_label_and_synopsis', ( tester, ) async { await _pumpApp(tester); await _openSeriesPlayer(tester); TextStyle styleOf(Finder finder) => tester.widget(finder).style ?? const TextStyle(); final label = styleOf(find.byKey(const Key('player-episode-label'))); final synopsis = styleOf(find.byKey(const Key('player-synopsis'))); final separator = styleOf( find.descendant( of: find.byKey(const Key('player-episode-synopsis')), matching: find.text('|'), ), ); for (final style in [label, synopsis, separator]) { expect(style.fontSize, 12); expect(style.fontWeight, FontWeight.w500); expect( style.color?.withValues(alpha: 1), AppColors.playerMuted.withValues(alpha: 1), ); } expect(separator.color, isNot(Colors.white)); }); 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, cardKey: const Key('home-feed-card-long-copy-demo'), ); 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)); 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('player-episodes-sheet')), findsOneWidget); expect(find.byKey(const Key('player-episodes-close')), findsOneWidget); final grid = tester.widget( find.descendant( of: find.byKey(const Key('player-episodes-sheet')), matching: find.byType(GridView), ), ); final delegate = grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; expect(delegate.crossAxisCount, 6); final overlay = tester.widget>( find.byKey(const Key('player-episodes-system-overlay')), ); expect(overlay.value.systemNavigationBarColor, AppColors.card); expect(overlay.value.systemNavigationBarContrastEnforced, isFalse); final safeArea = tester.widget( find .descendant( of: find.byKey(const Key('player-episodes-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, cardKey: const Key('home-feed-card-locked-demo'), ); 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 { 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, ), ), ); await _openSeriesPlayer(tester); 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, ); }); } 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-episode-label')), 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(36, 36)); } 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 _openSeriesPlayer( WidgetTester tester, { Key cardKey = const Key('home-feed-card-city-lights-demo'), }) async { await tester.tap(find.byKey(const Key('tab-home'))); await tester.pumpAndSettle(); final card = find.byKey(cardKey); await tester.scrollUntilVisible( card, 220, scrollable: find .descendant( of: find.byKey(const Key('home-grid-refresh')), matching: find.byType(Scrollable), ) .first, ); await tester.ensureVisible(card); await tester.pumpAndSettle(); await tester.tap(card); await tester.pumpAndSettle(); } 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; @override Future initialize() async {} @override Future play() async { playing = true; } @override Future pause() async { playing = false; } @override Future dispose() async {} } class _PendingPlaybackRepository implements PlaybackRepository { final Completer ticket = Completer(); @override Future ticketFor(String episodeId, {bool forceRefresh = false}) => ticket.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', ), ], );