diff --git a/lib/core/data/services/home_feed_controller.dart b/lib/core/data/services/home_feed_controller.dart index 6e54e09..8d667fd 100644 --- a/lib/core/data/services/home_feed_controller.dart +++ b/lib/core/data/services/home_feed_controller.dart @@ -10,28 +10,31 @@ import '../../../pages/home/home_feed_channel.dart'; /// 首页内容类型频道状态:每频道独立缓存、刷新和游标分页。 class HomeFeedController extends ChangeNotifier { - HomeFeedController(this._repository) - : _states = { - for (final channel in HomeFeedChannel.values) - channel: _ChannelFeedState(), - }; + HomeFeedController( + this._repository, { + this.requestTimeout = defaultRequestTimeout, + }) : _states = { + for (final channel in HomeFeedChannel.values) + channel: _ChannelFeedState(), + }; static const int pageSize = 10; + static const Duration defaultRequestTimeout = Duration(seconds: 10); final CatalogRepository _repository; + final Duration requestTimeout; final Map _states; HomeFeedChannel _channel = HomeFeedChannel.recommend; - String? _lastError; bool _disposed = false; HomeFeedChannel get channel => _channel; - String? get lastError => _lastError; + String? get lastError => _state(_channel).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; + bool get loadingFirstPage => !loaded && _state(_channel).loading; /// 用首页聚合接口打平的数据填充推荐首屏,不发起网络请求。 void seedRecommend(List dramas) { @@ -42,27 +45,41 @@ class HomeFeedController extends ChangeNotifier { ..cursor = null ..hasMore = false ..loaded = true - ..loadingMore = false; - _lastError = null; - if (_channel == HomeFeedChannel.recommend) _notify(); + ..loading = false + ..loadingMore = false + ..lastError = null + ..inFlight = null; + _notifyIfCurrent(HomeFeedChannel.recommend); } - /// 同步切到目标频道;有缓存先渲染,再在后台静默刷新。 + /// 同步切到目标频道;有缓存先渲染,无缓存时复用或等待在飞请求。 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); + if (!state.loading) { + unawaited(_refresh(next, keepData: true)); + } + return; } + if (state.loading) { + final inFlight = state.inFlight; + if (inFlight != null) await inFlight; + return; + } + await _refresh(next, keepData: false); } /// 刷新当前频道,失败时保留旧数据。 - Future refreshCurrent() => _refresh(_channel, keepData: true); + Future refreshCurrent() async { + final state = _state(_channel); + if (state.loading) { + return await state.inFlight ?? false; + } + return _refresh(_channel, keepData: true); + } /// 加载当前频道下一页并去重追加。 Future loadMore() async { @@ -71,14 +88,16 @@ class HomeFeedController extends ChangeNotifier { if (!state.hasMore || state.loadingMore || state.cursor == null) return; final seq = ++state.requestSeq; state.loadingMore = true; - _notify(); + _notifyIfCurrent(channel); try { - final page = await _repository.fetchFeed( - types: channel.apiTypes, - cursor: state.cursor, - size: pageSize, - ); - if (!_accept(channel, seq)) return; + final page = await _repository + .fetchFeed( + types: channel.apiTypes, + cursor: state.cursor, + size: pageSize, + ) + .timeout(requestTimeout); + if (!_acceptSequence(channel, seq)) return; final seen = state.dramas.map((drama) => drama.id).toSet(); final appended = page.dramas .where((drama) => seen.add(drama.id)) @@ -87,62 +106,78 @@ class HomeFeedController extends ChangeNotifier { ..dramas = List.unmodifiable([...state.dramas, ...appended]) ..cursor = page.nextCursor ..hasMore = page.hasMore - ..loaded = true; - _lastError = null; + ..loaded = true + ..lastError = null; } catch (error) { - if (_accept(channel, seq)) _lastError = error.toString(); + if (_acceptSequence(channel, seq)) { + state.lastError = error.toString(); + } } finally { state.loadingMore = false; - if (_acceptChannel(channel)) _notify(); + _notifyIfCurrent(channel); } } - Future _loadFirst(HomeFeedChannel channel) async { + Future _refresh(HomeFeedChannel channel, {required bool keepData}) { final state = _state(channel); - state.loaded = false; - await _refresh(channel, keepData: state.dramas.isNotEmpty); + final operation = _performRefresh(channel, keepData: keepData); + state.inFlight = operation; + unawaited(_clearInFlight(state, operation)); + return operation; } - Future _refresh( + Future _performRefresh( HomeFeedChannel channel, { required bool keepData, }) async { final state = _state(channel); final seq = ++state.requestSeq; - _lastError = null; - if (_acceptChannel(channel)) _notify(); + state + ..loading = true + ..lastError = null; + _notifyIfCurrent(channel); try { - final page = await _repository.fetchFeed( - types: channel.apiTypes, - size: pageSize, - ); - if (!_accept(channel, seq)) return false; + final page = await _repository + .fetchFeed(types: channel.apiTypes, size: pageSize) + .timeout(requestTimeout); + if (!_acceptSequence(channel, seq)) return false; state ..dramas = List.unmodifiable(page.dramas) ..cursor = page.nextCursor ..hasMore = page.hasMore ..loaded = true - ..loadingMore = false; - _lastError = null; - _notify(); + ..loading = false + ..lastError = null; + _notifyIfCurrent(channel); return true; } catch (error) { - if (!_accept(channel, seq)) return false; + if (!_acceptSequence(channel, seq)) return false; if (!keepData) state.dramas = const []; - state.loaded = true; - _lastError = error.toString(); - _notify(); + state + ..loaded = true + ..loading = false + ..lastError = error.toString(); + _notifyIfCurrent(channel); return false; } } + Future _clearInFlight( + _ChannelFeedState state, + Future operation, + ) async { + await operation; + if (identical(state.inFlight, operation)) state.inFlight = null; + } + _ChannelFeedState _state(HomeFeedChannel channel) => _states[channel]!; - bool _accept(HomeFeedChannel channel, int seq) => - !_disposed && channel == _channel && _state(channel).requestSeq == seq; + bool _acceptSequence(HomeFeedChannel channel, int seq) => + !_disposed && _state(channel).requestSeq == seq; - bool _acceptChannel(HomeFeedChannel channel) => - !_disposed && channel == _channel; + void _notifyIfCurrent(HomeFeedChannel channel) { + if (!_disposed && channel == _channel) _notify(); + } void _notify() { if (!_disposed) notifyListeners(); @@ -160,6 +195,9 @@ class _ChannelFeedState { String? cursor; bool hasMore = true; bool loaded = false; + bool loading = false; bool loadingMore = false; + String? lastError; int requestSeq = 0; + Future? inFlight; } diff --git a/lib/pages/player/player_logic.dart b/lib/pages/player/player_logic.dart index f1b0b6c..463f180 100644 --- a/lib/pages/player/player_logic.dart +++ b/lib/pages/player/player_logic.dart @@ -102,6 +102,13 @@ class PlayerLogic extends ChangeNotifier { bool _disposed = false; PlayerFactory? _sessionFactory; PlaybackCoordinator? _sessionCoordinator; + int feedRevision = 0; + List _latestFeed = const []; + String? _latestKeepDramaId; + bool _latestResetToFirst = false; + bool _feedUpdateRunning = false; + bool _hasPendingFeed = false; + Future _feedUpdateFuture = Future.value(); /// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化, /// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。 @@ -111,6 +118,9 @@ class PlayerLogic extends ChangeNotifier { bool get chromeHidden => immersive; + /// 最近一次 feed 更新流程完成;测试和页面退出可等待它结束。 + Future get feedUpdateSettled => _feedUpdateFuture; + /// 当前 PageView 对应用户正在观看的短剧 id,尚未起播时为 null。 String? get currentDramaId { if (!_started || visibleIndices.isEmpty) return null; @@ -173,38 +183,26 @@ class PlayerLogic extends ChangeNotifier { 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(); + /// 串行替换首页 feed 队列;频道变化时重置到第 1 个,刷新时尽量保留当前剧。 + void updateFeed( + List dramas, { + String? keepDramaId, + bool resetToFirst = false, + }) { + feedRevision++; + pageRequest++; + _latestFeed = List.unmodifiable(dramas); + _latestKeepDramaId = keepDramaId; + _latestResetToFirst = resetToFirst; + _hasPendingFeed = true; + if (!_started) { + feedDramas = _latestFeed; 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; + if (!_feedUpdateRunning) { + _feedUpdateFuture = _drainFeedUpdates(); + unawaited(_feedUpdateFuture); } - _replaceSession(nextEpisodes, keepIndex >= 0 ? keepIndex : 0); } PlaybackSession _createSession( @@ -222,7 +220,77 @@ class PlayerLogic extends ChangeNotifier { ); } - void _replaceSession(List nextEpisodes, int targetIndex) { + Future _drainFeedUpdates() async { + _feedUpdateRunning = true; + while (true) { + if (!_hasPendingFeed) { + _feedUpdateRunning = false; + return; + } + _hasPendingFeed = false; + final revision = feedRevision; + final dramas = _latestFeed; + final keepDramaId = _latestKeepDramaId; + final resetToFirst = _latestResetToFirst; + if (revision != feedRevision) continue; + await _applyFeedUpdate( + dramas, + keepDramaId: keepDramaId, + resetToFirst: resetToFirst, + revision: revision, + ); + } + } + + Future _applyFeedUpdate( + List dramas, { + required String? keepDramaId, + required bool resetToFirst, + required int revision, + }) async { + if (!_started || revision != feedRevision) return; + final currentId = currentDramaId; + final nextQueue = PlaybackQueue.home(dramas); + final nextVisible = List.generate(nextQueue.length, (index) => index); + final nextEpisodes = [for (final entry in nextQueue.entries) entry.episode]; + feedDramas = dramas; + queue = nextQueue; + visibleIndices = nextVisible; + if (dramas.isNotEmpty) pageDrama = dramas.first; + if (nextQueue.length == 0) { + session.setRouteVisible(false); + notify(); + return; + } + final keepIndex = + !resetToFirst && 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; + } + await _replaceSession( + nextEpisodes, + keepIndex >= 0 ? keepIndex : 0, + revision: revision, + ); + } + + Future _replaceSession( + List nextEpisodes, + int targetIndex, { + required int revision, + }) async { + if (revision != feedRevision) return; final factory = _sessionFactory; final coordinator = _sessionCoordinator; if (factory == null || coordinator == null) return; @@ -232,12 +300,14 @@ class PlayerLogic extends ChangeNotifier { trackedPlayer = null; trackedEpisode = null; trackedDrama = null; - session = _createSession(factory, coordinator); - session.setRouteVisible(active && visibleIndices.isNotEmpty); - unawaited(previous.close()); - _jumpToFeedPage(targetIndex); - unawaited(selectEpisode(targetIndex)); + final nextSession = _createSession(factory, coordinator); + session = nextSession; + nextSession.setRouteVisible(active && visibleIndices.isNotEmpty); notify(); + await previous.close(); + if (revision != feedRevision || !identical(session, nextSession)) return; + _jumpToFeedPage(targetIndex); + await selectEpisode(targetIndex, revision: revision); } void _jumpToFeedPage(int index) { @@ -625,15 +695,24 @@ class PlayerLogic extends ChangeNotifier { ); } - Future selectEpisode(int index) async { + Future selectEpisode(int index, {int? revision}) async { + final feedRevisionAtStart = revision ?? feedRevision; final request = ++pageRequest; cancelScrub(); // Capture the old position synchronously, persist without blocking a swipe. unawaited(saveCurrentProgress()); - if (request != pageRequest || !isMounted()) return; + if (request != pageRequest || + feedRevisionAtStart != feedRevision || + !isMounted()) { + return; + } // 起播前先要票据:拿不到就不起播,交给三态 UI 提示(失败可重试) final ready = await ensureTicket(index); - if (request != pageRequest || !isMounted()) return; + if (request != pageRequest || + feedRevisionAtStart != feedRevision || + !isMounted()) { + return; + } if (!ready && ticketError != null) return; await session.select(index, preloadIndex: nextVisibleIndex(index)); } diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 600fe92..6627e28 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -2,7 +2,6 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -131,20 +130,25 @@ class _PlayerPageState extends State void didUpdateWidget(covariant PlayerPage oldWidget) { super.didUpdateWidget(oldWidget); logic.attach(widget, isMounted: () => mounted); + final channelChanged = oldWidget.feedChannel != widget.feedChannel; + final feedChanged = + _feedSignature(oldWidget.feedDramas) != + _feedSignature(widget.feedDramas); if (widget.homeMode && widget.feedDramas != null && - (!listEquals(oldWidget.feedDramas, widget.feedDramas) || - oldWidget.feedChannel != widget.feedChannel)) { + (feedChanged || channelChanged)) { logic.updateFeed( widget.feedDramas!, - keepDramaId: oldWidget.feedChannel == widget.feedChannel - ? logic.currentDramaId - : null, + keepDramaId: channelChanged ? null : logic.currentDramaId, + resetToFirst: channelChanged, ); } logic.onPageUpdated(oldWidget); } + String _feedSignature(List? dramas) => + dramas == null ? '' : dramas.map((drama) => drama.id).join('|'); + @override void didChangeDependencies() { super.didChangeDependencies(); diff --git a/test/core/data/services/home_feed_controller_test.dart b/test/core/data/services/home_feed_controller_test.dart index e5c7a72..3aab7b3 100644 --- a/test/core/data/services/home_feed_controller_test.dart +++ b/test/core/data/services/home_feed_controller_test.dart @@ -95,26 +95,109 @@ void main() { controller.dispose(); }); - test('should_ignore_stale_response_when_channels_switch_quickly', () async { - final anime = Completer(); - final live = Completer(); + test( + 'should_cache_both_channels_when_responses_complete_out_of_order', + () async { + final anime = Completer(); + final live = Completer(); + final animeRefresh = Completer(); + final repository = _ScriptedFeedRepository() + ..enqueue(() => anime.future) + ..enqueue(() => live.future) + ..enqueue(() => animeRefresh.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'); + await controller.selectChannel(HomeFeedChannel.anime); + expect(controller.dramas.single.id, 'anime'); + animeRefresh.complete(FeedPage(dramas: [_drama('anime-refresh')])); + await pumpEventQueue(); + expect(controller.dramas.single.id, 'anime-refresh'); + controller.dispose(); + }, + ); + + test('should_retry_automatically_when_returning_to_failed_channel', () async { final repository = _ScriptedFeedRepository() - ..enqueue(() => anime.future) - ..enqueue(() => live.future); + ..enqueue(() async => throw StateError('offline')) + ..enqueue(() async => FeedPage(dramas: [_drama('live')])) + ..enqueue(() async => FeedPage(dramas: [_drama('anime-retry')])); 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; + await controller.selectChannel(HomeFeedChannel.anime); + expect(controller.lastError, contains('offline')); + expect(controller.loadingFirstPage, isFalse); - expect(controller.channel, HomeFeedChannel.live); - expect(controller.dramas.single.id, 'live'); + await controller.selectChannel(HomeFeedChannel.live); + await controller.selectChannel(HomeFeedChannel.anime); + + expect(controller.dramas.single.id, 'anime-retry'); + expect(repository.calls, hasLength(3)); controller.dispose(); }); + test('should_not_report_first_page_loading_without_inflight_request', () { + final controller = HomeFeedController(_ScriptedFeedRepository()); + + expect(controller.loaded, isFalse); + expect(controller.loadingFirstPage, isFalse); + controller.dispose(); + }); + + test('should_finish_loading_with_error_when_request_times_out', () async { + final pending = Completer(); + final repository = _ScriptedFeedRepository()..enqueue(() => pending.future); + final controller = HomeFeedController( + repository, + requestTimeout: const Duration(milliseconds: 10), + ); + + await controller.selectChannel(HomeFeedChannel.anime); + + expect(controller.loadingFirstPage, isFalse); + expect(controller.loaded, isTrue); + expect(controller.lastError, isNotNull); + controller.dispose(); + }); + + test( + 'should_ignore_old_failure_when_same_channel_new_request_succeeds', + () async { + final oldRefresh = Completer(); + final repository = _ScriptedFeedRepository() + ..enqueue( + () async => FeedPage( + dramas: [_drama('a')], + nextCursor: 'next', + hasMore: true, + ), + ) + ..enqueue(() async => FeedPage(dramas: [_drama('live')])) + ..enqueue(() => oldRefresh.future) + ..enqueue(() async => FeedPage(dramas: [_drama('b')], hasMore: false)); + final controller = HomeFeedController(repository); + await controller.selectChannel(HomeFeedChannel.anime); + await controller.selectChannel(HomeFeedChannel.live); + await controller.selectChannel(HomeFeedChannel.anime); + + await controller.loadMore(); + oldRefresh.completeError(StateError('old failure')); + await pumpEventQueue(); + + expect(controller.dramas.map((drama) => drama.id), ['a', 'b']); + expect(controller.lastError, isNull); + controller.dispose(); + }, + ); + test('should_keep_old_data_when_refresh_fails', () async { final repository = _ScriptedFeedRepository() ..enqueue(() async => FeedPage(dramas: [_drama('old')])) diff --git a/test/player/player_logic_test.dart b/test/player/player_logic_test.dart index 7c23e67..3abc840 100644 --- a/test/player/player_logic_test.dart +++ b/test/player/player_logic_test.dart @@ -431,7 +431,7 @@ void main() { final activeBefore = logic.session.active; logic.updateFeed([...feed, _drama('third')], keepDramaId: 'signal'); - await pumpEventQueue(); + await logic.feedUpdateSettled; expect(logic.currentDramaId, 'signal'); expect(logic.visibleIndices, [0, 1, 2]); @@ -463,7 +463,7 @@ void main() { [_drama('city'), _drama('signal'), _drama('third')], keepDramaId: 'city', ); - await pumpEventQueue(); + await logic.feedUpdateSettled; expect(logic.currentDramaId, 'city'); expect(logic.session.currentIndex, 0); @@ -488,7 +488,11 @@ void main() { logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); await _drain(logic); - logic.updateFeed([_drama('anime-a'), _drama('anime-b')]); + logic.updateFeed( + [_drama('anime-a'), _drama('anime-b')], + resetToFirst: true, + ); + await logic.feedUpdateSettled; await _drain(logic); expect(logic.queue.entryAt(0).drama.id, 'anime-a'); @@ -517,6 +521,7 @@ void main() { await _drain(logic); logic.updateFeed(const []); + await logic.feedUpdateSettled; expect(logic.queue.length, 0); expect(logic.visibleIndices, isEmpty); @@ -524,6 +529,95 @@ void main() { logic.dispose(); await _drain(logic); }); + + test('should_apply_only_latest_feed_when_update_is_called_five_times', + () async { + final factory = _RecordingFactory(); + final logic = _logic(FakeWatchHistory()); + final initial = [_drama('initial')]; + logic.attach( + PlayerPage( + drama: initial.first, + initialIndex: 0, + homeMode: true, + feedDramas: initial, + ), + isMounted: () => true, + ); + logic.start(factory: factory.create, coordinator: PlaybackCoordinator()); + await _drain(logic); + + for (var index = 1; index <= 5; index++) { + logic.updateFeed([_drama('feed-$index')], resetToFirst: true); + } + await logic.feedUpdateSettled; + await _drain(logic); + + expect(logic.feedRevision, 5); + expect(logic.queue.entryAt(0).drama.id, 'feed-5'); + expect(logic.sessionEpisodes.single.id, 'feed-5-1'); + expect(logic.currentDramaId, 'feed-5'); + logic.dispose(); + await _drain(logic); + }); + + test('should_select_first_item_when_feed_recovers_from_empty', () async { + final factory = _RecordingFactory(); + 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: factory.create, coordinator: PlaybackCoordinator()); + await _drain(logic); + logic.updateFeed(const []); + await logic.feedUpdateSettled; + + logic.updateFeed([_drama('restored')], resetToFirst: true); + await logic.feedUpdateSettled; + await _drain(logic); + + expect(logic.queue.entryAt(0).drama.id, 'restored'); + expect(logic.session.currentIndex, 0); + expect(logic.currentDramaId, 'restored'); + expect(factory.live, greaterThan(0)); + logic.dispose(); + await _drain(logic); + }); + + test('should_reset_to_first_when_channel_changes_with_same_ids', + () async { + final factory = _RecordingFactory(); + final feed = [_drama('same-a'), _drama('same-b')]; + 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); + expect(logic.session.currentIndex, 1); + + logic.updateFeed(feed, resetToFirst: true); + await logic.feedUpdateSettled; + await _drain(logic); + + expect(logic.session.currentIndex, 0); + expect(logic.currentDramaId, 'same-a'); + logic.dispose(); + await _drain(logic); + }); }); group('lifecycle', () {