From ae61d9ec4c9ea80cfc31c629fa2ed72769d88a02 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 17:33:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(home):=20=E9=A6=96=E9=A1=B5=E6=8C=89?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E7=B1=BB=E5=9E=8B=E5=88=86=E9=A2=91=E9=81=93?= =?UTF-8?q?=20Feed=20=E5=B9=B6=E6=94=AF=E6=8C=81=E5=88=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/data/models/feed_page.dart | 31 ++++ lib/core/data/remote/catalog_api.dart | 21 +++ .../data/repositories/catalog_repository.dart | 15 +- .../data/services/home_feed_controller.dart | 165 +++++++++++++++++ lib/core/data/services/playback_session.dart | 23 ++- lib/pages/home/home_feed_channel.dart | 14 ++ lib/pages/home/home_view.dart | 112 +++++++++-- lib/pages/player/player_logic.dart | 92 ++++++++- lib/pages/player/player_view.dart | 25 +++ .../player/widgets/home_feed_header.dart | 73 +++----- test/catalog/catalog_controller_test.dart | 8 + test/catalog/feed_page_test.dart | 78 ++++++++ .../services/home_feed_controller_test.dart | 175 ++++++++++++++++++ .../home/catalog_controller_refresh_test.dart | 8 + test/home/home_feed_header_test.dart | 54 ++++++ test/home/home_preview_layout_test.dart | 3 + test/home/home_startup_splash_test.dart | 15 ++ test/home/k28_a_navigation_test.dart | 12 +- test/player/player_logic_test.dart | 114 ++++++++++++ test/support/fake_catalog.dart | 18 ++ test/watch/watch_records_controller_test.dart | 8 + test/watch/watch_records_page_test.dart | 8 + 22 files changed, 1001 insertions(+), 71 deletions(-) create mode 100644 lib/core/data/models/feed_page.dart create mode 100644 lib/core/data/services/home_feed_controller.dart create mode 100644 lib/pages/home/home_feed_channel.dart create mode 100644 test/catalog/feed_page_test.dart create mode 100644 test/core/data/services/home_feed_controller_test.dart create mode 100644 test/home/home_feed_header_test.dart diff --git a/lib/core/data/models/feed_page.dart b/lib/core/data/models/feed_page.dart new file mode 100644 index 0000000..7f16a2a --- /dev/null +++ b/lib/core/data/models/feed_page.dart @@ -0,0 +1,31 @@ +// Author: fruit.com.cn + +import 'drama.dart'; + +/// `/app/feed` 的游标分页数据。 +class FeedPage { + const FeedPage({ + this.dramas = const [], + this.nextCursor, + this.hasMore = false, + }); + + final List dramas; + final String? nextCursor; + final bool hasMore; + + factory FeedPage.fromJson(Map json) { + final rawCursor = json['nextCursor']; + final cursor = rawCursor?.toString(); + return FeedPage( + dramas: (json['list'] as List? ?? const []) + .whereType>() + .map(Drama.fromCardJson) + .toList(), + nextCursor: cursor == null || cursor.isEmpty || cursor == 'null' + ? null + : cursor, + hasMore: json['hasMore'] == true, + ); + } +} diff --git a/lib/core/data/remote/catalog_api.dart b/lib/core/data/remote/catalog_api.dart index 666c56f..d32508d 100644 --- a/lib/core/data/remote/catalog_api.dart +++ b/lib/core/data/remote/catalog_api.dart @@ -1,4 +1,5 @@ import '../models/drama.dart'; +import '../models/feed_page.dart'; import '../models/home_feed.dart'; import '../models/search_result.dart'; import 'api_client.dart'; @@ -17,6 +18,26 @@ class CatalogApi { return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed'))); } + /// 内容类型推荐流;types 为空表示推荐全部,cursor 为上一页末条。 + Future feed({ + List types = const [], + String? cursor, + int size = 10, + }) async { + return FeedPage.fromJson( + _asMap( + await _client.get( + '/app/feed', + query: { + if (types.isNotEmpty) 'types': types.join(','), + if (cursor != null && cursor.isNotEmpty) 'cursor': cursor, + 'size': '$size', + }, + ), + ), + ); + } + /// 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表。 Future search( String keyword, { diff --git a/lib/core/data/repositories/catalog_repository.dart b/lib/core/data/repositories/catalog_repository.dart index a36472a..a8d2e0e 100644 --- a/lib/core/data/repositories/catalog_repository.dart +++ b/lib/core/data/repositories/catalog_repository.dart @@ -1,6 +1,7 @@ // Author: fruit.com.cn import '../models/drama.dart'; +import '../models/feed_page.dart'; import '../models/home_feed.dart'; import '../remote/catalog_api.dart'; @@ -13,6 +14,12 @@ abstract interface class CatalogRepository { Future fetchDiscoverFeed(); + Future fetchFeed({ + List types = const [], + String? cursor, + int size = 10, + }); + Future fetchDrama(String dramaId); } @@ -28,6 +35,12 @@ class HttpCatalogRepository implements CatalogRepository { Future fetchDiscoverFeed() => _api.discoverFeed(); @override - Future fetchDrama(String dramaId) => _api.dramaDetail(dramaId); + Future fetchFeed({ + List types = const [], + String? cursor, + int size = 10, + }) => _api.feed(types: types, cursor: cursor, size: size); + @override + Future fetchDrama(String dramaId) => _api.dramaDetail(dramaId); } diff --git a/lib/core/data/services/home_feed_controller.dart b/lib/core/data/services/home_feed_controller.dart new file mode 100644 index 0000000..6e54e09 --- /dev/null +++ b/lib/core/data/services/home_feed_controller.dart @@ -0,0 +1,165 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../../data/models/drama.dart'; +import '../../data/repositories/catalog_repository.dart'; +import '../../../pages/home/home_feed_channel.dart'; + +/// 首页内容类型频道状态:每频道独立缓存、刷新和游标分页。 +class HomeFeedController extends ChangeNotifier { + HomeFeedController(this._repository) + : _states = { + for (final channel in HomeFeedChannel.values) + channel: _ChannelFeedState(), + }; + + static const int pageSize = 10; + + final CatalogRepository _repository; + final Map _states; + HomeFeedChannel _channel = HomeFeedChannel.recommend; + String? _lastError; + bool _disposed = false; + + HomeFeedChannel get channel => _channel; + String? get lastError => _lastError; + List get dramas => _state(_channel).dramas; + String? get cursor => _state(_channel).cursor; + bool get hasMore => _state(_channel).hasMore; + bool get loaded => _state(_channel).loaded; + bool get loadingMore => _state(_channel).loadingMore; + bool get loadingFirstPage => !loaded; + + /// 用首页聚合接口打平的数据填充推荐首屏,不发起网络请求。 + void seedRecommend(List dramas) { + final state = _state(HomeFeedChannel.recommend); + state + ..requestSeq = state.requestSeq + 1 + ..dramas = List.unmodifiable(dramas) + ..cursor = null + ..hasMore = false + ..loaded = true + ..loadingMore = false; + _lastError = null; + if (_channel == HomeFeedChannel.recommend) _notify(); + } + + /// 同步切到目标频道;有缓存先渲染,再在后台静默刷新。 + Future selectChannel(HomeFeedChannel next) async { + if (next == _channel) return; + _channel = next; + _lastError = null; + _notify(); + final state = _state(next); + if (state.dramas.isNotEmpty) { + unawaited(_refresh(next, keepData: true)); + } else { + await _loadFirst(next); + } + } + + /// 刷新当前频道,失败时保留旧数据。 + Future refreshCurrent() => _refresh(_channel, keepData: true); + + /// 加载当前频道下一页并去重追加。 + Future loadMore() async { + final channel = _channel; + final state = _state(channel); + if (!state.hasMore || state.loadingMore || state.cursor == null) return; + final seq = ++state.requestSeq; + state.loadingMore = true; + _notify(); + try { + final page = await _repository.fetchFeed( + types: channel.apiTypes, + cursor: state.cursor, + size: pageSize, + ); + if (!_accept(channel, seq)) return; + final seen = state.dramas.map((drama) => drama.id).toSet(); + final appended = page.dramas + .where((drama) => seen.add(drama.id)) + .toList(); + state + ..dramas = List.unmodifiable([...state.dramas, ...appended]) + ..cursor = page.nextCursor + ..hasMore = page.hasMore + ..loaded = true; + _lastError = null; + } catch (error) { + if (_accept(channel, seq)) _lastError = error.toString(); + } finally { + state.loadingMore = false; + if (_acceptChannel(channel)) _notify(); + } + } + + Future _loadFirst(HomeFeedChannel channel) async { + final state = _state(channel); + state.loaded = false; + await _refresh(channel, keepData: state.dramas.isNotEmpty); + } + + Future _refresh( + HomeFeedChannel channel, { + required bool keepData, + }) async { + final state = _state(channel); + final seq = ++state.requestSeq; + _lastError = null; + if (_acceptChannel(channel)) _notify(); + try { + final page = await _repository.fetchFeed( + types: channel.apiTypes, + size: pageSize, + ); + if (!_accept(channel, seq)) return false; + state + ..dramas = List.unmodifiable(page.dramas) + ..cursor = page.nextCursor + ..hasMore = page.hasMore + ..loaded = true + ..loadingMore = false; + _lastError = null; + _notify(); + return true; + } catch (error) { + if (!_accept(channel, seq)) return false; + if (!keepData) state.dramas = const []; + state.loaded = true; + _lastError = error.toString(); + _notify(); + return false; + } + } + + _ChannelFeedState _state(HomeFeedChannel channel) => _states[channel]!; + + bool _accept(HomeFeedChannel channel, int seq) => + !_disposed && channel == _channel && _state(channel).requestSeq == seq; + + bool _acceptChannel(HomeFeedChannel channel) => + !_disposed && channel == _channel; + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} + +class _ChannelFeedState { + List dramas = const []; + String? cursor; + bool hasMore = true; + bool loaded = false; + bool loadingMore = false; + int requestSeq = 0; +} diff --git a/lib/core/data/services/playback_session.dart b/lib/core/data/services/playback_session.dart index 9bca68a..6127fa9 100644 --- a/lib/core/data/services/playback_session.dart +++ b/lib/core/data/services/playback_session.dart @@ -105,6 +105,27 @@ class PlaybackSession extends ChangeNotifier { return null; } + /// 队列重排后把当前仍在播放的 slot 映射到新索引,不重新创建播放器。 + void remapCurrentIndex(int index, {int? preloadIndex}) { + if (_closed || index < 0 || index >= episodes.length) return; + final activeSlot = _active; + if (activeSlot == null || activeSlot.index != _currentIndex) { + _currentIndex = index; + _nextIndex = preloadIndex; + _notify(); + return; + } + _generation++; + activeSlot.index = index; + _currentIndex = index; + _nextIndex = preloadIndex; + final staleNext = _next; + _next = null; + _notify(); + unawaited(_enqueue(() async => _retire(staleNext))); + if (_visible && !_overlayOpen) _schedulePreload(); + } + Future _enqueue(Future Function() operation) { final task = _operations.then((_) => operation()); // A plugin failure must not poison subsequent transitions or cleanup. @@ -363,7 +384,7 @@ class PlaybackSession extends ChangeNotifier { class _PlayerSlot { _PlayerSlot(this.index, this.player); - final int index; + int index; final PlayerPort player; late final Future ready; bool prepared = false; diff --git a/lib/pages/home/home_feed_channel.dart b/lib/pages/home/home_feed_channel.dart new file mode 100644 index 0000000..9518dd9 --- /dev/null +++ b/lib/pages/home/home_feed_channel.dart @@ -0,0 +1,14 @@ +// Author: fruit.com.cn + +/// 首页顶栏的三个内容频道。 +enum HomeFeedChannel { + anime('ANIME'), + live('LIVE'), + recommend(null); + + const HomeFeedChannel(this.apiType); + + final String? apiType; + + List get apiTypes => apiType == null ? const [] : [apiType!]; +} diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 61c2e62..e6be466 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -8,7 +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'; import '../../core/data/services/category_controller.dart'; @@ -24,8 +24,10 @@ import '../discover/discover_types.dart'; import '../discover/discover_view.dart'; import '../create/create_view.dart'; import '../mine/mine_types.dart'; +import '../player/widgets/home_feed_header.dart'; import '../player/player_view.dart'; import '../mine/mine_view.dart'; +import 'home_feed_channel.dart'; import 'home_binding.dart'; import 'home_logic.dart'; @@ -43,6 +45,8 @@ class _HomePageState extends State with WidgetsBindingObserver { static const _defaultSplashSize = 384.0; bool _resumeRetryInFlight = false; + HomeFeedController? _homeFeed; + bool _recommendSeeded = false; @override void initState() { @@ -52,6 +56,7 @@ class _HomePageState extends State with WidgetsBindingObserver { @override void dispose() { + _homeFeed?.dispose(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } @@ -131,11 +136,19 @@ class _HomePageState extends State with WidgetsBindingObserver { ); } - return _HomeTabs( - key: ValueKey(catalog.dramas.first.id), - dramas: catalog.dramas, - onRefreshFeed: catalog.refresh, - ); + final homeFeed = _ensureHomeFeed(catalog); + if (!_recommendSeeded) { + _recommendSeeded = true; + homeFeed.seedRecommend(catalog.dramas); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(homeFeed.refreshCurrent()); + }); + } + return _HomeTabs(key: const Key('home-tabs'), feedController: homeFeed); + } + + HomeFeedController _ensureHomeFeed(CatalogController catalog) { + return _homeFeed ??= HomeFeedController(catalog.repository); } Widget _startupSplash() { @@ -199,14 +212,9 @@ class HomeFailureView extends StatelessWidget { /// 四个根页面共享同一路由并保持各自状态。 class _HomeTabs extends StatefulWidget { - const _HomeTabs({ - super.key, - required this.dramas, - required this.onRefreshFeed, - }); + const _HomeTabs({super.key, required this.feedController}); - final List dramas; - final Future Function() onRefreshFeed; + final HomeFeedController feedController; @override State<_HomeTabs> createState() => _HomeTabsState(); @@ -218,15 +226,21 @@ class _HomeTabsState extends State<_HomeTabs> { @override void initState() { super.initState(); - logic = HomeBinding().create(widget.dramas); + logic = HomeBinding().create(widget.feedController.dramas); + widget.feedController.addListener(_onFeedChanged); } @override void dispose() { + widget.feedController.removeListener(_onFeedChanged); logic.dispose(); super.dispose(); } + void _onFeedChanged() { + if (mounted) setState(() {}); + } + @override Widget build(BuildContext context) { return ListenableBuilder( @@ -277,16 +291,21 @@ 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: widget.dramas.first, + drama: dramas.first, initialIndex: 0, - feedDramas: widget.dramas, + feedDramas: dramas, + feedChannel: widget.feedController.channel, onOpenFullPlayer: (drama, episode) => logic.playDrama(context, drama, episodeId: episode.id), - onRefreshFeed: widget.onRefreshFeed, + onRefreshFeed: widget.feedController.refreshCurrent, + onLoadMore: widget.feedController.loadMore, + onChannelSelected: _selectFeedChannel, onDramaChanged: logic.onDramaChanged, onImmersiveChanged: logic.onImmersiveChanged, onSearch: () => logic.select(1, search: true), @@ -295,6 +314,65 @@ class _HomeTabsState extends State<_HomeTabs> { ); } + Widget _feedChannelPlaceholder(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Stack( + fit: StackFit.expand, + children: [ + const ColoredBox(color: Colors.black), + SafeArea( + bottom: false, + child: Column( + children: [ + HomeFeedHeader( + l10n: l10n, + channel: widget.feedController.channel, + onChannelSelected: _selectFeedChannel, + onRefresh: () => _refreshPlaceholder(context), + onSearch: () => logic.select(1, search: true), + onMore: () {}, + ), + Expanded( + child: widget.feedController.loadingFirstPage + ? const Center( + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator( + key: Key('home-feed-channel-loading'), + strokeWidth: 2.4, + color: Colors.white70, + ), + ), + ) + : EmptyState( + icon: Icons.movie_outlined, + title: l10n.noStories, + actionLabel: l10n.retry, + onAction: () => + unawaited(widget.feedController.refreshCurrent()), + ), + ), + ], + ), + ), + ], + ); + } + + Future _refreshPlaceholder(BuildContext context) async { + final ok = await widget.feedController.refreshCurrent(); + if (!ok && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.catalogError)), + ); + } + return ok; + } + + void _selectFeedChannel(HomeFeedChannel channel) { + unawaited(widget.feedController.selectChannel(channel)); + } + Widget _discover(BuildContext context) { return DiscoverPage( active: logic.tab == 1, diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index 37095af..f1b0b6c 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -100,6 +100,8 @@ class PlayerLogic extends ChangeNotifier { ValueChanged? onDramaChanged; ValueChanged? onImmersiveChanged; bool _disposed = false; + PlayerFactory? _sessionFactory; + PlaybackCoordinator? _sessionCoordinator; /// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化, /// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。 @@ -109,6 +111,14 @@ class PlayerLogic extends ChangeNotifier { bool get chromeHidden => immersive; + /// 当前 PageView 对应用户正在观看的短剧 id,尚未起播时为 null。 + String? get currentDramaId { + if (!_started || visibleIndices.isEmpty) return null; + final index = session.currentIndex; + if (index < 0 || index >= queue.length) return null; + return queue.entryAt(index).drama.id; + } + Episode displayEpisode(Drama drama, Episode fallback) { final number = pickedEpisode[drama.id]; if (number == null) return fallback; @@ -145,6 +155,8 @@ class PlayerLogic extends ChangeNotifier { required PlaybackCoordinator coordinator, }) { _started = true; + _sessionFactory = factory; + _sessionCoordinator = coordinator; pictureInPicture.setStateListener(onPipChanged); queue = feedDramas == null ? PlaybackQueue.series(pageDrama) @@ -153,7 +165,53 @@ class PlayerLogic extends ChangeNotifier { pages = PageController(initialPage: initialIndex); // 票据解析后要把新地址写回这个列表(PlaybackSession 的 canPlay 读的就是它) sessionEpisodes = [for (final entry in queue.entries) entry.episode]; - session = PlaybackSession( + session = _createSession(factory, coordinator); + if (!active || visibleIndices.isEmpty) { + session.setRouteVisible(false); + } + unawaited(loadResumePoint()); + unawaited(selectEpisode(initialIndex)); + } + + /// 原地替换首页 feed 队列;频道变化时重置到第 1 个,刷新时尽量保留当前剧。 + void updateFeed(List dramas, {String? keepDramaId}) { + final currentId = currentDramaId; + final nextQueue = PlaybackQueue.home(List.unmodifiable(dramas)); + final nextVisible = List.generate(nextQueue.length, (index) => index); + final nextEpisodes = [for (final entry in nextQueue.entries) entry.episode]; + feedDramas = List.unmodifiable(dramas); + queue = nextQueue; + visibleIndices = nextVisible; + if (dramas.isNotEmpty) pageDrama = dramas.first; + if (!_started) return; + if (nextQueue.length == 0) { + session.setRouteVisible(false); + notify(); + return; + } + final keepIndex = keepDramaId != null && keepDramaId == currentId + ? nextQueue.entries.indexWhere((entry) => entry.drama.id == keepDramaId) + : -1; + if (keepIndex >= 0 && session.active != null) { + sessionEpisodes + ..clear() + ..addAll(nextEpisodes); + session.remapCurrentIndex( + keepIndex, + preloadIndex: nextVisibleIndex(keepIndex), + ); + _jumpToFeedPage(keepIndex); + notify(); + return; + } + _replaceSession(nextEpisodes, keepIndex >= 0 ? keepIndex : 0); + } + + PlaybackSession _createSession( + PlayerFactory factory, + PlaybackCoordinator coordinator, + ) { + return PlaybackSession( episodes: sessionEpisodes, factory: factory, // 地址只由后端下发(票据或详情里的免费集签名地址),拿不到地址即视为锁定 @@ -162,11 +220,35 @@ class PlayerLogic extends ChangeNotifier { activate: activatePlayer, coordinator: coordinator, ); - if (!active || visibleIndices.isEmpty) { - session.setRouteVisible(false); + } + + void _replaceSession(List nextEpisodes, int targetIndex) { + final factory = _sessionFactory; + final coordinator = _sessionCoordinator; + if (factory == null || coordinator == null) return; + unawaited(saveCurrentProgress()); + final previous = session; + sessionEpisodes = List.of(nextEpisodes); + trackedPlayer = null; + trackedEpisode = null; + trackedDrama = null; + session = _createSession(factory, coordinator); + session.setRouteVisible(active && visibleIndices.isNotEmpty); + unawaited(previous.close()); + _jumpToFeedPage(targetIndex); + unawaited(selectEpisode(targetIndex)); + notify(); + } + + void _jumpToFeedPage(int index) { + if (!_started) return; + if (pages.hasClients) { + pages.jumpToPage(index); + return; } - unawaited(loadResumePoint()); - unawaited(selectEpisode(initialIndex)); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (pages.hasClients) pages.jumpToPage(index); + }); } /// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。 diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 5f860d7..600fe92 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -2,6 +2,7 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -22,6 +23,7 @@ import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/download_action.dart'; import '../../l10n/app_localizations.dart'; +import '../home/home_feed_channel.dart'; import 'comments_auth.dart'; import 'player_binding.dart'; import 'player_logic.dart'; @@ -57,10 +59,13 @@ class PlayerPage extends StatefulWidget { this.onSearch, this.onMenu, this.feedDramas, + this.feedChannel, this.onDramaChanged, this.onImmersiveChanged, this.onOpenFullPlayer, this.onRefreshFeed, + this.onLoadMore, + this.onChannelSelected, this.wallet, this.downloads, }); @@ -77,10 +82,13 @@ class PlayerPage extends StatefulWidget { final VoidCallback? onSearch; final VoidCallback? onMenu; final List? feedDramas; + final HomeFeedChannel? feedChannel; final ValueChanged? onDramaChanged; final ValueChanged? onImmersiveChanged; final Future Function(Drama drama, Episode episode)? onOpenFullPlayer; final Future Function()? onRefreshFeed; + final Future Function()? onLoadMore; + final ValueChanged? onChannelSelected; final WalletController? wallet; final DownloadController? downloads; @@ -123,6 +131,17 @@ class _PlayerPageState extends State void didUpdateWidget(covariant PlayerPage oldWidget) { super.didUpdateWidget(oldWidget); logic.attach(widget, isMounted: () => mounted); + if (widget.homeMode && + widget.feedDramas != null && + (!listEquals(oldWidget.feedDramas, widget.feedDramas) || + oldWidget.feedChannel != widget.feedChannel)) { + logic.updateFeed( + widget.feedDramas!, + keepDramaId: oldWidget.feedChannel == widget.feedChannel + ? logic.currentDramaId + : null, + ); + } logic.onPageUpdated(oldWidget); } @@ -250,6 +269,10 @@ class _PlayerPageState extends State final index = indices[position]; widget.onDramaChanged?.call(logic.queue.entryAt(index).drama); unawaited(logic.selectEpisode(index)); + if (position >= indices.length - 3) { + final loadMore = widget.onLoadMore; + if (loadMore != null) unawaited(loadMore()); + } }, itemBuilder: (context, position) => _buildEpisode(context, indices[position], interactions), @@ -268,6 +291,8 @@ class _PlayerPageState extends State bottom: false, child: HomeFeedHeader( l10n: l10n, + channel: widget.feedChannel ?? HomeFeedChannel.recommend, + onChannelSelected: widget.onChannelSelected ?? (_) {}, onRefresh: () async { final ok = await widget.onRefreshFeed?.call() ?? true; if (context.mounted && !ok) { diff --git a/lib/pages/player/widgets/home_feed_header.dart b/lib/pages/player/widgets/home_feed_header.dart index fad82ac..1723d5d 100644 --- a/lib/pages/player/widgets/home_feed_header.dart +++ b/lib/pages/player/widgets/home_feed_header.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; +import '../../home/home_feed_channel.dart'; import '../../../l10n/app_localizations.dart'; const double _topIconSize = 36; @@ -16,12 +17,16 @@ class HomeFeedHeader extends StatefulWidget { const HomeFeedHeader({ super.key, required this.l10n, + required this.channel, + required this.onChannelSelected, required this.onRefresh, required this.onSearch, required this.onMore, }); final AppLocalizations l10n; + final HomeFeedChannel channel; + final ValueChanged onChannelSelected; final Future Function() onRefresh; final VoidCallback onSearch; final VoidCallback onMore; @@ -31,7 +36,6 @@ class HomeFeedHeader extends StatefulWidget { } class _HomeFeedHeaderState extends State { - _FeedTab _tab = _FeedTab.recommend; bool _refreshing = false; @override @@ -54,8 +58,12 @@ class _HomeFeedHeaderState extends State { scrollDirection: Axis.horizontal, child: Row( children: [ - for (var index = 0; index < _FeedTab.values.length; index++) - _tabSlot(_FeedTab.values[index], index), + for ( + var index = 0; + index < HomeFeedChannel.values.length; + index++ + ) + _tabSlot(HomeFeedChannel.values[index], index), ], ), ), @@ -74,15 +82,15 @@ class _HomeFeedHeaderState extends State { ); } - Widget _tabSlot(_FeedTab tab, int index) { + Widget _tabSlot(HomeFeedChannel channel, int index) { final item = _FeedTabItem( - tab: tab, - label: tab.label(widget.l10n), - selected: _tab == tab, + channel: channel, + label: _label(channel), + selected: widget.channel == channel, fontSize: _tabFontSize, underlineWidth: _underlineWidth, underlineHeight: _underlineHeight, - onTap: () => _selectTab(tab), + onTap: () => widget.onChannelSelected(channel), ); if (index == 0) return item; return Padding( @@ -91,14 +99,6 @@ class _HomeFeedHeaderState extends State { ); } - void _selectTab(_FeedTab tab) { - if (tab.comingSoon) { - _showComingSoon(); - return; - } - setState(() => _tab = tab); - } - Future _refresh() async { if (_refreshing) return; setState(() => _refreshing = true); @@ -106,31 +106,10 @@ class _HomeFeedHeaderState extends State { if (mounted) setState(() => _refreshing = false); } - void _showComingSoon() { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(widget.l10n.comingSoon))); - } -} - -/// 顶部频道 tab:漫剧点击提示即将上线。 -enum _FeedTab { - anime, - real, - recommend; - - bool get comingSoon => this == _FeedTab.anime; - - String get keyName => switch (this) { - _FeedTab.anime => 'anime', - _FeedTab.real => 'real', - _FeedTab.recommend => 'recommend', - }; - - String label(AppLocalizations l10n) => switch (this) { - _FeedTab.anime => l10n.feedTabAnime, - _FeedTab.real => l10n.feedTabReal, - _FeedTab.recommend => l10n.feedTabForYou, + String _label(HomeFeedChannel channel) => switch (channel) { + HomeFeedChannel.anime => widget.l10n.feedTabAnime, + HomeFeedChannel.live => widget.l10n.feedTabReal, + HomeFeedChannel.recommend => widget.l10n.feedTabForYou, }; } @@ -223,7 +202,7 @@ class _RefreshButton extends StatelessWidget { class _FeedTabItem extends StatelessWidget { const _FeedTabItem({ - required this.tab, + required this.channel, required this.label, required this.selected, required this.fontSize, @@ -232,7 +211,7 @@ class _FeedTabItem extends StatelessWidget { required this.onTap, }); - final _FeedTab tab; + final HomeFeedChannel channel; final String label; final bool selected; final double fontSize; @@ -243,7 +222,7 @@ class _FeedTabItem extends StatelessWidget { @override Widget build(BuildContext context) { return GestureDetector( - key: Key('home-feed-tab-${tab.keyName}'), + key: Key('home-feed-tab-${_keyName(channel)}'), behavior: HitTestBehavior.opaque, onTap: onTap, child: Column( @@ -272,4 +251,10 @@ class _FeedTabItem extends StatelessWidget { ), ); } + + String _keyName(HomeFeedChannel channel) => switch (channel) { + HomeFeedChannel.anime => 'anime', + HomeFeedChannel.live => 'real', + HomeFeedChannel.recommend => 'recommend', + }; } diff --git a/test/catalog/catalog_controller_test.dart b/test/catalog/catalog_controller_test.dart index 1654c61..9227dae 100644 --- a/test/catalog/catalog_controller_test.dart +++ b/test/catalog/catalog_controller_test.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/feed_page.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; import 'package:fruitshorts/core/data/services/catalog_controller.dart'; @@ -136,6 +137,13 @@ class ScriptedCatalogRepository implements CatalogRepository { @override Future fetchDiscoverFeed() async => feed; + @override + Future fetchFeed({ + List types = const [], + String? cursor, + int size = 10, + }) async => const FeedPage(); + @override Future fetchDrama(String dramaId) async { detailCalls++; diff --git a/test/catalog/feed_page_test.dart b/test/catalog/feed_page_test.dart new file mode 100644 index 0000000..a8a35b6 --- /dev/null +++ b/test/catalog/feed_page_test.dart @@ -0,0 +1,78 @@ +// Author: fruit.com.cn + +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/remote/api_client.dart'; +import 'package:fruitshorts/core/data/remote/catalog_api.dart'; + +import '../support/fake_http_adapter.dart'; + +void main() { + String envelope(Object? data) => + jsonEncode({'code': 0, 'message': 'ok', 'data': data}); + + test('should_send_types_and_cursor_when_feed_query_has_filters', () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + envelope({ + 'list': [ + { + 'id': '11', + 'title': 'Anime', + 'cover': '/files/anime.png', + 'preview': { + 'id': '12', + 'number': 1, + 'title': 'Episode 1', + 'isFree': true, + }, + }, + ], + 'nextCursor': 10, + 'hasMore': true, + }), + ), + ); + final api = CatalogApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final page = await api.feed( + types: const ['ANIME', 'LIVE'], + cursor: '9', + size: 20, + ); + + expect(adapter.requests.single.uri.path, '/app/feed'); + expect(adapter.requests.single.queryParameters, { + 'types': 'ANIME,LIVE', + 'cursor': '9', + 'size': '20', + }); + expect(page.dramas.single.id, '11'); + expect(page.nextCursor, '10'); + expect(page.hasMore, isTrue); + }); + + test( + 'should_omit_empty_types_and_treat_null_cursor_string_as_empty', + () async { + final adapter = FakeHttpAdapter( + (_) async => FakeHttpResponse( + envelope({'list': [], 'nextCursor': 'null', 'hasMore': false}), + ), + ); + final api = CatalogApi( + ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter), + ); + + final page = await api.feed(); + + expect(adapter.requests.single.queryParameters, {'size': '10'}); + expect(page.dramas, isEmpty); + expect(page.nextCursor, isNull); + expect(page.hasMore, isFalse); + }, + ); +} diff --git a/test/core/data/services/home_feed_controller_test.dart b/test/core/data/services/home_feed_controller_test.dart new file mode 100644 index 0000000..e5c7a72 --- /dev/null +++ b/test/core/data/services/home_feed_controller_test.dart @@ -0,0 +1,175 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/feed_page.dart'; +import 'package:fruitshorts/core/data/models/home_feed.dart'; +import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; +import 'package:fruitshorts/core/data/services/home_feed_controller.dart'; +import 'package:fruitshorts/pages/home/home_feed_channel.dart'; + +void main() { + test( + 'should_seed_recommend_without_requesting_when_initial_feed_is_ready', + () async { + final repository = _ScriptedFeedRepository(); + final controller = HomeFeedController(repository); + + controller.seedRecommend([_drama('seed')]); + + expect(controller.channel, HomeFeedChannel.recommend); + expect(controller.dramas.map((drama) => drama.id), ['seed']); + expect(controller.loaded, isTrue); + expect(repository.calls, isEmpty); + controller.dispose(); + }, + ); + + test('should_request_anime_live_and_recommend_with_expected_types', () async { + final repository = _ScriptedFeedRepository() + ..enqueue(() async => FeedPage(dramas: [_drama('anime')])) + ..enqueue(() async => FeedPage(dramas: [_drama('live')])) + ..enqueue(() async => FeedPage(dramas: [_drama('recommend')])); + final controller = HomeFeedController(repository); + controller.seedRecommend([_drama('seed')]); + + await controller.selectChannel(HomeFeedChannel.anime); + await controller.selectChannel(HomeFeedChannel.live); + await controller.selectChannel(HomeFeedChannel.recommend); + await pumpEventQueue(); + + expect(repository.calls.map((call) => call.types), [ + ['ANIME'], + ['LIVE'], + [], + ]); + controller.dispose(); + }); + + test( + 'should_keep_cached_data_while_silently_refreshing_cached_channel', + () async { + final refresh = Completer(); + final repository = _ScriptedFeedRepository() + ..enqueue(() async => FeedPage(dramas: [_drama('anime-old')])) + ..enqueue(() async => FeedPage(dramas: [_drama('live')])) + ..enqueue(() => refresh.future); + final controller = HomeFeedController(repository); + + await controller.selectChannel(HomeFeedChannel.anime); + await controller.selectChannel(HomeFeedChannel.live); + await controller.selectChannel(HomeFeedChannel.anime); + + expect(controller.dramas.single.id, 'anime-old'); + refresh.complete(FeedPage(dramas: [_drama('anime-new')])); + await pumpEventQueue(); + expect(controller.dramas.single.id, 'anime-new'); + controller.dispose(); + }, + ); + + test('should_append_unique_items_and_stop_when_has_more_is_false', () async { + final repository = _ScriptedFeedRepository() + ..enqueue( + () async => FeedPage( + dramas: [_drama('a'), _drama('b')], + nextCursor: 'next', + hasMore: true, + ), + ) + ..enqueue( + () async => + FeedPage(dramas: [_drama('b'), _drama('c')], hasMore: false), + ); + final controller = HomeFeedController(repository); + await controller.selectChannel(HomeFeedChannel.anime); + + await controller.loadMore(); + await controller.loadMore(); + + expect(controller.dramas.map((drama) => drama.id), ['a', 'b', 'c']); + expect(repository.calls, hasLength(2)); + expect(repository.calls.last.cursor, 'next'); + controller.dispose(); + }); + + test('should_ignore_stale_response_when_channels_switch_quickly', () async { + final anime = Completer(); + final live = Completer(); + final repository = _ScriptedFeedRepository() + ..enqueue(() => anime.future) + ..enqueue(() => live.future); + final controller = HomeFeedController(repository); + + final animeLoad = controller.selectChannel(HomeFeedChannel.anime); + final liveLoad = controller.selectChannel(HomeFeedChannel.live); + live.complete(FeedPage(dramas: [_drama('live')])); + await liveLoad; + anime.complete(FeedPage(dramas: [_drama('anime')])); + await animeLoad; + + expect(controller.channel, HomeFeedChannel.live); + expect(controller.dramas.single.id, 'live'); + controller.dispose(); + }); + + test('should_keep_old_data_when_refresh_fails', () async { + final repository = _ScriptedFeedRepository() + ..enqueue(() async => FeedPage(dramas: [_drama('old')])) + ..enqueue(() async => throw StateError('offline')); + final controller = HomeFeedController(repository); + await controller.selectChannel(HomeFeedChannel.anime); + + final refreshed = await controller.refreshCurrent(); + + expect(refreshed, isFalse); + expect(controller.dramas.single.id, 'old'); + expect(controller.lastError, contains('offline')); + controller.dispose(); + }); +} + +class FeedCall { + const FeedCall({ + required this.types, + required this.cursor, + required this.size, + }); + + final List types; + final String? cursor; + final int size; +} + +class _ScriptedFeedRepository implements CatalogRepository { + final List calls = []; + final List Function()> _responses = []; + + void enqueue(Future Function() response) { + _responses.add(response); + } + + @override + Future fetchFeed({ + List types = const [], + String? cursor, + int size = 10, + }) { + calls.add(FeedCall(types: types, cursor: cursor, size: size)); + return _responses.removeAt(0)(); + } + + @override + Future fetchHomeFeed() async => const HomeFeed(); + + @override + Future fetchDiscoverFeed() async => const HomeFeed(); + + @override + Future fetchDrama(String dramaId) async => _drama(dramaId); +} + +Drama _drama(String id) => + Drama(id: id, title: id, synopsis: id, posterUrl: ''); diff --git a/test/home/catalog_controller_refresh_test.dart b/test/home/catalog_controller_refresh_test.dart index 87a36f1..fd8352e 100644 --- a/test/home/catalog_controller_refresh_test.dart +++ b/test/home/catalog_controller_refresh_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/feed_page.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; import 'package:fruitshorts/core/data/services/catalog_controller.dart'; @@ -59,6 +60,13 @@ class _FakeCatalog implements CatalogRepository { @override Future fetchDiscoverFeed() async => const HomeFeed(); + @override + Future fetchFeed({ + List types = const [], + String? cursor, + int size = 10, + }) async => const FeedPage(); + @override Future fetchDrama(String dramaId) async => _drama; } diff --git a/test/home/home_feed_header_test.dart b/test/home/home_feed_header_test.dart new file mode 100644 index 0000000..ae6966a --- /dev/null +++ b/test/home/home_feed_header_test.dart @@ -0,0 +1,54 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/theme/app_theme.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/home/home_feed_channel.dart'; +import 'package:fruitshorts/pages/player/widgets/home_feed_header.dart'; + +void main() { + testWidgets('should_emit_channel_callbacks_and_render_selected_channel', ( + tester, + ) async { + final selected = []; + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + home: Builder( + builder: (context) => Scaffold( + body: HomeFeedHeader( + l10n: AppLocalizations.of(context)!, + channel: HomeFeedChannel.anime, + onChannelSelected: selected.add, + onRefresh: () async => true, + onSearch: () {}, + onMore: () {}, + ), + ), + ), + ), + ); + + expect(find.byKey(const Key('home-feed-tab-anime')), findsOneWidget); + expect(find.byKey(const Key('home-feed-tab-real')), findsOneWidget); + expect(find.byKey(const Key('home-feed-tab-recommend')), findsOneWidget); + expect( + tester.widget(find.text('Anime')).style?.fontWeight, + FontWeight.w800, + ); + + await tester.tap(find.byKey(const Key('home-feed-tab-real'))); + await tester.tap(find.byKey(const Key('home-feed-tab-recommend'))); + await tester.tap(find.byKey(const Key('home-feed-tab-anime'))); + + expect(selected, [ + HomeFeedChannel.live, + HomeFeedChannel.recommend, + HomeFeedChannel.anime, + ]); + }); +} diff --git a/test/home/home_preview_layout_test.dart b/test/home/home_preview_layout_test.dart index 876b140..4064f01 100644 --- a/test/home/home_preview_layout_test.dart +++ b/test/home/home_preview_layout_test.dart @@ -15,6 +15,7 @@ import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/core/theme/app_theme.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; +import 'package:fruitshorts/pages/home/home_feed_channel.dart'; import 'package:fruitshorts/pages/player/widgets/home_feed_header.dart'; import 'package:fruitshorts/pages/player/widgets/home_feed_overlay.dart'; import 'package:video_player/video_player.dart'; @@ -439,6 +440,8 @@ void main() { builder: (context) => Scaffold( body: HomeFeedHeader( l10n: AppLocalizations.of(context)!, + channel: HomeFeedChannel.recommend, + onChannelSelected: (_) {}, onRefresh: () { calls++; return completer.future; diff --git a/test/home/home_startup_splash_test.dart b/test/home/home_startup_splash_test.dart index 9e37466..374e357 100644 --- a/test/home/home_startup_splash_test.dart +++ b/test/home/home_startup_splash_test.dart @@ -12,6 +12,7 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/config/app_config.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/feed_page.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; @@ -158,6 +159,13 @@ class _EmptyCatalogRepository implements CatalogRepository { @override Future fetchDiscoverFeed() async => const HomeFeed(); + @override + Future fetchFeed({ + List types = const [], + String? cursor, + int size = 10, + }) async => const FeedPage(); + @override Future fetchDrama(String dramaId) async => _feed.dramas.first; } @@ -177,6 +185,13 @@ class _PendingCatalogRepository implements CatalogRepository { @override Future fetchDiscoverFeed() async => _feed; + @override + Future fetchFeed({ + List types = const [], + String? cursor, + int size = 10, + }) async => FeedPage(dramas: _feed.dramas); + @override Future fetchDrama(String dramaId) async => _feed.dramas.first; } diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index df83f11..eefbdd5 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -116,12 +116,18 @@ void main() { expect(player.homeMode, isFalse); }); - testWidgets('should_show_coming_soon_for_anime_tab', (tester) async { + testWidgets('should_switch_to_anime_channel', (tester) async { await _pumpApp(tester); await tester.tap(find.byKey(const Key('home-feed-tab-anime'))); - await tester.pump(); - expect(find.text('Coming soon'), findsOneWidget); + await tester.pumpAndSettle(); + + expect(find.text('Coming soon'), findsNothing); + expect( + tester.widget(find.text('Anime')).style?.fontWeight, + FontWeight.w800, + ); + expect(find.byKey(const Key('home-feed-title')), findsOneWidget); }); testWidgets('should_refresh_feed_without_blank_loading', (tester) async { diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index 9c42526..7c23e67 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -412,6 +412,120 @@ void main() { }); }); + group('updateFeed', () { + test('should_append_feed_without_restarting_current_drama', () async { + final factory = _RecordingFactory(); + final feed = [_drama('signal'), _drama('city')]; + final logic = _logic(FakeWatchHistory()); + logic.attach( + PlayerPage( + drama: feed.first, + initialIndex: 0, + homeMode: true, + feedDramas: feed, + ), + isMounted: () => true, + ); + logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); + await _drain(logic); + final activeBefore = logic.session.active; + + logic.updateFeed([...feed, _drama('third')], keepDramaId: 'signal'); + await pumpEventQueue(); + + expect(logic.currentDramaId, 'signal'); + expect(logic.visibleIndices, [0, 1, 2]); + expect(logic.sessionEpisodes, hasLength(3)); + expect(logic.session.active, same(activeBefore)); + logic.dispose(); + await _drain(logic); + }); + + test('should_remap_current_drama_without_restarting_when_order_changes', + () async { + final factory = _RecordingFactory(); + final feed = [_drama('signal'), _drama('city')]; + final logic = _logic(FakeWatchHistory()); + logic.attach( + PlayerPage( + drama: feed.first, + initialIndex: 1, + homeMode: true, + feedDramas: feed, + ), + isMounted: () => true, + ); + logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); + await _drain(logic); + final activeBefore = logic.session.active; + + logic.updateFeed( + [_drama('city'), _drama('signal'), _drama('third')], + keepDramaId: 'city', + ); + await pumpEventQueue(); + + expect(logic.currentDramaId, 'city'); + expect(logic.session.currentIndex, 0); + expect(logic.session.active, same(activeBefore)); + logic.dispose(); + await _drain(logic); + }); + + test('should_return_to_first_drama_when_feed_channel_changes', () async { + final factory = _RecordingFactory(); + final feed = [_drama('signal'), _drama('city')]; + final logic = _logic(FakeWatchHistory()); + logic.attach( + PlayerPage( + drama: feed.first, + initialIndex: 0, + homeMode: true, + feedDramas: feed, + ), + isMounted: () => true, + ); + logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); + await _drain(logic); + + logic.updateFeed([_drama('anime-a'), _drama('anime-b')]); + await _drain(logic); + + expect(logic.queue.entryAt(0).drama.id, 'anime-a'); + expect(logic.currentDramaId, 'anime-a'); + expect(logic.session.currentIndex, 0); + logic.dispose(); + await _drain(logic); + }); + + test('should_clear_queue_safely_when_feed_becomes_empty', () async { + final feed = [_drama('signal')]; + final logic = _logic(FakeWatchHistory()); + logic.attach( + PlayerPage( + drama: feed.first, + initialIndex: 0, + homeMode: true, + feedDramas: feed, + ), + isMounted: () => true, + ); + logic.start( + factory: _RecordingFactory().create, + coordinator: PlaybackCoordinator(), + ); + await _drain(logic); + + logic.updateFeed(const []); + + expect(logic.queue.length, 0); + expect(logic.visibleIndices, isEmpty); + expect(logic.currentDramaId, isNull); + logic.dispose(); + await _drain(logic); + }); + }); + group('lifecycle', () { test('should_pause_active_player_when_backgrounded_and_play_it_when_resumed', () async { final factory = _RecordingFactory(); diff --git a/test/support/fake_catalog.dart b/test/support/fake_catalog.dart index 075febe..28d6156 100644 --- a/test/support/fake_catalog.dart +++ b/test/support/fake_catalog.dart @@ -1,4 +1,5 @@ import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/feed_page.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; import 'package:fruitshorts/core/data/models/search_result.dart'; import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; @@ -18,6 +19,23 @@ class FakeCatalogRepository implements CatalogRepository, SearchRepository { @override Future fetchDiscoverFeed() async => _feed(); + @override + Future fetchFeed({ + List types = const [], + String? cursor, + int size = 10, + }) async { + final filtered = types.isEmpty + ? dramas + : dramas.where((drama) => _matchesFeedTypes(drama, types)).toList(); + return FeedPage(dramas: filtered.take(size).toList()); + } + + bool _matchesFeedTypes(Drama drama, List types) { + final dramaType = drama.id == 'the-signal-demo' ? 'ANIME' : 'LIVE'; + return types.contains(dramaType); + } + @override Future fetchDrama(String dramaId) async { return dramas.firstWhere((drama) => drama.id == dramaId); diff --git a/test/watch/watch_records_controller_test.dart b/test/watch/watch_records_controller_test.dart index f6a795b..7266b92 100644 --- a/test/watch/watch_records_controller_test.dart +++ b/test/watch/watch_records_controller_test.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/feed_page.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/core/data/repositories/catalog_repository.dart'; @@ -265,6 +266,13 @@ class ScriptedWatchCatalog implements CatalogRepository { @override Future fetchDiscoverFeed() async => const HomeFeed(); + @override + Future fetchFeed({ + List types = const [], + String? cursor, + int size = 10, + }) async => const FeedPage(); + @override Future fetchDrama(String dramaId) async { calls.add(dramaId); diff --git a/test/watch/watch_records_page_test.dart b/test/watch/watch_records_page_test.dart index ec98178..8e6410d 100644 --- a/test/watch/watch_records_page_test.dart +++ b/test/watch/watch_records_page_test.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/feed_page.dart'; import 'package:fruitshorts/core/data/models/home_feed.dart'; import 'package:fruitshorts/core/data/models/watch_progress.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart'; @@ -271,6 +272,13 @@ class _FailingWatchCatalog implements CatalogRepository { @override Future fetchDiscoverFeed() => _delegate.fetchDiscoverFeed(); + @override + Future fetchFeed({ + List types = const [], + String? cursor, + int size = 10, + }) => _delegate.fetchFeed(types: types, cursor: cursor, size: size); + @override Future fetchDrama(String dramaId) async { if (dramaId == 'broken') throw StateError('missing detail');