From 677e49fa6e45022cb8c5df7cfd74f2ab1cdf019e Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 17:53:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(home):=20=E9=A6=96=E5=88=87=E9=A2=91?= =?UTF-8?q?=E9=81=93=E9=A2=84=E5=8F=96=E5=B9=B6=E4=BF=9D=E7=95=99=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E7=94=BB=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/services/home_feed_controller.dart | 21 +++- lib/pages/home/home_view.dart | 97 +++++++++++++++---- lib/pages/player/player_view.dart | 7 +- .../services/home_feed_controller_test.dart | 61 ++++++++++++ test/home/k28_a_navigation_test.dart | 83 +++++++++++++++- 5 files changed, 242 insertions(+), 27 deletions(-) diff --git a/lib/core/data/services/home_feed_controller.dart b/lib/core/data/services/home_feed_controller.dart index 8d667fd..c85d681 100644 --- a/lib/core/data/services/home_feed_controller.dart +++ b/lib/core/data/services/home_feed_controller.dart @@ -52,6 +52,14 @@ class HomeFeedController extends ChangeNotifier { _notifyIfCurrent(HomeFeedChannel.recommend); } + /// 后台预取漫剧与真人剧首屏,不切换频道,也不影响当前列表。 + Future prefetchChannels() async { + await Future.wait([ + _prefetchChannel(HomeFeedChannel.anime), + _prefetchChannel(HomeFeedChannel.live), + ]); + } + /// 同步切到目标频道;有缓存先渲染,无缓存时复用或等待在飞请求。 Future selectChannel(HomeFeedChannel next) async { if (next == _channel) return; @@ -59,7 +67,9 @@ class HomeFeedController extends ChangeNotifier { _notify(); final state = _state(next); if (state.dramas.isNotEmpty) { - if (!state.loading) { + if (state.skipNextRefresh) { + state.skipNextRefresh = false; + } else if (!state.loading) { unawaited(_refresh(next, keepData: true)); } return; @@ -67,6 +77,7 @@ class HomeFeedController extends ChangeNotifier { if (state.loading) { final inFlight = state.inFlight; if (inFlight != null) await inFlight; + state.skipNextRefresh = false; return; } await _refresh(next, keepData: false); @@ -126,6 +137,13 @@ class HomeFeedController extends ChangeNotifier { return operation; } + Future _prefetchChannel(HomeFeedChannel channel) async { + final state = _state(channel); + if (state.loaded || state.loading) return; + final success = await _refresh(channel, keepData: false); + if (success && !_disposed) state.skipNextRefresh = true; + } + Future _performRefresh( HomeFeedChannel channel, { required bool keepData, @@ -200,4 +218,5 @@ class _ChannelFeedState { String? lastError; int requestSeq = 0; Future? inFlight; + bool skipNextRefresh = false; } diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index e6be466..dcd4075 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -8,6 +8,7 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../core/config/app_config.dart'; +import '../../core/data/models/drama.dart'; import '../../core/data/services/home_feed_controller.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/catalog_retry.dart'; @@ -141,7 +142,7 @@ class _HomePageState extends State with WidgetsBindingObserver { _recommendSeeded = true; homeFeed.seedRecommend(catalog.dramas); WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) unawaited(homeFeed.refreshCurrent()); + if (mounted) unawaited(_refreshRecommendAndPrefetch(homeFeed)); }); } return _HomeTabs(key: const Key('home-tabs'), feedController: homeFeed); @@ -151,6 +152,12 @@ class _HomePageState extends State with WidgetsBindingObserver { return _homeFeed ??= HomeFeedController(catalog.repository); } + Future _refreshRecommendAndPrefetch(HomeFeedController homeFeed) async { + if (await homeFeed.refreshCurrent()) { + await homeFeed.prefetchChannels(); + } + } + Widget _startupSplash() { final isAndroid = defaultTargetPlatform == TargetPlatform.android; final size = isAndroid ? _androidSplashSize : _defaultSplashSize; @@ -222,11 +229,15 @@ class _HomeTabs extends StatefulWidget { class _HomeTabsState extends State<_HomeTabs> { late final HomeLogic logic; + List? _visibleDramas; + HomeFeedChannel? _visibleChannel; @override void initState() { super.initState(); logic = HomeBinding().create(widget.feedController.dramas); + _visibleDramas = widget.feedController.dramas; + _visibleChannel = widget.feedController.channel; widget.feedController.addListener(_onFeedChanged); } @@ -291,26 +302,70 @@ class _HomeTabsState extends State<_HomeTabs> { } Widget _home(BuildContext context) { - final dramas = widget.feedController.dramas; - if (dramas.isEmpty) return _feedChannelPlaceholder(context); - return PlayerPage( - homeMode: true, - embedded: true, - active: logic.tab == 0, - drama: dramas.first, - initialIndex: 0, - feedDramas: dramas, - feedChannel: widget.feedController.channel, - onOpenFullPlayer: (drama, episode) => - logic.playDrama(context, drama, episodeId: episode.id), - onRefreshFeed: widget.feedController.refreshCurrent, - onLoadMore: widget.feedController.loadMore, - onChannelSelected: _selectFeedChannel, - onDramaChanged: logic.onDramaChanged, - onImmersiveChanged: logic.onImmersiveChanged, - onSearch: () => logic.select(1, search: true), - onDiscover: () => logic.select(1), - onMy: () => logic.select(3), + final currentDramas = widget.feedController.dramas; + final currentChannel = widget.feedController.channel; + if (currentDramas.isNotEmpty) { + _visibleDramas = currentDramas; + _visibleChannel = currentChannel; + } + if (currentDramas.isEmpty && !widget.feedController.loadingFirstPage) { + return _feedChannelPlaceholder(context); + } + final visibleDramas = _visibleDramas; + final visibleChannel = _visibleChannel; + final switching = + currentDramas.isEmpty && widget.feedController.loadingFirstPage; + if (visibleDramas == null || + visibleDramas.isEmpty || + visibleChannel == null) { + return _feedChannelPlaceholder(context); + } + final playerDramas = currentDramas.isEmpty ? visibleDramas : currentDramas; + final playerChannel = currentDramas.isEmpty + ? visibleChannel + : currentChannel; + return Stack( + fit: StackFit.expand, + children: [ + PlayerPage( + homeMode: true, + embedded: true, + active: logic.tab == 0, + drama: playerDramas.first, + initialIndex: 0, + feedDramas: playerDramas, + feedChannel: playerChannel, + headerChannel: currentChannel, + onOpenFullPlayer: (drama, episode) => + logic.playDrama(context, drama, episodeId: episode.id), + onRefreshFeed: widget.feedController.refreshCurrent, + onLoadMore: widget.feedController.loadMore, + onChannelSelected: _selectFeedChannel, + onDramaChanged: logic.onDramaChanged, + onImmersiveChanged: logic.onImmersiveChanged, + onSearch: () => logic.select(1, search: true), + onDiscover: () => logic.select(1), + onMy: () => logic.select(3), + ), + if (switching) + Positioned.fill( + child: IgnorePointer( + key: const Key('home-feed-channel-loading'), + child: ColoredBox( + color: Colors.black26, + child: Center( + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator( + strokeWidth: 2.4, + color: Colors.white70, + ), + ), + ), + ), + ), + ), + ], ); } diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 6627e28..5ed0578 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -59,6 +59,7 @@ class PlayerPage extends StatefulWidget { this.onMenu, this.feedDramas, this.feedChannel, + this.headerChannel, this.onDramaChanged, this.onImmersiveChanged, this.onOpenFullPlayer, @@ -82,6 +83,7 @@ class PlayerPage extends StatefulWidget { final VoidCallback? onMenu; final List? feedDramas; final HomeFeedChannel? feedChannel; + final HomeFeedChannel? headerChannel; final ValueChanged? onDramaChanged; final ValueChanged? onImmersiveChanged; final Future Function(Drama drama, Episode episode)? onOpenFullPlayer; @@ -295,7 +297,10 @@ class _PlayerPageState extends State bottom: false, child: HomeFeedHeader( l10n: l10n, - channel: widget.feedChannel ?? HomeFeedChannel.recommend, + channel: + widget.headerChannel ?? + widget.feedChannel ?? + HomeFeedChannel.recommend, onChannelSelected: widget.onChannelSelected ?? (_) {}, onRefresh: () async { final ok = await widget.onRefreshFeed?.call() ?? true; diff --git a/test/core/data/services/home_feed_controller_test.dart b/test/core/data/services/home_feed_controller_test.dart index 3aab7b3..87213c6 100644 --- a/test/core/data/services/home_feed_controller_test.dart +++ b/test/core/data/services/home_feed_controller_test.dart @@ -27,6 +27,67 @@ void main() { }, ); + test( + 'should_use_prefetched_channel_without_another_request_on_first_select', + () async { + final repository = _ScriptedFeedRepository() + ..enqueue(() async => FeedPage(dramas: [_drama('anime')])) + ..enqueue(() async => FeedPage(dramas: [_drama('live')])); + final controller = HomeFeedController(repository); + controller.seedRecommend([_drama('seed')]); + + await controller.prefetchChannels(); + await controller.selectChannel(HomeFeedChannel.anime); + + expect(controller.dramas.single.id, 'anime'); + expect(repository.calls.map((call) => call.types), [ + ['ANIME'], + ['LIVE'], + ]); + controller.dispose(); + }, + ); + + test('should_keep_recommend_when_prefetch_fails', () async { + final repository = _ScriptedFeedRepository() + ..enqueue(() async => throw StateError('offline')) + ..enqueue(() async => FeedPage(dramas: [_drama('live')])); + final controller = HomeFeedController(repository); + controller.seedRecommend([_drama('seed')]); + + await controller.prefetchChannels(); + + expect(controller.channel, HomeFeedChannel.recommend); + expect(controller.dramas.single.id, 'seed'); + expect(controller.lastError, isNull); + controller.dispose(); + }); + + test( + 'should_wait_prefetch_without_duplicate_request_when_channel_is_selected', + () async { + final anime = Completer(); + final repository = _ScriptedFeedRepository() + ..enqueue(() => anime.future) + ..enqueue(() async => FeedPage(dramas: [_drama('live')])); + final controller = HomeFeedController(repository); + controller.seedRecommend([_drama('seed')]); + + final prefetch = controller.prefetchChannels(); + final select = controller.selectChannel(HomeFeedChannel.anime); + anime.complete(FeedPage(dramas: [_drama('anime')])); + await prefetch; + await select; + + expect( + repository.calls.where((call) => call.types.contains('ANIME')), + hasLength(1), + ); + expect(controller.dramas.single.id, 'anime'); + controller.dispose(); + }, + ); + test('should_request_anime_live_and_recommend_with_expected_types', () async { final repository = _ScriptedFeedRepository() ..enqueue(() async => FeedPage(dramas: [_drama('anime')])) diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index eefbdd5..5b04f60 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -1,12 +1,16 @@ // 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:fruitshorts/app.dart'; +import 'package:fruitshorts/core/data/models/feed_page.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/pages/home/home_feed_channel.dart'; import 'package:fruitshorts/pages/player/player_view.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'; @@ -130,6 +134,44 @@ void main() { expect(find.byKey(const Key('home-feed-title')), findsOneWidget); }); + testWidgets('should_keep_player_when_first_anime_switch_is_loading', ( + tester, + ) async { + final catalog = _PendingAnimeCatalog(); + await _pumpApp(tester, catalog: catalog); + for (var attempt = 0; attempt < 5 && catalog.animeCalls == 0; attempt++) { + await tester.pump(); + } + expect(catalog.animeCalls, 1); + + await tester.tap(find.byKey(const Key('home-feed-tab-anime'))); + await tester.pump(); + + expect(find.byType(PlayerPage), findsOneWidget); + expect(find.byKey(const Key('home-feed-channel-loading')), findsOneWidget); + + catalog.anime.complete(FeedPage(dramas: [demoDramas[1]])); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('home-feed-channel-loading')), findsNothing); + expect( + tester.widget(find.byType(PlayerPage)).feedChannel, + HomeFeedChannel.anime, + ); + }); + + testWidgets('should_show_empty_state_when_anime_has_no_dramas', ( + tester, + ) async { + await _pumpApp(tester, catalog: _EmptyAnimeCatalog()); + + await tester.tap(find.byKey(const Key('home-feed-tab-anime'))); + await tester.pumpAndSettle(); + + expect(find.byType(PlayerPage), findsNothing); + expect(find.text('No stories available yet.'), findsOneWidget); + }); + testWidgets('should_refresh_feed_without_blank_loading', (tester) async { await _pumpApp(tester); @@ -446,19 +488,20 @@ bool _hasLightStatusBar(WidgetTester tester) => tester Future _pumpApp( WidgetTester tester, { Size size = const Size(390, 844), + FakeCatalogRepository? catalog, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - final catalog = FakeCatalogRepository(); + final repository = catalog ?? FakeCatalogRepository(); await tester.pumpWidget( LumaDramaApp( playerFactory: createWidgetPlayer, - catalogRepository: catalog, - categoryRepository: FakeCategoryRepository(catalog: catalog), + catalogRepository: repository, + categoryRepository: FakeCategoryRepository(catalog: repository), watchHistoryRepository: InMemoryWatchHistoryRepository(), - favoriteRepository: InMemoryFavoriteRepository(cards: catalog.dramas), + favoriteRepository: InMemoryFavoriteRepository(cards: repository.dramas), playbackRepository: FakePlaybackRepository(), commentRepository: FakeCommentRepository(), interactionRepository: FakeInteractionRepository(), @@ -467,3 +510,35 @@ Future _pumpApp( ); await tester.pumpAndSettle(); } + +class _PendingAnimeCatalog extends FakeCatalogRepository { + final anime = Completer(); + int animeCalls = 0; + + @override + Future fetchFeed({ + List types = const [], + String? cursor, + int size = 10, + }) { + if (types.contains('ANIME')) { + animeCalls++; + return anime.future; + } + return super.fetchFeed(types: types, cursor: cursor, size: size); + } +} + +class _EmptyAnimeCatalog extends FakeCatalogRepository { + @override + Future fetchFeed({ + List types = const [], + String? cursor, + int size = 10, + }) { + if (types.contains('ANIME')) { + return Future.value(const FeedPage(dramas: [])); + } + return super.fetchFeed(types: types, cursor: cursor, size: size); + } +}