feat(flutter): 接入分类记录收藏与下载授权

This commit is contained in:
Zhou Weidong committed 2026-10-17 14:49:16 +08:00
1 parent 00280ca0da
commit cc4d7c5a9d
52 files changed
+4440 -171

No files matched your search

@@ -0,0 +1,17 @@
// Author: fruit.com.cn
/// 服务端内容分类。id 按契约保持字符串,避免雪花 ID 精度丢失。
class ContentCategory {
const ContentCategory({required this.id, required this.name});
final String id;
final String name;
/// 解析 `GET /app/categories` 的单项。
factory ContentCategory.fromJson(Map<String, dynamic> json) {
return ContentCategory(
id: (json['id'] ?? '').toString(),
name: json['name'] as String? ?? '',
);
}
}
@@ -0,0 +1,67 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 下载授权与任务进度合并视图,对应 `GET /app/downloads` 的列表项。
class DownloadLicense {
const DownloadLicense({
required this.licenseId,
required this.dramaId,
required this.dramaTitle,
required this.episodeId,
required this.episodeNo,
required this.deviceId,
required this.quality,
required this.playUrl,
required this.licenseKey,
required this.status,
required this.grantedAt,
required this.expireAt,
required this.taskStatus,
required this.progressPercent,
required this.downloadedBytes,
required this.totalBytes,
required this.errorMsg,
});
final String licenseId;
final String dramaId;
final String dramaTitle;
final String episodeId;
final int episodeNo;
final String deviceId;
final String quality;
final String playUrl;
final String licenseKey;
final String status;
final DateTime? grantedAt;
final DateTime? expireAt;
final String taskStatus;
final int progressPercent;
final int downloadedBytes;
final int totalBytes;
final String errorMsg;
/// 解析授权响应;所有 ID 与 Long 字段兼容字符串下发。
factory DownloadLicense.fromJson(Map<String, dynamic> json) {
return DownloadLicense(
licenseId: (json['licenseId'] ?? '').toString(),
dramaId: (json['dramaId'] ?? '').toString(),
dramaTitle: json['dramaTitle'] as String? ?? '',
episodeId: (json['episodeId'] ?? '').toString(),
episodeNo: jsonToInt(json['episodeNo']),
deviceId: json['deviceId'] as String? ?? '',
quality: json['quality'] as String? ?? '',
playUrl: json['playUrl'] as String? ?? '',
licenseKey: json['licenseKey'] as String? ?? '',
status: json['status'] as String? ?? '',
grantedAt: DateTime.tryParse(json['grantedAt'] as String? ?? ''),
expireAt: DateTime.tryParse(json['expireAt'] as String? ?? ''),
taskStatus: json['taskStatus'] as String? ?? '',
progressPercent: jsonToInt(json['progressPercent']),
downloadedBytes: jsonToInt(json['downloadedBytes']),
totalBytes: jsonToInt(json['totalBytes']),
errorMsg: json['errorMsg'] as String? ?? '',
);
}
}
+54
View File
@@ -0,0 +1,54 @@
// Author: fruit.com.cn
import 'json_numbers.dart';
/// 下载策略与当前设备配额,对应 `GET /app/downloads/policy`。
class DownloadPolicy {
const DownloadPolicy({
required this.maxPerDevice,
required this.licenseDays,
required this.wifiOnly,
required this.maxQuality,
required this.maxStorageBytes,
required this.used,
required this.remaining,
required this.expirePolicyText,
});
final int maxPerDevice;
final int licenseDays;
final bool wifiOnly;
final String maxQuality;
final int maxStorageBytes;
final int used;
final int remaining;
final String expirePolicyText;
/// 设置更新期间用于本地乐观展示。
DownloadPolicy copyWith({bool? wifiOnly, String? maxQuality}) {
return DownloadPolicy(
maxPerDevice: maxPerDevice,
licenseDays: licenseDays,
wifiOnly: wifiOnly ?? this.wifiOnly,
maxQuality: maxQuality ?? this.maxQuality,
maxStorageBytes: maxStorageBytes,
used: used,
remaining: remaining,
expirePolicyText: expirePolicyText,
);
}
/// 解析策略响应;Long 字段兼容字符串。
factory DownloadPolicy.fromJson(Map<String, dynamic> json) {
return DownloadPolicy(
maxPerDevice: jsonToInt(json['maxPerDevice']),
licenseDays: jsonToInt(json['licenseDays']),
wifiOnly: json['wifiOnly'] == true,
maxQuality: json['maxQuality'] as String? ?? '',
maxStorageBytes: jsonToInt(json['maxStorageBytes']),
used: jsonToInt(json['used']),
remaining: jsonToInt(json['remaining']),
expirePolicyText: json['expirePolicyText'] as String? ?? '',
);
}
}
+4
View File
@@ -14,6 +14,7 @@ class WatchProgress {
required this.duration,
this.finished = false,
this.playCount = 0,
this.updatedAt,
});
final String dramaId;
@@ -22,6 +23,7 @@ class WatchProgress {
final Duration duration;
final bool finished;
final int playCount;
final DateTime? updatedAt;
bool get hasPosition => position > Duration.zero;
@@ -39,6 +41,7 @@ class WatchProgress {
duration: Duration(milliseconds: jsonToInt(json['durationMs'])),
finished: json['finished'] == true,
playCount: jsonToInt(json['playCount']),
updatedAt: DateTime.tryParse(json['updatedAt'] as String? ?? ''),
);
}
@@ -49,6 +52,7 @@ class WatchProgress {
'durationMs': duration.inMilliseconds,
'finished': finished,
'playCount': playCount,
if (updatedAt != null) 'updatedAt': updatedAt!.toIso8601String(),
};
}
+44
View File
@@ -0,0 +1,44 @@
// Author: fruit.com.cn
import 'drama.dart';
import 'watch_progress.dart';
/// 观看记录页的一条数据:进度记录与按 dramaId 补取的详情卡片。
class WatchRecord {
const WatchRecord({
required this.progress,
this.drama,
this.detailLoading = true,
this.detailFailed = false,
});
final WatchProgress progress;
final Drama? drama;
final bool detailLoading;
final bool detailFailed;
String get dramaId => progress.dramaId;
/// 详情中按 episodeId 定位集号;缺失时返回 0。
int get episodeNumber {
final episodes = drama?.episodes ?? const <Episode>[];
for (var index = 0; index < episodes.length; index++) {
if (episodes[index].id == progress.episodeId) return index + 1;
}
return 0;
}
/// 生成卡片加载中、成功或失败后的新状态。
WatchRecord copyWith({
Drama? drama,
bool? detailLoading,
bool? detailFailed,
}) {
return WatchRecord(
progress: progress,
drama: drama ?? this.drama,
detailLoading: detailLoading ?? this.detailLoading,
detailFailed: detailFailed ?? this.detailFailed,
);
}
}
+15
View File
@@ -79,6 +79,21 @@ class ApiClient {
);
}
Future<Object?> put(
String path, {
Object? body,
Map<String, String>? query,
bool skipAuthRetry = false,
}) {
return _request(
'PUT',
path,
body: body,
query: query,
skipAuthRetry: skipAuthRetry,
);
}
Future<Object?> delete(
String path, {
Object? body,
+21
View File
@@ -0,0 +1,21 @@
// Author: fruit.com.cn
import '../models/content_category.dart';
import 'api_client.dart';
/// 内容分类接口,对应契约 `GET /app/categories`。
class CategoryApi {
const CategoryApi(this._client);
final ApiClient _client;
/// 拉取已启用分类,id 全程按字符串解析。
Future<List<ContentCategory>> list() async {
final data = await _client.get('/app/categories');
if (data is! List) return const [];
return [
for (final item in data)
if (item is Map<String, dynamic>) ContentCategory.fromJson(item),
];
}
}
+101
View File
@@ -0,0 +1,101 @@
// Author: fruit.com.cn
import '../models/download_license.dart';
import '../models/download_policy.dart';
import 'api_client.dart';
/// 离线下载授权接口,对应契约 §1.6 的全部 8 个接口。
///
/// 设备号由 [ApiClient] 统一通过 `X-Device-Id` 发送,本类不另造设备参数。
class DownloadApi {
const DownloadApi(this._client);
final ApiClient _client;
/// 拉取配额和偏好策略。
Future<DownloadPolicy> policy() async {
return DownloadPolicy.fromJson(
_asMap(await _client.get('/app/downloads/policy')),
);
}
/// 更新仅 Wi-Fi 与最高清晰度偏好。
Future<void> updateSettings({bool? wifiOnly, String? maxQuality}) async {
await _client.put(
'/app/downloads/settings',
body: {'wifiOnly': ?wifiOnly, 'maxQuality': ?maxQuality},
);
}
/// 申请下载授权;同集重复申请由服务端保证幂等。
Future<DownloadLicense> grant(String episodeId, {String? quality}) async {
return DownloadLicense.fromJson(
_asMap(
await _client.post(
'/app/downloads',
body: {'episodeId': episodeId, 'quality': ?quality},
),
),
);
}
/// 拉取当前设备的授权列表。
Future<List<DownloadLicense>> list() async {
final data = await _client.get('/app/downloads');
if (data is! List) return const [];
return [
for (final item in data)
if (item is Map<String, dynamic>) DownloadLicense.fromJson(item),
];
}
/// 续传前刷新短时效下载地址。
Future<DownloadLicense> refreshUrl(String episodeId) async {
return DownloadLicense.fromJson(
_asMap(await _client.get('/app/downloads/$episodeId/url')),
);
}
/// 上报下载进度;本批只提供方法,不启动文件落盘。
Future<DownloadLicense> reportProgress(
String episodeId, {
String? deviceId,
String? status,
int? progressPercent,
int? downloadedBytes,
int? totalBytes,
String? errorMsg,
}) async {
return DownloadLicense.fromJson(
_asMap(
await _client.post(
'/app/downloads/$episodeId/progress',
body: {
'deviceId': ?deviceId,
'status': ?status,
'progressPercent': ?progressPercent,
'downloadedBytes': ?downloadedBytes,
'totalBytes': ?totalBytes,
'errorMsg': ?errorMsg,
},
),
),
);
}
/// 吊销单集授权。
Future<void> remove(String episodeId) async {
await _client.delete('/app/downloads/$episodeId');
}
/// 清空当前设备授权并返回清除条数。
Future<int> clear() async {
final data = await _client.delete('/app/downloads');
return data is int ? data : 0;
}
Map<String, dynamic> _asMap(Object? data) {
if (data is Map<String, dynamic>) return data;
throw const ApiException(-1, '');
}
}
+5
View File
@@ -56,4 +56,9 @@ class WatchApi {
Future<void> removeProgress(String dramaId) async {
await _client.delete('/app/watch/progress/$dramaId');
}
/// 清空当前账号的全部观看记录。
Future<void> clearProgress() async {
await _client.delete('/app/watch/progress');
}
}
@@ -0,0 +1,30 @@
// Author: fruit.com.cn
import '../models/content_category.dart';
import '../models/home_feed.dart';
import '../remote/category_api.dart';
import 'catalog_repository.dart';
/// 发现页分类数据来源:分类清单与发现 feed。
abstract interface class CategoryRepository {
/// 拉取服务端分类。
Future<List<ContentCategory>> fetchCategories();
/// 拉取发现页分区;分类内容只从这里读取。
Future<HomeFeed> fetchDiscoverFeed();
}
/// HTTP 实现。发现 feed 复用既有目录仓库,不新增接口。
class HttpCategoryRepository implements CategoryRepository {
const HttpCategoryRepository(this._categoryApi, this._catalogRepository);
final CategoryApi _categoryApi;
final CatalogRepository _catalogRepository;
@override
Future<List<ContentCategory>> fetchCategories() => _categoryApi.list();
@override
Future<HomeFeed> fetchDiscoverFeed() =>
_catalogRepository.fetchDiscoverFeed();
}
@@ -0,0 +1,82 @@
// Author: fruit.com.cn
import '../models/download_license.dart';
import '../models/download_policy.dart';
import '../remote/download_api.dart';
/// 下载授权数据来源,页面只依赖该抽象。
abstract interface class DownloadRepository {
Future<DownloadPolicy> fetchPolicy();
Future<List<DownloadLicense>> list();
Future<DownloadLicense> grant(String episodeId, {String? quality});
Future<void> updateSettings({bool? wifiOnly, String? maxQuality});
Future<DownloadLicense> refreshUrl(String episodeId);
Future<DownloadLicense> reportProgress(
String episodeId, {
String? deviceId,
String? status,
int? progressPercent,
int? downloadedBytes,
int? totalBytes,
String? errorMsg,
});
Future<void> remove(String episodeId);
Future<int> clear();
}
/// HTTP 下载授权仓库。
class HttpDownloadRepository implements DownloadRepository {
const HttpDownloadRepository(this._api);
final DownloadApi _api;
@override
Future<DownloadPolicy> fetchPolicy() => _api.policy();
@override
Future<List<DownloadLicense>> list() => _api.list();
@override
Future<DownloadLicense> grant(String episodeId, {String? quality}) =>
_api.grant(episodeId, quality: quality);
@override
Future<void> updateSettings({bool? wifiOnly, String? maxQuality}) =>
_api.updateSettings(wifiOnly: wifiOnly, maxQuality: maxQuality);
@override
Future<DownloadLicense> refreshUrl(String episodeId) =>
_api.refreshUrl(episodeId);
@override
Future<DownloadLicense> reportProgress(
String episodeId, {
String? deviceId,
String? status,
int? progressPercent,
int? downloadedBytes,
int? totalBytes,
String? errorMsg,
}) => _api.reportProgress(
episodeId,
deviceId: deviceId,
status: status,
progressPercent: progressPercent,
downloadedBytes: downloadedBytes,
totalBytes: totalBytes,
errorMsg: errorMsg,
);
@override
Future<void> remove(String episodeId) => _api.remove(episodeId);
@override
Future<int> clear() => _api.clear();
}
@@ -47,7 +47,9 @@ class InMemoryFavoriteRepository implements FavoriteRepository {
final List<Drama> _cards;
bool failToggle = false;
bool failList = false;
bool failRemove = false;
int toggleCalls = 0;
int listCalls = 0;
@override
Future<bool> toggle(String dramaId) async {
@@ -59,10 +61,14 @@ 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();
}
@override
Future<void> remove(String dramaId) async => _saved.remove(dramaId);
Future<void> remove(String dramaId) async {
if (failRemove) throw StateError('favorite unavailable');
_saved.remove(dramaId);
}
}
@@ -25,6 +25,9 @@ abstract interface class WatchHistoryRepository {
/// 清理某部剧的记录(K2 的记录页会用,这里先备好)。
Future<void> remove(String dramaId);
/// 清空当前账号的全部观看记录。
Future<void> clear();
}
class HttpWatchHistoryRepository implements WatchHistoryRepository {
@@ -70,12 +73,20 @@ class HttpWatchHistoryRepository implements WatchHistoryRepository {
if (!_signedIn) return;
await _api.removeProgress(dramaId);
}
@override
Future<void> clear() async {
if (!_signedIn) return;
await _api.clearProgress();
}
}
/// 内存实现:单测与 widget 测试注入用(不依赖网络与偏好存储)。
class InMemoryWatchHistoryRepository implements WatchHistoryRepository {
InMemoryWatchHistoryRepository({List<WatchProgress>? seed})
: _records = {for (final item in seed ?? const <WatchProgress>[]) item.dramaId: item};
: _records = {
for (final item in seed ?? const <WatchProgress>[]) item.dramaId: item,
};
final Map<String, WatchProgress> _records;
int failReads = 0;
@@ -122,6 +133,9 @@ class InMemoryWatchHistoryRepository implements WatchHistoryRepository {
@override
Future<void> remove(String dramaId) async => _records.remove(dramaId);
@override
Future<void> clear() async => _records.clear();
}
/// 续看点辅助:把服务端记录换算成"第几集 + 从哪一秒开始"。
@@ -0,0 +1,82 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/content_category.dart';
import '../models/drama.dart';
import '../models/home_feed.dart';
import '../repositories/category_repository.dart';
/// 发现页分类状态:服务端分类清单、发现 feed 与当前筛选。
class CategoryController extends ChangeNotifier {
CategoryController(this._repository);
static const String allCode = 'all';
static const String hotCode = 'hot';
static const String newCode = 'new';
final CategoryRepository _repository;
List<ContentCategory> categories = const [];
HomeFeed feed = const HomeFeed();
String selectedCode = allCode;
bool loading = false;
String? error;
bool _disposed = false;
/// 当前筛选对应的分区 code;全部分类为空字符串语义。
bool get selectedSectionMissing =>
selectedCode != allCode && _selectedSection == null;
/// 当前筛选应展示的剧集;全部分类按 feed 顺序去重。
List<Drama> get visibleDramas {
if (selectedCode == allCode) return feed.dramas;
return _selectedSection?.dramas ?? const [];
}
FeedSection? get _selectedSection {
for (final section in feed.sections) {
if (section.code == selectedCode) return section;
}
return null;
}
/// 加载分类与发现 feed;失败保留可重试错误态。
Future<void> load() async {
loading = true;
error = null;
_notify();
try {
final results = await Future.wait<Object>([
_repository.fetchCategories(),
_repository.fetchDiscoverFeed(),
]);
categories = results[0] as List<ContentCategory>;
feed = results[1] as HomeFeed;
} catch (_) {
error = 'categoriesError';
} finally {
loading = false;
_notify();
}
}
/// 选择服务端分类,内容只匹配 `category-{id}`。
void selectCategory(String id) => selectCode('category-$id');
/// 选择内置或分区 code。
void selectCode(String code) {
selectedCode = code;
_notify();
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
@@ -0,0 +1,168 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/download_license.dart';
import '../models/download_policy.dart';
import '../remote/api_exception.dart';
import '../repositories/download_repository.dart';
/// 一次下载授权申请的结果;后端业务拒绝时保留原始 message。
class DownloadActionResult {
const DownloadActionResult({required this.ok, this.license, this.message});
final bool ok;
final DownloadLicense? license;
final String? message;
}
/// 下载授权与策略状态。
class DownloadController extends ChangeNotifier {
DownloadController(this._repository);
final DownloadRepository _repository;
DownloadPolicy? policy;
List<DownloadLicense> licenses = const [];
bool loading = false;
bool clearing = false;
String? error;
String? actionError;
bool _disposed = false;
/// 同时拉取策略与当前设备授权列表。
Future<void> load() async {
loading = true;
error = null;
_notify();
try {
final results = await Future.wait<Object>([
_repository.fetchPolicy(),
_repository.list(),
]);
policy = results[0] as DownloadPolicy;
licenses = results[1] as List<DownloadLicense>;
} catch (_) {
error = 'downloadsError';
} finally {
loading = false;
_notify();
}
}
/// 申请授权;付费、未解锁、配额满等判定完全以后端 message 为准。
Future<DownloadActionResult> grant(
String episodeId, {
String? quality,
}) async {
try {
final license = await _repository.grant(episodeId, quality: quality);
licenses = [
license,
for (final item in licenses)
if (item.episodeId != license.episodeId) item,
];
actionError = null;
_notify();
return DownloadActionResult(ok: true, license: license);
} on ApiException catch (exception) {
actionError = exception.message;
_notify();
return DownloadActionResult(ok: false, message: exception.message);
} catch (_) {
actionError = 'downloadGrantFailed';
_notify();
return const DownloadActionResult(ok: false);
}
}
/// 更新设置,先本地展示,失败恢复服务端旧值。
Future<bool> updateSettings({bool? wifiOnly, String? maxQuality}) async {
final previous = policy;
if (previous != null) {
policy = previous.copyWith(wifiOnly: wifiOnly, maxQuality: maxQuality);
_notify();
}
try {
await _repository.updateSettings(
wifiOnly: wifiOnly,
maxQuality: maxQuality,
);
actionError = null;
return true;
} catch (_) {
policy = previous;
actionError = 'downloadSettingsFailed';
return false;
} finally {
_notify();
}
}
/// 删除单条授权,失败按原位置恢复。
Future<bool> remove(String episodeId) async {
final index = licenses.indexWhere((item) => item.episodeId == episodeId);
if (index < 0) return true;
final removed = licenses[index];
licenses = [...licenses]..removeAt(index);
actionError = null;
_notify();
try {
await _repository.remove(episodeId);
return true;
} catch (_) {
final restored = [...licenses];
restored.insert(
index > restored.length ? restored.length : index,
removed,
);
licenses = restored;
actionError = 'downloadDeleteFailed';
return false;
} finally {
_notify();
}
}
/// 清空授权,失败恢复原列表。
Future<bool> clear() async {
if (licenses.isEmpty) return true;
final previous = licenses;
licenses = const [];
clearing = true;
actionError = null;
_notify();
try {
await _repository.clear();
return true;
} catch (_) {
licenses = previous;
actionError = 'downloadClearFailed';
return false;
} finally {
clearing = false;
_notify();
}
}
/// 退出登录时清空账号相关状态,避免跨账号串联。
void reset() {
policy = null;
licenses = const [];
loading = false;
clearing = false;
error = null;
actionError = null;
_notify();
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
+11 -12
View File
@@ -88,24 +88,23 @@ class FavoriteController extends ChangeNotifier {
/// 取消收藏("我的"页列表里删除某条)。
Future<bool> remove(String dramaId) async {
final removed = cards.firstWhere(
(drama) => drama.id == dramaId,
orElse: () => const Drama(
id: '',
title: '',
synopsis: '',
posterUrl: '',
),
);
final hadCard = removed.id.isNotEmpty;
final index = cards.indexWhere((drama) => drama.id == dramaId);
if (index < 0) return true;
final removed = cards[index];
_saved.remove(dramaId);
cards = cards.where((drama) => drama.id != dramaId).toList();
final next = [...cards]..removeAt(index);
cards = next;
notify();
try {
await _repository.remove(dramaId);
return true;
} catch (_) {
if (hadCard) cards = [...cards, removed];
final restored = [...cards];
restored.insert(
index > restored.length ? restored.length : index,
removed,
);
cards = restored;
_saved.add(dramaId);
notify();
return false;
@@ -0,0 +1,205 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/drama.dart';
import '../models/watch_progress.dart';
import '../models/watch_record.dart';
import '../repositories/catalog_repository.dart';
import '../repositories/watch_history_repository.dart';
/// 独立观看记录页状态。
///
/// 最近记录只下发 dramaId;详情按 dramaId 去重后补取,并以 4 个并发为上限。
class WatchRecordsController extends ChangeNotifier {
factory WatchRecordsController({
required WatchHistoryRepository history,
required CatalogRepository catalog,
int limit = defaultLimit,
}) => WatchRecordsController._(history, catalog, limit);
WatchRecordsController._(this._history, this._catalog, this.limit);
static const int defaultLimit = 20;
static const int detailConcurrency = 4;
final WatchHistoryRepository _history;
final CatalogRepository _catalog;
final int limit;
final Map<String, Drama> _detailCache = {};
List<WatchRecord> records = const [];
bool loading = false;
bool clearing = false;
String? error;
String? actionError;
bool _disposed = false;
/// 加载最近记录并补齐详情;单条详情失败不拖垮整页。
Future<void> load() async {
loading = true;
error = null;
actionError = null;
_notify();
try {
final recent = await _history.recent(limit: limit);
if (_disposed) return;
records = _uniqueRecords(recent);
_notify();
await _hydrate(records.map((record) => record.dramaId).toList());
} catch (_) {
if (!_disposed) error = 'progressError';
} finally {
if (!_disposed) {
loading = false;
_notify();
}
}
}
/// 只重试一条详情失败记录。
Future<void> retryDetail(String dramaId) async {
final index = _recordIndex(dramaId);
if (index < 0) return;
_replaceAt(
index,
records[index].copyWith(detailLoading: true, detailFailed: false),
);
_notify();
await _loadDetail(dramaId);
_notify();
}
/// 删除单条记录,先更新 UI,失败按原位置回滚。
Future<bool> remove(String dramaId) async {
final index = _recordIndex(dramaId);
if (index < 0) return true;
final removed = records[index];
records = [...records]..removeAt(index);
actionError = null;
_notify();
try {
await _history.remove(dramaId);
return true;
} catch (_) {
final next = [...records];
next.insert(index > next.length ? next.length : index, removed);
records = next;
actionError = 'recordDeleteFailed';
return false;
} finally {
_notify();
}
}
/// 清空全部记录,先更新 UI,失败恢复原列表。
Future<bool> clear() async {
if (records.isEmpty) return true;
final previous = records;
records = const [];
clearing = true;
actionError = null;
_notify();
try {
await _history.clear();
return true;
} catch (_) {
records = previous;
actionError = 'recordClearFailed';
return false;
} finally {
clearing = false;
_notify();
}
}
List<WatchRecord> _uniqueRecords(List<WatchProgress> recent) {
final seen = <String>{};
return [
for (final progress in recent)
if (seen.add(progress.dramaId)) WatchRecord(progress: progress),
];
}
Future<void> _hydrate(List<String> dramaIds) async {
final queue = [
for (final id in dramaIds)
if (!_detailCache.containsKey(id)) id,
];
final workerCount = queue.length < detailConcurrency
? queue.length
: detailConcurrency;
Future<void> worker() async {
while (queue.isNotEmpty && !_disposed) {
await _loadDetail(queue.removeAt(0));
}
}
await Future.wait([
for (var index = 0; index < workerCount; index++) worker(),
]);
}
Future<void> _loadDetail(String dramaId) async {
final index = _recordIndex(dramaId);
if (index < 0) return;
final cached = _detailCache[dramaId];
if (cached != null) {
_replaceAt(
index,
records[index].copyWith(
drama: cached,
detailLoading: false,
detailFailed: false,
),
);
return;
}
try {
final detail = await _catalog.fetchDrama(dramaId);
if (_disposed) return;
_detailCache[dramaId] = detail;
final current = _recordIndex(dramaId);
if (current < 0) return;
_replaceAt(
current,
records[current].copyWith(
drama: detail,
detailLoading: false,
detailFailed: false,
),
);
} catch (_) {
if (_disposed) return;
final current = _recordIndex(dramaId);
if (current < 0) return;
_replaceAt(
current,
records[current].copyWith(detailLoading: false, detailFailed: true),
);
}
}
int _recordIndex(String dramaId) {
for (var index = 0; index < records.length; index++) {
if (records[index].dramaId == dramaId) return index;
}
return -1;
}
void _replaceAt(int index, WatchRecord record) {
final next = [...records];
next[index] = record;
records = next;
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}