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