Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e89c71f50 | ||
|
|
2ce92aa7dd | ||
|
|
a6d6b66d31 | ||
|
|
57f84c45bd | ||
|
|
a4d08e7c1e | ||
|
|
9c9a277217 | ||
|
|
1be8ebf1f9 |
No files matched your search
@@ -71,42 +71,45 @@ flutter pub get
|
||||
```bash
|
||||
adb devices
|
||||
adb reverse tcp:8081 tcp:8081
|
||||
flutter run -d <device> \
|
||||
--dart-define=API_BASE_URL=http://127.0.0.1:8081
|
||||
flutter run -d <device>
|
||||
```
|
||||
|
||||
`adb reverse` 将手机的 `127.0.0.1:8081` 转发到电脑;USB 断开或重新连接后需要
|
||||
重新执行。
|
||||
重新执行。此时用调试条把地址改成 `http://127.0.0.1:8081`(见下节)。
|
||||
|
||||
Android 模拟器访问宿主机时使用:
|
||||
|
||||
```bash
|
||||
flutter run --dart-define=API_BASE_URL=http://10.0.2.2:8081
|
||||
```
|
||||
Android 模拟器访问宿主机时,同样在调试条里填 `http://10.0.2.2:8081`。
|
||||
|
||||
### iOS
|
||||
|
||||
手机与 Mac 需在同一网络,首次启动允许“本地网络”权限:
|
||||
|
||||
```bash
|
||||
flutter run -d <udid> \
|
||||
--dart-define=API_BASE_URL=http://<Mac局域网IP>:8081
|
||||
flutter run -d <udid>
|
||||
```
|
||||
|
||||
### Debug 运行时地址
|
||||
### 服务地址配置
|
||||
|
||||
debug 包在找剧页加载失败时会显示调试条,点其中的「修改服务器地址」可直接改成
|
||||
当前电脑 IP,保存后立即重载,不用重新打包。地址优先级:
|
||||
地址是代码里的一个开关,**不做任何网络探测**,也不用传构建参数。两套环境各占
|
||||
一个文件:
|
||||
|
||||
```text
|
||||
本地运行时设置 > --dart-define(API_BASE_URL) > AppConfig.defaultBaseUrl
|
||||
| 文件 | 用途 | 默认值 |
|
||||
| --- | --- | --- |
|
||||
| `lib/core/config/dev_config.dart` | 开发 / 联调 | `http://192.168.31.243:8081` |
|
||||
| `lib/core/config/prod_config.dart` | 生产 | `http://39.162.50.99:8081` |
|
||||
|
||||
切换环境只改 `lib/core/config/app_config.dart` 里的一个布尔值:
|
||||
|
||||
```dart
|
||||
static const bool useDevConfig = true; // false 即用生产地址
|
||||
```
|
||||
|
||||
`devHostBaseUrl` 默认写死为 `http://192.168.31.243:8081`
|
||||
(`lib/core/config/app_config.dart`)。debug 包启动时按
|
||||
「环回 `127.0.0.1` → `devHostBaseUrl` → 公网」顺序探测可用地址;release 包不探测,
|
||||
固定使用公网地址。换 Wi-Fi、路由器换网段后 `devHostBaseUrl` 会失效,此时要么传
|
||||
`--dart-define`,要么改这个常量。release 包不显示地址入口,也不读取本地覆盖。
|
||||
换 Wi-Fi 或换网段时只改 `dev_config.dart` 的 `baseUrl`。打生产包前把
|
||||
`useDevConfig` 改成 `false`;release 构建若仍为 `true` 会在启动时直接报错,
|
||||
避免线上包连到开发机。
|
||||
|
||||
debug 包在找剧页加载失败时会显示调试条,点「修改服务器地址」可临时改成当前
|
||||
电脑 IP,保存后立即重载、不需要重新打包,该覆盖保存在本地偏好里(仅 debug
|
||||
生效,release 不读取)。
|
||||
|
||||
## 画中画
|
||||
|
||||
@@ -151,30 +154,26 @@ xcodebuild -resolvePackageDependencies \
|
||||
|
||||
```bash
|
||||
adb uninstall com.stormg.lumadrama
|
||||
flutter run -d <device> \
|
||||
--dart-define=API_BASE_URL=http://127.0.0.1:8081
|
||||
flutter run -d <device>
|
||||
```
|
||||
|
||||
## 生产打包(Android)
|
||||
|
||||
生产包只包含以下两个 ABI,**打成一个 APK**,不做 split:
|
||||
两个渠道出两种产物,源码同一套:
|
||||
|
||||
| ABI | 适用设备 |
|
||||
| --- | --- |
|
||||
| `arm64-v8a` | 现代手机(64 位) |
|
||||
| `armeabi-v7a` | 老 32 位机 |
|
||||
|
||||
```bash
|
||||
flutter build apk --release --target-platform android-arm64,android-arm
|
||||
# 产出单个:build/app/outputs/flutter-apk/app-release.apk(约 37MB)
|
||||
```
|
||||
| 渠道 | 产物 | 命令 | 体积 |
|
||||
| --- | --- | --- | --- |
|
||||
| Google Play | AAB(按设备下发) | `flutter build appbundle --release` | 上传 28MB,用户下载约 8MB |
|
||||
| 国内各大市场 | 通用 APK(arm64 + v7) | `flutter build apk --release` | 约 13MB |
|
||||
|
||||
说明:
|
||||
|
||||
- `android/app/build.gradle.kts` 的 `release` 已配置 `abiFilters`,只保留 `arm64-v8a` 与 `armeabi-v7a`,插件也不会再带入 `x86_64` 原生库。
|
||||
- `x86_64` 只用于模拟器,不进入生产包。
|
||||
- 不要直接运行 `flutter build apk --release`:默认会编译三套原生库,包体约 57MB。
|
||||
- 只有需要进一步压缩单机下载体积时,才考虑 `--split-per-abi` 出分架构包(arm64 约 21MB、armv7 约 19MB),默认不用。
|
||||
- `android/gradle.properties` 里固定了 `split-debug-info=build/symbols` 与 `dart-obfuscation=true`,**IDE 里点 Build APK / Build App Bundle 也会生效**:`libapp.so` 从 ~8MB/ABI 降到 ~1.1MB/ABI。
|
||||
- `useLegacyPackaging = true`:原生库以压缩形式存储,这是国内 APK 从 40MB 降到 13MB 的主要原因;代价是安装时解压。
|
||||
- **每次发版都要归档 `build/symbols`**,线上崩溃栈需要它还原。
|
||||
- release 仍在用 debug 签名(`build.gradle.kts` 的 TODO),上架前必须换成正式 keystore。
|
||||
- 不要用 `--split-per-abi`:与 `abiFilters` 冲突;Play 的 AAB 已经按设备拆好。
|
||||
|
||||
## 生产打包(iOS)
|
||||
|
||||
|
||||
@@ -35,6 +35,13 @@ android {
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
}
|
||||
// R8 裁剪未使用的 Java/Kotlin 代码与资源,减小 dex 与 res。
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
@@ -52,6 +59,9 @@ kotlin {
|
||||
androidComponents {
|
||||
onVariants(selector().withBuildType("release")) { variant ->
|
||||
variant.packaging.jniLibs.excludes.add("lib/x86_64/**")
|
||||
// 原生库随 APK 压缩存储:下载体积明显小于解压后的体积,代价是安装时解压。
|
||||
// 本包 90% 体积是 so,这一项对国内商店的 APK 分发影响最大。
|
||||
variant.packaging.jniLibs.useLegacyPackaging = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
# Author: fruit.com.cn
|
||||
#
|
||||
# R8 保号规则:Flutter 引擎与插件自带的 consumer rules 已覆盖大部分场景,
|
||||
# 这里只补本项目原生层通过反射/通道访问、裁剪后容易失效的入口。
|
||||
|
||||
# 画中画与权限通道由 MainActivity 直接注册,保留应用包名下的入口类。
|
||||
-keep class com.stormg.lumadrama.** { *; }
|
||||
|
||||
# Flutter 引擎在 release 下通过 JNI 回调这些类,禁止改名。
|
||||
-keep class io.flutter.** { *; }
|
||||
-dontwarn io.flutter.embedding.**
|
||||
@@ -6,3 +6,8 @@ android.useAndroidX=true
|
||||
android.newDsl=false
|
||||
# This builtInKotlin flag was added by the Flutter template
|
||||
android.builtInKotlin=false
|
||||
|
||||
# release 包默认剥离 Dart 调试符号并混淆:libapp.so 由 ~8MB 降到 ~1MB/ABI。
|
||||
# 符号表输出到 build/symbols,崩溃分析时用它还原堆栈,发布前务必归档。
|
||||
split-debug-info=build/symbols
|
||||
dart-obfuscation=true
|
||||
@@ -47,6 +47,7 @@ import 'core/data/services/playlist_controller.dart';
|
||||
import 'core/data/services/event_tracker.dart';
|
||||
import 'core/data/services/playback_position_cache.dart';
|
||||
import 'core/data/services/playback_session.dart';
|
||||
import 'core/data/services/unlocked_episode_registry.dart';
|
||||
import 'core/data/services/video_player_port.dart';
|
||||
import 'core/data/services/wallet_controller.dart';
|
||||
import 'core/navigation/app_route_observer.dart';
|
||||
@@ -207,6 +208,10 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
||||
|
||||
late final LocaleController _localeController = LocaleController();
|
||||
|
||||
/// 登录期内共享的「已解锁收费集」:播放页进出、feed 与全屏切换都要沿用。
|
||||
late final UnlockedEpisodeRegistry _unlockedEpisodes =
|
||||
UnlockedEpisodeRegistry();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -226,6 +231,8 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
||||
if (_auth.isSignedIn == _wasSignedIn) return;
|
||||
_wasSignedIn = _auth.isSignedIn;
|
||||
_interactionController.clear();
|
||||
// 解锁结论属于账号:换人登录后不能沿用上一个人的可播判断。
|
||||
_unlockedEpisodes.clear();
|
||||
if (_auth.isSignedIn) {
|
||||
unawaited(_favoriteController.load());
|
||||
unawaited(_walletController.load());
|
||||
@@ -271,6 +278,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
|
||||
Provider<CatalogApi>.value(value: _catalogApi),
|
||||
Provider<PlaybackRepository>.value(value: _playback),
|
||||
Provider<PlaybackPositionCache>.value(value: _playbackPositionCache),
|
||||
Provider<UnlockedEpisodeRegistry>.value(value: _unlockedEpisodes),
|
||||
Provider<WatchHistoryRepository>.value(value: _watchHistory),
|
||||
Provider<FavoriteRepository>.value(value: _favorites),
|
||||
Provider<CommentRepository>.value(value: _commentRepository),
|
||||
|
||||
+24
-153
@@ -1,173 +1,51 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:io';
|
||||
import 'dev_config.dart';
|
||||
import 'prod_config.dart';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// 客户端与后端约定的运行时配置。
|
||||
/// 客户端运行时配置。
|
||||
///
|
||||
/// 接口地址优先级:运行时设置 > `--dart-define=API_BASE_URL=...` > 平台默认值。
|
||||
/// 开发机的局域网地址会随网络变化,换网络后改 [devHostBaseUrl] 即可。
|
||||
class AppConfig {
|
||||
const AppConfig._();
|
||||
|
||||
/// 开发调试默认服务地址,当前指向本机局域网 IP。
|
||||
/// 两套环境各一个文件:
|
||||
/// - `dev_config.dart`:[DevConfig](本机联调)
|
||||
/// - `prod_config.dart`:[ProdConfig](生产)
|
||||
///
|
||||
/// 切换环境只改下面这一个布尔值,不用传任何构建参数、也不做网络探测。
|
||||
abstract final class AppConfig {
|
||||
/// **环境开关**:true 用开发环境,false 用生产环境。
|
||||
///
|
||||
/// 换网络时只改这一行,地址不要带结尾斜杠。
|
||||
static const String devHostBaseUrl = 'http://192.168.31.243:8081';
|
||||
/// 打生产包前必须改成 false;release 构建若仍为 true 会在启动时直接报错。
|
||||
static const bool useDevConfig = true;
|
||||
|
||||
/// 本机后端地址:通过 `adb reverse tcp:8081 tcp:8081` 映射到开发机,
|
||||
/// 探测可达时 App 直连本机服务;不可达时回落到公网地址。
|
||||
static const String lanBaseUrl = 'http://127.0.0.1:8081';
|
||||
|
||||
/// 公网地址(远程/移动网络访问时使用)。
|
||||
static const String publicBaseUrl = 'http://39.162.50.99:8081';
|
||||
|
||||
static const String _overrideBaseUrl = String.fromEnvironment('API_BASE_URL');
|
||||
/// 当前选中的环境地址。
|
||||
static String get baseUrl =>
|
||||
useDevConfig ? DevConfig.baseUrl : ProdConfig.baseUrl;
|
||||
|
||||
/// 调试面板手改的地址(见 `ServerEndpoint`);为空表示用上面的环境地址。
|
||||
static String _runtimeBaseUrl = '';
|
||||
|
||||
/// 启动期异步准备(iOS 无线数据权限弹窗),目录请求前等待它。
|
||||
static Future<void>? _ready;
|
||||
|
||||
/// 启动地址探测完成信号;默认已完成,避免影响既有测试与调用方。
|
||||
/// 启动准备完成信号;默认已完成,避免影响既有测试与调用方。
|
||||
static Future<void> get ready => _ready ?? Future<void>.value();
|
||||
static set ready(Future<void> value) => _ready = value;
|
||||
|
||||
static const Duration _probeTimeout = Duration(seconds: 2);
|
||||
static const List<String> _probeCandidates = [
|
||||
lanBaseUrl,
|
||||
devHostBaseUrl,
|
||||
if (devHostBaseUrl != publicBaseUrl) publicBaseUrl,
|
||||
];
|
||||
static const Duration requestTimeout = Duration(seconds: 8);
|
||||
|
||||
/// Build-time server URL supplied through `--dart-define=API_BASE_URL`.
|
||||
static String get compiledBaseUrl => _overrideBaseUrl;
|
||||
/// 实际使用的服务地址:运行时手改优先,否则用环境开关选中的地址。
|
||||
static String get apiBaseUrl =>
|
||||
_runtimeBaseUrl.isNotEmpty ? _runtimeBaseUrl : baseUrl;
|
||||
|
||||
/// Existing platform-aware fallback used when no runtime or build override exists.
|
||||
static String get defaultBaseUrl => _defaultBaseUrl;
|
||||
|
||||
/// Effective server URL, including the debug-only runtime override.
|
||||
static String get apiBaseUrl => _runtimeBaseUrl.isNotEmpty
|
||||
? _runtimeBaseUrl
|
||||
: (compiledBaseUrl.isNotEmpty ? compiledBaseUrl : defaultBaseUrl);
|
||||
|
||||
/// 生产包固定走公网地址;仅 debug 才按平台默认到环回/开发机。
|
||||
static final String _defaultBaseUrl = kReleaseMode
|
||||
? publicBaseUrl
|
||||
: (_runsOnDevMachine
|
||||
? lanBaseUrl
|
||||
: devHostBaseUrl);
|
||||
|
||||
/// 模拟器、桌面端与开发机同机,环回地址可用;iOS 真机不满足。
|
||||
/// Android 没有等价的探测方式,统一按真机处理(模拟器用
|
||||
/// `--dart-define=API_BASE_URL=http://10.0.2.2:8081` 覆盖)。
|
||||
static final bool _runsOnDevMachine =
|
||||
kIsWeb ||
|
||||
defaultTargetPlatform == TargetPlatform.macOS ||
|
||||
defaultTargetPlatform == TargetPlatform.windows ||
|
||||
defaultTargetPlatform == TargetPlatform.linux ||
|
||||
(defaultTargetPlatform == TargetPlatform.iOS &&
|
||||
Platform.environment.containsKey('SIMULATOR_DEVICE_NAME'));
|
||||
|
||||
/// Applies an already normalized runtime URL. Passing null clears it.
|
||||
/// 应用一个已经规范化的运行时地址;传 null 表示清除。
|
||||
static void applyRuntimeBaseUrl(String? value) {
|
||||
_runtimeBaseUrl = value ?? '';
|
||||
}
|
||||
|
||||
/// Clears the in-memory runtime override, mainly for tests and restarts.
|
||||
/// 清除运行时地址,主要给测试与重启流程用。
|
||||
static void clearRuntimeBaseUrl() {
|
||||
_runtimeBaseUrl = '';
|
||||
}
|
||||
|
||||
static const Duration requestTimeout = Duration(seconds: 8);
|
||||
|
||||
/// 按环回、开发机、公网顺序探测,写入首个返回 200 的地址。
|
||||
///
|
||||
/// 全部不可达时使用平台默认地址,真机保留开发机地址,避免退回公网。
|
||||
static Future<void> probeAndApplyBaseUrl({
|
||||
Future<bool> Function(String baseUrl)? probe,
|
||||
String? fallbackBaseUrl,
|
||||
|
||||
/// 编译期地址测试注入;未传时读取 `API_BASE_URL`。
|
||||
String? compiledOverride,
|
||||
}) async {
|
||||
final compiled = compiledOverride ?? compiledBaseUrl;
|
||||
if (compiled.isNotEmpty) {
|
||||
if (_runtimeBaseUrl.isEmpty) applyRuntimeBaseUrl(compiled);
|
||||
return;
|
||||
}
|
||||
// 生产包固定公网地址,不再探测环回/开发机局域网地址,避免请求到开发机。
|
||||
if (kReleaseMode) {
|
||||
if (_runtimeBaseUrl.isEmpty) applyRuntimeBaseUrl(publicBaseUrl);
|
||||
return;
|
||||
}
|
||||
final candidates = <String>[
|
||||
if (_runtimeBaseUrl.isNotEmpty) _runtimeBaseUrl,
|
||||
for (final candidate in _probeCandidates)
|
||||
if (candidate != _runtimeBaseUrl) candidate,
|
||||
];
|
||||
final results = await Future.wait([
|
||||
for (final candidate in candidates)
|
||||
probe == null
|
||||
? _probeBaseUrl(candidate)
|
||||
: _runInjectedProbe(candidate, probe),
|
||||
]);
|
||||
for (var index = 0; index < candidates.length; index++) {
|
||||
final candidate = candidates[index];
|
||||
final result = results[index];
|
||||
if (kDebugMode) {
|
||||
final status = result.ok
|
||||
? 'ok'
|
||||
: 'fail(${result.reason ?? 'request failed'})';
|
||||
debugPrint('[probe] $candidate -> $status');
|
||||
}
|
||||
if (result.ok) {
|
||||
applyRuntimeBaseUrl(candidate);
|
||||
return;
|
||||
}
|
||||
}
|
||||
applyRuntimeBaseUrl(fallbackBaseUrl ?? defaultBaseUrl);
|
||||
}
|
||||
|
||||
static Future<_ProbeResult> _runInjectedProbe(
|
||||
String baseUrl,
|
||||
Future<bool> Function(String baseUrl) probe,
|
||||
) async {
|
||||
try {
|
||||
return _ProbeResult(ok: await probe(baseUrl));
|
||||
} catch (error) {
|
||||
return _ProbeResult(ok: false, reason: error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
static Future<_ProbeResult> _probeBaseUrl(String baseUrl) async {
|
||||
final client = HttpClient()..connectionTimeout = _probeTimeout;
|
||||
try {
|
||||
return await _requestCategories(
|
||||
client,
|
||||
Uri.parse('$baseUrl/app/categories'),
|
||||
).timeout(_probeTimeout);
|
||||
} catch (error) {
|
||||
return _ProbeResult(ok: false, reason: error.toString());
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<_ProbeResult> _requestCategories(
|
||||
HttpClient client,
|
||||
Uri uri,
|
||||
) async {
|
||||
final request = await client.getUrl(uri);
|
||||
final response = await request.close();
|
||||
await response.drain<void>();
|
||||
final ok = response.statusCode == 200;
|
||||
return _ProbeResult(
|
||||
ok: ok,
|
||||
reason: ok ? null : 'HTTP ${response.statusCode}',
|
||||
);
|
||||
}
|
||||
|
||||
/// 后端返回的是相对路径(/files/...),这里补上服务地址。
|
||||
static String mediaUrl(String path) {
|
||||
if (path.isEmpty) return '';
|
||||
@@ -175,10 +53,3 @@ class AppConfig {
|
||||
return '$apiBaseUrl$path';
|
||||
}
|
||||
}
|
||||
|
||||
class _ProbeResult {
|
||||
const _ProbeResult({required this.ok, this.reason});
|
||||
|
||||
final bool ok;
|
||||
final String? reason;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
/// 开发环境配置。
|
||||
///
|
||||
/// 本机联调用:换 Wi-Fi、换网段后只改这里的 [baseUrl],地址不要带结尾斜杠。
|
||||
abstract final class DevConfig {
|
||||
/// 开发机后端地址。
|
||||
static const String baseUrl = 'http://192.168.31.243:8081';
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
/// 生产环境配置。
|
||||
///
|
||||
/// 发版前确认这里的 [baseUrl],地址不要带结尾斜杠。
|
||||
abstract final class ProdConfig {
|
||||
/// 生产后端地址。
|
||||
static const String baseUrl = 'http://39.162.50.99:8081';
|
||||
}
|
||||
@@ -22,16 +22,9 @@ abstract final class ServerEndpoint {
|
||||
return value.replaceFirst(RegExp(r'/+$'), '');
|
||||
}
|
||||
|
||||
/// Resolves runtime > dart-define > default using normalized valid values.
|
||||
static String resolve({
|
||||
required String? stored,
|
||||
required String compiled,
|
||||
required String fallback,
|
||||
}) {
|
||||
return normalize(stored) ??
|
||||
normalize(compiled) ??
|
||||
normalize(fallback) ??
|
||||
fallback;
|
||||
/// 运行时手改优先,其次构建期默认地址;两者都必须先通过规范校验。
|
||||
static String resolve({required String? stored, required String fallback}) {
|
||||
return normalize(stored) ?? normalize(fallback) ?? fallback;
|
||||
}
|
||||
|
||||
/// Loads the persisted override before the app creates its ApiClient.
|
||||
@@ -46,11 +39,7 @@ abstract final class ServerEndpoint {
|
||||
final prefs = preferences ?? SharedPreferencesAsync();
|
||||
final stored = await prefs.getString(preferenceKey);
|
||||
AppConfig.applyRuntimeBaseUrl(
|
||||
resolve(
|
||||
stored: stored,
|
||||
compiled: AppConfig.compiledBaseUrl,
|
||||
fallback: AppConfig.defaultBaseUrl,
|
||||
),
|
||||
resolve(stored: stored, fallback: AppConfig.baseUrl),
|
||||
);
|
||||
} catch (_) {
|
||||
AppConfig.clearRuntimeBaseUrl();
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../models/account_device.dart';
|
||||
import '../models/account_export.dart';
|
||||
import '../models/account_profile.dart';
|
||||
import 'api_client.dart';
|
||||
import 'response_decoders.dart';
|
||||
|
||||
/// 个人中心接口:`/app/me*`。
|
||||
class AccountApi {
|
||||
@@ -23,11 +24,9 @@ class AccountApi {
|
||||
|
||||
Future<List<AccountDevice>> devices() async {
|
||||
final data = await _client.get('/app/me/devices');
|
||||
if (data is! List) return const [];
|
||||
return [
|
||||
for (final item in data)
|
||||
if (item is Map) AccountDevice.fromJson(item.cast<String, dynamic>()),
|
||||
];
|
||||
return ResponseDecoders.asMapList(
|
||||
data,
|
||||
).map(AccountDevice.fromJson).toList();
|
||||
}
|
||||
|
||||
Future<AccountExport> export() async {
|
||||
@@ -47,6 +46,7 @@ class AccountApi {
|
||||
await _client.post('/app/me/deactivate/cancel');
|
||||
}
|
||||
|
||||
/// 个人资料允许缺失:后端未返回对象时按空对象解析,页面用默认值兜底。
|
||||
Map<String, dynamic> _map(Object? data) =>
|
||||
data is Map ? data.cast<String, dynamic>() : const {};
|
||||
ResponseDecoders.tryMap(data) ?? const {};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import '../models/page_result.dart';
|
||||
import '../models/playlist_detail.dart';
|
||||
import '../models/search_result.dart';
|
||||
import 'api_client.dart';
|
||||
import 'response_decoders.dart';
|
||||
|
||||
/// 客户端目录接口,对应后端 `/app/**`。
|
||||
class CatalogApi {
|
||||
@@ -14,11 +15,15 @@ class CatalogApi {
|
||||
final ApiClient _client;
|
||||
|
||||
Future<HomeFeed> homeFeed() async {
|
||||
return HomeFeed.fromJson(_asMap(await _client.get('/app/home/feed')));
|
||||
return HomeFeed.fromJson(
|
||||
ResponseDecoders.asMap(await _client.get('/app/home/feed')),
|
||||
);
|
||||
}
|
||||
|
||||
Future<HomeFeed> discoverFeed() async {
|
||||
return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed')));
|
||||
return HomeFeed.fromJson(
|
||||
ResponseDecoders.asMap(await _client.get('/app/discover/feed')),
|
||||
);
|
||||
}
|
||||
|
||||
/// 推荐流;types/categoryId 为空表示不过滤,cursor 为上一页末条。
|
||||
@@ -29,7 +34,7 @@ class CatalogApi {
|
||||
int size = 10,
|
||||
}) async {
|
||||
return FeedPage.fromJson(
|
||||
_asMap(
|
||||
ResponseDecoders.asMap(
|
||||
await _client.get(
|
||||
'/app/feed',
|
||||
query: {
|
||||
@@ -58,7 +63,7 @@ class CatalogApi {
|
||||
'pageSize': '$pageSize',
|
||||
},
|
||||
);
|
||||
return SearchResult.fromJson(_asMap(data));
|
||||
return SearchResult.fromJson(ResponseDecoders.asMap(data));
|
||||
}
|
||||
|
||||
static const int playlistPageSize = 10;
|
||||
@@ -86,7 +91,7 @@ class CatalogApi {
|
||||
int pageSize = playlistDramaPageSize,
|
||||
}) async {
|
||||
return PlaylistDetail.fromJson(
|
||||
_asMap(
|
||||
ResponseDecoders.asMap(
|
||||
await _client.get(
|
||||
'/app/playlists/$id',
|
||||
query: {
|
||||
@@ -138,20 +143,13 @@ class CatalogApi {
|
||||
|
||||
Future<Drama> dramaDetail(String dramaId) async {
|
||||
return Drama.fromDetailJson(
|
||||
_asMap(await _client.get('/app/dramas/$dramaId')),
|
||||
ResponseDecoders.asMap(await _client.get('/app/dramas/$dramaId')),
|
||||
);
|
||||
}
|
||||
|
||||
List<ContentCategory> _categories(Object? data) {
|
||||
if (data is! List) return const [];
|
||||
return [
|
||||
for (final item in data)
|
||||
if (item is Map<String, dynamic>) ContentCategory.fromJson(item),
|
||||
];
|
||||
}
|
||||
|
||||
Map<String, dynamic> _asMap(Object? data) {
|
||||
if (data is Map<String, dynamic>) return data;
|
||||
throw const ApiException(-1, '返回数据格式异常');
|
||||
return ResponseDecoders.asMapList(
|
||||
data,
|
||||
).map(ContentCategory.fromJson).toList();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import '../models/content_category.dart';
|
||||
import '../models/drama.dart';
|
||||
import '../models/page_result.dart';
|
||||
import 'api_client.dart';
|
||||
import 'response_decoders.dart';
|
||||
|
||||
/// 内容分类接口,对应契约 `GET /app/categories`。
|
||||
class CategoryApi {
|
||||
@@ -16,11 +17,9 @@ class CategoryApi {
|
||||
/// 拉取已启用分类,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),
|
||||
];
|
||||
return ResponseDecoders.asMapList(
|
||||
data,
|
||||
).map(ContentCategory.fromJson).toList();
|
||||
}
|
||||
|
||||
/// 分类下已上线短剧,pageIndex 从 1 开始。
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../models/order.dart';
|
||||
import '../models/product.dart';
|
||||
import '../models/wallet.dart';
|
||||
import 'api_client.dart';
|
||||
import 'response_decoders.dart';
|
||||
|
||||
/// 金币与解锁接口,对应契约 §1.8。
|
||||
class CommerceApi {
|
||||
@@ -13,8 +14,7 @@ class CommerceApi {
|
||||
|
||||
Future<WalletSnapshot> wallet() async {
|
||||
final data = await _client.get('/app/wallet');
|
||||
if (data is Map<String, dynamic>) return WalletSnapshot.fromJson(data);
|
||||
throw const ApiException(-1, '返回数据格式异常');
|
||||
return WalletSnapshot.fromJson(ResponseDecoders.asMap(data));
|
||||
}
|
||||
|
||||
/// 上架商品列表;`type` 传 `COIN_PACK` 或 `VIP`。
|
||||
@@ -23,13 +23,9 @@ class CommerceApi {
|
||||
'/app/products',
|
||||
query: type == null ? null : {'type': type},
|
||||
);
|
||||
if (data is List) {
|
||||
return data
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(AppProduct.fromJson)
|
||||
.toList();
|
||||
}
|
||||
throw const ApiException(-1, '返回数据格式异常');
|
||||
return ResponseDecoders.requiredMapList(
|
||||
data,
|
||||
).map(AppProduct.fromJson).toList();
|
||||
}
|
||||
|
||||
/// 创建充值订单,返回 `CREATED` 状态订单。
|
||||
@@ -38,13 +34,11 @@ class CommerceApi {
|
||||
'/app/orders',
|
||||
body: {'productId': productId},
|
||||
);
|
||||
if (data is Map<String, dynamic>) return AppOrder.fromJson(data);
|
||||
throw const ApiException(-1, '返回数据格式异常');
|
||||
return AppOrder.fromJson(ResponseDecoders.asMap(data));
|
||||
}
|
||||
|
||||
Future<UnlockResult> unlock(String episodeId) async {
|
||||
final data = await _client.post('/app/episodes/$episodeId/unlock');
|
||||
if (data is Map<String, dynamic>) return UnlockResult.fromJson(data);
|
||||
throw const ApiException(-1, '返回数据格式异常');
|
||||
return UnlockResult.fromJson(ResponseDecoders.asMap(data));
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import '../models/download_license.dart';
|
||||
import '../models/download_policy.dart';
|
||||
import 'api_client.dart';
|
||||
import 'response_decoders.dart';
|
||||
|
||||
/// 离线下载授权接口,对应契约 §1.6 的全部 8 个接口。
|
||||
///
|
||||
@@ -15,7 +16,7 @@ class DownloadApi {
|
||||
/// 拉取配额和偏好策略。
|
||||
Future<DownloadPolicy> policy() async {
|
||||
return DownloadPolicy.fromJson(
|
||||
_asMap(await _client.get('/app/downloads/policy')),
|
||||
ResponseDecoders.asMap(await _client.get('/app/downloads/policy')),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +31,7 @@ class DownloadApi {
|
||||
/// 申请下载授权;同集重复申请由服务端保证幂等。
|
||||
Future<DownloadLicense> grant(String episodeId, {String? quality}) async {
|
||||
return DownloadLicense.fromJson(
|
||||
_asMap(
|
||||
ResponseDecoders.asMap(
|
||||
await _client.post(
|
||||
'/app/downloads',
|
||||
body: {'episodeId': episodeId, 'quality': ?quality},
|
||||
@@ -42,17 +43,17 @@ class DownloadApi {
|
||||
/// 拉取当前设备的授权列表。
|
||||
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),
|
||||
];
|
||||
return ResponseDecoders.asMapList(
|
||||
data,
|
||||
).map(DownloadLicense.fromJson).toList();
|
||||
}
|
||||
|
||||
/// 续传前刷新短时效下载地址。
|
||||
Future<DownloadLicense> refreshUrl(String episodeId) async {
|
||||
return DownloadLicense.fromJson(
|
||||
_asMap(await _client.get('/app/downloads/$episodeId/url')),
|
||||
ResponseDecoders.asMap(
|
||||
await _client.get('/app/downloads/$episodeId/url'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -67,7 +68,7 @@ class DownloadApi {
|
||||
String? errorMsg,
|
||||
}) async {
|
||||
return DownloadLicense.fromJson(
|
||||
_asMap(
|
||||
ResponseDecoders.asMap(
|
||||
await _client.post(
|
||||
'/app/downloads/$episodeId/progress',
|
||||
body: {
|
||||
@@ -93,9 +94,4 @@ class DownloadApi {
|
||||
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, '');
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import '../models/episode_stats.dart';
|
||||
import '../models/play_ticket.dart';
|
||||
import 'api_client.dart';
|
||||
import 'response_decoders.dart';
|
||||
|
||||
/// 播放票据接口:`GET /app/episodes/{id}/play`(契约 §1.4)。
|
||||
///
|
||||
@@ -15,14 +16,12 @@ class PlaybackApi {
|
||||
|
||||
Future<PlayTicket> ticket(String episodeId) async {
|
||||
final data = await _client.get('/app/episodes/$episodeId/play');
|
||||
if (data is Map<String, dynamic>) return PlayTicket.fromJson(data);
|
||||
throw const ApiException(-1, '返回数据格式异常');
|
||||
return PlayTicket.fromJson(ResponseDecoders.asMap(data));
|
||||
}
|
||||
|
||||
/// 轻量刷新互动计数:不签发地址,也不占用播放票据缓存。
|
||||
Future<EpisodeStats> episodeStats(String episodeId) async {
|
||||
final data = await _client.get('/app/episodes/$episodeId/stats');
|
||||
if (data is Map<String, dynamic>) return EpisodeStats.fromJson(data);
|
||||
throw const ApiException(-1, '返回数据格式异常');
|
||||
return EpisodeStats.fromJson(ResponseDecoders.asMap(data));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'api_exception.dart';
|
||||
|
||||
/// 接口响应 `data` 字段的统一解析。
|
||||
///
|
||||
/// 此前每个 `*_api.dart` 各写一份 `is Map` 判断与错误文案,还出现过抛空
|
||||
/// message 导致界面显示空白的情况;统一收口到这里,语义只有三种:
|
||||
/// 必须为对象、可为空对象、对象数组。
|
||||
abstract final class ResponseDecoders {
|
||||
/// 业务失败码:HTTP 成功但 `data` 结构不符合约定。
|
||||
static const int malformedCode = -1;
|
||||
|
||||
/// `data` 结构异常时的提示,可直接展示给用户。
|
||||
static const String malformedMessage = '返回数据格式异常';
|
||||
|
||||
/// 要求 `data` 是对象,否则抛 [ApiException]。
|
||||
static Map<String, dynamic> asMap(Object? data) {
|
||||
final map = tryMap(data);
|
||||
if (map == null) {
|
||||
throw const ApiException(malformedCode, malformedMessage);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// 允许缺失:`data` 为空或不是对象时返回 null。
|
||||
static Map<String, dynamic>? tryMap(Object? data) {
|
||||
if (data is Map<String, dynamic>) return data;
|
||||
if (data is Map) return data.cast<String, dynamic>();
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 对象数组;不是数组时按空列表处理,避免列表页因结构异常白屏。
|
||||
static List<Map<String, dynamic>> asMapList(Object? data) {
|
||||
if (data is! List) return const [];
|
||||
return data
|
||||
.whereType<Map>()
|
||||
.map((item) => item.cast<String, dynamic>())
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 对象数组;不是数组时抛 [ApiException],用于不能静默为空的数据(如商品)。
|
||||
static List<Map<String, dynamic>> requiredMapList(Object? data) {
|
||||
if (data is! List) {
|
||||
throw const ApiException(malformedCode, malformedMessage);
|
||||
}
|
||||
return asMapList(data);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import '../models/page_result.dart';
|
||||
import '../models/watch_progress.dart';
|
||||
import 'api_client.dart';
|
||||
import 'response_decoders.dart';
|
||||
|
||||
/// 观看进度接口:`/app/watch/*`(契约 §1.5)。支持账号或设备身份。
|
||||
class WatchApi {
|
||||
@@ -28,7 +29,8 @@ class WatchApi {
|
||||
'source': source,
|
||||
},
|
||||
);
|
||||
return data is Map<String, dynamic> ? WatchProgress.fromJson(data) : null;
|
||||
final map = ResponseDecoders.tryMap(data);
|
||||
return map == null ? null : WatchProgress.fromJson(map);
|
||||
}
|
||||
|
||||
/// 某部剧的续看位置;没有记录时后端返回空 data。
|
||||
@@ -37,7 +39,8 @@ class WatchApi {
|
||||
'/app/watch/progress',
|
||||
query: {'dramaId': dramaId},
|
||||
);
|
||||
return data is Map<String, dynamic> ? WatchProgress.fromJson(data) : null;
|
||||
final map = ResponseDecoders.tryMap(data);
|
||||
return map == null ? null : WatchProgress.fromJson(map);
|
||||
}
|
||||
|
||||
/// 最近观看分页:`pageIndex`/`pageSize`,解包 `PageResult`。
|
||||
@@ -49,13 +52,9 @@ class WatchApi {
|
||||
'/app/watch/recent',
|
||||
query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'},
|
||||
);
|
||||
if (data is! Map) {
|
||||
return const PageResult();
|
||||
}
|
||||
return PageResult.fromJson(
|
||||
Map<String, dynamic>.from(data),
|
||||
WatchProgress.fromJson,
|
||||
);
|
||||
final map = ResponseDecoders.tryMap(data);
|
||||
if (map == null) return const PageResult();
|
||||
return PageResult.fromJson(map, WatchProgress.fromJson);
|
||||
}
|
||||
|
||||
/// 删除某部剧的观看记录(记录页清单条用)。
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import '../../config/app_config.dart';
|
||||
|
||||
/// 重新探测服务地址后再加载指定目录数据。
|
||||
Future<void> retryCatalogLoad({
|
||||
required Future<void> Function() reload,
|
||||
Future<void> Function()? probe,
|
||||
}) async {
|
||||
await (probe ?? AppConfig.probeAndApplyBaseUrl)();
|
||||
await reload();
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../data/models/drama.dart';
|
||||
import '../../data/models/feed_page.dart';
|
||||
|
||||
/// 单个分桶(频道/分类)的游标分页状态。
|
||||
class ChannelFeedState {
|
||||
List<Drama> dramas = const [];
|
||||
String? cursor;
|
||||
bool hasMore = true;
|
||||
bool loaded = false;
|
||||
bool loading = false;
|
||||
bool loadingMore = false;
|
||||
String? lastError;
|
||||
int requestSeq = 0;
|
||||
Future<bool>? inFlight;
|
||||
bool skipNextRefresh = false;
|
||||
|
||||
/// 正在持有 [loading] 的那次刷新请求的序号;被抢占时据此只由原请求复位。
|
||||
int? loadingOwner;
|
||||
}
|
||||
|
||||
/// 按 key 分桶的游标分页控制器基类。
|
||||
///
|
||||
/// 首页频道与剧场分类的逻辑完全同构:缓存优先渲染、请求序号防串台、
|
||||
/// 分页去重追加。两者此前各写一份,改动容易只改一边,故收口到这里,
|
||||
/// 子类只需提供分桶 key 与「如何发一次请求」。
|
||||
abstract class ChannelFeedController<K> extends ChangeNotifier {
|
||||
ChannelFeedController(
|
||||
K initialKey, {
|
||||
bool initiallySelected = true,
|
||||
this.requestTimeout = defaultRequestTimeout,
|
||||
}) : _current = initialKey,
|
||||
_selected = initiallySelected;
|
||||
|
||||
static const int pageSize = 20;
|
||||
static const Duration defaultRequestTimeout = Duration(seconds: 10);
|
||||
|
||||
final Duration requestTimeout;
|
||||
final Map<K, ChannelFeedState> _states = {};
|
||||
K _current;
|
||||
bool _selected;
|
||||
bool _disposed = false;
|
||||
|
||||
K get currentKey => _current;
|
||||
String? get lastError => _state(_current).lastError;
|
||||
List<Drama> get dramas => _state(_current).dramas;
|
||||
String? get cursor => _state(_current).cursor;
|
||||
bool get hasMore => _state(_current).hasMore;
|
||||
bool get loaded => _state(_current).loaded;
|
||||
bool get isLoading => _state(_current).loading;
|
||||
bool get loadingMore => _state(_current).loadingMore;
|
||||
bool get loadingFirstPage => !loaded && _state(_current).loading;
|
||||
|
||||
/// 子类实现:按 key 拉一页;[cursor] 为空表示首屏/刷新。
|
||||
@protected
|
||||
Future<FeedPage> fetchPage(K key, {String? cursor});
|
||||
|
||||
/// 同步切换分桶;有缓存先渲染,无缓存时复用或等待在飞请求。
|
||||
@protected
|
||||
Future<void> selectKey(K next) async {
|
||||
if (_selected && next == _current) return;
|
||||
_current = next;
|
||||
_selected = true;
|
||||
_notify();
|
||||
final state = _state(next);
|
||||
if (state.dramas.isNotEmpty) {
|
||||
if (state.skipNextRefresh) {
|
||||
state.skipNextRefresh = false;
|
||||
} else if (!state.loading) {
|
||||
unawaited(_refresh(next, keepData: true));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (state.loading) {
|
||||
final inFlight = state.inFlight;
|
||||
if (inFlight != null) await inFlight;
|
||||
state.skipNextRefresh = false;
|
||||
return;
|
||||
}
|
||||
await _refresh(next, keepData: false);
|
||||
}
|
||||
|
||||
/// 用已有数据填充某个分桶,不发起网络请求。
|
||||
@protected
|
||||
void seedKey(K key, List<Drama> dramas) {
|
||||
final state = _state(key);
|
||||
state
|
||||
..requestSeq = state.requestSeq + 1
|
||||
..dramas = List.unmodifiable(dramas)
|
||||
..cursor = null
|
||||
..hasMore = false
|
||||
..loaded = true
|
||||
..loading = false
|
||||
..loadingOwner = null
|
||||
..loadingMore = false
|
||||
..lastError = null
|
||||
..inFlight = null;
|
||||
_notifyIfCurrent(key);
|
||||
}
|
||||
|
||||
/// 后台预取某个分桶首屏;成功后标记「切过去不必再刷一次」。
|
||||
@protected
|
||||
Future<void> prefetchKey(K key) async {
|
||||
final state = _state(key);
|
||||
if (state.loaded || state.loading) return;
|
||||
final success = await _refresh(key, keepData: false);
|
||||
if (success && !_disposed) state.skipNextRefresh = true;
|
||||
}
|
||||
|
||||
/// 刷新当前分桶,失败时保留旧数据。
|
||||
Future<bool> refreshCurrent() async {
|
||||
final state = _state(_current);
|
||||
if (state.loading) {
|
||||
return await state.inFlight ?? false;
|
||||
}
|
||||
return _refresh(_current, keepData: true);
|
||||
}
|
||||
|
||||
/// 加载当前分桶下一页并去重追加。
|
||||
Future<void> loadMore() async {
|
||||
final key = _current;
|
||||
final state = _state(key);
|
||||
if (!state.hasMore || state.loadingMore || state.cursor == null) return;
|
||||
final seq = ++state.requestSeq;
|
||||
state.loadingMore = true;
|
||||
_notifyIfCurrent(key);
|
||||
try {
|
||||
final page = await fetchPage(
|
||||
key,
|
||||
cursor: state.cursor,
|
||||
).timeout(requestTimeout);
|
||||
if (!_acceptSequence(key, 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 (_acceptSequence(key, seq)) {
|
||||
state.lastError = error.toString();
|
||||
}
|
||||
} finally {
|
||||
state.loadingMore = false;
|
||||
_notifyIfCurrent(key);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _refresh(K key, {required bool keepData}) {
|
||||
final state = _state(key);
|
||||
final operation = _performRefresh(key, keepData: keepData);
|
||||
state.inFlight = operation;
|
||||
unawaited(_clearInFlight(state, operation));
|
||||
return operation;
|
||||
}
|
||||
|
||||
Future<bool> _performRefresh(K key, {required bool keepData}) async {
|
||||
final state = _state(key);
|
||||
final seq = ++state.requestSeq;
|
||||
state
|
||||
..loading = true
|
||||
..loadingOwner = seq
|
||||
..lastError = null;
|
||||
_notifyIfCurrent(key);
|
||||
try {
|
||||
final page = await fetchPage(key).timeout(requestTimeout);
|
||||
if (!_acceptSequence(key, seq)) return false;
|
||||
state
|
||||
..dramas = List.unmodifiable(page.dramas)
|
||||
..cursor = page.nextCursor
|
||||
..hasMore = page.hasMore
|
||||
..loaded = true
|
||||
..lastError = null;
|
||||
_notifyIfCurrent(key);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!_acceptSequence(key, seq)) return false;
|
||||
if (!keepData) state.dramas = const [];
|
||||
state
|
||||
..loaded = true
|
||||
..lastError = error.toString();
|
||||
_notifyIfCurrent(key);
|
||||
return false;
|
||||
} finally {
|
||||
// 只由「真正拥有 loading 的那次请求」复位:被 loadMore 抢占时它不再
|
||||
// 更新数据,但必须收掉自己打开的加载态,否则该分桶会一直显示加载中,
|
||||
// 之后 refreshCurrent 也只会读到已清空的 inFlight 而永远返回 false。
|
||||
if (!_disposed && state.loadingOwner == seq) {
|
||||
state
|
||||
..loading = false
|
||||
..loadingOwner = null;
|
||||
_notifyIfCurrent(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearInFlight(
|
||||
ChannelFeedState state,
|
||||
Future<bool> operation,
|
||||
) async {
|
||||
await operation;
|
||||
if (identical(state.inFlight, operation)) state.inFlight = null;
|
||||
}
|
||||
|
||||
ChannelFeedState _state(K key) =>
|
||||
_states.putIfAbsent(key, ChannelFeedState.new);
|
||||
|
||||
bool _acceptSequence(K key, int seq) =>
|
||||
!_disposed && _state(key).requestSeq == seq;
|
||||
|
||||
void _notifyIfCurrent(K key) {
|
||||
if (!_disposed && key == _current) _notify();
|
||||
}
|
||||
|
||||
void _notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/drama.dart';
|
||||
import '../models/page_result.dart';
|
||||
import '../remote/api_client.dart';
|
||||
import '../repositories/favorite_repository.dart';
|
||||
import 'paged_list_controller.dart';
|
||||
|
||||
/// 收藏状态:**以服务端为准**。
|
||||
///
|
||||
/// 点击收藏先乐观更新(立刻变色),失败再回滚并给出提示 —— 网络抖动时不至于
|
||||
/// "点了没反应"或"看起来收藏了其实没有"。
|
||||
class FavoriteController extends ChangeNotifier {
|
||||
FavoriteController(this._repository, {this.pageSize = defaultPageSize});
|
||||
class FavoriteController extends PagedListController<Drama> {
|
||||
FavoriteController(this._repository, {this.pageSize = defaultPageSize})
|
||||
: super(fallbackErrorKey: 'favoritesError');
|
||||
|
||||
static const int defaultPageSize = 20;
|
||||
|
||||
@@ -19,17 +20,9 @@ class FavoriteController extends ChangeNotifier {
|
||||
final int pageSize;
|
||||
final Set<String> _saved = {};
|
||||
final Map<String, int> _favoriteCounts = {};
|
||||
List<Drama> cards = const [];
|
||||
int total = 0;
|
||||
bool loading = false;
|
||||
bool loadingMore = false;
|
||||
bool hasMore = false;
|
||||
String? error;
|
||||
String? loadMoreError;
|
||||
int? lastErrorCode;
|
||||
int _pageIndex = 0;
|
||||
int _requestSeq = 0;
|
||||
bool _disposed = false;
|
||||
|
||||
/// 收藏列表卡片(按服务端顺序)。
|
||||
List<Drama> get cards => items;
|
||||
|
||||
Set<String> get savedIds => Set.unmodifiable(_saved);
|
||||
|
||||
@@ -38,6 +31,33 @@ class FavoriteController extends ChangeNotifier {
|
||||
/// 当前剧收藏数;包含本地 toggle 增量。
|
||||
int? favoriteCount(String dramaId) => _favoriteCounts[dramaId];
|
||||
|
||||
/// 页面统一把收藏失败翻译成固定 key。
|
||||
@override
|
||||
String? errorKeyFor(Object failure) => 'favoritesError';
|
||||
|
||||
@override
|
||||
Future<PageResult<Drama>> fetchPage(int pageIndex) {
|
||||
return _repository.page(pageIndex: pageIndex, pageSize: pageSize);
|
||||
}
|
||||
|
||||
@override
|
||||
String itemKey(Drama item) => item.id;
|
||||
|
||||
/// 第一页到位后,用服务端列表校准收藏集合与计数。
|
||||
@override
|
||||
void onItemsReplaced() {
|
||||
_saved
|
||||
..clear()
|
||||
..addAll(items.map((drama) => drama.id));
|
||||
_rememberCardCounts(items);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onPageLoaded(List<Drama> added) async {
|
||||
_saved.addAll(added.map((drama) => drama.id));
|
||||
_rememberCardCounts(added);
|
||||
}
|
||||
|
||||
/// 首次展示时缓存卡片基数;之后统一由本控制器维护,避免页面各存一份。
|
||||
int? favoriteCountFor(Drama drama) {
|
||||
final current = _favoriteCounts[drama.id];
|
||||
@@ -82,81 +102,6 @@ class FavoriteController extends ChangeNotifier {
|
||||
if (notify) this.notify();
|
||||
}
|
||||
|
||||
void notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
/// 拉收藏列表:列表既用于"我的"页展示,也用于校准 [savedIds]。
|
||||
Future<void> load() async {
|
||||
final request = ++_requestSeq;
|
||||
loading = true;
|
||||
loadingMore = false;
|
||||
error = null;
|
||||
loadMoreError = null;
|
||||
lastErrorCode = null;
|
||||
notify();
|
||||
try {
|
||||
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));
|
||||
_rememberCardCounts(list);
|
||||
total = page.total;
|
||||
_pageIndex = 1;
|
||||
hasMore = page.list.isNotEmpty && cards.length < total;
|
||||
} catch (failure) {
|
||||
if (!_disposed && request == _requestSeq) {
|
||||
error = 'favoritesError';
|
||||
if (failure is ApiException) lastErrorCode = failure.code;
|
||||
}
|
||||
} finally {
|
||||
if (!_disposed && request == _requestSeq) {
|
||||
loading = false;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 追加下一页;正在刷新、已在加载或无更多时直接忽略。
|
||||
Future<void> loadMore() async {
|
||||
if (loading || loadingMore || !hasMore) return;
|
||||
final request = ++_requestSeq;
|
||||
loadingMore = true;
|
||||
loadMoreError = null;
|
||||
lastErrorCode = 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));
|
||||
_rememberCardCounts(appended);
|
||||
total = page.total;
|
||||
_pageIndex += 1;
|
||||
hasMore = page.list.isNotEmpty && cards.length < total;
|
||||
} catch (failure) {
|
||||
if (!_disposed && request == _requestSeq) {
|
||||
loadMoreError = 'favoritesError';
|
||||
if (failure is ApiException) lastErrorCode = failure.code;
|
||||
}
|
||||
} finally {
|
||||
if (!_disposed && request == _requestSeq) {
|
||||
loadingMore = false;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 每次切换都以接口状态为准;匿名请求由设备号归属。
|
||||
///
|
||||
/// [card] 是当前剧的卡片(播放页/详情页手上就有):收藏成功时直接放进列表,
|
||||
@@ -169,15 +114,13 @@ class FavoriteController extends ChangeNotifier {
|
||||
: favoriteCountFor(card);
|
||||
final previousCards = cards;
|
||||
final previousTotal = total;
|
||||
final previousHasMore = hasMore;
|
||||
// 乐观更新
|
||||
if (wasSaved) {
|
||||
_saved.remove(dramaId);
|
||||
_stepCount(dramaId, -1);
|
||||
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;
|
||||
setItems(cards.where((drama) => drama.id != dramaId).toList());
|
||||
setTotal(total - 1);
|
||||
}
|
||||
} else {
|
||||
_saved.add(dramaId);
|
||||
@@ -191,13 +134,11 @@ class FavoriteController extends ChangeNotifier {
|
||||
_saved.add(dramaId);
|
||||
if (wasSaved) {
|
||||
_restoreCount(dramaId, previousCount);
|
||||
cards = previousCards;
|
||||
total = previousTotal;
|
||||
hasMore = previousHasMore;
|
||||
setItems(previousCards);
|
||||
setTotal(previousTotal);
|
||||
} else if (card != null && !cards.any((drama) => drama.id == dramaId)) {
|
||||
cards = [card, ...cards];
|
||||
total += 1;
|
||||
hasMore = cards.length < total;
|
||||
setItems([card, ...cards]);
|
||||
setTotal(total + 1);
|
||||
}
|
||||
} else {
|
||||
_saved.remove(dramaId);
|
||||
@@ -205,18 +146,16 @@ class FavoriteController extends ChangeNotifier {
|
||||
_restoreCount(dramaId, previousCount);
|
||||
}
|
||||
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;
|
||||
setItems(cards.where((drama) => drama.id != dramaId).toList());
|
||||
setTotal(total - 1);
|
||||
}
|
||||
}
|
||||
notify();
|
||||
return true;
|
||||
} catch (failure) {
|
||||
// 回滚
|
||||
cards = previousCards;
|
||||
total = previousTotal;
|
||||
hasMore = previousHasMore;
|
||||
setItems(previousCards);
|
||||
setTotal(previousTotal);
|
||||
if (wasSaved) {
|
||||
_saved.add(dramaId);
|
||||
} else {
|
||||
@@ -235,27 +174,24 @@ class FavoriteController extends ChangeNotifier {
|
||||
if (ids.isEmpty) return true;
|
||||
final previousCards = cards;
|
||||
final previousTotal = total;
|
||||
final previousHasMore = hasMore;
|
||||
final removed = cards.where((drama) => ids.contains(drama.id)).toList();
|
||||
if (removed.isEmpty) return true;
|
||||
final previousCounts = {
|
||||
for (final drama in removed) drama.id: _favoriteCounts[drama.id],
|
||||
};
|
||||
cards = cards.where((drama) => !ids.contains(drama.id)).toList();
|
||||
setItems(cards.where((drama) => !ids.contains(drama.id)).toList());
|
||||
for (final id in ids) {
|
||||
_saved.remove(id);
|
||||
_stepCount(id, -1);
|
||||
}
|
||||
total = total > removed.length ? total - removed.length : 0;
|
||||
hasMore = cards.length < total;
|
||||
setTotal(total - removed.length);
|
||||
notify();
|
||||
try {
|
||||
await _repository.removeMany(dramaIds);
|
||||
return true;
|
||||
} catch (_) {
|
||||
cards = previousCards;
|
||||
total = previousTotal;
|
||||
hasMore = previousHasMore;
|
||||
setItems(previousCards);
|
||||
setTotal(previousTotal);
|
||||
for (final drama in removed) {
|
||||
_saved.add(drama.id);
|
||||
}
|
||||
@@ -276,9 +212,8 @@ class FavoriteController extends ChangeNotifier {
|
||||
_saved.remove(dramaId);
|
||||
_stepCount(dramaId, -1);
|
||||
final next = [...cards]..removeAt(index);
|
||||
cards = next;
|
||||
total = total > 0 ? total - 1 : 0;
|
||||
hasMore = cards.length < total;
|
||||
setItems(next);
|
||||
setTotal(total - 1);
|
||||
notify();
|
||||
try {
|
||||
await _repository.remove(dramaId);
|
||||
@@ -289,11 +224,10 @@ class FavoriteController extends ChangeNotifier {
|
||||
index > restored.length ? restored.length : index,
|
||||
removed,
|
||||
);
|
||||
cards = restored;
|
||||
setItems(restored);
|
||||
_saved.add(dramaId);
|
||||
_restoreCount(dramaId, previousCount);
|
||||
total += 1;
|
||||
hasMore = cards.length < total;
|
||||
setTotal(total + 1);
|
||||
notify();
|
||||
return false;
|
||||
}
|
||||
@@ -301,27 +235,11 @@ class FavoriteController extends ChangeNotifier {
|
||||
|
||||
/// 退出登录时清空:收藏状态属于账号,不能跨账号串联。
|
||||
void clear() {
|
||||
_requestSeq += 1;
|
||||
resetPaging();
|
||||
_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,
|
||||
];
|
||||
}
|
||||
|
||||
void _rememberCardCounts(List<Drama> dramas) {
|
||||
for (final drama in dramas) {
|
||||
final count = drama.favoriteCount;
|
||||
@@ -345,10 +263,4 @@ class FavoriteController extends ChangeNotifier {
|
||||
_favoriteCounts[dramaId] = count;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,223 +1,41 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../data/models/drama.dart';
|
||||
import '../../data/models/feed_page.dart';
|
||||
import '../../data/repositories/catalog_repository.dart';
|
||||
import '../../../pages/home/home_feed_channel.dart';
|
||||
import 'channel_feed_controller.dart';
|
||||
|
||||
/// 首页内容类型频道状态:每频道独立缓存、刷新和游标分页。
|
||||
class HomeFeedController extends ChangeNotifier {
|
||||
HomeFeedController(
|
||||
this._repository, {
|
||||
this.requestTimeout = defaultRequestTimeout,
|
||||
}) : _states = {
|
||||
for (final channel in HomeFeedChannel.values)
|
||||
channel: _ChannelFeedState(),
|
||||
};
|
||||
|
||||
static const int pageSize = 20;
|
||||
static const Duration defaultRequestTimeout = Duration(seconds: 10);
|
||||
class HomeFeedController extends ChannelFeedController<HomeFeedChannel> {
|
||||
HomeFeedController(this._repository, {super.requestTimeout})
|
||||
: super(HomeFeedChannel.recommend);
|
||||
|
||||
final CatalogRepository _repository;
|
||||
final Duration requestTimeout;
|
||||
final Map<HomeFeedChannel, _ChannelFeedState> _states;
|
||||
HomeFeedChannel _channel = HomeFeedChannel.recommend;
|
||||
bool _disposed = false;
|
||||
|
||||
HomeFeedChannel get channel => _channel;
|
||||
String? get lastError => _state(_channel).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 isLoading => _state(_channel).loading;
|
||||
bool get loadingMore => _state(_channel).loadingMore;
|
||||
bool get loadingFirstPage => !loaded && _state(_channel).loading;
|
||||
HomeFeedChannel get channel => currentKey;
|
||||
|
||||
/// 用首页聚合接口打平的数据填充推荐首屏,不发起网络请求。
|
||||
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
|
||||
..loading = false
|
||||
..loadingMore = false
|
||||
..lastError = null
|
||||
..inFlight = null;
|
||||
_notifyIfCurrent(HomeFeedChannel.recommend);
|
||||
}
|
||||
void seedRecommend(List<Drama> dramas) =>
|
||||
seedKey(HomeFeedChannel.recommend, dramas);
|
||||
|
||||
/// 后台预取漫剧与真人剧首屏,不切换频道,也不影响当前列表。
|
||||
Future<void> prefetchChannels() async {
|
||||
await Future.wait([
|
||||
_prefetchChannel(HomeFeedChannel.anime),
|
||||
_prefetchChannel(HomeFeedChannel.live),
|
||||
prefetchKey(HomeFeedChannel.anime),
|
||||
prefetchKey(HomeFeedChannel.live),
|
||||
]);
|
||||
}
|
||||
|
||||
/// 同步切到目标频道;有缓存先渲染,无缓存时复用或等待在飞请求。
|
||||
Future<void> selectChannel(HomeFeedChannel next) async {
|
||||
if (next == _channel) return;
|
||||
_channel = next;
|
||||
_notify();
|
||||
final state = _state(next);
|
||||
if (state.dramas.isNotEmpty) {
|
||||
if (state.skipNextRefresh) {
|
||||
state.skipNextRefresh = false;
|
||||
} else if (!state.loading) {
|
||||
unawaited(_refresh(next, keepData: true));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (state.loading) {
|
||||
final inFlight = state.inFlight;
|
||||
if (inFlight != null) await inFlight;
|
||||
state.skipNextRefresh = false;
|
||||
return;
|
||||
}
|
||||
await _refresh(next, keepData: false);
|
||||
}
|
||||
|
||||
/// 刷新当前频道,失败时保留旧数据。
|
||||
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 {
|
||||
final channel = _channel;
|
||||
final state = _state(channel);
|
||||
if (!state.hasMore || state.loadingMore || state.cursor == null) return;
|
||||
final seq = ++state.requestSeq;
|
||||
state.loadingMore = true;
|
||||
_notifyIfCurrent(channel);
|
||||
try {
|
||||
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))
|
||||
.toList();
|
||||
state
|
||||
..dramas = List.unmodifiable([...state.dramas, ...appended])
|
||||
..cursor = page.nextCursor
|
||||
..hasMore = page.hasMore
|
||||
..loaded = true
|
||||
..lastError = null;
|
||||
} catch (error) {
|
||||
if (_acceptSequence(channel, seq)) {
|
||||
state.lastError = error.toString();
|
||||
}
|
||||
} finally {
|
||||
state.loadingMore = false;
|
||||
_notifyIfCurrent(channel);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _refresh(HomeFeedChannel channel, {required bool keepData}) {
|
||||
final state = _state(channel);
|
||||
final operation = _performRefresh(channel, keepData: keepData);
|
||||
state.inFlight = operation;
|
||||
unawaited(_clearInFlight(state, operation));
|
||||
return operation;
|
||||
}
|
||||
|
||||
Future<void> _prefetchChannel(HomeFeedChannel channel) async {
|
||||
final state = _state(channel);
|
||||
if (state.loaded || state.loading) return;
|
||||
final success = await _refresh(channel, keepData: false);
|
||||
if (success && !_disposed) state.skipNextRefresh = true;
|
||||
}
|
||||
|
||||
Future<bool> _performRefresh(
|
||||
HomeFeedChannel channel, {
|
||||
required bool keepData,
|
||||
}) async {
|
||||
final state = _state(channel);
|
||||
final seq = ++state.requestSeq;
|
||||
state
|
||||
..loading = true
|
||||
..lastError = null;
|
||||
_notifyIfCurrent(channel);
|
||||
try {
|
||||
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
|
||||
..loading = false
|
||||
..lastError = null;
|
||||
_notifyIfCurrent(channel);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!_acceptSequence(channel, seq)) return false;
|
||||
if (!keepData) state.dramas = const [];
|
||||
state
|
||||
..loaded = true
|
||||
..loading = false
|
||||
..lastError = error.toString();
|
||||
_notifyIfCurrent(channel);
|
||||
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]!;
|
||||
|
||||
bool _acceptSequence(HomeFeedChannel channel, int seq) =>
|
||||
!_disposed && _state(channel).requestSeq == seq;
|
||||
|
||||
void _notifyIfCurrent(HomeFeedChannel channel) {
|
||||
if (!_disposed && channel == _channel) _notify();
|
||||
}
|
||||
|
||||
void _notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
Future<void> selectChannel(HomeFeedChannel next) => selectKey(next);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
Future<FeedPage> fetchPage(HomeFeedChannel key, {String? cursor}) {
|
||||
return _repository.fetchFeed(
|
||||
types: key.apiTypes,
|
||||
cursor: cursor,
|
||||
size: ChannelFeedController.pageSize,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChannelFeedState {
|
||||
List<Drama> dramas = const [];
|
||||
String? cursor;
|
||||
bool hasMore = true;
|
||||
bool loaded = false;
|
||||
bool loading = false;
|
||||
bool loadingMore = false;
|
||||
String? lastError;
|
||||
int requestSeq = 0;
|
||||
Future<bool>? inFlight;
|
||||
bool skipNextRefresh = false;
|
||||
}
|
||||
@@ -1,118 +1,30 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/notice.dart';
|
||||
import '../remote/api_client.dart';
|
||||
import '../models/page_result.dart';
|
||||
import '../repositories/notice_repository.dart';
|
||||
import 'paged_list_controller.dart';
|
||||
|
||||
/// Pagination and state controller for the anonymous notices page.
|
||||
class NoticesController extends ChangeNotifier {
|
||||
NoticesController(this._repository, {this.pageSize = 20});
|
||||
/// 公告页状态:匿名可看,按页追加。
|
||||
class NoticesController extends PagedListController<AppNotice> {
|
||||
NoticesController(this._repository, {this.pageSize = 20})
|
||||
: super(fallbackErrorKey: 'noticesLoadFailed');
|
||||
|
||||
final NoticeRepository _repository;
|
||||
final int pageSize;
|
||||
final List<AppNotice> _notices = [];
|
||||
int _pageIndex = 0;
|
||||
int _total = 0;
|
||||
bool _disposed = false;
|
||||
bool loading = false;
|
||||
bool loadingMore = false;
|
||||
bool refreshing = false;
|
||||
String? error;
|
||||
String? loadMoreError;
|
||||
|
||||
/// Loaded notices in server order.
|
||||
List<AppNotice> get notices => List.unmodifiable(_notices);
|
||||
/// 当前已加载公告(服务端顺序)。
|
||||
List<AppNotice> get notices => items;
|
||||
|
||||
/// Whether another page is expected from the latest total.
|
||||
bool get hasMore => _notices.length < _total;
|
||||
|
||||
/// Loads the first page.
|
||||
Future<void> load() async {
|
||||
loading = true;
|
||||
error = null;
|
||||
loadMoreError = null;
|
||||
_notify();
|
||||
await _replaceFirstPage();
|
||||
loading = false;
|
||||
_notify();
|
||||
}
|
||||
|
||||
/// Refreshes from page one while keeping the current page visible.
|
||||
Future<void> refresh() async {
|
||||
refreshing = true;
|
||||
error = null;
|
||||
loadMoreError = null;
|
||||
_notify();
|
||||
await _replaceFirstPage();
|
||||
refreshing = false;
|
||||
_notify();
|
||||
}
|
||||
|
||||
/// Appends the next page when loaded count is below total.
|
||||
Future<void> loadMore() async {
|
||||
if (loading || loadingMore || refreshing || !hasMore) return;
|
||||
loadingMore = true;
|
||||
loadMoreError = null;
|
||||
_notify();
|
||||
try {
|
||||
final page = await _repository.page(
|
||||
pageIndex: _pageIndex + 1,
|
||||
pageSize: pageSize,
|
||||
);
|
||||
_appendUnique(page.notices);
|
||||
_pageIndex++;
|
||||
_total = page.total;
|
||||
} catch (failure, stackTrace) {
|
||||
debugPrint('[NoticesController] load more failed: $failure');
|
||||
debugPrintStack(
|
||||
stackTrace: stackTrace,
|
||||
label: '[NoticesController] load more failed',
|
||||
);
|
||||
loadMoreError = _message(failure) ?? 'noticesLoadFailed';
|
||||
} finally {
|
||||
loadingMore = false;
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _replaceFirstPage() async {
|
||||
try {
|
||||
final page = await _repository.page(pageIndex: 1, pageSize: pageSize);
|
||||
if (_disposed) return;
|
||||
_notices
|
||||
..clear()
|
||||
..addAll(page.notices);
|
||||
_pageIndex = 1;
|
||||
_total = page.total;
|
||||
} catch (failure, stackTrace) {
|
||||
debugPrint('[NoticesController] load failed: $failure');
|
||||
debugPrintStack(
|
||||
stackTrace: stackTrace,
|
||||
label: '[NoticesController] load failed',
|
||||
);
|
||||
if (!_disposed) error = _message(failure) ?? 'noticesLoadFailed';
|
||||
}
|
||||
}
|
||||
|
||||
void _appendUnique(List<AppNotice> incoming) {
|
||||
final ids = _notices.map((notice) => notice.id).toSet();
|
||||
for (final notice in incoming) {
|
||||
if (ids.add(notice.id)) _notices.add(notice);
|
||||
}
|
||||
}
|
||||
|
||||
String? _message(Object failure) =>
|
||||
failure is ApiException ? failure.message : null;
|
||||
|
||||
void _notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
@override
|
||||
Future<PageResult<AppNotice>> fetchPage(int pageIndex) async {
|
||||
final page = await _repository.page(
|
||||
pageIndex: pageIndex,
|
||||
pageSize: pageSize,
|
||||
);
|
||||
return PageResult(total: page.total, list: page.notices);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
String itemKey(AppNotice item) => item.id;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/page_result.dart';
|
||||
import '../remote/api_client.dart';
|
||||
|
||||
/// 分页列表控制器基类(`pageIndex` + `total` 模式)。
|
||||
///
|
||||
/// 收藏、观看记录、剧单、公告四个页面的分页逻辑此前各写一份:请求序号防串台、
|
||||
/// loading/loadingMore 互斥、跨页去重追加、hasMore 判定 —— 修一处 bug 要同步
|
||||
/// 抄四遍。这里收口成模板方法,子类只回答两件事:怎么取一页、怎么标识一条。
|
||||
abstract class PagedListController<T> extends ChangeNotifier {
|
||||
PagedListController({this.fallbackErrorKey, this.dedupe = true});
|
||||
|
||||
/// 非 [ApiException] 的失败文案 key(页面用 `errorText` 翻译)。
|
||||
final String? fallbackErrorKey;
|
||||
|
||||
/// 是否跨页按 [itemKey] 去重。
|
||||
final bool dedupe;
|
||||
|
||||
List<T> _items = const [];
|
||||
int _pageIndex = 0;
|
||||
int _total = 0;
|
||||
int _requestSeq = 0;
|
||||
bool _disposed = false;
|
||||
|
||||
bool loading = false;
|
||||
bool loadingMore = false;
|
||||
bool refreshing = false;
|
||||
bool loaded = false;
|
||||
String? error;
|
||||
String? loadMoreError;
|
||||
int? lastErrorCode;
|
||||
|
||||
/// 当前已加载条目(按服务端顺序)。
|
||||
List<T> get items => _items;
|
||||
int get total => _total;
|
||||
int get pageIndex => _pageIndex;
|
||||
|
||||
/// 已加载数未达总数即视为还有下一页。
|
||||
bool get hasMore => _items.length < _total;
|
||||
|
||||
@protected
|
||||
bool get isDisposed => _disposed;
|
||||
|
||||
/// 子类实现:取第 [pageIndex] 页(从 1 开始)。
|
||||
@protected
|
||||
Future<PageResult<T>> fetchPage(int pageIndex);
|
||||
|
||||
/// 子类实现:跨页去重用的稳定标识。
|
||||
@protected
|
||||
String itemKey(T item);
|
||||
|
||||
/// 失败文案:默认优先用后端消息,其次 [fallbackErrorKey]。
|
||||
@protected
|
||||
String? errorKeyFor(Object failure) {
|
||||
if (failure is ApiException && failure.message.isNotEmpty) {
|
||||
return failure.message;
|
||||
}
|
||||
return fallbackErrorKey;
|
||||
}
|
||||
|
||||
/// 第一页替换完成后的同步钩子(如同步收藏集合)。
|
||||
@protected
|
||||
void onItemsReplaced() {}
|
||||
|
||||
/// 每页数据写入后的异步钩子(如补取详情)。
|
||||
@protected
|
||||
Future<void> onPageLoaded(List<T> added) async {}
|
||||
|
||||
/// 子类做局部乐观更新时改写列表。
|
||||
@protected
|
||||
void setItems(List<T> next) => _items = next;
|
||||
|
||||
/// 子类做局部乐观更新时改写总数。
|
||||
@protected
|
||||
void setTotal(int value) => _total = value < 0 ? 0 : value;
|
||||
|
||||
/// 子类回滚分页游标。
|
||||
@protected
|
||||
void restorePageIndex(int value) => _pageIndex = value;
|
||||
|
||||
/// 子类清空/重置分页状态,并让在飞请求失效。
|
||||
@protected
|
||||
void resetPaging() {
|
||||
_requestSeq += 1;
|
||||
_items = const [];
|
||||
_total = 0;
|
||||
_pageIndex = 0;
|
||||
loading = false;
|
||||
loadingMore = false;
|
||||
refreshing = false;
|
||||
loaded = false;
|
||||
error = null;
|
||||
loadMoreError = null;
|
||||
lastErrorCode = null;
|
||||
}
|
||||
|
||||
/// 让在飞请求失效(切换身份、清空等)。
|
||||
@protected
|
||||
void invalidateRequests() => _requestSeq += 1;
|
||||
|
||||
/// 重新拉第一页;失败时保留旧数据。
|
||||
Future<void> load() => _fetchFirstPage(refresh: false);
|
||||
|
||||
/// 刷新第一页,且不清空当前列表(滚动位置不跳)。
|
||||
Future<void> refresh() => _fetchFirstPage(refresh: true);
|
||||
|
||||
/// 第一页请求会抢占在飞的分页请求:被抢占的一方不再复位标志,
|
||||
/// 所以三个 loading 标志必须在这里统一接管,否则会永远停在 true。
|
||||
Future<void> _fetchFirstPage({required bool refresh}) async {
|
||||
final request = ++_requestSeq;
|
||||
loading = !refresh;
|
||||
refreshing = refresh;
|
||||
loadingMore = false;
|
||||
error = null;
|
||||
loadMoreError = null;
|
||||
lastErrorCode = null;
|
||||
notify();
|
||||
try {
|
||||
final page = await fetchPage(1);
|
||||
if (_stale(request)) return;
|
||||
final list = _unique(page.list);
|
||||
_items = list;
|
||||
_total = page.total;
|
||||
_pageIndex = 1;
|
||||
loaded = true;
|
||||
onItemsReplaced();
|
||||
notify();
|
||||
await onPageLoaded(list);
|
||||
} catch (failure) {
|
||||
if (!_stale(request)) _recordError(failure, more: false);
|
||||
} finally {
|
||||
if (!_stale(request)) {
|
||||
loading = false;
|
||||
refreshing = false;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 追加下一页;刷新中、加载中或没有更多时忽略。
|
||||
Future<void> loadMore() async {
|
||||
if (loading || loadingMore || refreshing || !hasMore) return;
|
||||
final request = ++_requestSeq;
|
||||
loadingMore = true;
|
||||
loadMoreError = null;
|
||||
lastErrorCode = null;
|
||||
notify();
|
||||
try {
|
||||
final page = await fetchPage(_pageIndex + 1);
|
||||
if (_stale(request)) return;
|
||||
// 关掉去重的列表(如剧单)按服务端顺序原样追加,不做跨页过滤。
|
||||
final appended = dedupe ? _newItems(page.list) : page.list;
|
||||
_items = [..._items, ...appended];
|
||||
// 服务端 total 偏大但已翻到空页时,按已加载数收口,避免反复请求空页。
|
||||
_total = page.list.isEmpty ? _items.length : page.total;
|
||||
_pageIndex += 1;
|
||||
notify();
|
||||
await onPageLoaded(appended);
|
||||
} catch (failure) {
|
||||
if (!_stale(request)) _recordError(failure, more: true);
|
||||
} finally {
|
||||
if (!_stale(request)) {
|
||||
loadingMore = false;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<T> _unique(List<T> source) {
|
||||
if (!dedupe) return source;
|
||||
final seen = <String>{};
|
||||
return [
|
||||
for (final item in source)
|
||||
if (seen.add(itemKey(item))) item,
|
||||
];
|
||||
}
|
||||
|
||||
/// 追加页去重:既要过滤页内重复,也要过滤与已加载条目重复。
|
||||
List<T> _newItems(List<T> source) {
|
||||
final known = _items.map(itemKey).toSet();
|
||||
return [
|
||||
for (final item in source)
|
||||
if (known.add(itemKey(item))) item,
|
||||
];
|
||||
}
|
||||
|
||||
void _recordError(Object failure, {required bool more}) {
|
||||
if (kDebugMode) {
|
||||
final phase = more ? 'load more' : 'load';
|
||||
debugPrint('[$runtimeType] $phase failed: $failure');
|
||||
}
|
||||
final message = errorKeyFor(failure);
|
||||
if (more) {
|
||||
loadMoreError = message;
|
||||
} else {
|
||||
error = message;
|
||||
}
|
||||
if (failure is ApiException) lastErrorCode = failure.code;
|
||||
}
|
||||
|
||||
bool _stale(int request) => _disposed || request != _requestSeq;
|
||||
|
||||
@protected
|
||||
void notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -2,71 +2,32 @@
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/page_result.dart';
|
||||
import '../models/playlist_detail.dart';
|
||||
import '../remote/catalog_api.dart';
|
||||
import '../repositories/playlist_repository.dart';
|
||||
import 'paged_list_controller.dart';
|
||||
|
||||
/// 推荐页剧单:已启用剧单及其短剧卡片。
|
||||
class PlaylistController extends ChangeNotifier {
|
||||
PlaylistController(this._repository);
|
||||
|
||||
final PlaylistRepository _repository;
|
||||
class PlaylistController extends PagedListController<PlaylistDetail> {
|
||||
PlaylistController(this._repository)
|
||||
: super(fallbackErrorKey: 'playlistsError', dedupe: false);
|
||||
|
||||
static const int pageSize = CatalogApi.playlistPageSize;
|
||||
|
||||
List<PlaylistDetail> items = const [];
|
||||
bool loading = false;
|
||||
bool loadingMore = false;
|
||||
bool loaded = false;
|
||||
bool hasMore = false;
|
||||
String? error;
|
||||
int _pageIndex = 1;
|
||||
bool _disposed = false;
|
||||
final PlaylistRepository _repository;
|
||||
|
||||
/// 从第一页重新拉取。失败保留错误态,成功后替换本地缓存。
|
||||
Future<void> load() async {
|
||||
loading = true;
|
||||
error = null;
|
||||
_notify();
|
||||
try {
|
||||
final page = await _repository.fetchPlaylists(pageSize: pageSize);
|
||||
items = page.list;
|
||||
_pageIndex = 1;
|
||||
hasMore = items.length < page.total;
|
||||
loaded = true;
|
||||
} catch (exception) {
|
||||
error = 'playlistsError';
|
||||
if (kDebugMode) debugPrint('[playlist] $exception');
|
||||
} finally {
|
||||
loading = false;
|
||||
_notify();
|
||||
}
|
||||
@override
|
||||
Future<PageResult<PlaylistDetail>> fetchPage(int pageIndex) {
|
||||
return _repository.fetchPlaylists(pageIndex: pageIndex, pageSize: pageSize);
|
||||
}
|
||||
|
||||
/// 滚到底时追加下一页。没有更多或正在请求时直接返回。
|
||||
Future<void> loadMore() async {
|
||||
if (loading || loadingMore || !hasMore) return;
|
||||
loadingMore = true;
|
||||
_notify();
|
||||
try {
|
||||
final page = await _repository.fetchPlaylists(
|
||||
pageIndex: _pageIndex + 1,
|
||||
pageSize: pageSize,
|
||||
);
|
||||
if (page.list.isEmpty) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
items = [...items, ...page.list];
|
||||
_pageIndex += 1;
|
||||
hasMore = items.length < page.total;
|
||||
}
|
||||
} catch (exception) {
|
||||
if (kDebugMode) debugPrint('[playlist] more $exception');
|
||||
} finally {
|
||||
loadingMore = false;
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
@override
|
||||
String itemKey(PlaylistDetail item) => item.id;
|
||||
|
||||
/// 剧单页把失败统一收敛成固定 key,由页面翻译成文案。
|
||||
@override
|
||||
String? errorKeyFor(Object failure) => 'playlistsError';
|
||||
|
||||
/// 打开某一剧单时再取一次详情;失败则回落到列表里已有的那条。
|
||||
Future<PlaylistDetail> detail(
|
||||
@@ -94,14 +55,4 @@ class PlaylistController extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,187 +1,27 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../data/models/drama.dart';
|
||||
import '../../data/models/feed_page.dart';
|
||||
import '../../data/repositories/catalog_repository.dart';
|
||||
import 'channel_feed_controller.dart';
|
||||
|
||||
/// 剧场页服务端分类列表:按 categoryId 独立缓存、刷新和游标分页。
|
||||
class TheaterFeedController extends ChangeNotifier {
|
||||
TheaterFeedController(
|
||||
this._repository, {
|
||||
this.requestTimeout = defaultRequestTimeout,
|
||||
});
|
||||
|
||||
static const int pageSize = 20;
|
||||
static const Duration defaultRequestTimeout = Duration(seconds: 10);
|
||||
class TheaterFeedController extends ChannelFeedController<String?> {
|
||||
TheaterFeedController(this._repository, {super.requestTimeout})
|
||||
: super(null, initiallySelected: false);
|
||||
|
||||
final CatalogRepository _repository;
|
||||
final Duration requestTimeout;
|
||||
final Map<String?, _CategoryFeedState> _states = {};
|
||||
String? _categoryId;
|
||||
bool _initialized = false;
|
||||
bool _disposed = false;
|
||||
|
||||
String? get categoryId => _categoryId;
|
||||
String? get lastError => _state(_categoryId).lastError;
|
||||
List<Drama> get dramas => _state(_categoryId).dramas;
|
||||
String? get cursor => _state(_categoryId).cursor;
|
||||
bool get hasMore => _state(_categoryId).hasMore;
|
||||
bool get loaded => _state(_categoryId).loaded;
|
||||
bool get loadingMore => _state(_categoryId).loadingMore;
|
||||
bool get loadingFirstPage => !loaded && _state(_categoryId).loading;
|
||||
String? get categoryId => currentKey;
|
||||
|
||||
/// 同步切换分类;有缓存先渲染,无缓存时等待或发起首屏请求。
|
||||
Future<void> select(String? next) async {
|
||||
if (_initialized && next == _categoryId) return;
|
||||
_categoryId = next;
|
||||
_initialized = true;
|
||||
_notify();
|
||||
|
||||
final state = _state(next);
|
||||
if (state.dramas.isNotEmpty) {
|
||||
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<bool> refreshCurrent() async {
|
||||
final state = _state(_categoryId);
|
||||
if (state.loading) {
|
||||
return await state.inFlight ?? false;
|
||||
}
|
||||
return _refresh(_categoryId, keepData: true);
|
||||
}
|
||||
|
||||
/// 加载当前分类下一页并去重追加。
|
||||
Future<void> loadMore() async {
|
||||
final categoryId = _categoryId;
|
||||
final state = _state(categoryId);
|
||||
if (!state.hasMore || state.loadingMore || state.cursor == null) return;
|
||||
final sequence = ++state.requestSeq;
|
||||
state.loadingMore = true;
|
||||
_notifyIfCurrent(categoryId);
|
||||
try {
|
||||
final page = await _repository
|
||||
.fetchFeed(
|
||||
categoryId: categoryId,
|
||||
cursor: state.cursor,
|
||||
size: pageSize,
|
||||
)
|
||||
.timeout(requestTimeout);
|
||||
if (!_acceptSequence(categoryId, sequence)) 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 (_acceptSequence(categoryId, sequence)) {
|
||||
state.lastError = error.toString();
|
||||
}
|
||||
} finally {
|
||||
state.loadingMore = false;
|
||||
_notifyIfCurrent(categoryId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _refresh(String? categoryId, {required bool keepData}) {
|
||||
final state = _state(categoryId);
|
||||
final operation = _performRefresh(categoryId, keepData: keepData);
|
||||
state.inFlight = operation;
|
||||
unawaited(_clearInFlight(state, operation));
|
||||
return operation;
|
||||
}
|
||||
|
||||
Future<bool> _performRefresh(
|
||||
String? categoryId, {
|
||||
required bool keepData,
|
||||
}) async {
|
||||
final state = _state(categoryId);
|
||||
final sequence = ++state.requestSeq;
|
||||
state
|
||||
..loading = true
|
||||
..lastError = null;
|
||||
_notifyIfCurrent(categoryId);
|
||||
try {
|
||||
final page = await _repository
|
||||
.fetchFeed(categoryId: categoryId, size: pageSize)
|
||||
.timeout(requestTimeout);
|
||||
if (!_acceptSequence(categoryId, sequence)) return false;
|
||||
state
|
||||
..dramas = List.unmodifiable(page.dramas)
|
||||
..cursor = page.nextCursor
|
||||
..hasMore = page.hasMore
|
||||
..loaded = true
|
||||
..loading = false
|
||||
..lastError = null;
|
||||
_notifyIfCurrent(categoryId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!_acceptSequence(categoryId, sequence)) return false;
|
||||
if (!keepData) state.dramas = const [];
|
||||
state
|
||||
..loaded = true
|
||||
..loading = false
|
||||
..lastError = error.toString();
|
||||
_notifyIfCurrent(categoryId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearInFlight(
|
||||
_CategoryFeedState state,
|
||||
Future<bool> operation,
|
||||
) async {
|
||||
await operation;
|
||||
if (identical(state.inFlight, operation)) state.inFlight = null;
|
||||
}
|
||||
|
||||
_CategoryFeedState _state(String? categoryId) =>
|
||||
_states.putIfAbsent(categoryId, _CategoryFeedState.new);
|
||||
|
||||
bool _acceptSequence(String? categoryId, int sequence) =>
|
||||
!_disposed && _state(categoryId).requestSeq == sequence;
|
||||
|
||||
void _notifyIfCurrent(String? categoryId) {
|
||||
if (!_disposed && categoryId == _categoryId) _notify();
|
||||
}
|
||||
|
||||
void _notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
Future<void> select(String? next) => selectKey(next);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
Future<FeedPage> fetchPage(String? key, {String? cursor}) {
|
||||
return _repository.fetchFeed(
|
||||
categoryId: key,
|
||||
cursor: cursor,
|
||||
size: ChannelFeedController.pageSize,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryFeedState {
|
||||
List<Drama> dramas = const [];
|
||||
String? cursor;
|
||||
bool hasMore = true;
|
||||
bool loaded = false;
|
||||
bool loading = false;
|
||||
bool loadingMore = false;
|
||||
String? lastError;
|
||||
int requestSeq = 0;
|
||||
Future<bool>? inFlight;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
/// 本次登录期内已确认可播的收费集。
|
||||
///
|
||||
/// 解锁本身是服务端行为(`POST /app/episodes/{id}/unlock` 成功即生效),
|
||||
/// 客户端只需要记住「这一集不用再挡」。若不记住,退出播放页再进来会先闪
|
||||
/// 一次锁定态,且会被 [PlaybackSession.selectLocked] 提前释放播放器。
|
||||
///
|
||||
/// 生命周期跟随账号:登录态变化时由 `app.dart` 调用 [clear],避免换账号后
|
||||
/// 沿用上一个人的解锁结论。
|
||||
class UnlockedEpisodeRegistry {
|
||||
final Set<String> _episodeIds = <String>{};
|
||||
|
||||
bool contains(String episodeId) => _episodeIds.contains(episodeId);
|
||||
|
||||
void markUnlocked(String episodeId) {
|
||||
if (episodeId.isEmpty) return;
|
||||
_episodeIds.add(episodeId);
|
||||
}
|
||||
|
||||
void clear() => _episodeIds.clear();
|
||||
}
|
||||
@@ -3,23 +3,26 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/drama.dart';
|
||||
import '../models/page_result.dart';
|
||||
import '../models/watch_progress.dart';
|
||||
import '../models/watch_record.dart';
|
||||
import '../remote/api_client.dart';
|
||||
import '../repositories/catalog_repository.dart';
|
||||
import '../repositories/watch_history_repository.dart';
|
||||
import 'paged_list_controller.dart';
|
||||
|
||||
/// 独立观看记录页状态。
|
||||
///
|
||||
/// 分页接口提供标题/封面/集号;详情按 dramaId 去重后补取,供点击播放使用。
|
||||
class WatchRecordsController extends ChangeNotifier {
|
||||
class WatchRecordsController extends PagedListController<WatchRecord> {
|
||||
factory WatchRecordsController({
|
||||
required WatchHistoryRepository history,
|
||||
required CatalogRepository catalog,
|
||||
int pageSize = defaultPageSize,
|
||||
}) => WatchRecordsController._(history, catalog, pageSize);
|
||||
|
||||
WatchRecordsController._(this._history, this._catalog, this.pageSize);
|
||||
WatchRecordsController._(this._history, this._catalog, this.pageSize)
|
||||
: super(fallbackErrorKey: 'progressError');
|
||||
|
||||
static const int defaultPageSize = 20;
|
||||
static const int detailConcurrency = 4;
|
||||
@@ -29,98 +32,32 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
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? lastErrorCode;
|
||||
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;
|
||||
lastErrorCode = null;
|
||||
_notify();
|
||||
try {
|
||||
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) {
|
||||
debugPrint(
|
||||
'[WatchRecordsController] recent failed '
|
||||
'${failure.runtimeType}: $failure',
|
||||
);
|
||||
debugPrintStack(
|
||||
stackTrace: stackTrace,
|
||||
label: '[WatchRecordsController] recent failed',
|
||||
);
|
||||
if (!_disposed && request == _requestSeq) {
|
||||
error = failure is ApiException ? failure.message : 'progressError';
|
||||
if (failure is ApiException) lastErrorCode = failure.code;
|
||||
}
|
||||
} finally {
|
||||
if (!_disposed && request == _requestSeq) {
|
||||
loading = false;
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
/// 当前已加载记录(按服务端顺序)。
|
||||
List<WatchRecord> get records => items;
|
||||
|
||||
@override
|
||||
Future<PageResult<WatchRecord>> fetchPage(int pageIndex) async {
|
||||
final page = await _history.recent(
|
||||
pageIndex: pageIndex,
|
||||
pageSize: pageSize,
|
||||
);
|
||||
return PageResult(total: page.total, list: _uniqueRecords(page.list));
|
||||
}
|
||||
|
||||
/// 追加下一页;刷新、加载中或无更多时忽略。
|
||||
Future<void> loadMore() async {
|
||||
if (loading || loadingMore || !hasMore) return;
|
||||
final request = ++_requestSeq;
|
||||
loadingMore = true;
|
||||
loadMoreError = null;
|
||||
lastErrorCode = 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';
|
||||
if (failure is ApiException) lastErrorCode = failure.code;
|
||||
}
|
||||
} finally {
|
||||
if (!_disposed && request == _requestSeq) {
|
||||
loadingMore = false;
|
||||
_notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
@override
|
||||
String itemKey(WatchRecord item) => item.dramaId;
|
||||
|
||||
/// 第一页成功替换后清掉上一轮的操作错误(如删除失败提示)。
|
||||
@override
|
||||
void onItemsReplaced() => actionError = null;
|
||||
|
||||
/// 只补缺标题的详情,单条失败不拖垮整页。
|
||||
@override
|
||||
Future<void> onPageLoaded(List<WatchRecord> added) =>
|
||||
_hydrate(added.map((record) => record.dramaId).toList());
|
||||
|
||||
/// 只重试一条详情失败记录。
|
||||
Future<void> retryDetail(String dramaId) async {
|
||||
@@ -130,9 +67,9 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
index,
|
||||
records[index].copyWith(detailLoading: true, detailFailed: false),
|
||||
);
|
||||
_notify();
|
||||
notify();
|
||||
await _loadDetail(dramaId);
|
||||
_notify();
|
||||
notify();
|
||||
}
|
||||
|
||||
/// 点击有标题的记录时再按需加载详情,避免列表阶段产生 N+1。
|
||||
@@ -140,7 +77,7 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
final cached = _detailCache[dramaId];
|
||||
if (cached != null) return cached;
|
||||
await _loadDetail(dramaId);
|
||||
_notify();
|
||||
notify();
|
||||
return _detailCache[dramaId];
|
||||
}
|
||||
|
||||
@@ -157,62 +94,66 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
final index = _recordIndex(dramaId);
|
||||
if (index < 0) return true;
|
||||
final removed = records[index];
|
||||
records = [...records]..removeAt(index);
|
||||
total = total > 0 ? total - 1 : 0;
|
||||
hasMore = records.length < total;
|
||||
setItems([...records]..removeAt(index));
|
||||
setTotal(total - 1);
|
||||
actionError = null;
|
||||
lastErrorCode = null;
|
||||
_notify();
|
||||
notify();
|
||||
try {
|
||||
await _history.remove(dramaId);
|
||||
return true;
|
||||
} catch (failure) {
|
||||
final next = [...records];
|
||||
final next = [...items];
|
||||
next.insert(index > next.length ? next.length : index, removed);
|
||||
records = next;
|
||||
total += 1;
|
||||
hasMore = records.length < total;
|
||||
setItems(next);
|
||||
setTotal(total + 1);
|
||||
actionError = 'recordDeleteFailed';
|
||||
if (failure is ApiException) lastErrorCode = failure.code;
|
||||
return false;
|
||||
} finally {
|
||||
_notify();
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// 清空全部记录,先更新 UI,失败恢复原列表。
|
||||
Future<bool> clear() async {
|
||||
if (records.isEmpty) return true;
|
||||
_requestSeq += 1;
|
||||
invalidateRequests();
|
||||
final previous = records;
|
||||
final previousTotal = total;
|
||||
final previousPageIndex = _pageIndex;
|
||||
records = const [];
|
||||
total = 0;
|
||||
hasMore = false;
|
||||
_pageIndex = 0;
|
||||
final previousPageIndex = pageIndex;
|
||||
setItems(const []);
|
||||
setTotal(0);
|
||||
restorePageIndex(0);
|
||||
loadingMore = false;
|
||||
clearing = true;
|
||||
actionError = null;
|
||||
lastErrorCode = null;
|
||||
_notify();
|
||||
notify();
|
||||
try {
|
||||
await _history.clear();
|
||||
return true;
|
||||
} catch (failure) {
|
||||
records = previous;
|
||||
total = previousTotal;
|
||||
_pageIndex = previousPageIndex;
|
||||
hasMore = records.length < total;
|
||||
setItems(previous);
|
||||
setTotal(previousTotal);
|
||||
restorePageIndex(previousPageIndex);
|
||||
actionError = 'recordClearFailed';
|
||||
if (failure is ApiException) lastErrorCode = failure.code;
|
||||
return false;
|
||||
} finally {
|
||||
clearing = false;
|
||||
_notify();
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换账号/设备身份时清除内存数据,不删除服务端记录。
|
||||
void reset() {
|
||||
resetPaging();
|
||||
clearing = false;
|
||||
actionError = null;
|
||||
notify();
|
||||
}
|
||||
|
||||
List<WatchRecord> _uniqueRecords(List<WatchProgress> recent) {
|
||||
final seen = <String>{};
|
||||
return [
|
||||
@@ -239,7 +180,7 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
? queue.length
|
||||
: detailConcurrency;
|
||||
Future<void> worker() async {
|
||||
while (queue.isNotEmpty && !_disposed) {
|
||||
while (queue.isNotEmpty && !isDisposed) {
|
||||
await _loadDetail(queue.removeAt(0));
|
||||
}
|
||||
}
|
||||
@@ -272,7 +213,7 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
}
|
||||
try {
|
||||
final detail = await _catalog.fetchDrama(dramaId);
|
||||
if (_disposed) return null;
|
||||
if (isDisposed) return null;
|
||||
_detailCache[dramaId] = detail;
|
||||
final current = _recordIndex(dramaId);
|
||||
if (current < 0) return detail;
|
||||
@@ -294,7 +235,7 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
stackTrace: stackTrace,
|
||||
label: '[WatchRecordsController] detail failed dramaId=$dramaId',
|
||||
);
|
||||
if (_disposed) return null;
|
||||
if (isDisposed) return null;
|
||||
final current = _recordIndex(dramaId);
|
||||
if (current < 0) return null;
|
||||
_replaceAt(
|
||||
@@ -315,33 +256,6 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
void _replaceAt(int index, WatchRecord record) {
|
||||
final next = [...records];
|
||||
next[index] = record;
|
||||
records = next;
|
||||
}
|
||||
|
||||
void _notify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
/// 切换账号/设备身份时清除内存数据,不删除服务端记录。
|
||||
void reset() {
|
||||
_requestSeq += 1;
|
||||
records = const [];
|
||||
total = 0;
|
||||
loading = false;
|
||||
loadingMore = false;
|
||||
hasMore = false;
|
||||
clearing = false;
|
||||
error = null;
|
||||
loadMoreError = null;
|
||||
actionError = null;
|
||||
lastErrorCode = null;
|
||||
_pageIndex = 0;
|
||||
_notify();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
setItems(next);
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,11 @@ const String _invalid = '请输入合法地址,例如 http://192.168.1.10:8081
|
||||
const String _saveFailed = '服务地址保存失败';
|
||||
const String _localNetworkHint = '手机需与电脑同一 Wi-Fi,并在 iOS 设置-隐私与安全性-本地网络 中允许本应用';
|
||||
|
||||
/// Debug 下编辑服务地址,保存后重新探测并加载目录。
|
||||
/// Debug 下编辑服务地址,保存后立即按新地址重新加载目录。
|
||||
Future<void> showCatalogEndpointEditor(
|
||||
BuildContext context, {
|
||||
required Future<void> Function() reload,
|
||||
Future<bool> Function(String value)? save,
|
||||
Future<void> Function()? probe,
|
||||
}) async {
|
||||
if (!kDebugMode) return;
|
||||
final value = await showDialog<String>(
|
||||
@@ -37,7 +36,6 @@ Future<void> showCatalogEndpointEditor(
|
||||
}
|
||||
return;
|
||||
}
|
||||
await (probe ?? () => AppConfig.probeAndApplyBaseUrl())();
|
||||
await reload();
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -13,10 +13,14 @@ import 'core/platform/wireless_data_prompt.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
// 生产包不能连开发机:忘了改开关时宁可启动即失败。
|
||||
if (kReleaseMode && AppConfig.useDevConfig) {
|
||||
throw StateError('release 构建请把 AppConfig.useDevConfig 改成 false');
|
||||
}
|
||||
await ServerEndpoint.load();
|
||||
AppConfig.ready = WirelessDataPrompt.awaitDecision(
|
||||
enabled: !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS,
|
||||
).then((_) => AppConfig.probeAndApplyBaseUrl());
|
||||
);
|
||||
runApp(const LumaDramaApp());
|
||||
unawaited(
|
||||
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]),
|
||||
|
||||
@@ -10,7 +10,6 @@ import 'package:provider/provider.dart';
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/data/repositories/catalog_repository.dart';
|
||||
import '../../core/data/repositories/search_repository.dart';
|
||||
import '../../core/data/services/catalog_retry.dart';
|
||||
import '../../core/data/services/category_controller.dart';
|
||||
import '../../core/data/services/theater_feed_controller.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
@@ -408,15 +407,11 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
);
|
||||
|
||||
Future<void> _retryCategories(CategoryController categories) {
|
||||
return retryCatalogLoad(reload: categories.load);
|
||||
return categories.load();
|
||||
}
|
||||
|
||||
Future<void> _retryTheater() async {
|
||||
await retryCatalogLoad(
|
||||
reload: () async {
|
||||
await theater.refreshCurrent();
|
||||
},
|
||||
);
|
||||
Future<void> _retryTheater() {
|
||||
return theater.refreshCurrent();
|
||||
}
|
||||
|
||||
Future<void> _editTheaterEndpoint() {
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/data/remote/catalog_api.dart';
|
||||
import '../../core/data/services/home_feed_controller.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
@@ -67,7 +66,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
|
||||
) async {
|
||||
_resumeRetryInFlight = true;
|
||||
try {
|
||||
await AppConfig.probeAndApplyBaseUrl();
|
||||
// 地址是构建期常量,回前台只需要重试失败的数据请求。
|
||||
await Future.wait([
|
||||
if (catalog.error != null) catalog.load(),
|
||||
if (categories.error != null) categories.load(),
|
||||
@@ -80,10 +79,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final catalog = context.watch<CatalogController>();
|
||||
return Theme(
|
||||
data: AppTheme.dark(),
|
||||
child: _buildHome(context, catalog),
|
||||
);
|
||||
return Theme(data: AppTheme.dark(), child: _buildHome(context, catalog));
|
||||
}
|
||||
|
||||
Widget _buildHome(BuildContext context, CatalogController catalog) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../core/data/repositories/playback_repository.dart';
|
||||
import '../../core/data/services/android_picture_in_picture.dart';
|
||||
import '../../core/data/services/download_controller.dart';
|
||||
import '../../core/data/services/playback_position_cache.dart';
|
||||
import '../../core/data/services/unlocked_episode_registry.dart';
|
||||
import '../../core/data/services/wallet_controller.dart';
|
||||
import 'player_logic.dart';
|
||||
|
||||
@@ -13,6 +14,7 @@ class PlayerBinding {
|
||||
required WatchHistoryRepository history,
|
||||
required PlaybackRepository playback,
|
||||
required PlaybackPositionCache positionCache,
|
||||
UnlockedEpisodeRegistry? unlockedEpisodes,
|
||||
WalletController? wallet,
|
||||
DownloadController? downloads,
|
||||
bool Function()? isSignedIn,
|
||||
@@ -20,6 +22,7 @@ class PlayerBinding {
|
||||
history: history,
|
||||
playback: playback,
|
||||
positionCache: positionCache,
|
||||
unlockedEpisodes: unlockedEpisodes,
|
||||
pictureInPicture: AndroidPictureInPicture(),
|
||||
wallet: wallet,
|
||||
downloads: downloads,
|
||||
|
||||
@@ -27,6 +27,7 @@ import '../../core/data/services/platform_share_service.dart';
|
||||
import '../../core/data/services/playback_position_cache.dart';
|
||||
import '../../core/data/services/playback_queue.dart';
|
||||
import '../../core/data/services/playback_session.dart';
|
||||
import '../../core/data/services/unlocked_episode_registry.dart';
|
||||
import '../../core/data/services/video_player_port.dart';
|
||||
import '../../core/data/services/wallet_controller.dart';
|
||||
import '../../core/formatting/localized_content.dart';
|
||||
@@ -64,11 +65,13 @@ class PlayerLogic extends ChangeNotifier {
|
||||
required this.pictureInPicture,
|
||||
this.wallet,
|
||||
this.downloads,
|
||||
UnlockedEpisodeRegistry? unlockedEpisodes,
|
||||
PlaybackPositionCache? positionCache,
|
||||
Future<void> Function()? waitForFrame,
|
||||
DateTime Function()? clock,
|
||||
bool Function()? isSignedIn,
|
||||
}) : positionCache = positionCache ?? PlaybackPositionCache(),
|
||||
}) : unlockedEpisodes = unlockedEpisodes ?? UnlockedEpisodeRegistry(),
|
||||
positionCache = positionCache ?? PlaybackPositionCache(),
|
||||
_waitForFrame =
|
||||
waitForFrame ?? (() => WidgetsBinding.instance.endOfFrame),
|
||||
_clock = clock ?? DateTime.now,
|
||||
@@ -79,6 +82,9 @@ class PlayerLogic extends ChangeNotifier {
|
||||
final AndroidPictureInPicture pictureInPicture;
|
||||
final WalletController? wallet;
|
||||
final DownloadController? downloads;
|
||||
|
||||
/// 已确认可播的收费集(登录期内共享):退出播放页再进来不必重新挡一次。
|
||||
final UnlockedEpisodeRegistry unlockedEpisodes;
|
||||
final PlaybackPositionCache positionCache;
|
||||
final Future<void> Function() _waitForFrame;
|
||||
final DateTime Function() _clock;
|
||||
@@ -169,14 +175,15 @@ class PlayerLogic extends ChangeNotifier {
|
||||
|
||||
/// 当前设备是否为 iOS 真机环境(面板据此显示自动小窗开关)。
|
||||
bool get iosDevice => !kIsWeb && Platform.isIOS;
|
||||
|
||||
/// PiP 状态是全局单通道回调,须广播给所有存活的 PlayerLogic:
|
||||
/// 推荐页每页一个 logic,若只保留最后一次注册的 handler,
|
||||
/// 相邻预加载页会盖掉正在播放页的注册,导致小窗 UI 不隐藏且无声音。
|
||||
static final Set<PlayerLogic> _pipSubscribers = <PlayerLogic>{};
|
||||
|
||||
/// 「记住上次选集」:dramaId → 集号。只在用户从选集面板主动挑集时写入
|
||||
/// (见 [_recordPickedEpisode]),feed 里同一部剧据此显示上次选中的集。
|
||||
static final Map<String, int> pickedEpisode = {};
|
||||
final Set<String> _unlockedEpisodeIds = {};
|
||||
final Map<String, EpisodeStats> _episodeStats = {};
|
||||
final Map<String, DateTime> _episodeStatsFetchedAt = {};
|
||||
final Map<String, Future<void>> _episodeStatsInFlight = {};
|
||||
@@ -562,7 +569,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
final localPath = await downloads?.findLocalPath(episode.id);
|
||||
if (request != pageRequest) return false;
|
||||
if (localPath != null) {
|
||||
_unlockedEpisodeIds.add(episode.id);
|
||||
unlockedEpisodes.markUnlocked(episode.id);
|
||||
blockedTicket = null;
|
||||
blockedEpisodeId = null;
|
||||
qualities = const [];
|
||||
@@ -587,7 +594,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
final playUrl = preferred ?? ticket.playUrl;
|
||||
if (preferred == null) selectedQuality = 'AUTO';
|
||||
if (ticket.accessible && playUrl.isNotEmpty) {
|
||||
_unlockedEpisodeIds.add(episode.id);
|
||||
unlockedEpisodes.markUnlocked(episode.id);
|
||||
// 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host)
|
||||
sessionEpisodes[index] = episode.withVideoUrl(
|
||||
playUrl,
|
||||
@@ -860,7 +867,7 @@ class PlayerLogic extends ChangeNotifier {
|
||||
unlockError = service.unlockError;
|
||||
return false;
|
||||
}
|
||||
_unlockedEpisodeIds.add(episode.id);
|
||||
unlockedEpisodes.markUnlocked(episode.id);
|
||||
final ready = await ensureTicket(index, forceRefresh: true);
|
||||
if (ready) {
|
||||
await _selectVisible(index);
|
||||
@@ -1688,7 +1695,16 @@ class PlayerLogic extends ChangeNotifier {
|
||||
/// 收费集只有在本会话确认可播或解锁成功后才解除门禁。
|
||||
bool isEpisodeLocked(Episode episode) {
|
||||
return (episode.locked || !episode.isFree) &&
|
||||
!_unlockedEpisodeIds.contains(episode.id);
|
||||
!unlockedEpisodes.contains(episode.id);
|
||||
}
|
||||
|
||||
/// 单页锁定判定:剧集自身锁定 + 会话锁态(只约束当前页)+ 后端拒绝。
|
||||
///
|
||||
/// 播放页的多个渲染分支都必须走这里,否则相邻预加载页会被会话锁态误判成锁定。
|
||||
bool isEpisodeBlockedFor(Episode episode, int index) {
|
||||
return isEpisodeLocked(episode) ||
|
||||
(index == session.currentIndex && session.isLocked) ||
|
||||
blockedEpisodeId == episode.id;
|
||||
}
|
||||
|
||||
/// 按可见队列选择一集,并预热上一集与下一集。
|
||||
|
||||
@@ -20,6 +20,7 @@ import '../../core/data/services/playback_position_cache.dart';
|
||||
import '../../core/data/services/playback_session.dart';
|
||||
import '../../core/data/services/sprite_thumbnail_axis.dart';
|
||||
import '../../core/data/services/playback_thumbnail_service.dart';
|
||||
import '../../core/data/services/unlocked_episode_registry.dart';
|
||||
import '../../core/data/services/video_player_port.dart';
|
||||
import '../../core/data/services/wallet_controller.dart';
|
||||
import '../../core/formatting/error_text.dart';
|
||||
@@ -167,6 +168,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
history: context.read<WatchHistoryRepository>(),
|
||||
playback: context.read<PlaybackRepository>(),
|
||||
positionCache: context.read<PlaybackPositionCache>(),
|
||||
unlockedEpisodes: _readUnlockedEpisodes(context),
|
||||
wallet: widget.wallet,
|
||||
downloads: widget.downloads,
|
||||
isSignedIn: () => context.read<AuthController>().isSignedIn,
|
||||
@@ -349,6 +351,16 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
setState(() => _playbackSpeed = value);
|
||||
}
|
||||
|
||||
/// 全局解锁登记表(登录期内共享);直接构造播放页的测试没有 Provider,
|
||||
/// 返回 null 让 PlayerLogic 退化成页面自用的一份。
|
||||
UnlockedEpisodeRegistry? _readUnlockedEpisodes(BuildContext context) {
|
||||
try {
|
||||
return context.read<UnlockedEpisodeRegistry>();
|
||||
} on ProviderNotFoundException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _hideChrome() {
|
||||
if (!_chromeVisible) return;
|
||||
setState(() => _chromeVisible = false);
|
||||
@@ -956,10 +968,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
}
|
||||
final episode = _resolvedEpisode(index);
|
||||
final selected = index == currentIndex;
|
||||
final locked =
|
||||
logic.isEpisodeLocked(episode) ||
|
||||
(selected && logic.session.isLocked) ||
|
||||
logic.blockedEpisodeId == episode.id;
|
||||
final locked = logic.isEpisodeBlockedFor(episode, index);
|
||||
final port = locked
|
||||
? null
|
||||
: (selected ? logic.session.active : null) ??
|
||||
@@ -1042,10 +1051,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
final drama = entry.drama;
|
||||
final episode = _resolvedEpisode(index);
|
||||
final selected = index == logic.session.currentIndex;
|
||||
final locked =
|
||||
logic.isEpisodeLocked(episode) ||
|
||||
logic.session.isLocked ||
|
||||
logic.blockedEpisodeId == episode.id;
|
||||
final locked = logic.isEpisodeBlockedFor(episode, index);
|
||||
// Selection changes before the prepared slot is promoted. Use its controller
|
||||
// throughout the handoff instead of briefly falling back to a placeholder.
|
||||
final port = locked
|
||||
@@ -1294,10 +1300,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
final drama = entry.drama;
|
||||
final episode = _resolvedEpisode(index);
|
||||
final selected = index == logic.session.currentIndex;
|
||||
final locked =
|
||||
logic.isEpisodeLocked(episode) ||
|
||||
logic.session.isLocked ||
|
||||
logic.blockedEpisodeId == episode.id;
|
||||
final locked = logic.isEpisodeBlockedFor(episode, index);
|
||||
final port = locked
|
||||
? null
|
||||
: (selected ? logic.session.active : null) ??
|
||||
|
||||
@@ -105,14 +105,6 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: cupertino_icons
|
||||
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.0.9"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
+2
-4
@@ -33,9 +33,6 @@ dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
# 网络层用 dio(统一拦截器/超时/错误映射);纯 Dart 无原生插件,仍然不引代码生成与图片缓存库
|
||||
dio: ^5.7.0
|
||||
provider: ^6.1.5+1
|
||||
@@ -90,7 +87,8 @@ flutter:
|
||||
- assets/icon/splash_logo.png
|
||||
- assets/icon/splash_logo_android12.png
|
||||
- assets/icon/rail/
|
||||
- assets/auth/
|
||||
# 只打包代码实际引用的那张登录背景(其余 3 张留在仓库但不进包)。
|
||||
- assets/auth/bg_3.jpg
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'dart:async';
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -6,6 +6,8 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
|
||||
import 'package:fruitshorts/core/config/app_config.dart';
|
||||
import 'package:fruitshorts/core/config/dev_config.dart';
|
||||
import 'package:fruitshorts/core/config/prod_config.dart';
|
||||
import 'package:fruitshorts/core/config/server_endpoint.dart';
|
||||
|
||||
void main() {
|
||||
@@ -35,11 +37,10 @@ void main() {
|
||||
expect(ServerEndpoint.normalize('http://'), isNull);
|
||||
});
|
||||
|
||||
test('should_prioritize_runtime_over_dart_define_over_default', () {
|
||||
test('should_prefer_valid_stored_url_over_build_default', () {
|
||||
expect(
|
||||
ServerEndpoint.resolve(
|
||||
stored: ' http://runtime.test:8081/ ',
|
||||
compiled: 'http://define.test:8081',
|
||||
fallback: 'http://default.test:8081',
|
||||
),
|
||||
'http://runtime.test:8081',
|
||||
@@ -47,173 +48,57 @@ void main() {
|
||||
expect(
|
||||
ServerEndpoint.resolve(
|
||||
stored: 'ftp://invalid',
|
||||
compiled: 'http://define.test:8081/',
|
||||
fallback: 'http://default.test:8081',
|
||||
fallback: 'http://default.test:8081/',
|
||||
),
|
||||
'http://define.test:8081',
|
||||
'http://default.test:8081',
|
||||
);
|
||||
expect(
|
||||
ServerEndpoint.resolve(
|
||||
stored: null,
|
||||
compiled: '',
|
||||
fallback: 'http://default.test:8081/',
|
||||
fallback: 'http://default.test:8081',
|
||||
),
|
||||
'http://default.test:8081',
|
||||
);
|
||||
});
|
||||
|
||||
test('should_use_the_configured_local_address_for_device_default', () {
|
||||
final uri = Uri.parse(AppConfig.devHostBaseUrl);
|
||||
expect(uri.scheme, 'http');
|
||||
expect(uri.host, isNotEmpty);
|
||||
expect(uri.port, 8081);
|
||||
expect(uri.path, isEmpty);
|
||||
test('should_pick_address_by_environment_switch', () {
|
||||
// 仓库里的开关默认指向开发环境。
|
||||
expect(AppConfig.useDevConfig, isTrue);
|
||||
expect(AppConfig.baseUrl, DevConfig.baseUrl);
|
||||
expect(AppConfig.apiBaseUrl, DevConfig.baseUrl);
|
||||
expect(Uri.parse(AppConfig.baseUrl).port, 8081);
|
||||
});
|
||||
|
||||
test('should_skip_probe_when_compiled_override_is_set', () async {
|
||||
const compiled = 'http://compiled.test:8081';
|
||||
var probeCalls = 0;
|
||||
test('should_keep_dev_and_prod_addresses_in_separate_files', () {
|
||||
final dev = Uri.parse(DevConfig.baseUrl);
|
||||
final prod = Uri.parse(ProdConfig.baseUrl);
|
||||
|
||||
await AppConfig.probeAndApplyBaseUrl(
|
||||
compiledOverride: compiled,
|
||||
probe: (_) async {
|
||||
probeCalls++;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
expect(probeCalls, 0);
|
||||
expect(AppConfig.apiBaseUrl, compiled);
|
||||
expect(dev.scheme, 'http');
|
||||
expect(dev.port, 8081);
|
||||
expect(dev.path, isEmpty);
|
||||
expect(prod.scheme, 'http');
|
||||
expect(prod.port, 8081);
|
||||
expect(prod.path, isEmpty);
|
||||
expect(DevConfig.baseUrl, isNot(ProdConfig.baseUrl));
|
||||
});
|
||||
|
||||
test('should_keep_runtime_endpoint_when_compiled_override_is_set', () async {
|
||||
const runtime = 'http://runtime.test:8081';
|
||||
const compiled = 'http://compiled.test:8081';
|
||||
var probeCalls = 0;
|
||||
AppConfig.applyRuntimeBaseUrl(runtime);
|
||||
test('should_prefer_runtime_override_over_environment_switch', () {
|
||||
AppConfig.applyRuntimeBaseUrl('http://runtime.test:8081');
|
||||
|
||||
await AppConfig.probeAndApplyBaseUrl(
|
||||
compiledOverride: compiled,
|
||||
probe: (_) async {
|
||||
probeCalls++;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
expect(probeCalls, 0);
|
||||
expect(AppConfig.apiBaseUrl, runtime);
|
||||
expect(AppConfig.apiBaseUrl, 'http://runtime.test:8081');
|
||||
expect(AppConfig.baseUrl, DevConfig.baseUrl);
|
||||
});
|
||||
|
||||
test('should_keep_probing_when_compiled_override_is_empty', () async {
|
||||
final attempts = <String>[];
|
||||
|
||||
await AppConfig.probeAndApplyBaseUrl(
|
||||
compiledOverride: '',
|
||||
probe: (baseUrl) async {
|
||||
attempts.add(baseUrl);
|
||||
return baseUrl == AppConfig.publicBaseUrl;
|
||||
},
|
||||
test('should_append_host_to_relative_media_paths', () {
|
||||
expect(AppConfig.mediaUrl(''), '');
|
||||
expect(
|
||||
AppConfig.mediaUrl('https://cdn.test/a.mp4'),
|
||||
'https://cdn.test/a.mp4',
|
||||
);
|
||||
|
||||
expect(attempts, [
|
||||
AppConfig.lanBaseUrl,
|
||||
AppConfig.devHostBaseUrl,
|
||||
AppConfig.publicBaseUrl,
|
||||
]);
|
||||
expect(AppConfig.apiBaseUrl, AppConfig.publicBaseUrl);
|
||||
});
|
||||
|
||||
test(
|
||||
'should_probe_all_candidates_in_parallel_before_applying_priority',
|
||||
() async {
|
||||
final attempts = <String>[];
|
||||
var active = 0;
|
||||
var maxActive = 0;
|
||||
final allStarted = Completer<void>();
|
||||
|
||||
final probing = AppConfig.probeAndApplyBaseUrl(
|
||||
fallbackBaseUrl: AppConfig.devHostBaseUrl,
|
||||
probe: (baseUrl) async {
|
||||
attempts.add(baseUrl);
|
||||
active++;
|
||||
if (active > maxActive) maxActive = active;
|
||||
if (active == 3) allStarted.complete();
|
||||
await allStarted.future;
|
||||
active--;
|
||||
return baseUrl == AppConfig.lanBaseUrl ||
|
||||
baseUrl == AppConfig.devHostBaseUrl;
|
||||
},
|
||||
);
|
||||
|
||||
await probing.timeout(const Duration(seconds: 1));
|
||||
|
||||
expect(attempts, [
|
||||
AppConfig.lanBaseUrl,
|
||||
AppConfig.devHostBaseUrl,
|
||||
AppConfig.publicBaseUrl,
|
||||
]);
|
||||
expect(maxActive, 3);
|
||||
expect(AppConfig.apiBaseUrl, AppConfig.lanBaseUrl);
|
||||
},
|
||||
);
|
||||
|
||||
test('should_probe_loopback_then_public_in_order', () async {
|
||||
final attempts = <String>[];
|
||||
|
||||
await AppConfig.probeAndApplyBaseUrl(
|
||||
fallbackBaseUrl: AppConfig.devHostBaseUrl,
|
||||
probe: (baseUrl) async {
|
||||
attempts.add(baseUrl);
|
||||
return baseUrl == AppConfig.publicBaseUrl;
|
||||
},
|
||||
expect(
|
||||
AppConfig.mediaUrl('/files/a.mp4'),
|
||||
'${AppConfig.apiBaseUrl}/files/a.mp4',
|
||||
);
|
||||
|
||||
expect(attempts, [
|
||||
AppConfig.lanBaseUrl,
|
||||
AppConfig.devHostBaseUrl,
|
||||
AppConfig.publicBaseUrl,
|
||||
]);
|
||||
expect(AppConfig.apiBaseUrl, AppConfig.publicBaseUrl);
|
||||
});
|
||||
|
||||
test('should_use_platform_default_when_all_probes_fail', () async {
|
||||
final attempts = <String>[];
|
||||
|
||||
await AppConfig.probeAndApplyBaseUrl(
|
||||
fallbackBaseUrl: AppConfig.devHostBaseUrl,
|
||||
probe: (baseUrl) async {
|
||||
attempts.add(baseUrl);
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
expect(attempts, [
|
||||
AppConfig.lanBaseUrl,
|
||||
AppConfig.devHostBaseUrl,
|
||||
AppConfig.publicBaseUrl,
|
||||
]);
|
||||
expect(AppConfig.apiBaseUrl, AppConfig.devHostBaseUrl);
|
||||
});
|
||||
|
||||
test('should_probe_explicit_runtime_endpoint_first', () async {
|
||||
const custom = 'http://custom.test:8081';
|
||||
final attempts = <String>[];
|
||||
AppConfig.applyRuntimeBaseUrl(custom);
|
||||
|
||||
await AppConfig.probeAndApplyBaseUrl(
|
||||
probe: (baseUrl) async {
|
||||
attempts.add(baseUrl);
|
||||
return baseUrl == custom;
|
||||
},
|
||||
);
|
||||
|
||||
expect(attempts, [
|
||||
custom,
|
||||
AppConfig.lanBaseUrl,
|
||||
AppConfig.devHostBaseUrl,
|
||||
AppConfig.publicBaseUrl,
|
||||
]);
|
||||
expect(AppConfig.apiBaseUrl, custom);
|
||||
});
|
||||
|
||||
test('should_apply_stored_url_only_when_debug_is_enabled', () async {
|
||||
|
||||
@@ -136,19 +136,19 @@ void main() {
|
||||
});
|
||||
|
||||
test(
|
||||
'should_use_probed_endpoint_when_client_was_created_before_probe',
|
||||
'should_follow_runtime_endpoint_applied_after_client_creation',
|
||||
() async {
|
||||
const probedBaseUrl = 'http://probed.test:8081';
|
||||
const runtimeBaseUrl = 'http://runtime.test:8081';
|
||||
final adapter = FakeHttpAdapter(
|
||||
(_) async => FakeHttpResponse(envelope(null)),
|
||||
);
|
||||
final client = ApiClient(adapter: adapter);
|
||||
|
||||
AppConfig.applyRuntimeBaseUrl(probedBaseUrl);
|
||||
AppConfig.applyRuntimeBaseUrl(runtimeBaseUrl);
|
||||
addTearDown(AppConfig.clearRuntimeBaseUrl);
|
||||
await client.get('/app/home/feed');
|
||||
|
||||
expect(adapter.requests.single.uri.origin, probedBaseUrl);
|
||||
expect(adapter.requests.single.uri.origin, runtimeBaseUrl);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/models/feed_page.dart';
|
||||
import 'package:fruitshorts/core/data/services/channel_feed_controller.dart';
|
||||
|
||||
void main() {
|
||||
test('should_clear_loading_when_load_more_supersedes_refresh', () async {
|
||||
final pendingRefresh = Completer<FeedPage>();
|
||||
final controller = _ScriptedFeedController([
|
||||
() async => FeedPage(
|
||||
dramas: [_drama('a')],
|
||||
nextCursor: 'cursor-1',
|
||||
hasMore: true,
|
||||
),
|
||||
() => pendingRefresh.future,
|
||||
() async => FeedPage(dramas: [_drama('b')], hasMore: false),
|
||||
]);
|
||||
await controller.refreshCurrent();
|
||||
expect(controller.hasMore, isTrue);
|
||||
|
||||
final refresh = controller.refreshCurrent();
|
||||
final more = controller.loadMore();
|
||||
pendingRefresh.complete(FeedPage(dramas: [_drama('late')], hasMore: false));
|
||||
await Future.wait([refresh, more]);
|
||||
|
||||
// 刷新被翻页抢占后,加载态必须收掉,否则该分桶永远显示加载中且无法再刷新。
|
||||
expect(controller.isLoading, isFalse);
|
||||
expect(controller.loadingMore, isFalse);
|
||||
expect(controller.dramas.map((drama) => drama.id), contains('b'));
|
||||
expect(controller.dramas.map((drama) => drama.id), isNot(contains('late')));
|
||||
controller.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
class _ScriptedFeedController extends ChannelFeedController<String> {
|
||||
_ScriptedFeedController(this._pages) : super('all');
|
||||
|
||||
final List<Future<FeedPage> Function()> _pages;
|
||||
|
||||
@override
|
||||
Future<FeedPage> fetchPage(String key, {String? cursor}) =>
|
||||
_pages.removeAt(0)();
|
||||
}
|
||||
|
||||
Drama _drama(String id) =>
|
||||
Drama(id: id, title: id, synopsis: id, posterUrl: '');
|
||||
@@ -0,0 +1,129 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/page_result.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/services/paged_list_controller.dart';
|
||||
|
||||
/// 用最薄的子类验证基类契约:分页、去重、空页收口与错误映射。
|
||||
class _NumberController extends PagedListController<int> {
|
||||
_NumberController(this.pages, {super.fallbackErrorKey});
|
||||
|
||||
final List<PageResult<int>> pages;
|
||||
final List<int> requested = [];
|
||||
Object? failure;
|
||||
|
||||
@override
|
||||
Future<PageResult<int>> fetchPage(int pageIndex) async {
|
||||
requested.add(pageIndex);
|
||||
if (failure != null) throw failure!;
|
||||
if (pages.isEmpty) return const PageResult();
|
||||
return pages.removeAt(0);
|
||||
}
|
||||
|
||||
@override
|
||||
String itemKey(int item) => '$item';
|
||||
|
||||
/// 基类的失效入口是 protected,测试里包一层。
|
||||
void invalidate() => invalidateRequests();
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('should_append_only_new_items_when_next_page_overlaps', () async {
|
||||
final controller = _NumberController([
|
||||
const PageResult(total: 4, list: [1, 2]),
|
||||
const PageResult(total: 4, list: [2, 3]),
|
||||
]);
|
||||
|
||||
await controller.load();
|
||||
await controller.loadMore();
|
||||
|
||||
expect(controller.items, [1, 2, 3]);
|
||||
expect(controller.requested, [1, 2]);
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test('should_stop_paging_when_empty_page_arrives', () async {
|
||||
final controller = _NumberController([
|
||||
const PageResult(total: 99, list: [1]),
|
||||
const PageResult(total: 99, list: []),
|
||||
]);
|
||||
|
||||
await controller.load();
|
||||
expect(controller.hasMore, isTrue);
|
||||
|
||||
await controller.loadMore();
|
||||
expect(controller.hasMore, isFalse);
|
||||
await controller.loadMore();
|
||||
expect(controller.requested, [1, 2]);
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test('should_expose_backend_message_when_api_fails', () async {
|
||||
final controller = _NumberController([])
|
||||
..failure = const ApiException(500, '服务暂不可用');
|
||||
|
||||
await controller.load();
|
||||
|
||||
expect(controller.error, '服务暂不可用');
|
||||
expect(controller.lastErrorCode, 500);
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test('should_fall_back_to_key_when_failure_is_not_api_error', () async {
|
||||
final controller = _NumberController([], fallbackErrorKey: 'loadFailed')
|
||||
..failure = StateError('boom');
|
||||
|
||||
await controller.load();
|
||||
|
||||
expect(controller.error, 'loadFailed');
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test('should_ignore_stale_response_when_invalidated', () async {
|
||||
final controller = _NumberController([
|
||||
const PageResult(total: 1, list: [1]),
|
||||
]);
|
||||
|
||||
final pending = controller.load();
|
||||
controller.invalidate();
|
||||
await pending;
|
||||
|
||||
expect(controller.items, isEmpty);
|
||||
expect(controller.loaded, isFalse);
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test('should_not_stick_loading_when_refresh_supersedes_load', () async {
|
||||
final first = Completer<PageResult<int>>();
|
||||
final controller = _DeferredController([
|
||||
() => first.future,
|
||||
() async => const PageResult(total: 1, list: [2]),
|
||||
]);
|
||||
|
||||
final loading = controller.load();
|
||||
final refreshing = controller.refresh();
|
||||
first.complete(const PageResult(total: 1, list: [1]));
|
||||
await Future.wait([loading, refreshing]);
|
||||
|
||||
expect(controller.loading, isFalse);
|
||||
expect(controller.refreshing, isFalse);
|
||||
expect(controller.items, [2]);
|
||||
controller.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
/// 让每个请求各自挂起,用来构造「后发请求抢占先发请求」的时序。
|
||||
class _DeferredController extends PagedListController<int> {
|
||||
_DeferredController(this._pages);
|
||||
|
||||
final List<Future<PageResult<int>> Function()> _pages;
|
||||
|
||||
@override
|
||||
Future<PageResult<int>> fetchPage(int pageIndex) => _pages.removeAt(0)();
|
||||
|
||||
@override
|
||||
String itemKey(int item) => '$item';
|
||||
}
|
||||
@@ -22,12 +22,25 @@ void main() {
|
||||
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test('should_keep_duplicate_ids_when_repository_repeats_items', () async {
|
||||
// 剧单不去重:服务端分页错位时按原样展示,不能悄悄吞掉条目。
|
||||
final repository = _PagedPlaylists(total: 12, repeatLastOfFirstPage: true);
|
||||
final controller = PlaylistController(repository);
|
||||
|
||||
await controller.load();
|
||||
await controller.loadMore();
|
||||
|
||||
expect(controller.items, hasLength(12));
|
||||
controller.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
class _PagedPlaylists implements PlaylistRepository {
|
||||
_PagedPlaylists({required this.total});
|
||||
_PagedPlaylists({required this.total, this.repeatLastOfFirstPage = false});
|
||||
|
||||
final int total;
|
||||
final bool repeatLastOfFirstPage;
|
||||
|
||||
@override
|
||||
Future<PageResult<PlaylistDetail>> fetchPlaylists({
|
||||
@@ -44,7 +57,7 @@ class _PagedPlaylists implements PlaylistRepository {
|
||||
list: [
|
||||
for (var index = start; index < end; index++)
|
||||
PlaylistDetail(
|
||||
id: '$index',
|
||||
id: '${repeatLastOfFirstPage && index == pageSize ? start - 1 : index}',
|
||||
name: '剧单$index',
|
||||
description: '',
|
||||
dramaCount: 0,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/remote/download_api.dart';
|
||||
import 'package:fruitshorts/core/data/remote/response_decoders.dart';
|
||||
|
||||
import '../support/fake_http_adapter.dart';
|
||||
|
||||
void main() {
|
||||
group('ResponseDecoders', () {
|
||||
test('should_return_same_map_when_data_is_object', () {
|
||||
const source = {'id': '1'};
|
||||
expect(ResponseDecoders.asMap(source), same(source));
|
||||
});
|
||||
|
||||
test('should_throw_with_readable_message_when_data_is_not_object', () {
|
||||
expect(
|
||||
() => ResponseDecoders.asMap(const []),
|
||||
throwsA(
|
||||
isA<ApiException>()
|
||||
.having((e) => e.code, 'code', ResponseDecoders.malformedCode)
|
||||
.having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
ResponseDecoders.malformedMessage,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(ResponseDecoders.malformedMessage, isNotEmpty);
|
||||
});
|
||||
|
||||
test('should_return_null_when_try_map_receives_non_object', () {
|
||||
expect(ResponseDecoders.tryMap(null), isNull);
|
||||
expect(ResponseDecoders.tryMap('text'), isNull);
|
||||
expect(ResponseDecoders.tryMap({'a': 1})?['a'], 1);
|
||||
});
|
||||
|
||||
test('should_skip_non_object_items_when_decoding_object_list', () {
|
||||
final decoded = ResponseDecoders.asMapList([
|
||||
{'id': '1'},
|
||||
'bad',
|
||||
{'id': '2'},
|
||||
]);
|
||||
expect(decoded.map((item) => item['id']), ['1', '2']);
|
||||
});
|
||||
|
||||
test('should_return_empty_list_when_data_is_not_list', () {
|
||||
expect(ResponseDecoders.asMapList(const {}), isEmpty);
|
||||
expect(ResponseDecoders.asMapList(null), isEmpty);
|
||||
});
|
||||
|
||||
test('should_throw_when_required_list_receives_non_list', () {
|
||||
expect(
|
||||
() => ResponseDecoders.requiredMapList(const {}),
|
||||
throwsA(isA<ApiException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test(
|
||||
'should_raise_readable_error_when_download_policy_data_is_malformed',
|
||||
() async {
|
||||
final adapter = FakeHttpAdapter(
|
||||
(options) async => FakeHttpResponse(
|
||||
jsonEncode({'code': 0, 'message': 'ok', 'data': null}),
|
||||
),
|
||||
);
|
||||
final client = ApiClient(adapter: adapter);
|
||||
final api = DownloadApi(client);
|
||||
|
||||
await expectLater(
|
||||
api.policy(),
|
||||
throwsA(
|
||||
isA<ApiException>().having(
|
||||
(error) => error.message,
|
||||
'message',
|
||||
ResponseDecoders.malformedMessage,
|
||||
),
|
||||
),
|
||||
);
|
||||
client.close();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:fruitshorts/core/config/app_config.dart';
|
||||
import 'package:fruitshorts/core/data/services/catalog_retry.dart';
|
||||
import 'package:fruitshorts/core/theme/app_colors.dart';
|
||||
import 'package:fruitshorts/core/widgets/catalog_endpoint_editor.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
@@ -55,20 +54,7 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('should_probe_before_reloading_catalog', () async {
|
||||
final steps = <String>[];
|
||||
|
||||
await retryCatalogLoad(
|
||||
probe: () async => steps.add('probe'),
|
||||
reload: () async => steps.add('load'),
|
||||
);
|
||||
|
||||
expect(steps, ['probe', 'load']);
|
||||
});
|
||||
|
||||
testWidgets('should_save_probe_and_reload_after_valid_endpoint', (
|
||||
tester,
|
||||
) async {
|
||||
testWidgets('should_save_and_reload_after_valid_endpoint', (tester) async {
|
||||
AppConfig.applyRuntimeBaseUrl('http://127.0.0.1:8081');
|
||||
final steps = <String>[];
|
||||
await _pumpEndpointEditor(
|
||||
@@ -77,7 +63,6 @@ void main() {
|
||||
steps.add('save:$value');
|
||||
return true;
|
||||
},
|
||||
probe: () async => steps.add('probe'),
|
||||
reload: () async => steps.add('load'),
|
||||
);
|
||||
|
||||
@@ -101,12 +86,11 @@ void main() {
|
||||
await tester.tap(find.byKey(const Key('catalog-debug-endpoint-save')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(steps, ['save:http://10.0.0.8:8081', 'probe', 'load']);
|
||||
expect(steps, ['save:http://10.0.0.8:8081', 'load']);
|
||||
});
|
||||
|
||||
testWidgets('should_reject_invalid_endpoint_without_saving', (tester) async {
|
||||
var saves = 0;
|
||||
var probes = 0;
|
||||
var loads = 0;
|
||||
await _pumpEndpointEditor(
|
||||
tester,
|
||||
@@ -114,7 +98,6 @@ void main() {
|
||||
saves++;
|
||||
return true;
|
||||
},
|
||||
probe: () async => probes++,
|
||||
reload: () async => loads++,
|
||||
);
|
||||
|
||||
@@ -134,7 +117,6 @@ void main() {
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(saves, 0);
|
||||
expect(probes, 0);
|
||||
expect(loads, 0);
|
||||
});
|
||||
}
|
||||
@@ -142,7 +124,6 @@ void main() {
|
||||
Future<void> _pumpEndpointEditor(
|
||||
WidgetTester tester, {
|
||||
required Future<bool> Function(String value) save,
|
||||
required Future<void> Function() probe,
|
||||
required Future<void> Function() reload,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
@@ -152,12 +133,7 @@ Future<void> _pumpEndpointEditor(
|
||||
body: TextButton(
|
||||
key: const Key('open-endpoint-editor'),
|
||||
onPressed: () => unawaited(
|
||||
showCatalogEndpointEditor(
|
||||
context,
|
||||
save: save,
|
||||
probe: probe,
|
||||
reload: reload,
|
||||
),
|
||||
showCatalogEndpointEditor(context, save: save, reload: reload),
|
||||
),
|
||||
child: const Text('open'),
|
||||
),
|
||||
|
||||
@@ -18,9 +18,8 @@ void main() {
|
||||
);
|
||||
|
||||
expect(uri.isAbsolute, isTrue);
|
||||
// Uri.parse().toString() 会把 host 规范成小写,而 devHostBaseUrl 里的
|
||||
// Bonjour 名字带大写(jiangdeMacBook-Air.local)—— 比较时统一小写,
|
||||
// 否则会依赖运行平台(flutter test 里 defaultTargetPlatform 默认是 android)
|
||||
// Uri.parse().toString() 会把 host 规范成小写,而开发地址可能带大写
|
||||
// (Bonjour 名字),比较时统一小写,否则会依赖运行平台。
|
||||
expect(
|
||||
uri.toString().toLowerCase(),
|
||||
'${AppConfig.apiBaseUrl}/files/episode/202610/clip.mp4'.toLowerCase(),
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:fruitshorts/core/data/repositories/commerce_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
import 'package:fruitshorts/core/data/services/android_picture_in_picture.dart';
|
||||
import 'package:fruitshorts/core/data/services/playback_session.dart';
|
||||
import 'package:fruitshorts/core/data/services/unlocked_episode_registry.dart';
|
||||
import 'package:fruitshorts/core/data/services/wallet_controller.dart';
|
||||
import 'package:fruitshorts/pages/player/player_logic.dart';
|
||||
import 'package:fruitshorts/pages/player/player_view.dart';
|
||||
@@ -120,6 +121,36 @@ void main() {
|
||||
logic.dispose();
|
||||
await _drain(logic);
|
||||
});
|
||||
|
||||
test('should_keep_episode_unlocked_when_reentering_player_page', () async {
|
||||
final registry = UnlockedEpisodeRegistry();
|
||||
registry.markUnlocked('locked-1');
|
||||
final playback = FakePlaybackRepository(
|
||||
ticket: const PlayTicket(
|
||||
episodeId: 'locked-1',
|
||||
accessible: true,
|
||||
accessType: 'ENTITLED',
|
||||
playUrl: '/files/locked-1.mp4',
|
||||
),
|
||||
);
|
||||
// 重进播放页 = 新建 PlayerLogic,但沿用同一张解锁登记表。
|
||||
final logic = _logic(playback, unlockedEpisodes: registry);
|
||||
logic.attach(
|
||||
const PlayerPage(drama: _lockedDrama, initialIndex: 0),
|
||||
isMounted: () => true,
|
||||
);
|
||||
logic.start(
|
||||
factory: (_) => _NoopPlayer(),
|
||||
coordinator: PlaybackCoordinator(),
|
||||
);
|
||||
await _drain(logic);
|
||||
|
||||
expect(logic.isEpisodeLocked(logic.sessionEpisodes.single), isFalse);
|
||||
expect(logic.session.active, isNotNull);
|
||||
|
||||
logic.dispose();
|
||||
await _drain(logic);
|
||||
});
|
||||
}
|
||||
|
||||
PlayerLogic _logic(
|
||||
@@ -127,11 +158,13 @@ PlayerLogic _logic(
|
||||
bool signedIn = true,
|
||||
bool Function()? isSignedIn,
|
||||
WalletController? wallet,
|
||||
UnlockedEpisodeRegistry? unlockedEpisodes,
|
||||
}) {
|
||||
return PlayerLogic(
|
||||
history: FakeWatchHistory(),
|
||||
playback: playback,
|
||||
wallet: wallet,
|
||||
unlockedEpisodes: unlockedEpisodes,
|
||||
pictureInPicture: AndroidPictureInPicture(
|
||||
channel: const MethodChannel('test/player_locked_auth_flow/pip'),
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user