feat(library): 收藏与观看历史支持分页

This commit is contained in:
Zhou Weidong committed 2026-10-22 12:34:27 +08:00
1 parent 63d3b0f7ca
commit 235b50598a
24 files changed
+1015 -105

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 服务端标准分页响应 `{total, list}`。
class PageResult<T> {
const PageResult({this.total = 0, this.list = const []});
final int total;
final List<T> list;
/// 解析分页壳,条目由调用方提供具体解析函数。
factory PageResult.fromJson(
Map<String, dynamic> json,
T Function(Map<String, dynamic>) parseItem,
) {
final items = (json['list'] as List? ?? const [])
.whereType<Map<String, dynamic>>()
.map(parseItem)
.toList(growable: false);
return PageResult(total: jsonToInt(json['total']), list: items);
}
}
+20
View File
@@ -12,6 +12,10 @@ class WatchProgress {
required this.episodeId,
required this.position,
required this.duration,
this.dramaTitle,
this.dramaCover,
this.episodeNumber,
this.totalEpisodes,
this.finished = false,
this.playCount = 0,
this.updatedAt,
@@ -19,6 +23,10 @@ class WatchProgress {
final String dramaId;
final String episodeId;
final String? dramaTitle;
final String? dramaCover;
final int? episodeNumber;
final int? totalEpisodes;
final Duration position;
final Duration duration;
final bool finished;
@@ -37,6 +45,14 @@ class WatchProgress {
return WatchProgress(
dramaId: (json['dramaId'] ?? '').toString(),
episodeId: (json['episodeId'] ?? '').toString(),
dramaTitle: json['dramaTitle']?.toString(),
dramaCover: json['dramaCover']?.toString(),
episodeNumber: json['episodeNumber'] == null
? null
: jsonToInt(json['episodeNumber']),
totalEpisodes: json['totalEpisodes'] == null
? null
: jsonToInt(json['totalEpisodes']),
position: Duration(milliseconds: jsonToInt(json['positionMs'])),
duration: Duration(milliseconds: jsonToInt(json['durationMs'])),
finished: json['finished'] == true,
@@ -48,6 +64,10 @@ class WatchProgress {
Map<String, dynamic> toJson() => {
'dramaId': dramaId,
'episodeId': episodeId,
if (dramaTitle != null) 'dramaTitle': dramaTitle,
if (dramaCover != null) 'dramaCover': dramaCover,
if (episodeNumber != null) 'episodeNumber': episodeNumber,
if (totalEpisodes != null) 'totalEpisodes': totalEpisodes,
'positionMs': position.inMilliseconds,
'durationMs': duration.inMilliseconds,
'finished': finished,
+21 -1
View File
@@ -19,8 +19,10 @@ class WatchRecord {
String get dramaId => progress.dramaId;
/// 详情中按 episodeId 定位集号;缺失时返回 0。
/// 优先使用分页接口集号,其次从已加载详情定位。
int get episodeNumber {
final pageEpisode = progress.episodeNumber;
if (pageEpisode != null && pageEpisode > 0) return pageEpisode;
final episodes = drama?.episodes ?? const <Episode>[];
for (var index = 0; index < episodes.length; index++) {
if (episodes[index].id == progress.episodeId) return index + 1;
@@ -28,6 +30,24 @@ class WatchRecord {
return 0;
}
/// 服务端分页标题优先,详情标题作为回退。
String get displayTitle {
final pageTitle = progress.dramaTitle?.trim() ?? '';
return pageTitle.isNotEmpty ? pageTitle : drama?.title.trim() ?? '';
}
/// 服务端分页封面优先,详情封面作为回退。
String get displayCover {
final pageCover = progress.dramaCover?.trim() ?? '';
return pageCover.isNotEmpty ? pageCover : drama?.posterUrl ?? '';
}
/// 分页接口已带展示字段时无需等待详情加载。
bool get hasDisplayMetadata =>
displayTitle.isNotEmpty ||
progress.dramaCover?.trim().isNotEmpty == true ||
(progress.episodeNumber ?? 0) > 0;
/// 生成卡片加载中、成功或失败后的新状态。
WatchRecord copyWith({
Drama? drama,
+20 -7
View File
@@ -1,6 +1,7 @@
// Author: fruit.com.cn
import '../models/drama.dart';
import '../models/page_result.dart';
import 'api_client.dart';
/// 收藏接口:`/app/favorites*`(契约 §1.5)。需要登录态。
@@ -20,13 +21,25 @@ class LibraryApi {
return data == true;
}
Future<List<Drama>> favorites() async {
final data = await _client.get('/app/favorites');
if (data is! List) return const [];
return [
for (final item in data)
if (item is Map<String, dynamic>) Drama.fromCardJson(item),
];
Future<PageResult<Drama>> favorites({
int pageIndex = 1,
int pageSize = 20,
}) async {
final data = await _client.get(
'/app/favorites',
query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'},
);
if (data is Map<String, dynamic>) {
return PageResult.fromJson(data, Drama.fromCardJson);
}
if (data is List) {
final list = data
.whereType<Map<String, dynamic>>()
.map(Drama.fromCardJson)
.toList(growable: false);
return PageResult(total: list.length, list: list);
}
return const PageResult();
}
Future<void> removeFavorite(String dramaId) async {
+18 -8
View File
@@ -1,5 +1,6 @@
// Author: fruit.com.cn
import '../models/page_result.dart';
import '../models/watch_progress.dart';
import 'api_client.dart';
@@ -39,17 +40,26 @@ class WatchApi {
return data is Map<String, dynamic> ? WatchProgress.fromJson(data) : null;
}
/// 最近观看(服务端按下标返回,已按更新时间倒序)。
Future<List<WatchProgress>> recent({int limit = 20}) async {
/// 最近观看分页(服务端已按更新时间倒序)。
Future<PageResult<WatchProgress>> recent({
int pageIndex = 1,
int pageSize = 20,
}) async {
final data = await _client.get(
'/app/watch/recent',
query: {'limit': '$limit'},
query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'},
);
if (data is! List) return const [];
return [
for (final item in data)
if (item is Map<String, dynamic>) WatchProgress.fromJson(item),
];
if (data is Map<String, dynamic>) {
return PageResult.fromJson(data, WatchProgress.fromJson);
}
if (data is List) {
final list = data
.whereType<Map<String, dynamic>>()
.map(WatchProgress.fromJson)
.toList(growable: false);
return PageResult(total: list.length, list: list);
}
return const PageResult();
}
/// 删除某部剧的观看记录(记录页清单条用)。
@@ -1,6 +1,7 @@
// Author: fruit.com.cn
import '../models/drama.dart';
import '../models/page_result.dart';
import '../remote/library_api.dart';
/// 收藏来源:**以服务端为准**(本地不再存收藏状态)。
@@ -8,8 +9,8 @@ abstract interface class FavoriteRepository {
/// 切换收藏,返回切换后的状态(true = 已收藏)。
Future<bool> toggle(String dramaId);
/// 收藏列表(卡片结构与首页一致)。
Future<List<Drama>> list();
/// 收藏分页(卡片结构与首页一致)。
Future<PageResult<Drama>> page({int pageIndex = 1, int pageSize = 20});
Future<void> remove(String dramaId);
}
@@ -28,9 +29,9 @@ class HttpFavoriteRepository implements FavoriteRepository {
Future<bool> toggle(String dramaId) => _api.toggleFavorite(dramaId);
@override
Future<List<Drama>> list() async {
if (!_signedIn) return const [];
return _api.favorites();
Future<PageResult<Drama>> page({int pageIndex = 1, int pageSize = 20}) async {
if (!_signedIn) return const PageResult();
return _api.favorites(pageIndex: pageIndex, pageSize: pageSize);
}
@override
@@ -46,10 +47,10 @@ class InMemoryFavoriteRepository implements FavoriteRepository {
final Set<String> _saved;
final List<Drama> _cards;
bool failToggle = false;
bool failList = false;
bool failPage = false;
bool failRemove = false;
int toggleCalls = 0;
int listCalls = 0;
int pageCalls = 0;
@override
Future<bool> toggle(String dramaId) async {
@@ -60,10 +61,18 @@ class InMemoryFavoriteRepository implements FavoriteRepository {
}
@override
Future<List<Drama>> list() async {
listCalls++;
if (failList) throw StateError('favorite unavailable');
return _cards.where((card) => _saved.contains(card.id)).toList();
Future<PageResult<Drama>> page({int pageIndex = 1, int pageSize = 20}) async {
pageCalls++;
if (failPage) throw StateError('favorite unavailable');
final filtered = _cards.where((card) => _saved.contains(card.id)).toList();
final offset = (pageIndex - 1) * pageSize;
if (offset >= filtered.length) {
return PageResult(total: filtered.length);
}
return PageResult(
total: filtered.length,
list: filtered.skip(offset).take(pageSize).toList(),
);
}
@override
@@ -1,5 +1,6 @@
// Author: fruit.com.cn
import '../models/page_result.dart';
import '../models/watch_progress.dart';
import '../remote/watch_api.dart';
@@ -20,8 +21,11 @@ abstract interface class WatchHistoryRepository {
bool finished = false,
});
/// 最近观看(默认 20 条)。
Future<List<WatchProgress>> recent({int limit = 20});
/// 最近观看分页。
Future<PageResult<WatchProgress>> recent({
int pageIndex = 1,
int pageSize = 20,
});
/// 清理某部剧的记录(K2 的记录页会用,这里先备好)。
Future<void> remove(String dramaId);
@@ -63,9 +67,12 @@ class HttpWatchHistoryRepository implements WatchHistoryRepository {
}
@override
Future<List<WatchProgress>> recent({int limit = 20}) async {
if (!_signedIn) return const [];
return _api.recent(limit: limit);
Future<PageResult<WatchProgress>> recent({
int pageIndex = 1,
int pageSize = 20,
}) async {
if (!_signedIn) return const PageResult();
return _api.recent(pageIndex: pageIndex, pageSize: pageSize);
}
@override
@@ -123,12 +130,23 @@ class InMemoryWatchHistoryRepository implements WatchHistoryRepository {
}
@override
Future<List<WatchProgress>> recent({int limit = 20}) async {
Future<PageResult<WatchProgress>> recent({
int pageIndex = 1,
int pageSize = 20,
}) async {
if (failReads > 0) {
failReads -= 1;
throw StateError('progress unavailable');
}
return _records.values.take(limit).toList();
final items = _records.values.toList();
final offset = (pageIndex - 1) * pageSize;
if (offset >= items.length) {
return PageResult(total: items.length);
}
return PageResult(
total: items.length,
list: items.skip(offset).take(pageSize).toList(),
);
}
@override
+102 -8
View File
@@ -10,13 +10,22 @@ import '../repositories/favorite_repository.dart';
/// 点击收藏先乐观更新(立刻变色),失败再回滚并给出提示 —— 网络抖动时不至于
/// "点了没反应"或"看起来收藏了其实没有"。
class FavoriteController extends ChangeNotifier {
FavoriteController(this._repository);
FavoriteController(this._repository, {this.pageSize = defaultPageSize});
static const int defaultPageSize = 20;
final FavoriteRepository _repository;
final int pageSize;
final Set<String> _saved = {};
List<Drama> cards = const [];
int total = 0;
bool loading = false;
bool loadingMore = false;
bool hasMore = false;
String? error;
String? loadMoreError;
int _pageIndex = 0;
int _requestSeq = 0;
bool _disposed = false;
Set<String> get savedIds => Set.unmodifiable(_saved);
@@ -29,20 +38,66 @@ class FavoriteController extends ChangeNotifier {
/// 拉收藏列表:列表既用于"我的"页展示,也用于校准 [savedIds]。
Future<void> load() async {
final request = ++_requestSeq;
loading = true;
loadingMore = false;
error = null;
loadMoreError = null;
notify();
try {
final list = await _repository.list();
final page = await _repository.page(pageIndex: 1, pageSize: pageSize);
if (_disposed || request != _requestSeq) return;
final list = _uniqueCards(page.list);
cards = list;
_saved
..clear()
..addAll(list.map((drama) => drama.id));
total = page.total;
_pageIndex = 1;
hasMore = page.list.isNotEmpty && cards.length < total;
} catch (_) {
error = 'favoritesError';
if (!_disposed && request == _requestSeq) {
error = 'favoritesError';
}
} finally {
loading = false;
notify();
if (!_disposed && request == _requestSeq) {
loading = false;
notify();
}
}
}
/// 追加下一页;正在刷新、已在加载或无更多时直接忽略。
Future<void> loadMore() async {
if (loading || loadingMore || !hasMore) return;
final request = ++_requestSeq;
loadingMore = true;
loadMoreError = null;
notify();
try {
final page = await _repository.page(
pageIndex: _pageIndex + 1,
pageSize: pageSize,
);
if (_disposed || request != _requestSeq) return;
final known = cards.map((drama) => drama.id).toSet();
final appended = _uniqueCards(
page.list,
).where((drama) => known.add(drama.id)).toList(growable: false);
cards = [...cards, ...appended];
_saved.addAll(appended.map((drama) => drama.id));
total = page.total;
_pageIndex += 1;
hasMore = page.list.isNotEmpty && cards.length < total;
} catch (_) {
if (!_disposed && request == _requestSeq) {
loadMoreError = 'favoritesError';
}
} finally {
if (!_disposed && request == _requestSeq) {
loadingMore = false;
notify();
}
}
}
@@ -52,10 +107,17 @@ class FavoriteController extends ChangeNotifier {
/// 这样"收藏完打开我的收藏"能立刻看到,不必等下一次整表刷新。
Future<bool> toggle(String dramaId, {Drama? card}) async {
final wasSaved = _saved.contains(dramaId);
final previousCards = cards;
final previousTotal = total;
final previousHasMore = hasMore;
// 乐观更新
if (wasSaved) {
_saved.remove(dramaId);
cards = cards.where((drama) => drama.id != dramaId).toList();
if (cards.any((drama) => drama.id == dramaId)) {
cards = cards.where((drama) => drama.id != dramaId).toList();
total = total > 0 ? total - 1 : 0;
hasMore = cards.length < total;
}
} else {
_saved.add(dramaId);
}
@@ -65,17 +127,30 @@ class FavoriteController extends ChangeNotifier {
// 以服务端返回的状态为准(可能与本地猜测不同,例如并发操作)
if (saved) {
_saved.add(dramaId);
if (card != null && !cards.any((drama) => drama.id == dramaId)) {
if (wasSaved) {
cards = previousCards;
total = previousTotal;
hasMore = previousHasMore;
} else if (card != null && !cards.any((drama) => drama.id == dramaId)) {
cards = [card, ...cards];
total += 1;
hasMore = cards.length < total;
}
} else {
_saved.remove(dramaId);
cards = cards.where((drama) => drama.id != dramaId).toList();
if (cards.any((drama) => drama.id == dramaId)) {
cards = cards.where((drama) => drama.id != dramaId).toList();
total = total > 0 ? total - 1 : 0;
hasMore = cards.length < total;
}
}
notify();
return true;
} catch (_) {
// 回滚
cards = previousCards;
total = previousTotal;
hasMore = previousHasMore;
if (wasSaved) {
_saved.add(dramaId);
} else {
@@ -94,6 +169,8 @@ class FavoriteController extends ChangeNotifier {
_saved.remove(dramaId);
final next = [...cards]..removeAt(index);
cards = next;
total = total > 0 ? total - 1 : 0;
hasMore = cards.length < total;
notify();
try {
await _repository.remove(dramaId);
@@ -106,6 +183,8 @@ class FavoriteController extends ChangeNotifier {
);
cards = restored;
_saved.add(dramaId);
total += 1;
hasMore = cards.length < total;
notify();
return false;
}
@@ -113,12 +192,27 @@ class FavoriteController extends ChangeNotifier {
/// 退出登录时清空:收藏状态属于账号,不能跨账号串联。
void clear() {
_requestSeq += 1;
_saved.clear();
cards = const [];
total = 0;
loading = false;
hasMore = false;
loadingMore = false;
loadMoreError = null;
error = null;
_pageIndex = 0;
notify();
}
List<Drama> _uniqueCards(List<Drama> source) {
final seen = <String>{};
return [
for (final drama in source)
if (seen.add(drama.id)) drama,
];
}
@override
void dispose() {
_disposed = true;
@@ -11,41 +11,53 @@ import '../repositories/watch_history_repository.dart';
/// 独立观看记录页状态。
///
/// 最近记录只下发 dramaId;详情按 dramaId 去重后补取,并以 4 个并发为上限。
/// 分页接口提供标题/封面/集号;详情按 dramaId 去重后补取,供点击播放使用。
class WatchRecordsController extends ChangeNotifier {
factory WatchRecordsController({
required WatchHistoryRepository history,
required CatalogRepository catalog,
int limit = defaultLimit,
}) => WatchRecordsController._(history, catalog, limit);
int pageSize = defaultPageSize,
}) => WatchRecordsController._(history, catalog, pageSize);
WatchRecordsController._(this._history, this._catalog, this.limit);
WatchRecordsController._(this._history, this._catalog, this.pageSize);
static const int defaultLimit = 20;
static const int defaultPageSize = 20;
static const int detailConcurrency = 4;
final WatchHistoryRepository _history;
final CatalogRepository _catalog;
final int limit;
final int pageSize;
final Map<String, Drama> _detailCache = {};
List<WatchRecord> records = const [];
int total = 0;
bool loading = false;
bool loadingMore = false;
bool hasMore = false;
bool clearing = false;
String? error;
String? loadMoreError;
String? actionError;
int _pageIndex = 0;
int _requestSeq = 0;
bool _disposed = false;
/// 加载最近记录并补齐详情;单条详情失败不拖垮整页。
Future<void> load() async {
final request = ++_requestSeq;
loading = true;
loadingMore = false;
error = null;
loadMoreError = null;
actionError = null;
_notify();
try {
final recent = await _history.recent(limit: limit);
if (_disposed) return;
records = _uniqueRecords(recent);
final page = await _history.recent(pageIndex: 1, pageSize: pageSize);
if (_disposed || request != _requestSeq) return;
records = _uniqueRecords(page.list);
total = page.total;
_pageIndex = 1;
hasMore = page.list.isNotEmpty && records.length < total;
_notify();
await _hydrate(records.map((record) => record.dramaId).toList());
} catch (failure, stackTrace) {
@@ -57,17 +69,54 @@ class WatchRecordsController extends ChangeNotifier {
stackTrace: stackTrace,
label: '[WatchRecordsController] recent failed',
);
if (!_disposed) {
if (!_disposed && request == _requestSeq) {
error = failure is ApiException ? failure.message : 'progressError';
}
} finally {
if (!_disposed) {
if (!_disposed && request == _requestSeq) {
loading = false;
_notify();
}
}
}
/// 追加下一页;刷新、加载中或无更多时忽略。
Future<void> loadMore() async {
if (loading || loadingMore || !hasMore) return;
final request = ++_requestSeq;
loadingMore = true;
loadMoreError = null;
_notify();
try {
final page = await _history.recent(
pageIndex: _pageIndex + 1,
pageSize: pageSize,
);
if (_disposed || request != _requestSeq) return;
final known = records.map((record) => record.dramaId).toSet();
final appended = _uniqueRecords(
page.list,
).where((record) => known.add(record.dramaId)).toList(growable: false);
records = [...records, ...appended];
total = page.total;
_pageIndex += 1;
hasMore = page.list.isNotEmpty && records.length < total;
_notify();
await _hydrate(appended.map((record) => record.dramaId).toList());
} catch (failure) {
if (!_disposed && request == _requestSeq) {
loadMoreError = failure is ApiException
? failure.message
: 'progressError';
}
} finally {
if (!_disposed && request == _requestSeq) {
loadingMore = false;
_notify();
}
}
}
/// 只重试一条详情失败记录。
Future<void> retryDetail(String dramaId) async {
final index = _recordIndex(dramaId);
@@ -87,6 +136,8 @@ class WatchRecordsController extends ChangeNotifier {
if (index < 0) return true;
final removed = records[index];
records = [...records]..removeAt(index);
total = total > 0 ? total - 1 : 0;
hasMore = records.length < total;
actionError = null;
_notify();
try {
@@ -96,6 +147,8 @@ class WatchRecordsController extends ChangeNotifier {
final next = [...records];
next.insert(index > next.length ? next.length : index, removed);
records = next;
total += 1;
hasMore = records.length < total;
actionError = 'recordDeleteFailed';
return false;
} finally {
@@ -106,8 +159,15 @@ class WatchRecordsController extends ChangeNotifier {
/// 清空全部记录,先更新 UI,失败恢复原列表。
Future<bool> clear() async {
if (records.isEmpty) return true;
_requestSeq += 1;
final previous = records;
final previousTotal = total;
final previousPageIndex = _pageIndex;
records = const [];
total = 0;
hasMore = false;
_pageIndex = 0;
loadingMore = false;
clearing = true;
actionError = null;
_notify();
@@ -116,6 +176,9 @@ class WatchRecordsController extends ChangeNotifier {
return true;
} catch (_) {
records = previous;
total = previousTotal;
_pageIndex = previousPageIndex;
hasMore = records.length < total;
actionError = 'recordClearFailed';
return false;
} finally {