feat(home): 首页按内容类型分频道 Feed 并支持分页

This commit is contained in:
Zhou Weidong committed 2026-10-21 17:33:52 +08:00
1 parent 4d0be47ee3
commit ae61d9ec4c
22 files changed
+1001 -71

No files matched your search

@@ -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<HomeFeedChannel, _ChannelFeedState> _states;
HomeFeedChannel _channel = HomeFeedChannel.recommend;
String? _lastError;
bool _disposed = false;
HomeFeedChannel get channel => _channel;
String? get lastError => _lastError;
List<Drama> 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<Drama> 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<void> 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<bool> refreshCurrent() => _refresh(_channel, keepData: true);
/// 加载当前频道下一页并去重追加。
Future<void> 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<void> _loadFirst(HomeFeedChannel channel) async {
final state = _state(channel);
state.loaded = false;
await _refresh(channel, keepData: state.dramas.isNotEmpty);
}
Future<bool> _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<Drama> dramas = const [];
String? cursor;
bool hasMore = true;
bool loaded = false;
bool loadingMore = false;
int requestSeq = 0;
}
+22 -1
View File
@@ -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<void> _enqueue(Future<void> 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<void> ready;
bool prepared = false;