Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26321e3348 | ||
|
|
3590cd0c55 | ||
|
|
c311985601 | ||
|
|
4dd37b2588 | ||
|
|
543d0aadd5 | ||
|
|
d5bcda54d3 | ||
|
|
79463e65c2 | ||
|
|
ad7aba3daf | ||
|
|
47fdd4a29d | ||
|
|
fe49527dd8 | ||
|
|
4aaa5bbd60 | ||
|
|
5649e4c3d4 | ||
|
|
9f7301305e | ||
|
|
63191b4692 | ||
|
|
0f777b19f2 | ||
|
|
fb9001a02d | ||
|
|
1b80f841be | ||
|
|
3e89c71f50 | ||
|
|
2ce92aa7dd | ||
|
|
a6d6b66d31 | ||
|
|
57f84c45bd | ||
|
|
a4d08e7c1e | ||
|
|
9c9a277217 | ||
|
|
1be8ebf1f9 |
No files matched your search
@@ -0,0 +1,25 @@
|
||||
# Author: fruit.com.cn
|
||||
# ReelShort 客户端 CI:静态分析 + 测试门禁
|
||||
name: flutter-ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop, main]
|
||||
pull_request:
|
||||
branches: [develop, main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
cache: true
|
||||
- name: 依赖
|
||||
run: flutter pub get
|
||||
- name: 静态分析(零告警门禁)
|
||||
run: dart analyze --fatal-infos
|
||||
- name: 单元测试(全绿门禁)
|
||||
run: flutter test -r expanded
|
||||
@@ -65,3 +65,6 @@ app.*.map.json
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# IDE 的 Flutter Widget Preview 生成目录。
|
||||
.widget_preview/
|
||||
@@ -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();
|
||||
|
||||
@@ -97,11 +97,13 @@ class Drama {
|
||||
required this.title,
|
||||
required this.synopsis,
|
||||
required this.posterUrl,
|
||||
this.coverLandscape = '',
|
||||
this.episodes = const [],
|
||||
this.titleLocales = const {},
|
||||
this.synopsisLocales = const {},
|
||||
this.tag = '',
|
||||
this.tagLocales = const {},
|
||||
this.tagCode = '',
|
||||
this.tagColor = '',
|
||||
this.category = '',
|
||||
this.categoryLocales = const {},
|
||||
@@ -127,8 +129,14 @@ class Drama {
|
||||
final Map<String, String> synopsisLocales;
|
||||
final String tag;
|
||||
final Map<String, String> tagLocales;
|
||||
|
||||
/// 标签语义码(如 `NEW_ARRIVAL`)。文案会随语言变,语义判断只认这个码。
|
||||
final String tagCode;
|
||||
final String tagColor;
|
||||
final String posterUrl;
|
||||
|
||||
/// 横版封面,用于继续观看横幅等横向展示位;为空时回退竖版 `posterUrl`。
|
||||
final String coverLandscape;
|
||||
final List<Episode> episodes;
|
||||
final String category;
|
||||
final Map<String, String> categoryLocales;
|
||||
@@ -172,8 +180,10 @@ class Drama {
|
||||
synopsisLocales: text.synopsisLocales,
|
||||
tag: text.tag,
|
||||
tagLocales: text.tagLocales,
|
||||
tagCode: (json['tagCode'] as String? ?? '').trim(),
|
||||
tagColor: json['tagColor'] as String? ?? '',
|
||||
posterUrl: json['cover'] as String? ?? '',
|
||||
coverLandscape: json['coverLandscape'] as String? ?? '',
|
||||
episodes: preview is Map<String, dynamic>
|
||||
? [Episode.fromJson(preview)]
|
||||
: const [],
|
||||
@@ -204,8 +214,10 @@ class Drama {
|
||||
synopsisLocales: text.synopsisLocales,
|
||||
tag: text.tag,
|
||||
tagLocales: text.tagLocales,
|
||||
tagCode: (json['tagCode'] as String? ?? '').trim(),
|
||||
tagColor: json['tagColor'] as String? ?? '',
|
||||
posterUrl: json['cover'] as String? ?? '',
|
||||
coverLandscape: json['coverLandscape'] as String? ?? '',
|
||||
category: text.category,
|
||||
categoryLocales: text.categoryLocales,
|
||||
types: text.types,
|
||||
@@ -315,7 +327,10 @@ class _DramaText {
|
||||
final category = _firstText(categoryLocales, json['category'], types);
|
||||
return _DramaText(
|
||||
title: _firstText(titleLocales, json['title']),
|
||||
synopsis: _firstText(synopsisLocales, json['synopsis'] ?? json['description']),
|
||||
synopsis: _firstText(
|
||||
synopsisLocales,
|
||||
json['synopsis'] ?? json['description'],
|
||||
),
|
||||
titleLocales: titleLocales,
|
||||
synopsisLocales: synopsisLocales,
|
||||
tag: _firstText(tagLocales, json['tag']),
|
||||
@@ -328,7 +343,11 @@ class _DramaText {
|
||||
}
|
||||
}
|
||||
|
||||
String _firstText(Map<String, String> locales, Object? fallback, [List<Map<String, String>>? types]) {
|
||||
String _firstText(
|
||||
Map<String, String> locales,
|
||||
Object? fallback, [
|
||||
List<Map<String, String>>? types,
|
||||
]) {
|
||||
final chinese = locales['zh'] ?? '';
|
||||
if (chinese.isNotEmpty) return chinese;
|
||||
if (types != null && types.isNotEmpty) {
|
||||
@@ -339,7 +358,9 @@ String _firstText(Map<String, String> locales, Object? fallback, [List<Map<Strin
|
||||
}
|
||||
|
||||
String _genreText(List<Map<String, String>> types, Object? fallback) {
|
||||
final names = types.map((item) => item['zh'] ?? '').where((value) => value.isNotEmpty);
|
||||
final names = types
|
||||
.map((item) => item['zh'] ?? '')
|
||||
.where((value) => value.isNotEmpty);
|
||||
final joined = names.join(' / ');
|
||||
if (joined.isNotEmpty) return joined;
|
||||
return fallback is String ? fallback : '';
|
||||
|
||||
@@ -16,6 +16,7 @@ class WatchProgress {
|
||||
this.dramaTitle,
|
||||
this.titleLocales = const {},
|
||||
this.dramaCover,
|
||||
this.dramaCoverLandscape,
|
||||
this.episodeNumber,
|
||||
this.totalEpisodes,
|
||||
this.finished = false,
|
||||
@@ -29,6 +30,9 @@ class WatchProgress {
|
||||
final String? dramaTitle;
|
||||
final Map<String, String> titleLocales;
|
||||
final String? dramaCover;
|
||||
|
||||
/// 横版封面,用于继续观看横幅等横向位;为空时回退 [dramaCover]。
|
||||
final String? dramaCoverLandscape;
|
||||
final int? episodeNumber;
|
||||
final int? totalEpisodes;
|
||||
final Duration position;
|
||||
@@ -60,6 +64,7 @@ class WatchProgress {
|
||||
dramaTitle: json['dramaTitle']?.toString(),
|
||||
titleLocales: localeMap(json['titleLocales']),
|
||||
dramaCover: json['dramaCover']?.toString(),
|
||||
dramaCoverLandscape: json['dramaCoverLandscape']?.toString(),
|
||||
episodeNumber: json['episodeNumber'] == null
|
||||
? null
|
||||
: jsonToInt(json['episodeNumber']),
|
||||
@@ -83,6 +88,7 @@ class WatchProgress {
|
||||
if (dramaTitle != null) 'dramaTitle': dramaTitle,
|
||||
if (titleLocales.isNotEmpty) 'titleLocales': titleLocales,
|
||||
if (dramaCover != null) 'dramaCover': dramaCover,
|
||||
if (dramaCoverLandscape != null) 'dramaCoverLandscape': dramaCoverLandscape,
|
||||
if (episodeNumber != null) 'episodeNumber': episodeNumber,
|
||||
if (totalEpisodes != null) 'totalEpisodes': totalEpisodes,
|
||||
'positionMs': position.inMilliseconds,
|
||||
|
||||
@@ -46,6 +46,14 @@ class WatchRecord {
|
||||
return pageCover.isNotEmpty ? pageCover : drama?.posterUrl ?? '';
|
||||
}
|
||||
|
||||
/// 横向展示位(继续观看横幅)优先用横版封面,缺省回退竖版。
|
||||
String get displayLandscapeCover {
|
||||
final pageLandscape = progress.dramaCoverLandscape?.trim() ?? '';
|
||||
if (pageLandscape.isNotEmpty) return pageLandscape;
|
||||
final landscape = drama?.coverLandscape.trim() ?? '';
|
||||
return landscape.isNotEmpty ? landscape : displayCover;
|
||||
}
|
||||
|
||||
/// 分页接口已带展示字段时无需等待详情加载。
|
||||
bool get hasDisplayMetadata =>
|
||||
displayTitle.isNotEmpty ||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -33,10 +33,15 @@ class PlaybackPositionCache {
|
||||
PlaybackPositionCache({
|
||||
DateTime Function()? now,
|
||||
this.maxAge = _defaultMaxAge,
|
||||
this.maxEntries = defaultMaxEntries,
|
||||
}) : _now = now ?? DateTime.now;
|
||||
|
||||
/// 最多保留的集数:长会话连看几十部剧也不至于让 Map 无限增长。
|
||||
static const int defaultMaxEntries = 100;
|
||||
|
||||
final DateTime Function() _now;
|
||||
final Duration maxAge;
|
||||
final int maxEntries;
|
||||
final Map<String, CachedPlaybackPosition> _positions = {};
|
||||
|
||||
/// 保存最近有效播放位置;零位置不会覆盖已有记录。
|
||||
@@ -57,6 +62,24 @@ class PlaybackPositionCache {
|
||||
duration: duration,
|
||||
updatedAt: _now(),
|
||||
);
|
||||
_evictOverflow();
|
||||
}
|
||||
|
||||
/// 超出上限时按写入时间淘汰最旧的一条,避免长会话内存只增不减。
|
||||
void _evictOverflow() {
|
||||
while (maxEntries > 0 && _positions.length > maxEntries) {
|
||||
String? oldestKey;
|
||||
DateTime? oldestAt;
|
||||
for (final entry in _positions.entries) {
|
||||
final at = entry.value.updatedAt;
|
||||
if (oldestAt == null || at.isBefore(oldestAt)) {
|
||||
oldestAt = at;
|
||||
oldestKey = entry.key;
|
||||
}
|
||||
}
|
||||
if (oldestKey == null) return;
|
||||
_positions.remove(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取同剧同集且未过期的最近位置。
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import 'package:dio/dio.dart';
|
||||
|
||||
import '../../config/app_config.dart';
|
||||
|
||||
const int spriteThumbnailDecodeMaxDimension = 2048;
|
||||
/// 雪碧图解码上限:擦洗预览只占很小一块区域,2048 会让单张位图膨胀到 16MB。
|
||||
const int spriteThumbnailDecodeMaxDimension = 1024;
|
||||
|
||||
/// VTT 资源拒绝访问,服务层据此清缓存并重试一次。
|
||||
class SpriteThumbnailForbiddenException implements Exception {
|
||||
|
||||
@@ -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;
|
||||
@@ -286,15 +227,18 @@ class WatchRecordsController extends ChangeNotifier {
|
||||
);
|
||||
return detail;
|
||||
} catch (failure, stackTrace) {
|
||||
debugPrint(
|
||||
'[WatchRecordsController] detail failed dramaId=$dramaId '
|
||||
'${failure.runtimeType}: $failure',
|
||||
);
|
||||
debugPrintStack(
|
||||
stackTrace: stackTrace,
|
||||
label: '[WatchRecordsController] detail failed dramaId=$dramaId',
|
||||
);
|
||||
if (_disposed) return null;
|
||||
// 弱网时详情会成批失败,release 下不需要逐条打堆栈。
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[WatchRecordsController] detail failed dramaId=$dramaId '
|
||||
'${failure.runtimeType}: $failure',
|
||||
);
|
||||
debugPrintStack(
|
||||
stackTrace: stackTrace,
|
||||
label: '[WatchRecordsController] detail failed dramaId=$dramaId',
|
||||
);
|
||||
}
|
||||
if (isDisposed) return null;
|
||||
final current = _recordIndex(dramaId);
|
||||
if (current < 0) return null;
|
||||
_replaceAt(
|
||||
@@ -315,33 +259,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);
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ abstract final class AppDimens {
|
||||
static const double previewSectionTitle = 14;
|
||||
static const double chipFont = 10;
|
||||
static const double navLabel = 13;
|
||||
static const double navLabelGap = 5;
|
||||
static const double navLabelGap = 1;
|
||||
|
||||
static const double buttonHeight = 48;
|
||||
static const double primaryButtonHeight = 52;
|
||||
@@ -150,8 +150,8 @@ abstract final class AppDimens {
|
||||
static const double recommendSectionTitle = 17;
|
||||
static const double recommendSectionSubtitle = 11;
|
||||
static const double recommendSectionMore = 12;
|
||||
// 封面 122*(4/3)≈163 + 间距6 + 两行标题39 + 间距6 + 标签13 ≈ 227。
|
||||
static const double recommendRowHeight = 228;
|
||||
// 封面 122*(4/3)≈163 + 间距6 + 两行标题≈42 + 间距6 + 标签≈13 ≈ 230,留 6 余量。
|
||||
static const double recommendRowHeight = 236;
|
||||
static const double recommendCardWidth = 122;
|
||||
|
||||
/// 首页热播榜。
|
||||
@@ -189,9 +189,9 @@ abstract final class AppDimens {
|
||||
static const double followingAddIconStroke = 1.5;
|
||||
static const double followingGridGap = 6;
|
||||
static const double followingGridRowGap = 12;
|
||||
// 单行标题 16*1.3=20.8 + 间距 6+4 + 副标题行高约 18.6 ≈ 49.4,留 0.6 余量;
|
||||
// 按 48 估算在副标题行高偏高的字体下会溢出 1~2px。
|
||||
static const double followingCardTextExtent = 50;
|
||||
|
||||
/// 追剧网格卡文字区:间距 6 + 两行剧名 16*1.3*2=41.6 ≈ 48,留 4 余量。
|
||||
static const double followingCardTextExtent = 52;
|
||||
static const double followingSubtitleFont = 12;
|
||||
static const double followingSubtitleGap = 4;
|
||||
static const double followingProgressHeight = 2;
|
||||
|
||||
@@ -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
-4
@@ -316,6 +316,7 @@
|
||||
}
|
||||
},
|
||||
"episodes": "Episodes",
|
||||
"similarDramas": "More like this",
|
||||
"synopsisTitle": "Synopsis",
|
||||
"watchEpisodeOne": "Watch episode 1",
|
||||
"continueEpisode": "Continue episode {number}",
|
||||
@@ -432,7 +433,7 @@
|
||||
"joinVip": "Join VIP",
|
||||
"earnRewards": "Earn Rewards",
|
||||
"freeBonus": "Get Free Bonus",
|
||||
"watchHistory": "Watch History",
|
||||
"watchHistory": "History",
|
||||
"settings": "Settings",
|
||||
"serverAddress": "Server address",
|
||||
"serverSettings": "Server settings",
|
||||
@@ -492,7 +493,7 @@
|
||||
"progressError": "Your watch position could not be loaded.",
|
||||
"favoritesError": "Your favorites could not be loaded.",
|
||||
"favoriteToggleFailed": "Could not update favorites. Please retry.",
|
||||
"myFavorites": "My favorites",
|
||||
"myFavorites": "Favorites",
|
||||
"noFavorites": "No favorites yet.",
|
||||
"downloads": "Downloads",
|
||||
"downloadsError": "Download settings could not be loaded.",
|
||||
@@ -785,7 +786,7 @@
|
||||
"visitor": "Visitor",
|
||||
"copyUid": "Copy UID",
|
||||
"uidCopied": "UID copied",
|
||||
"offlineDownloads": "Offline downloads",
|
||||
"offlineDownloads": "Downloads",
|
||||
"watchPreferences": "Watch preferences",
|
||||
"contactSupport": "Contact support",
|
||||
"vipBrandName": "FruitShorts VIP",
|
||||
@@ -803,7 +804,7 @@
|
||||
"total": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"myFavorites": "My Favorites",
|
||||
"myFavorites": "Favorites",
|
||||
"manage": "Manage",
|
||||
"continueWatching": "Continue Watching",
|
||||
"continueWatchingSubtitle": "Episode {episode} of {total}",
|
||||
|
||||
+5
-4
@@ -295,6 +295,7 @@
|
||||
}
|
||||
},
|
||||
"episodes": "Episode",
|
||||
"similarDramas": "Rekomendasi Serupa",
|
||||
"synopsisTitle": "Sinopsis",
|
||||
"watchEpisodeOne": "Tonton episode 1",
|
||||
"continueEpisode": "Lanjut episode {number}",
|
||||
@@ -411,7 +412,7 @@
|
||||
"joinVip": "Gabung VIP",
|
||||
"earnRewards": "Dapatkan Hadiah",
|
||||
"freeBonus": "Bonus Gratis",
|
||||
"watchHistory": "Riwayat Tontonan",
|
||||
"watchHistory": "Riwayat",
|
||||
"settings": "Pengaturan",
|
||||
"serverAddress": "Alamat server",
|
||||
"serverSettings": "Pengaturan server",
|
||||
@@ -471,7 +472,7 @@
|
||||
"progressError": "Posisi tonton gagal dimuat.",
|
||||
"favoritesError": "Daftar favorit gagal dimuat.",
|
||||
"favoriteToggleFailed": "Gagal memperbarui favorit. Coba lagi.",
|
||||
"myFavorites": "Favorit saya",
|
||||
"myFavorites": "Favorit",
|
||||
"noFavorites": "Belum ada favorit.",
|
||||
"downloads": "Unduhan",
|
||||
"downloadsError": "Pengaturan unduhan gagal dimuat.",
|
||||
@@ -747,7 +748,7 @@
|
||||
"visitor": "Pengunjung",
|
||||
"copyUid": "Salin UID",
|
||||
"uidCopied": "UID disalin",
|
||||
"offlineDownloads": "Unduhan offline",
|
||||
"offlineDownloads": "Unduhan",
|
||||
"watchPreferences": "Preferensi tontonan",
|
||||
"contactSupport": "Hubungi dukungan",
|
||||
"vipBrandName": "FruitShorts VIP",
|
||||
@@ -765,7 +766,7 @@
|
||||
"total": { "type": "int" }
|
||||
}
|
||||
},
|
||||
"myFavorites": "Favorit Saya",
|
||||
"myFavorites": "Favorit",
|
||||
"manage": "Kelola",
|
||||
"continueWatching": "Lanjutkan",
|
||||
"continueWatchingSubtitle": "Episode {episode} dari {total}",
|
||||
|
||||
@@ -1276,6 +1276,12 @@ abstract class AppLocalizations {
|
||||
/// **'Episodes'**
|
||||
String get episodes;
|
||||
|
||||
/// No description provided for @similarDramas.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'More like this'**
|
||||
String get similarDramas;
|
||||
|
||||
/// No description provided for @synopsisTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -1771,7 +1777,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @watchHistory.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Watch History'**
|
||||
/// **'History'**
|
||||
String get watchHistory;
|
||||
|
||||
/// No description provided for @settings.
|
||||
@@ -2131,7 +2137,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @myFavorites.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'My Favorites'**
|
||||
/// **'Favorites'**
|
||||
String get myFavorites;
|
||||
|
||||
/// No description provided for @noFavorites.
|
||||
@@ -3415,7 +3421,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @offlineDownloads.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Offline downloads'**
|
||||
/// **'Downloads'**
|
||||
String get offlineDownloads;
|
||||
|
||||
/// No description provided for @watchPreferences.
|
||||
|
||||
@@ -644,6 +644,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get episodes => 'Episodes';
|
||||
|
||||
@override
|
||||
String get similarDramas => 'More like this';
|
||||
|
||||
@override
|
||||
String get synopsisTitle => 'Synopsis';
|
||||
|
||||
@@ -903,7 +906,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get freeBonus => 'Get Free Bonus';
|
||||
|
||||
@override
|
||||
String get watchHistory => 'Watch History';
|
||||
String get watchHistory => 'History';
|
||||
|
||||
@override
|
||||
String get settings => 'Settings';
|
||||
@@ -1089,7 +1092,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
'Could not update favorites. Please retry.';
|
||||
|
||||
@override
|
||||
String get myFavorites => 'My Favorites';
|
||||
String get myFavorites => 'Favorites';
|
||||
|
||||
@override
|
||||
String get noFavorites => 'No favorites yet.';
|
||||
@@ -1783,7 +1786,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get uidCopied => 'UID copied';
|
||||
|
||||
@override
|
||||
String get offlineDownloads => 'Offline downloads';
|
||||
String get offlineDownloads => 'Downloads';
|
||||
|
||||
@override
|
||||
String get watchPreferences => 'Watch preferences';
|
||||
|
||||
@@ -645,6 +645,9 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get episodes => 'Episode';
|
||||
|
||||
@override
|
||||
String get similarDramas => 'Rekomendasi Serupa';
|
||||
|
||||
@override
|
||||
String get synopsisTitle => 'Sinopsis';
|
||||
|
||||
@@ -906,7 +909,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get freeBonus => 'Bonus Gratis';
|
||||
|
||||
@override
|
||||
String get watchHistory => 'Riwayat Tontonan';
|
||||
String get watchHistory => 'Riwayat';
|
||||
|
||||
@override
|
||||
String get settings => 'Pengaturan';
|
||||
@@ -1091,7 +1094,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get favoriteToggleFailed => 'Gagal memperbarui favorit. Coba lagi.';
|
||||
|
||||
@override
|
||||
String get myFavorites => 'Favorit Saya';
|
||||
String get myFavorites => 'Favorit';
|
||||
|
||||
@override
|
||||
String get noFavorites => 'Belum ada favorit.';
|
||||
@@ -1785,7 +1788,7 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get uidCopied => 'UID disalin';
|
||||
|
||||
@override
|
||||
String get offlineDownloads => 'Unduhan offline';
|
||||
String get offlineDownloads => 'Unduhan';
|
||||
|
||||
@override
|
||||
String get watchPreferences => 'Preferensi tontonan';
|
||||
|
||||
@@ -638,6 +638,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get episodes => '选集';
|
||||
|
||||
@override
|
||||
String get similarDramas => '相似推荐';
|
||||
|
||||
@override
|
||||
String get synopsisTitle => '简介';
|
||||
|
||||
|
||||
@@ -281,6 +281,7 @@
|
||||
"episodeCount": "{count} 集",
|
||||
"technicalDemoCount": "免费试看 · {count} 集",
|
||||
"episodes": "选集",
|
||||
"similarDramas": "相似推荐",
|
||||
"synopsisTitle": "简介",
|
||||
"watchEpisodeOne": "观看第 1 集",
|
||||
"continueEpisode": "续看第 {number} 集",
|
||||
|
||||
+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';
|
||||
@@ -154,14 +153,19 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
edgeOffset: _stickyHeaderHeight + 8,
|
||||
onRefresh: () =>
|
||||
Future.wait([categories.load(), theater.refreshCurrent()]),
|
||||
child: ListView(
|
||||
child: CustomScrollView(
|
||||
controller: logic.scroll,
|
||||
physics: AppPullToRefresh.physics,
|
||||
padding: EdgeInsets.only(
|
||||
top: showHeader ? _stickyHeaderHeight : 0,
|
||||
bottom: AppDimens.spaceXl,
|
||||
),
|
||||
children: _content(l10n, categories),
|
||||
slivers: [
|
||||
if (showHeader)
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: _stickyHeaderHeight),
|
||||
),
|
||||
..._content(l10n, categories),
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: AppDimens.spaceXl),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -229,12 +233,12 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
];
|
||||
}
|
||||
if (categories.loading && categories.categories.isEmpty) {
|
||||
return [_block(const CircularProgressIndicator(strokeWidth: 2))];
|
||||
return [_sliverBlock(const CircularProgressIndicator(strokeWidth: 2))];
|
||||
}
|
||||
final error = categories.error;
|
||||
if (error != null) {
|
||||
return [
|
||||
_block(
|
||||
_sliverBlock(
|
||||
EmptyState(
|
||||
key: const Key('home-categories-error'),
|
||||
icon: Icons.cloud_off_rounded,
|
||||
@@ -254,12 +258,12 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
];
|
||||
}
|
||||
if (theater.loadingFirstPage) {
|
||||
return [_block(const CircularProgressIndicator(strokeWidth: 2))];
|
||||
return [_sliverBlock(const CircularProgressIndicator(strokeWidth: 2))];
|
||||
}
|
||||
final theaterError = theater.lastError;
|
||||
if (theaterError != null && theater.dramas.isEmpty) {
|
||||
return [
|
||||
_block(
|
||||
_sliverBlock(
|
||||
EmptyState(
|
||||
key: const Key('home-categories-error'),
|
||||
icon: Icons.cloud_off_rounded,
|
||||
@@ -283,38 +287,49 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
List<Widget> _theaterBlocks(AppLocalizations l10n) {
|
||||
final dramas = theater.dramas;
|
||||
return [
|
||||
_toolbar(),
|
||||
SliverToBoxAdapter(child: _toolbar()),
|
||||
if (dramas.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||
child: EmptyState(
|
||||
key: const Key('discover-empty'),
|
||||
icon: Icons.movie_filter_outlined,
|
||||
title: l10n.noStories,
|
||||
subtitle: l10n.emptyHint,
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||
child: EmptyState(
|
||||
key: const Key('discover-empty'),
|
||||
icon: Icons.movie_filter_outlined,
|
||||
title: l10n.noStories,
|
||||
subtitle: l10n.emptyHint,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
DramaGrid(l10n: l10n, dramas: dramas, onOpen: _openDrama),
|
||||
SliverDramaGrid(
|
||||
key: const Key('home-all-dramas-grid'),
|
||||
l10n: l10n,
|
||||
dramas: dramas,
|
||||
onOpen: _openDrama,
|
||||
),
|
||||
if (theater.loadingMore)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 20),
|
||||
child: Center(
|
||||
child: SizedBox.square(
|
||||
key: Key('theater-load-more'),
|
||||
dimension: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 20),
|
||||
child: Center(
|
||||
child: SizedBox.square(
|
||||
key: Key('theater-load-more'),
|
||||
dimension: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (dramas.isNotEmpty && !theater.hasMore)
|
||||
Padding(
|
||||
key: const Key('paged-list-no-more'),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Text(
|
||||
l10n.noMoreItems,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppColors.textHint, fontSize: 12),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
key: const Key('paged-list-no-more'),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Text(
|
||||
l10n.noMoreItems,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppColors.textHint, fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
@@ -402,21 +417,19 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
Navigator.of(context).push<void>(MaterialPageRoute(builder: (_) => page));
|
||||
}
|
||||
|
||||
Widget _sliverBlock(Widget child) => SliverToBoxAdapter(child: _block(child));
|
||||
|
||||
Widget _block(Widget child) => ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 280),
|
||||
child: Center(child: child),
|
||||
);
|
||||
|
||||
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() {
|
||||
|
||||
@@ -10,6 +10,8 @@ import '../../../l10n/app_localizations.dart';
|
||||
import 'drama_grid.dart';
|
||||
|
||||
/// 剧场搜索结果:加载、失败与空结果都留在同一页内。
|
||||
///
|
||||
/// 自身是 sliver,好让结果网格跟着剧场页一起懒加载。
|
||||
class DiscoverSearchResults extends StatelessWidget {
|
||||
const DiscoverSearchResults({
|
||||
super.key,
|
||||
@@ -26,16 +28,24 @@ class DiscoverSearchResults extends StatelessWidget {
|
||||
final VoidCallback onRetry;
|
||||
final ValueChanged<Drama> onOpen;
|
||||
|
||||
static const EdgeInsets _topPadding = EdgeInsets.only(top: 12);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: _content(l10n),
|
||||
if (!loading && error == null && results.isNotEmpty) {
|
||||
return SliverPadding(
|
||||
padding: _topPadding,
|
||||
sliver: SliverDramaGrid(l10n: l10n, dramas: results, onOpen: onOpen),
|
||||
);
|
||||
}
|
||||
return SliverPadding(
|
||||
padding: _topPadding,
|
||||
sliver: SliverToBoxAdapter(child: _state(l10n)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _content(AppLocalizations l10n) {
|
||||
Widget _state(AppLocalizations l10n) {
|
||||
if (loading) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 80),
|
||||
@@ -61,14 +71,11 @@ class DiscoverSearchResults extends StatelessWidget {
|
||||
onAction: onRetry,
|
||||
);
|
||||
}
|
||||
if (results.isEmpty) {
|
||||
return EmptyState(
|
||||
key: const Key('discover-search-empty'),
|
||||
icon: Icons.search_off_rounded,
|
||||
title: l10n.noSearchResults,
|
||||
subtitle: l10n.emptyHint,
|
||||
);
|
||||
}
|
||||
return DramaGrid(l10n: l10n, dramas: results, onOpen: onOpen);
|
||||
return EmptyState(
|
||||
key: const Key('discover-search-empty'),
|
||||
icon: Icons.search_off_rounded,
|
||||
title: l10n.noSearchResults,
|
||||
subtitle: l10n.emptyHint,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/formatting/count_format.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
const int _columns = 2;
|
||||
const double _columnGap = 8;
|
||||
const double _cardGap = 14;
|
||||
const double _gridHorizontalPadding = 12;
|
||||
@@ -28,8 +28,10 @@ const Color _tagChipBackground = AppColors.raised;
|
||||
final RegExp _tagSplitter = RegExp(r'[,/、·]');
|
||||
|
||||
/// 剧场/分类页共用的双列剧目网格。
|
||||
class DramaGrid extends StatelessWidget {
|
||||
const DramaGrid({
|
||||
///
|
||||
/// 按行懒加载:翻到第 N 页时只构建当前可见的几张卡,长列表不会把整本目录都留在树上。
|
||||
class SliverDramaGrid extends StatelessWidget {
|
||||
const SliverDramaGrid({
|
||||
super.key,
|
||||
required this.l10n,
|
||||
required this.dramas,
|
||||
@@ -42,33 +44,41 @@ class DramaGrid extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
key: const Key('home-all-dramas-section'),
|
||||
final rowCount = (dramas.length + 1) ~/ 2;
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: _gridHorizontalPadding),
|
||||
child: Row(
|
||||
key: const Key('home-all-dramas-grid'),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _column(0)),
|
||||
const SizedBox(width: _columnGap),
|
||||
Expanded(child: _column(1)),
|
||||
],
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate((context, row) {
|
||||
final left = dramas[row * 2];
|
||||
final rightIndex = row * 2 + 1;
|
||||
final right = rightIndex < dramas.length ? dramas[rightIndex] : null;
|
||||
return Padding(
|
||||
// 行间距沿用卡片间距,首行不需要。
|
||||
padding: EdgeInsets.only(top: row == 0 ? 0 : _cardGap),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(child: _card(left)),
|
||||
const SizedBox(width: _columnGap),
|
||||
Expanded(
|
||||
child: right == null
|
||||
? const SizedBox.shrink()
|
||||
: _card(right),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}, childCount: rowCount),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _column(int start) {
|
||||
final children = <Widget>[];
|
||||
for (var index = start; index < dramas.length; index += _columns) {
|
||||
final drama = dramas[index];
|
||||
children.add(
|
||||
DramaGridCard(l10n: l10n, drama: drama, onTap: () => onOpen(drama)),
|
||||
);
|
||||
if (index + _columns < dramas.length) {
|
||||
children.add(const SizedBox(height: _cardGap));
|
||||
}
|
||||
}
|
||||
return Column(children: children);
|
||||
Widget _card(Drama drama) {
|
||||
return DramaGridCard(
|
||||
l10n: l10n,
|
||||
drama: drama,
|
||||
onTap: () => onOpen(drama),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,16 +228,28 @@ class _DiscoverCoverImage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final url = AppConfig.mediaUrl(path);
|
||||
if (url.isEmpty) return _placeholder();
|
||||
return Image.network(
|
||||
url,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder: (context, child, progress) {
|
||||
if (progress == null) return child;
|
||||
return _placeholder();
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// 卡片只有半屏宽,按原图解码一张竖版海报就是数 MB,按布局宽度限一下。
|
||||
final decodeWidth = constraints.hasBoundedWidth
|
||||
? resolveImageDecodePixels(
|
||||
constraints.maxWidth,
|
||||
MediaQuery.devicePixelRatioOf(context),
|
||||
)
|
||||
: null;
|
||||
return Image.network(
|
||||
url,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: decodeWidth,
|
||||
loadingBuilder: (context, child, progress) {
|
||||
if (progress == null) return child;
|
||||
return _placeholder();
|
||||
},
|
||||
errorBuilder: (_, _, _) => _placeholder(),
|
||||
);
|
||||
},
|
||||
errorBuilder: (_, _, _) => _placeholder(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -374,7 +374,7 @@ class _LicenseTile extends StatelessWidget {
|
||||
Expanded(
|
||||
child: Text(
|
||||
license.dramaTitle,
|
||||
maxLines: 1,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
|
||||
@@ -23,7 +23,9 @@ const double _optionHorizontal = 5;
|
||||
const double _optionVertical = 5;
|
||||
const double _optionRunGap = 4;
|
||||
const double _cardGap = 14;
|
||||
const double _gridAspectRatio = 0.56;
|
||||
|
||||
/// 卡片比例:三列网格里要装下封面 + 两行剧名 + 元信息。
|
||||
const double _gridAspectRatio = 0.53;
|
||||
const double _groupFontSize = 13;
|
||||
const double _labelFontSize = 12;
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ class FilterResultCard extends StatelessWidget {
|
||||
Text(
|
||||
drama.title,
|
||||
key: Key('filter-title-${drama.id}'),
|
||||
maxLines: 1,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
|
||||
@@ -167,18 +167,10 @@ class FollowingFavoritesGrid extends StatelessWidget {
|
||||
bool isEditing,
|
||||
Set<String> picked,
|
||||
) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
if (index == controller.cards.length) {
|
||||
return FollowingAddCard(onTap: onGoHome);
|
||||
}
|
||||
final drama = controller.cards[index];
|
||||
final record = history.recordForDrama(drama.id);
|
||||
final episode = drama.episodes.isEmpty ? 1 : drama.episodes.first.number;
|
||||
final watchedEpisode = record?.episodeNumber ?? 0;
|
||||
final total = drama.episodeCount;
|
||||
final subtitle = watchedEpisode > 0 && total > 0
|
||||
? l10n.watchedEpisodeTotal(watchedEpisode, total)
|
||||
: l10n.favoriteEpisodeProgress(episode, total);
|
||||
return FollowingGridCard(
|
||||
dramaId: drama.id,
|
||||
title: drama.localizedTitle(Localizations.localeOf(context).languageCode),
|
||||
@@ -186,10 +178,7 @@ class FollowingFavoritesGrid extends StatelessWidget {
|
||||
viewCount: drama.viewCount,
|
||||
editing: isEditing,
|
||||
selected: isEditing ? picked.contains(drama.id) : null,
|
||||
subtitle: subtitle,
|
||||
// TODO(backend): Drama 增加 hasUpdate / latestEpisodeNumber / isFinished
|
||||
// 字段后,在此传 updateBadge(l10n.hasUpdateBadge)、红色更新副标题
|
||||
// (l10n.updatedToEpisode)或灰色完结副标题(l10n.finishedAllEpisodes)。
|
||||
// TODO(backend): Drama 增加 hasUpdate 字段后,在此传 updateBadge。
|
||||
onTap: () => _openOrToggle(context, drama.id, isEditing),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,21 +19,18 @@ class FollowingGridCard extends StatelessWidget {
|
||||
required this.title,
|
||||
required this.posterUrl,
|
||||
required this.viewCount,
|
||||
required this.subtitle,
|
||||
required this.onTap,
|
||||
this.progress,
|
||||
this.editing = false,
|
||||
this.selected,
|
||||
this.onRemove,
|
||||
this.updateBadge,
|
||||
this.subtitleColor,
|
||||
});
|
||||
|
||||
final String dramaId;
|
||||
final String title;
|
||||
final String posterUrl;
|
||||
final int viewCount;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
final double? progress;
|
||||
final bool editing;
|
||||
@@ -44,9 +41,6 @@ class FollowingGridCard extends StatelessWidget {
|
||||
/// 待后端在 Drama 增加更新状态字段后由收藏网格传入。
|
||||
final String? updateBadge;
|
||||
|
||||
/// 副标题颜色;更新中用强调红,完结用默认灰。
|
||||
final Color? subtitleColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
@@ -165,7 +159,7 @@ class FollowingGridCard extends StatelessWidget {
|
||||
const SizedBox(height: AppDimens.cardCoverTitleGap),
|
||||
Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.left,
|
||||
style: const TextStyle(
|
||||
@@ -175,17 +169,6 @@ class FollowingGridCard extends StatelessWidget {
|
||||
height: AppDimens.cardTitleLineHeight,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppDimens.followingSubtitleGap),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.left,
|
||||
style: TextStyle(
|
||||
color: subtitleColor ?? AppColors.textTertiary,
|
||||
fontSize: AppDimens.followingCardSubtitleFontNew,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -69,10 +69,14 @@ class _FollowingPageState extends State<FollowingPage> {
|
||||
history: context.read<WatchHistoryRepository>(),
|
||||
catalog: context.read<CatalogRepository>(),
|
||||
);
|
||||
unawaited(_history.load());
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) unawaited(widget.favoriteController.load());
|
||||
});
|
||||
// IndexedStack 启动时就会建好本页:不可见时先不拉数据,等切到本 tab 再加载,
|
||||
// 免得冷启动和首页 feed 抢网络。
|
||||
if (widget.visible) {
|
||||
unawaited(_history.load());
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) unawaited(widget.favoriteController.load());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onAuthChanged() {
|
||||
@@ -107,6 +111,13 @@ class _FollowingPageState extends State<FollowingPage> {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!oldWidget.visible && widget.visible) {
|
||||
unawaited(_history.load());
|
||||
// 启动时本页不可见,收藏没在这里拉过;首次可见时补齐(已有数据就不重复请求)。
|
||||
// 放在帧后,避免构建期间触发 controller 通知。
|
||||
if (widget.favoriteController.cards.isEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) unawaited(widget.favoriteController.load());
|
||||
});
|
||||
}
|
||||
}
|
||||
if (oldWidget.historyRequest == widget.historyRequest) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
|
||||
@@ -33,13 +33,12 @@ class ContinueWatchingBanner extends StatelessWidget {
|
||||
return SizedBox(
|
||||
height: AppDimens.followingBannerHeight,
|
||||
child: ClipRRect(
|
||||
borderRadius:
|
||||
BorderRadius.circular(AppDimens.followingBannerRadius),
|
||||
borderRadius: BorderRadius.circular(AppDimens.followingBannerRadius),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RemoteImage(
|
||||
record.displayCover,
|
||||
record.displayLandscapeCover,
|
||||
fit: BoxFit.cover,
|
||||
showFallbackIcon: true,
|
||||
fallbackColor: AppColors.contentSurface,
|
||||
@@ -82,8 +81,7 @@ class ContinueWatchingBanner extends StatelessWidget {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.bannerTitleWarm,
|
||||
fontSize:
|
||||
AppDimens.followingBannerTitleFont,
|
||||
fontSize: AppDimens.followingBannerTitleFont,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
@@ -143,8 +141,7 @@ class _ContinueButton extends StatelessWidget {
|
||||
height: AppDimens.followingBannerButtonHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal:
|
||||
AppDimens.followingBannerButtonHorizontalPadding,
|
||||
horizontal: AppDimens.followingBannerButtonHorizontalPadding,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -188,9 +185,7 @@ class _ProgressBar extends StatelessWidget {
|
||||
height: AppDimens.followingBannerProgressHeight,
|
||||
width: double.infinity,
|
||||
child: DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0x66FFFFFF),
|
||||
),
|
||||
decoration: const BoxDecoration(color: Color(0x66FFFFFF)),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FractionallySizedBox(
|
||||
|
||||
@@ -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) {
|
||||
@@ -165,24 +161,18 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
}
|
||||
|
||||
Widget _body(BuildContext context) {
|
||||
final pages = <Widget>[
|
||||
_recommend(context),
|
||||
_recommendDiscover(context),
|
||||
FollowingPage(
|
||||
final builders = <WidgetBuilder>[
|
||||
_recommend,
|
||||
_recommendDiscover,
|
||||
(context) => FollowingPage(
|
||||
onGoHome: () => logic.select(0),
|
||||
favoriteController: context.read<FavoriteController>(),
|
||||
historyRequest: logic.followingHistoryRequest,
|
||||
visible: logic.tab == 2,
|
||||
),
|
||||
_my(context),
|
||||
_my,
|
||||
];
|
||||
return IndexedStack(
|
||||
index: logic.tab,
|
||||
children: [
|
||||
for (var index = 0; index < pages.length; index++)
|
||||
TickerMode(enabled: logic.tab == index, child: pages[index]),
|
||||
],
|
||||
);
|
||||
return _LazyTabStack(index: logic.tab, builders: builders);
|
||||
}
|
||||
|
||||
Widget _recommend(BuildContext context) {
|
||||
@@ -252,3 +242,42 @@ class _HomeTabsState extends State<_HomeTabs> {
|
||||
logic.select(tab);
|
||||
}
|
||||
}
|
||||
|
||||
/// 四个根 tab 的容器:只有被访问过的 tab 才真正构建并常驻。
|
||||
///
|
||||
/// 冷启动时只建推荐页,其余 tab 等第一次切过去再建,省掉三棵子树和它们各自的请求。
|
||||
class _LazyTabStack extends StatefulWidget {
|
||||
const _LazyTabStack({required this.index, required this.builders});
|
||||
|
||||
final int index;
|
||||
final List<WidgetBuilder> builders;
|
||||
|
||||
@override
|
||||
State<_LazyTabStack> createState() => _LazyTabStackState();
|
||||
}
|
||||
|
||||
class _LazyTabStackState extends State<_LazyTabStack> {
|
||||
late final Set<int> _visited = {widget.index};
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _LazyTabStack oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_visited.add(widget.index);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IndexedStack(
|
||||
index: widget.index,
|
||||
children: [
|
||||
for (var index = 0; index < widget.builders.length; index++)
|
||||
TickerMode(
|
||||
enabled: widget.index == index,
|
||||
child: _visited.contains(index)
|
||||
? widget.builders[index](context)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,9 @@ const int _moreLimit = 12;
|
||||
const int _filmHoles = 8;
|
||||
const double _cardPadding = 8;
|
||||
const double _cardWidth = 124;
|
||||
const double _rowHeight = 204;
|
||||
|
||||
/// 横向卡片行高:封面 144 + 两行剧名 + 上映日期。
|
||||
const double _rowHeight = 226;
|
||||
const double _cardRadius = 12;
|
||||
const double _loadMoreThreshold = 200;
|
||||
|
||||
@@ -135,64 +137,79 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
|
||||
child: AppPullToRefresh(
|
||||
key: const Key('new-releases-refresh'),
|
||||
onRefresh: _refresh,
|
||||
child: ListView(
|
||||
child: CustomScrollView(
|
||||
key: const Key('new-releases-list'),
|
||||
controller: _scroll,
|
||||
physics: AppPullToRefresh.physics,
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
children: hero == null
|
||||
slivers: hero == null
|
||||
? [
|
||||
SizedBox(
|
||||
height: 420,
|
||||
child: Center(
|
||||
child: Text(
|
||||
l10n.noStories,
|
||||
key: const Key('new-releases-empty'),
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 420,
|
||||
child: Center(
|
||||
child: Text(
|
||||
l10n.noStories,
|
||||
key: const Key('new-releases-empty'),
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
child: _heroCard(context, l10n, hero),
|
||||
),
|
||||
child: _heroCard(context, l10n, hero),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const _FilmStrip(),
|
||||
const SizedBox(height: 14),
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: 14),
|
||||
),
|
||||
const SliverToBoxAdapter(child: _FilmStrip()),
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: 14),
|
||||
),
|
||||
if (more.isNotEmpty) ...[
|
||||
_moreHeader(l10n),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
height: _rowHeight,
|
||||
child: ListView.separated(
|
||||
key: const Key('new-releases-row'),
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
SliverToBoxAdapter(child: _moreHeader(l10n)),
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: 10),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: _rowHeight,
|
||||
child: ListView.separated(
|
||||
key: const Key('new-releases-row'),
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
itemCount: more.length,
|
||||
separatorBuilder: (_, _) =>
|
||||
const SizedBox(width: 10),
|
||||
itemBuilder: (context, index) =>
|
||||
_moreCard(
|
||||
context,
|
||||
l10n,
|
||||
more[index],
|
||||
index,
|
||||
),
|
||||
),
|
||||
itemCount: more.length,
|
||||
separatorBuilder: (_, _) =>
|
||||
const SizedBox(width: 10),
|
||||
itemBuilder: (context, index) =>
|
||||
_moreCard(
|
||||
context,
|
||||
l10n,
|
||||
more[index],
|
||||
index,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
_allHeader(l10n),
|
||||
const SizedBox(height: 10),
|
||||
DramaGrid(
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: 20),
|
||||
),
|
||||
SliverToBoxAdapter(child: _allHeader(l10n)),
|
||||
const SliverToBoxAdapter(
|
||||
child: SizedBox(height: 10),
|
||||
),
|
||||
SliverDramaGrid(
|
||||
key: const Key('new-releases-all-grid'),
|
||||
l10n: l10n,
|
||||
dramas: visible,
|
||||
@@ -200,12 +217,17 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
|
||||
playDramaFromCatalog(context, drama),
|
||||
),
|
||||
),
|
||||
PagedLoadFooter(
|
||||
loading: pager.loading,
|
||||
hasMore: pager.hasMore,
|
||||
hasItems: visible.isNotEmpty,
|
||||
loadMoreKey: const Key(
|
||||
'new-releases-load-more',
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: PagedLoadFooter(
|
||||
loading: pager.loading,
|
||||
hasMore: pager.hasMore,
|
||||
hasItems: visible.isNotEmpty,
|
||||
loadMoreKey: const Key(
|
||||
'new-releases-load-more',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -445,7 +467,7 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
drama.title,
|
||||
maxLines: 1,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
|
||||
@@ -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';
|
||||
@@ -34,12 +35,11 @@ import '../../core/theme/app_colors.dart';
|
||||
import '../../core/navigation/fade_page_route.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../discover/search_page.dart';
|
||||
import '../drama_detail/drama_detail_view.dart';
|
||||
import '../mine/mine_types.dart';
|
||||
import '../mine/mine_view.dart';
|
||||
import 'player_view.dart';
|
||||
import 'widgets/drama_comments_sheet.dart';
|
||||
import 'widgets/episode_picker_sheet.dart';
|
||||
import 'widgets/drama_info_sheet.dart';
|
||||
|
||||
typedef _PlaybackSnapshot = ({
|
||||
Duration position,
|
||||
@@ -64,11 +64,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 +81,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;
|
||||
@@ -104,6 +109,9 @@ class PlayerLogic extends ChangeNotifier {
|
||||
ResumePoint? resumePoint;
|
||||
bool resumeLoaded = false;
|
||||
|
||||
/// 续看点加载中的请求:进页与首次准备播放会同时触发,复用同一次请求。
|
||||
Future<void>? _resumePointLoad;
|
||||
|
||||
/// 票据三态:加载中 / 失败(可重试)/ 被后端判定为不可播放
|
||||
bool ticketLoading = false;
|
||||
String? ticketError;
|
||||
@@ -169,14 +177,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 = {};
|
||||
@@ -505,7 +514,15 @@ class PlayerLogic extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。
|
||||
Future<void> loadResumePoint() async {
|
||||
Future<void> loadResumePoint() {
|
||||
final inFlight = _resumePointLoad;
|
||||
if (inFlight != null) return inFlight;
|
||||
return _resumePointLoad = _loadResumePoint().whenComplete(() {
|
||||
_resumePointLoad = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadResumePoint() async {
|
||||
final entry = queue.entryAt(initialIndex);
|
||||
final cached = positionCache.read(
|
||||
entry.episode.id,
|
||||
@@ -562,7 +579,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 +604,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 +877,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);
|
||||
@@ -1274,9 +1291,9 @@ class PlayerLogic extends ChangeNotifier {
|
||||
await _waitUntilFrameReady(player.controller);
|
||||
return;
|
||||
}
|
||||
await pendingWrite;
|
||||
// 上一条进度写盘与续看点拉取互不依赖,并行等,少一个网络往返的首帧延迟。
|
||||
// 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播
|
||||
if (resumePoint == null) await loadResumePoint();
|
||||
await Future.wait([pendingWrite, if (!resumeLoaded) loadResumePoint()]);
|
||||
if (resumePositionFor(episode.id) == null) {
|
||||
final dramaId = _dramaIdForEpisode(episode.id);
|
||||
final cached = dramaId == null
|
||||
@@ -1688,7 +1705,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;
|
||||
}
|
||||
|
||||
/// 按可见队列选择一集,并预热上一集与下一集。
|
||||
@@ -1806,22 +1832,9 @@ class PlayerLogic extends ChangeNotifier {
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
Future<void> openDetail(BuildContext context) async {
|
||||
session.setOverlayOpen(true);
|
||||
await saveCurrentProgress();
|
||||
try {
|
||||
if (!context.mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
FadePageRoute<void>(
|
||||
builder: (_) =>
|
||||
DramaDetailPage(drama: queue.entryAt(session.currentIndex).drama),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
session.setOverlayOpen(false);
|
||||
}
|
||||
await saveCurrentProgress();
|
||||
}
|
||||
/// 剧名与选集共用同一个弹窗:点剧名默认停在详情页签。
|
||||
Future<void> openDetail(BuildContext context) =>
|
||||
_showDramaSheet(context, DramaInfoTab.detail);
|
||||
|
||||
String? _dramaIdForEpisode(String episodeId) {
|
||||
for (final entry in queue.entries) {
|
||||
@@ -1857,13 +1870,21 @@ class PlayerLogic extends ChangeNotifier {
|
||||
await resumeAfterFullPlayer();
|
||||
}
|
||||
|
||||
Future<void> showEpisodes(BuildContext context) async {
|
||||
/// 选集入口:同一个弹窗,直接停在选集页签。
|
||||
Future<void> showEpisodes(BuildContext context) =>
|
||||
_showDramaSheet(context, DramaInfoTab.episodes);
|
||||
|
||||
Future<void> _showDramaSheet(
|
||||
BuildContext context,
|
||||
DramaInfoTab initialTab,
|
||||
) async {
|
||||
final entry = queue.entryAt(session.currentIndex);
|
||||
final drama = await _withFullEpisodes(context, entry.drama);
|
||||
if (!context.mounted) return;
|
||||
final homeFeed = feedDramas != null;
|
||||
final currentNumber = entry.episode.number;
|
||||
session.setOverlayOpen(true);
|
||||
await saveCurrentProgress();
|
||||
await session.playbackSettled;
|
||||
if (!context.mounted) {
|
||||
session.setOverlayOpen(false);
|
||||
@@ -1874,16 +1895,20 @@ class PlayerLogic extends ChangeNotifier {
|
||||
context: context,
|
||||
backgroundColor: AppColors.transparent,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => EpisodePickerSheet(
|
||||
builder: (_) => DramaInfoSheet(
|
||||
drama: drama,
|
||||
currentNumber: currentNumber,
|
||||
onSelect: (index) =>
|
||||
initialTab: initialTab,
|
||||
onSelectEpisode: (index) =>
|
||||
_selectFromSheet(context, drama, index, homeFeed, currentNumber),
|
||||
onOpenRelated: (related) =>
|
||||
unawaited(openSeriesAt(context, related, 0)),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
session.setOverlayOpen(false);
|
||||
}
|
||||
await saveCurrentProgress();
|
||||
}
|
||||
|
||||
/// 列表卡片只带一集可播预览,需要完整剧集时补一次详情;
|
||||
|
||||
@@ -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';
|
||||
@@ -60,6 +61,17 @@ const double _playerLoadingSize = 24;
|
||||
const double _playerLoadingStrokeWidth = 2;
|
||||
const double _feedRefreshTopGap = 56;
|
||||
|
||||
/// 相邻集海报预缓存宽度:列表卡片只有百来 dp,按原图解码会占数 MB。
|
||||
const int _posterPreviewWidth = 360;
|
||||
|
||||
/// 画中画还原封面接近全屏,保留更高分辨率但不必按原图。
|
||||
const int _posterPipWidth = 720;
|
||||
|
||||
/// 预缓存用低分辨率 provider;与 RemoteImage 一样限制解码尺寸,避免整张原图进缓存。
|
||||
ImageProvider _posterProvider(String url, {required int width}) {
|
||||
return ResizeImage(NetworkImage(url), width: width, allowUpscaling: false);
|
||||
}
|
||||
|
||||
class PlayerPageHandoff {
|
||||
PlayerPageHandoff({required this.sourceUserWantsPlay});
|
||||
|
||||
@@ -167,6 +179,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,
|
||||
@@ -176,6 +189,9 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
_thumbnailService = PlaybackThumbnailService(
|
||||
sourceResolver: _resolveThumbnailSource,
|
||||
);
|
||||
// 容量必须盖住「同时预取的数量」:渲染栈有上一集/当前集/下一集三个槽位,
|
||||
// 每槽预取当前 cue + 1 个相邻 cue,约 6 张。容量小于这个数时,每次重建都会
|
||||
// 把别处刚解码的雪碧图挤掉,形成「重建→重新下载解码→notify→重建」的死循环。
|
||||
_spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 8)
|
||||
..addListener(_handleSpriteFrameChanged);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
@@ -349,6 +365,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);
|
||||
@@ -495,9 +521,10 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
final imageUrl = AppConfig.mediaUrl(posterUrl);
|
||||
if (_precachedPosterUrls.add(imageUrl)) {
|
||||
unawaited(
|
||||
precacheImage(NetworkImage(imageUrl), context).catchError((
|
||||
Object _,
|
||||
) {
|
||||
precacheImage(
|
||||
_posterProvider(imageUrl, width: _posterPreviewWidth),
|
||||
context,
|
||||
).catchError((Object _) {
|
||||
_precachedPosterUrls.remove(imageUrl);
|
||||
}),
|
||||
);
|
||||
@@ -716,7 +743,15 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
if (logic.pip) {
|
||||
final poster = logic.trackedEpisode?.posterUrl?.trim();
|
||||
if (poster != null && poster.isNotEmpty) {
|
||||
unawaited(precacheImage(NetworkImage(poster), context));
|
||||
unawaited(
|
||||
precacheImage(
|
||||
_posterProvider(
|
||||
AppConfig.mediaUrl(poster),
|
||||
width: _posterPipWidth,
|
||||
),
|
||||
context,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return Stack(
|
||||
@@ -956,10 +991,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) ??
|
||||
@@ -982,19 +1014,19 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
final thumbnailReady =
|
||||
thumbnailsUrl != null &&
|
||||
_spriteAxisService.cachedFrameAt(thumbnailsUrl, Duration.zero) != null;
|
||||
if (!locked && !posterReady && thumbnailsUrl != null) {
|
||||
// 只在没命中时预取:prepareAt 会触发下载解码并 notifyListeners -> 重建,
|
||||
// 无条件调用会让每次重建都排一次异步任务。
|
||||
if (!locked && !posterReady && !thumbnailReady && thumbnailsUrl != null) {
|
||||
unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero));
|
||||
}
|
||||
if (selected &&
|
||||
!locked &&
|
||||
videoPort != null &&
|
||||
videoPort.controller.value.isInitialized) {
|
||||
unawaited(
|
||||
_spriteAxisService.prepareAt(
|
||||
thumbnailsUrl,
|
||||
videoPort.controller.value.position,
|
||||
),
|
||||
);
|
||||
final position = videoPort.controller.value.position;
|
||||
if (_spriteAxisService.cachedFrameAt(thumbnailsUrl, position) == null) {
|
||||
unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, position));
|
||||
}
|
||||
}
|
||||
final pagePosition = indices.indexOf(index);
|
||||
return PlayerRenderSlotData(
|
||||
@@ -1042,10 +1074,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
|
||||
@@ -1258,7 +1287,8 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
if (widget.homeMode && !_chromeVisible && !_boosting && !_scrubbing)
|
||||
Positioned(
|
||||
right: 12,
|
||||
bottom: 56,
|
||||
// 进度条触控带 16pt + 8pt 间距,退出清屏按钮贴着进度条上方。
|
||||
bottom: 24,
|
||||
child: ExitClearScreenButton(
|
||||
key: const Key('home-feed-exit-clear-screen'),
|
||||
tooltip: l10n.exitClearScreen,
|
||||
@@ -1294,10 +1324,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) ??
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/formatting/count_format.dart';
|
||||
import '../../../core/formatting/localized_content.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/app_glow_panel.dart';
|
||||
import '../../../core/widgets/auth_gate.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../../core/data/services/catalog_controller.dart';
|
||||
import '../../../core/data/services/favorite_controller.dart';
|
||||
import 'episode_picker_cell.dart';
|
||||
|
||||
const int _columns = 6;
|
||||
const double _sheetHeightFactor = 0.62;
|
||||
const double _cellAspect = 1.05;
|
||||
const double _sheetRadius = 20;
|
||||
const double _pageHorizontal = 20;
|
||||
const double _tabGap = 20;
|
||||
const double _posterWidth = 86;
|
||||
const double _posterRadius = 8;
|
||||
const double _relatedWidth = 104;
|
||||
const double _relatedGap = 10;
|
||||
const int _synopsisLines = 3;
|
||||
const int _maxTags = 4;
|
||||
const int _maxRelated = 8;
|
||||
final RegExp _tagSplitter = RegExp(r'[,/、·]');
|
||||
|
||||
const TextStyle _synopsisStyle = TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
height: 1.55,
|
||||
);
|
||||
|
||||
/// 弹窗页签:简介 / 选集。
|
||||
enum DramaInfoTab { detail, episodes }
|
||||
|
||||
/// 播放页的剧名弹窗:简介与选集在同一个面板内切换,不再跳转详情页。
|
||||
///
|
||||
/// 布局:拖拽条 → 页签 + 关闭 → 内容(海报信息块 / 选集网格)。
|
||||
class DramaInfoSheet extends StatefulWidget {
|
||||
const DramaInfoSheet({
|
||||
super.key,
|
||||
required this.drama,
|
||||
required this.currentNumber,
|
||||
required this.onSelectEpisode,
|
||||
this.onOpenRelated,
|
||||
this.initialTab = DramaInfoTab.detail,
|
||||
});
|
||||
|
||||
final Drama drama;
|
||||
|
||||
/// 当前播放集号,用于选集网格高亮。
|
||||
final int currentNumber;
|
||||
|
||||
/// 点击某集,参数为该集在原始列表中的索引。
|
||||
final ValueChanged<int> onSelectEpisode;
|
||||
|
||||
/// 点相似推荐里的短剧,交给外层决定怎么打开。
|
||||
final ValueChanged<Drama>? onOpenRelated;
|
||||
|
||||
final DramaInfoTab initialTab;
|
||||
|
||||
@override
|
||||
State<DramaInfoSheet> createState() => _DramaInfoSheetState();
|
||||
}
|
||||
|
||||
class _DramaInfoSheetState extends State<DramaInfoSheet> {
|
||||
late DramaInfoTab _tab = widget.initialTab;
|
||||
bool _synopsisExpanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
key: const Key('drama-info-system-overlay'),
|
||||
value: SystemUiOverlayStyle.light.copyWith(
|
||||
statusBarColor: AppColors.transparent,
|
||||
systemNavigationBarColor: AppColors.card,
|
||||
systemNavigationBarDividerColor: AppColors.transparent,
|
||||
systemNavigationBarIconBrightness: Brightness.light,
|
||||
systemNavigationBarContrastEnforced: false,
|
||||
),
|
||||
child: AppGlowPanel(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(_sheetRadius),
|
||||
),
|
||||
border: false,
|
||||
child: Container(
|
||||
key: const Key('drama-info-sheet'),
|
||||
height: MediaQuery.sizeOf(context).height * _sheetHeightFactor,
|
||||
decoration: const BoxDecoration(color: AppColors.transparent),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
bottom: true,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_grabber(),
|
||||
_headerRow(AppLocalizations.of(context)!),
|
||||
Expanded(
|
||||
child: _tab == DramaInfoTab.detail
|
||||
? _detailPane()
|
||||
: _episodePane(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _grabber() {
|
||||
return Center(
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(top: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.border,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _headerRow(AppLocalizations l10n) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(_pageHorizontal, 6, 8, 2),
|
||||
child: Row(
|
||||
children: [
|
||||
_tabButton(
|
||||
key: const Key('drama-info-tab-detail'),
|
||||
label: l10n.synopsisTitle,
|
||||
tab: DramaInfoTab.detail,
|
||||
),
|
||||
const SizedBox(width: _tabGap),
|
||||
_tabButton(
|
||||
key: const Key('drama-info-tab-episodes'),
|
||||
label: l10n.episodes,
|
||||
tab: DramaInfoTab.episodes,
|
||||
),
|
||||
const Spacer(),
|
||||
_closeButton(l10n),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 视觉上是个小圆钮,点按区域仍是 40pt,避免太小点不中。
|
||||
Widget _closeButton(AppLocalizations l10n) {
|
||||
return SizedBox(
|
||||
key: const Key('drama-info-close'),
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Center(
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: l10n.close,
|
||||
child: Material(
|
||||
color: AppColors.raised,
|
||||
shape: const CircleBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () => Navigator.pop(context),
|
||||
child: const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Icon(
|
||||
Icons.close_rounded,
|
||||
size: 14,
|
||||
color: AppColors.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tabButton({
|
||||
required Key key,
|
||||
required String label,
|
||||
required DramaInfoTab tab,
|
||||
}) {
|
||||
final selected = _tab == tab;
|
||||
return GestureDetector(
|
||||
key: key,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
if (_tab != tab) setState(() => _tab = tab);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: selected ? AppColors.onSurface : AppColors.muted,
|
||||
fontSize: 18,
|
||||
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _detailPane() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final drama = widget.drama;
|
||||
final tags = _tagValues(
|
||||
drama,
|
||||
Localizations.localeOf(context).languageCode,
|
||||
);
|
||||
final related = _relatedDramas();
|
||||
return ListView(
|
||||
key: const Key('drama-info-detail'),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
_pageHorizontal,
|
||||
12,
|
||||
_pageHorizontal,
|
||||
28,
|
||||
),
|
||||
children: [
|
||||
_headline(l10n, drama),
|
||||
const SizedBox(height: 16),
|
||||
_synopsis(l10n, drama),
|
||||
if (tags.isNotEmpty) ...[const SizedBox(height: 14), _tagRow(tags)],
|
||||
const SizedBox(height: 20),
|
||||
_favoriteButton(l10n, drama),
|
||||
if (related.isNotEmpty) ...[
|
||||
const SizedBox(height: 26),
|
||||
_relatedSection(l10n, related),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 海报 + 剧名 + 热度。
|
||||
Widget _headline(AppLocalizations l10n, Drama drama) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(_posterRadius),
|
||||
child: SizedBox(
|
||||
width: _posterWidth,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 3 / 4,
|
||||
child: RemoteImage(
|
||||
drama.posterUrl,
|
||||
fit: BoxFit.cover,
|
||||
showFallbackIcon: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
localizedDramaTitle(l10n, drama),
|
||||
key: const Key('drama-info-title'),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.25,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.local_fire_department_rounded,
|
||||
size: 16,
|
||||
color: AppColors.blue,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
formatInteractionCount(drama.viewCount),
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
l10n.episodeCountLabel(drama.episodeCount),
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _synopsis(AppLocalizations l10n, Drama drama) {
|
||||
final synopsis = localizedDramaSynopsis(l10n, drama).trim();
|
||||
if (synopsis.isEmpty) return const SizedBox.shrink();
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(text: synopsis, style: _synopsisStyle),
|
||||
maxLines: _synopsisLines,
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout(maxWidth: constraints.maxWidth);
|
||||
final overflows = painter.didExceedMaxLines;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
synopsis,
|
||||
key: const Key('drama-info-synopsis'),
|
||||
maxLines: _synopsisExpanded ? null : _synopsisLines,
|
||||
overflow: _synopsisExpanded
|
||||
? TextOverflow.visible
|
||||
: TextOverflow.ellipsis,
|
||||
style: _synopsisStyle,
|
||||
),
|
||||
if (overflows)
|
||||
GestureDetector(
|
||||
key: const Key('drama-info-synopsis-toggle'),
|
||||
onTap: () =>
|
||||
setState(() => _synopsisExpanded = !_synopsisExpanded),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
_synopsisExpanded ? l10n.showLess : l10n.expandSynopsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tagRow(List<String> tags) {
|
||||
return Wrap(
|
||||
key: const Key('drama-info-tags'),
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final tag in tags)
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(10, 5, 4, 5),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.raised,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
tag,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 14,
|
||||
color: AppColors.subtle,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _favoriteButton(AppLocalizations l10n, Drama drama) {
|
||||
final favorites = context.watch<FavoriteController>();
|
||||
final saved = favorites.isSaved(drama.id);
|
||||
return SizedBox(
|
||||
height: 48,
|
||||
child: Material(
|
||||
color: AppColors.raised,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
key: const Key('drama-info-favorite'),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => unawaited(_toggleFavorite(l10n, drama)),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
saved ? Icons.bookmark_rounded : Icons.bookmark_border_rounded,
|
||||
size: 20,
|
||||
color: AppColors.onPrimary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.favorite,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _relatedSection(AppLocalizations l10n, List<Drama> related) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.similarDramas,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: _relatedWidth / 3 * 4 + 8,
|
||||
child: ListView.separated(
|
||||
key: const Key('drama-info-related'),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: related.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: _relatedGap),
|
||||
itemBuilder: (context, index) {
|
||||
final drama = related[index];
|
||||
return GestureDetector(
|
||||
key: Key('drama-info-related-${drama.id}'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
widget.onOpenRelated?.call(drama);
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: SizedBox(
|
||||
width: _relatedWidth,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 3 / 4,
|
||||
child: RemoteImage(
|
||||
drama.posterUrl,
|
||||
fit: BoxFit.cover,
|
||||
showFallbackIcon: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _episodePane() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final episodes = widget.drama.episodes;
|
||||
if (episodes.isEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
l10n.noEpisodesYet,
|
||||
key: const Key('drama-info-no-episodes'),
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 13),
|
||||
),
|
||||
);
|
||||
}
|
||||
return GridView.builder(
|
||||
key: const Key('drama-info-episodes'),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
_pageHorizontal,
|
||||
8,
|
||||
_pageHorizontal,
|
||||
12,
|
||||
),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: _columns,
|
||||
mainAxisSpacing: AppDimens.spaceSm,
|
||||
crossAxisSpacing: AppDimens.spaceSm,
|
||||
childAspectRatio: _cellAspect,
|
||||
),
|
||||
itemCount: episodes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final episode = episodes[index];
|
||||
return EpisodePickerCell(
|
||||
key: Key('player-episode-${episode.number}'),
|
||||
episode: episode,
|
||||
selected: episode.number == widget.currentNumber,
|
||||
lockLabel: l10n.locked,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
widget.onSelectEpisode(index);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleFavorite(AppLocalizations l10n, Drama drama) async {
|
||||
final favorites = context.read<FavoriteController>();
|
||||
final ok = await favorites.toggle(drama.id, card: drama);
|
||||
if (ok || !mounted) return;
|
||||
if (favorites.lastErrorCode == 200007) {
|
||||
await ensureSignedIn(context);
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.favoriteToggleFailed)));
|
||||
}
|
||||
|
||||
/// 相似推荐先用目录里的其它剧兜底,没有目录来源就不显示这一块。
|
||||
List<Drama> _relatedDramas() {
|
||||
final CatalogController catalog;
|
||||
try {
|
||||
catalog = context.read<CatalogController>();
|
||||
} on ProviderNotFoundException {
|
||||
return const [];
|
||||
}
|
||||
return catalog.dramas
|
||||
.where((item) => item.id != widget.drama.id)
|
||||
.take(_maxRelated)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<String> _tagValues(Drama drama, String languageCode) {
|
||||
final localized = drama.localizedTypeLabels(languageCode);
|
||||
if (localized.isNotEmpty) {
|
||||
return localized.take(_maxTags).toList(growable: false);
|
||||
}
|
||||
final result = <String>[];
|
||||
for (final source in [drama.genre, drama.category]) {
|
||||
for (final part in source.split(_tagSplitter)) {
|
||||
final tag = part.trim();
|
||||
if (tag.isEmpty || result.contains(tag)) continue;
|
||||
result.add(tag);
|
||||
if (result.length == _maxTags) return result;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/formatting/localized_content.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/app_glow_panel.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import 'episode_picker_cell.dart';
|
||||
|
||||
const int _columns = 6;
|
||||
const double _sheetHeightFactor = 0.52;
|
||||
const double _cellAspect = 1.05;
|
||||
const double _sheetRadius = 20;
|
||||
|
||||
/// K28 选集面板:六列等宽网格、当前集高亮、锁定集显示锁标。
|
||||
class EpisodePickerSheet extends StatelessWidget {
|
||||
/// 创建选集面板。
|
||||
const EpisodePickerSheet({
|
||||
super.key,
|
||||
required this.drama,
|
||||
required this.currentNumber,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
/// 当前短剧。
|
||||
final Drama drama;
|
||||
|
||||
/// 当前集号。
|
||||
final int currentNumber;
|
||||
|
||||
/// 点击某集,参数为该集在原始列表中的索引。
|
||||
final ValueChanged<int> onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final episodes = drama.episodes;
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
key: const Key('player-episodes-system-overlay'),
|
||||
value: SystemUiOverlayStyle.light.copyWith(
|
||||
statusBarColor: AppColors.transparent,
|
||||
systemNavigationBarColor: AppColors.card,
|
||||
systemNavigationBarDividerColor: AppColors.transparent,
|
||||
systemNavigationBarIconBrightness: Brightness.light,
|
||||
systemNavigationBarContrastEnforced: false,
|
||||
),
|
||||
child: AppGlowPanel(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(_sheetRadius),
|
||||
),
|
||||
border: false,
|
||||
child: Container(
|
||||
key: const Key('player-episodes-sheet'),
|
||||
height: MediaQuery.sizeOf(context).height * _sheetHeightFactor,
|
||||
decoration: const BoxDecoration(color: AppColors.transparent),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
bottom: true,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 14, 8, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.episodes,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
key: const Key('player-episodes-close'),
|
||||
tooltip: l10n.close,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
localizedDramaTitle(l10n, drama),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
l10n.episodeCountLabel(episodes.length),
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: _columns,
|
||||
mainAxisSpacing: AppDimens.spaceSm,
|
||||
crossAxisSpacing: AppDimens.spaceSm,
|
||||
childAspectRatio: _cellAspect,
|
||||
),
|
||||
itemCount: episodes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final episode = episodes[index];
|
||||
return EpisodePickerCell(
|
||||
key: Key('player-episode-${episode.number}'),
|
||||
episode: episode,
|
||||
selected: episode.number == currentNumber,
|
||||
lockLabel: l10n.locked,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
onSelect(index);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
|
||||
const List<Shadow> _iconShadows = [
|
||||
Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)),
|
||||
];
|
||||
|
||||
/// Shared floating action used to leave clear-screen mode.
|
||||
class ExitClearScreenButton extends StatelessWidget {
|
||||
const ExitClearScreenButton({
|
||||
@@ -25,11 +29,15 @@ class ExitClearScreenButton extends StatelessWidget {
|
||||
onPressed: onPressed,
|
||||
padding: EdgeInsets.zero,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: AppColors.controlScrim,
|
||||
backgroundColor: AppColors.transparent,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
icon: const Icon(Icons.fullscreen_exit_rounded, size: 18),
|
||||
icon: const Icon(
|
||||
Icons.fullscreen_exit_rounded,
|
||||
size: 22,
|
||||
shadows: _iconShadows,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,13 +21,16 @@ import 'scrub_preview_card.dart';
|
||||
part 'home_feed_overlay_parts.dart';
|
||||
|
||||
const double _progressHeight = 2;
|
||||
const double _progressHitHeight = 32;
|
||||
|
||||
/// 进度条触控带高度:没有滑块圆点后收到 16pt,够手指按住这条线拖动。
|
||||
const double _progressHitHeight = 16;
|
||||
const double _pageHorizontal = 20;
|
||||
const double _railRight = 14;
|
||||
const double _railGap = 12;
|
||||
// 观看条高 44;2pt 进度线视觉上沿距观看条顶约 7pt(线中心距进度条盒底 8)。
|
||||
// 信息面板底边取 44+16=60,使简介↔进度线、进度线↔观看条两侧间距均为 7pt。
|
||||
const double _bottomInfoInset = homeFeedWatchAllHeight + 16;
|
||||
// 进度线贴着屏幕底部(线心距底 4pt),观看条底边只留 8pt,视觉上就是线上一根条。
|
||||
const double _watchAllBottomInset = 8;
|
||||
// 信息面板底边直接贴住观看条顶沿,中间不留缝。
|
||||
const double _bottomInfoInset = _watchAllBottomInset + homeFeedWatchAllHeight;
|
||||
const double _railBottomOffset = 40;
|
||||
const Duration _overlayFadeDuration = Duration(milliseconds: 120);
|
||||
const List<Shadow> _boostTextShadows = [
|
||||
@@ -152,7 +155,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: homeFeedWatchAllHeight + 12,
|
||||
bottom: _watchAllBottomInset + homeFeedWatchAllHeight + 12,
|
||||
child: AnimatedOpacity(
|
||||
duration: _overlayFadeDuration,
|
||||
opacity: widget.boosting ? 1 : 0,
|
||||
@@ -162,10 +165,30 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// 观看条在下、进度条在上:进度条 32pt 触控盒要盖住观看条下沿的空白,
|
||||
// 否则拖拽 seek 的起点会被观看条吞掉。两块的可见区域(文字/进度线)互不遮挡。
|
||||
Positioned(
|
||||
left: _pageHorizontal,
|
||||
right: _pageHorizontal,
|
||||
bottom: homeFeedWatchAllHeight,
|
||||
bottom: _watchAllBottomInset,
|
||||
child: AnimatedOpacity(
|
||||
duration: _overlayFadeDuration,
|
||||
opacity: widget.visible && !_scrubbing && !widget.boosting
|
||||
? 1
|
||||
: 0,
|
||||
child: IgnorePointer(
|
||||
ignoring: !widget.visible || _scrubbing || widget.boosting,
|
||||
child: HomeFeedWatchAllBar(
|
||||
drama: widget.drama,
|
||||
onTap: widget.onOpenFullPlayer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: _pageHorizontal,
|
||||
right: _pageHorizontal,
|
||||
bottom: 0,
|
||||
child: _FeedProgressBar(
|
||||
controller: widget.controller,
|
||||
locked: widget.locked,
|
||||
@@ -182,27 +205,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
onSeek: widget.onSeek,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: AnimatedOpacity(
|
||||
duration: _overlayFadeDuration,
|
||||
opacity: widget.visible && !_scrubbing && !widget.boosting
|
||||
? 1
|
||||
: 0,
|
||||
child: IgnorePointer(
|
||||
ignoring: !widget.visible || _scrubbing || widget.boosting,
|
||||
child: HomeFeedWatchAllBar(
|
||||
drama: widget.drama,
|
||||
onTap: widget.onOpenFullPlayer,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 信息面板放在最后(最上层):进度条 32px 触控盒向上伸进底部避让区,
|
||||
// 若面板在前,简介/展开按钮底部的触控留白会被进度条吞掉点击。
|
||||
// 视觉上避让条带为空,提层不改变观感。
|
||||
// 信息面板放在最后(最上层),底部避让区永远优先响应简介/展开按钮的点击。
|
||||
Positioned(
|
||||
left: _pageHorizontal,
|
||||
right: _railRight,
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
part of 'home_feed_overlay.dart';
|
||||
|
||||
const double _thumbSize = 6;
|
||||
const double _thumbActiveSize = 14;
|
||||
const double _trackActiveHeight = 6;
|
||||
const double _lineCenterFromBottom = 8;
|
||||
// 进度线贴住屏幕底部(线心距底 4pt);播放进度只靠蓝色已播段表达,不画滑块圆点。
|
||||
const double _lineCenterFromBottom = 4;
|
||||
const double _timeBubbleWidth = 96;
|
||||
const double _timeBubbleMargin = 12;
|
||||
const Key _inactiveTrackKey = Key('home-feed-progress-inactive');
|
||||
@@ -305,7 +304,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
height: widget.height,
|
||||
child: ColoredBox(
|
||||
key: _inactiveTrackKey,
|
||||
color: AppColors.border,
|
||||
color: AppColors.inactiveTrack,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -345,20 +344,11 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
Widget _track(double fraction, double width) {
|
||||
final dragging = _preview != null;
|
||||
final trackHeight = dragging ? _trackActiveHeight : widget.height;
|
||||
final size = dragging ? _thumbActiveSize : _thumbSize;
|
||||
final finger = (width * fraction).clamp(0.0, width).toDouble();
|
||||
final thumbSlotLeft = dragging
|
||||
? finger - _thumbActiveSize / 2
|
||||
: (finger - _thumbActiveSize / 2)
|
||||
.clamp(0.0, width - _thumbActiveSize)
|
||||
.toDouble();
|
||||
final playedWidth = dragging ? finger : width * fraction;
|
||||
final playedWidth = (width * fraction).clamp(0.0, width).toDouble();
|
||||
final trackSlotBottom = _lineCenterFromBottom - _trackActiveHeight / 2;
|
||||
final thumbSlotBottom = _lineCenterFromBottom - _thumbActiveSize / 2;
|
||||
return SizedBox(
|
||||
height: widget.hitHeight,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
@@ -374,7 +364,8 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
key: const Key('home-feed-progress-track'),
|
||||
width: width,
|
||||
height: trackHeight,
|
||||
color: AppColors.border,
|
||||
// 和剧集播放页进度条共用同一个槽底色,两个入口观感一致。
|
||||
color: AppColors.inactiveTrack,
|
||||
),
|
||||
),
|
||||
Align(
|
||||
@@ -390,24 +381,6 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: thumbSlotLeft,
|
||||
bottom: thumbSlotBottom,
|
||||
child: SizedBox.square(
|
||||
dimension: _thumbActiveSize,
|
||||
child: Center(
|
||||
child: Container(
|
||||
key: const Key('home-feed-progress-thumb'),
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.onPrimary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -13,7 +13,14 @@ import 'interaction_rail.dart';
|
||||
const double _seriesFont = 17;
|
||||
const double _tagFont = 12;
|
||||
const double _synopsisFont = 14;
|
||||
const double _watchAllHeight = 44;
|
||||
const double _watchAllHeight = 38;
|
||||
|
||||
/// 观看条内部左右留白与圆角:外沿由 overlay 按页面边距对齐,内部不再重复内缩。
|
||||
const double _watchAllInset = 12;
|
||||
const double _watchAllRadius = 12;
|
||||
|
||||
/// 标题、标签、简介之间的行间距:底部信息本来就挤,留 4pt 够分层。
|
||||
const double _blockGap = 4;
|
||||
const List<Shadow> _textShadows = [
|
||||
Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)),
|
||||
];
|
||||
@@ -57,6 +64,7 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
final dramaTitle = localizedDramaTitle(l10n, widget.drama);
|
||||
final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim();
|
||||
final genres = _genreValues(Localizations.localeOf(context).languageCode);
|
||||
final tags = _tagChips(l10n, genres);
|
||||
return Column(
|
||||
key: const Key('home-feed-info-panel'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -104,18 +112,21 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_tagMetaRow(l10n, genres),
|
||||
// 没有标签就整行跳过,不给标题和简介之间留空档。
|
||||
if (tags.isNotEmpty) ...[
|
||||
const SizedBox(height: _blockGap),
|
||||
_tagRow(tags),
|
||||
],
|
||||
if (synopsis.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: _blockGap),
|
||||
_synopsis(l10n, synopsis),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tagMetaRow(AppLocalizations l10n, List<String> genres) {
|
||||
final chips = <Widget>[
|
||||
List<Widget> _tagChips(AppLocalizations l10n, List<String> genres) {
|
||||
return [
|
||||
if (isHotDrama(widget.drama))
|
||||
_TagChip(
|
||||
key: const Key('home-feed-hot-badge'),
|
||||
@@ -125,6 +136,9 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
for (final genre in genres)
|
||||
_TagChip(key: Key('home-feed-tag-$genre'), label: genre),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _tagRow(List<Widget> chips) {
|
||||
return SizedBox(
|
||||
key: const Key('home-feed-tags'),
|
||||
height: 22,
|
||||
@@ -149,10 +163,7 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
|
||||
);
|
||||
final span = TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: prefix,
|
||||
style: baseStyle,
|
||||
),
|
||||
TextSpan(text: prefix, style: baseStyle),
|
||||
TextSpan(
|
||||
text: ' | ',
|
||||
style: baseStyle.copyWith(
|
||||
@@ -368,7 +379,7 @@ class _TagChip extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 底部通栏条:🎬 观看完整剧集 全 N 集。
|
||||
/// 底部「观看完整剧集」条:浮在底部,不再铺满整屏宽度。
|
||||
class HomeFeedWatchAllBar extends StatelessWidget {
|
||||
const HomeFeedWatchAllBar({
|
||||
super.key,
|
||||
@@ -382,42 +393,45 @@ class HomeFeedWatchAllBar extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Material(
|
||||
key: const Key('home-feed-full-series-bg'),
|
||||
color: AppColors.textPrimary.withValues(alpha: 0.15),
|
||||
child: InkWell(
|
||||
key: const Key('home-feed-full-series'),
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: _watchAllHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.movie_rounded,
|
||||
size: 22,
|
||||
color: AppColors.onPrimary,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.watchFullSeries('${drama.episodeCount}'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(_watchAllRadius),
|
||||
child: Material(
|
||||
key: const Key('home-feed-full-series-bg'),
|
||||
color: AppColors.textPrimary.withValues(alpha: 0.15),
|
||||
child: InkWell(
|
||||
key: const Key('home-feed-full-series'),
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: _watchAllHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: _watchAllInset),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.movie_rounded,
|
||||
size: 22,
|
||||
color: AppColors.onPrimary,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.watchFullSeries('${drama.episodeCount}'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 22,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
],
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 22,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -6,7 +6,9 @@ import '../../../core/theme/app_colors.dart';
|
||||
|
||||
const double _toggleSize = 68;
|
||||
const double _iconSize = 36;
|
||||
const Color _toggleBackground = AppColors.controlSoftScrim;
|
||||
|
||||
/// 播放/暂停压在画面上,垫一层半透明深色圆底保证亮画面下也能看见。
|
||||
const Color _toggleBackground = Color(0x66000000);
|
||||
const List<Shadow> _iconShadows = [
|
||||
Shadow(color: AppColors.shadow, blurRadius: 12),
|
||||
];
|
||||
|
||||
@@ -19,8 +19,17 @@ import 'seek_progress_bar.dart';
|
||||
|
||||
part 'player_overlay_parts.dart';
|
||||
|
||||
const double _topButtonSize = 36;
|
||||
const double _topButtonSize = 40;
|
||||
const double _topBarHeight = 58;
|
||||
|
||||
/// 底部信息、选集条、进度条之间的统一行距。
|
||||
const double _blockGap = 8;
|
||||
|
||||
/// 底部选集条与右侧清屏按钮的统一高度,两者必须等高。
|
||||
const double _bottomActionHeight = 34;
|
||||
|
||||
/// 选集条内部左右留白。
|
||||
const double _bottomActionInset = 10;
|
||||
const double _boostHintIconSize = 16;
|
||||
const double _boostHintFontSize = 13;
|
||||
const double _synopsisRailInset = 54;
|
||||
@@ -107,11 +116,12 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
key: const Key('player-back'),
|
||||
tooltip: data.l10n.backToSeries,
|
||||
icon: Icons.chevron_left_rounded,
|
||||
iconSize: 30,
|
||||
onPressed: actions.onBack!,
|
||||
)
|
||||
else
|
||||
const SizedBox(width: _topButtonSize),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 2),
|
||||
Expanded(
|
||||
child: Text(
|
||||
data.l10n.episodeLabel(data.episode.number),
|
||||
@@ -135,7 +145,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
_TopButton(
|
||||
key: const Key('player-more'),
|
||||
tooltip: data.l10n.more,
|
||||
icon: Icons.more_vert_rounded,
|
||||
icon: Icons.more_horiz_rounded,
|
||||
onPressed: actions.onMore,
|
||||
),
|
||||
],
|
||||
@@ -226,19 +236,20 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
child: _bottomInfo(context),
|
||||
),
|
||||
if (data.boosting) _boostHint(),
|
||||
_bottomProgress(),
|
||||
// 加速时收起底栏。拖动进度时底栏只隐藏,占位保留,进度条不挪位。
|
||||
// 加速时收起选集条。拖动进度时只隐藏,占位保留,底部不跳动。
|
||||
scrubbing
|
||||
? IgnorePointer(
|
||||
child: Opacity(opacity: 0, child: _bottomActions()),
|
||||
)
|
||||
: Offstage(offstage: data.boosting, child: _bottomActions()),
|
||||
// 进度条压在最底部,和首页 feed 一致。
|
||||
_bottomProgress(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 加速提示:橙色小三角 + 当前倍速文案,悬在进度条上方。
|
||||
/// 加速提示:橙色小三角 + 当前倍速文案,压在底部信息区下方。
|
||||
Widget _boostHint() {
|
||||
return Padding(
|
||||
key: const Key('player-speed-boost-hint'),
|
||||
@@ -270,25 +281,28 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
Widget _bottomInfo(BuildContext context) {
|
||||
final series = localizedDramaTitle(data.l10n, data.drama);
|
||||
final synopsis = localizedDramaSynopsis(data.l10n, data.drama);
|
||||
// 右侧让出互动栏的宽度:剧名和简介一样,收在互动栏左侧,长标题也不会钻到按钮下面。
|
||||
return Padding(
|
||||
key: const Key('player-controls-bottom'),
|
||||
padding: const EdgeInsets.fromLTRB(14, 8, 14, 0),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
14,
|
||||
_blockGap,
|
||||
14 + _synopsisRailInset,
|
||||
0,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_detailRow(series),
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: _synopsisRailInset),
|
||||
child: _EpisodeSynopsis(
|
||||
key: const Key('player-episode-synopsis'),
|
||||
l10n: data.l10n,
|
||||
drama: data.drama,
|
||||
episodeNumber: data.episode.number,
|
||||
synopsis: synopsis,
|
||||
authorNote: data.authorNote,
|
||||
),
|
||||
_EpisodeSynopsis(
|
||||
key: const Key('player-episode-synopsis'),
|
||||
l10n: data.l10n,
|
||||
drama: data.drama,
|
||||
episodeNumber: data.episode.number,
|
||||
synopsis: synopsis,
|
||||
authorNote: data.authorNote,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -296,9 +310,10 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _bottomProgress() {
|
||||
// 进度条不贴底:底部留 16pt,线心离底边更远,也不再和选集条挤在一起。
|
||||
return Padding(
|
||||
key: const Key('player-progress-container'),
|
||||
padding: EdgeInsets.fromLTRB(14, controlsVisible ? 2 : 8, 14, 0),
|
||||
padding: const EdgeInsets.fromLTRB(14, 2, 14, 16),
|
||||
child: SeekProgressBar(
|
||||
key: const Key('player-progress'),
|
||||
valueListenable: data.controller,
|
||||
@@ -345,10 +360,9 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: AppColors.playerMuted,
|
||||
color: AppColors.onPrimary,
|
||||
size: 20,
|
||||
shadows: _playerTextShadows,
|
||||
),
|
||||
@@ -362,55 +376,63 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
Widget _bottomActions() {
|
||||
return Padding(
|
||||
key: const Key('player-bottom-actions'),
|
||||
padding: const EdgeInsets.fromLTRB(14, 6, 14, 6),
|
||||
padding: const EdgeInsets.fromLTRB(14, 2, 14, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Material(
|
||||
key: const Key('player-episodes-pill'),
|
||||
color: AppColors.playerOverlayPill,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
key: const Key('player-episodes'),
|
||||
onTap: actions.onEpisodes,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${data.l10n.episodes} · '
|
||||
'${data.l10n.allEpisodesBadge(data.totalEpisodes)} · '
|
||||
'${data.l10n.freeWatch}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
// 清屏后只留右下角的退出按钮,左侧选集条一起收起。
|
||||
if (!controlsVisible)
|
||||
const Spacer()
|
||||
else ...[
|
||||
Expanded(
|
||||
// 选集条给一层浅底色:视频画面亮时纯文字不好认,也顺带标出可点区域。
|
||||
child: Material(
|
||||
key: const Key('player-episodes-pill'),
|
||||
color: AppColors.textPrimary.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
key: const Key('player-episodes'),
|
||||
onTap: actions.onEpisodes,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: _bottomActionInset,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: _bottomActionHeight,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${data.l10n.episodes} · '
|
||||
'${data.l10n.allEpisodesBadge(data.totalEpisodes)} · '
|
||||
'${data.l10n.freeWatch}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w800,
|
||||
shadows: _playerTextShadows,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
Icons.keyboard_arrow_up_rounded,
|
||||
color: AppColors.onPrimary,
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w800,
|
||||
size: 18,
|
||||
shadows: _playerTextShadows,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
Icons.keyboard_arrow_up_rounded,
|
||||
color: AppColors.onPrimary,
|
||||
size: 18,
|
||||
shadows: _playerTextShadows,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
_BottomIconButton(
|
||||
key: const Key('player-bottom-clear-screen'),
|
||||
tooltip: controlsVisible
|
||||
@@ -419,6 +441,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
|
||||
icon: controlsVisible
|
||||
? Icons.fullscreen_rounded
|
||||
: Icons.fullscreen_exit_rounded,
|
||||
height: _bottomActionHeight,
|
||||
onPressed: controlsVisible ? actions.onClear : actions.onExitClear,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -2,17 +2,20 @@
|
||||
|
||||
part of 'player_overlay.dart';
|
||||
|
||||
/// 顶部按钮:直接压在画面上,不加底色,靠文字/图标阴影保证可读性。
|
||||
class _TopButton extends StatelessWidget {
|
||||
const _TopButton({
|
||||
super.key,
|
||||
required this.tooltip,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
this.iconSize = 22,
|
||||
});
|
||||
|
||||
final String tooltip;
|
||||
final IconData icon;
|
||||
final VoidCallback onPressed;
|
||||
final double iconSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -22,11 +25,11 @@ class _TopButton extends StatelessWidget {
|
||||
tooltip: tooltip,
|
||||
padding: EdgeInsets.zero,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: AppColors.controlScrim,
|
||||
backgroundColor: AppColors.transparent,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
),
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon, size: 18),
|
||||
icon: Icon(icon, size: iconSize, shadows: _playerTextShadows),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -41,7 +44,7 @@ class _SpeedEntry extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: AppColors.controlScrim,
|
||||
color: AppColors.transparent,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
@@ -81,25 +84,30 @@ class _BottomIconButton extends StatelessWidget {
|
||||
required this.tooltip,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
this.height = 40,
|
||||
});
|
||||
|
||||
final String tooltip;
|
||||
final IconData icon;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
/// 与左侧选集条等高,两个控件底部齐平。
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox.square(
|
||||
dimension: 40,
|
||||
return SizedBox(
|
||||
width: 40,
|
||||
height: height,
|
||||
child: IconButton(
|
||||
tooltip: tooltip,
|
||||
padding: EdgeInsets.zero,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: AppColors.playerOverlayPill,
|
||||
backgroundColor: AppColors.transparent,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
),
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon, size: 22),
|
||||
icon: Icon(icon, size: 24, shadows: _playerTextShadows),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -242,8 +250,10 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
_toggle(widget.l10n.expandSynopsis,
|
||||
padding: const EdgeInsets.only(left: 6)),
|
||||
_toggle(
|
||||
widget.l10n.expandSynopsis,
|
||||
padding: const EdgeInsets.only(left: 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -291,12 +301,12 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
|
||||
/// 分隔竖线:同字号 w400 + 45% 透明白,靠两侧空格留白。
|
||||
/// 必须用 TextSpan:WidgetSpan 占位盒的基线对齐会让第n集与简介错位。
|
||||
TextSpan _separatorSpan() => TextSpan(
|
||||
text: ' | ',
|
||||
style: _lineStyle.copyWith(
|
||||
color: AppColors.onPrimary.withValues(alpha: 0.45),
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
);
|
||||
text: ' | ',
|
||||
style: _lineStyle.copyWith(
|
||||
color: AppColors.onPrimary.withValues(alpha: 0.45),
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
);
|
||||
|
||||
Widget _toggle(String label, {required EdgeInsets padding}) {
|
||||
return GestureDetector(
|
||||
|
||||
@@ -321,10 +321,8 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
|
||||
trackHeight: trackHeight,
|
||||
activeTrackColor: AppColors.blue,
|
||||
inactiveTrackColor: widget.inactiveTrackColor,
|
||||
thumbColor: AppColors.onPrimary,
|
||||
thumbShape: RoundSliderThumbShape(
|
||||
enabledThumbRadius: scrubbing ? 8 : 6,
|
||||
),
|
||||
// 播放页进度条不画滑块圆点:进度只靠蓝色已播段表达。
|
||||
thumbShape: SliderComponentShape.noThumb,
|
||||
overlayShape: RoundSliderOverlayShape(
|
||||
overlayRadius: widget.compact ? 12 : 18,
|
||||
),
|
||||
|
||||
@@ -464,7 +464,7 @@ class _RankingRow extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
drama.title,
|
||||
maxLines: 1,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
|
||||
@@ -138,8 +138,7 @@ class _RecommendDiscoverPageState extends State<RecommendDiscoverPage>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openDrama(Drama drama) =>
|
||||
playDramaFromCatalog(context, drama);
|
||||
Future<void> _openDrama(Drama drama) => playDramaFromCatalog(context, drama);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -163,9 +162,7 @@ class _RecommendDiscoverPageState extends State<RecommendDiscoverPage>
|
||||
Positioned.fill(
|
||||
child: categoryList.isEmpty
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.brandRed,
|
||||
),
|
||||
child: CircularProgressIndicator(color: AppColors.brandRed),
|
||||
)
|
||||
: PageView.builder(
|
||||
key: const Key('recommend-channel-pager'),
|
||||
@@ -184,7 +181,8 @@ class _RecommendDiscoverPageState extends State<RecommendDiscoverPage>
|
||||
carouselActive: widget.active && index == selectedIndex,
|
||||
mediaTop: mediaTop,
|
||||
onRequest: _ensureCategory,
|
||||
onRefresh: () => _refreshCategory(category.id, index == 0),
|
||||
onRefresh: () =>
|
||||
_refreshCategory(category.id, index == 0),
|
||||
onOpenDrama: _openDrama,
|
||||
);
|
||||
},
|
||||
@@ -294,11 +292,7 @@ class _ChannelFeedState extends State<_ChannelFeed>
|
||||
List<Drama> newArrivals = const [];
|
||||
List<Drama> rest = dramas;
|
||||
if (widget.isFeatured && dramas.isNotEmpty) {
|
||||
final lang = Localizations.localeOf(context).languageCode;
|
||||
final badge = AppLocalizations.of(context)!.newArrivalBadge;
|
||||
newArrivals = dramas
|
||||
.where((drama) => _isNewArrivalTag(drama, lang, badge))
|
||||
.toList(growable: false);
|
||||
newArrivals = dramas.where(isNewArrivalTag).toList(growable: false);
|
||||
if (newArrivals.isNotEmpty) {
|
||||
final newIds = newArrivals.map((drama) => drama.id).toSet();
|
||||
rest = dramas
|
||||
@@ -326,7 +320,9 @@ class _ChannelFeedState extends State<_ChannelFeed>
|
||||
hasScrollBody: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: showCarousel ? 0 : widget.mediaTop + RecommendChannelNav.height,
|
||||
top: showCarousel
|
||||
? 0
|
||||
: widget.mediaTop + RecommendChannelNav.height,
|
||||
),
|
||||
child: Center(child: _placeholder(slot.loading)),
|
||||
),
|
||||
@@ -334,12 +330,11 @@ class _ChannelFeedState extends State<_ChannelFeed>
|
||||
);
|
||||
} else if (widget.isFeatured) {
|
||||
// 无轮播图时内容需避开顶部悬浮导航。
|
||||
final contentTop =
|
||||
showCarousel ? 24.0 : widget.mediaTop + RecommendChannelNav.height + 16;
|
||||
final contentTop = showCarousel
|
||||
? 24.0
|
||||
: widget.mediaTop + RecommendChannelNav.height + 16;
|
||||
if (newArrivals.isNotEmpty) {
|
||||
sliverList.add(
|
||||
SliverToBoxAdapter(child: SizedBox(height: contentTop)),
|
||||
);
|
||||
sliverList.add(SliverToBoxAdapter(child: SizedBox(height: contentTop)));
|
||||
sliverList.add(
|
||||
SliverToBoxAdapter(
|
||||
child: HomeNewArrivalRow(
|
||||
@@ -348,9 +343,7 @@ class _ChannelFeedState extends State<_ChannelFeed>
|
||||
),
|
||||
),
|
||||
);
|
||||
sliverList.add(
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 28)),
|
||||
);
|
||||
sliverList.add(const SliverToBoxAdapter(child: SizedBox(height: 28)));
|
||||
}
|
||||
sliverList.add(
|
||||
_gridSliver(
|
||||
@@ -363,7 +356,9 @@ class _ChannelFeedState extends State<_ChannelFeed>
|
||||
sliverList.add(
|
||||
_gridSliver(
|
||||
dramas,
|
||||
topPadding: showCarousel ? 20 : widget.mediaTop + RecommendChannelNav.height + 16,
|
||||
topPadding: showCarousel
|
||||
? 20
|
||||
: widget.mediaTop + RecommendChannelNav.height + 16,
|
||||
crossAxisCount: 3,
|
||||
),
|
||||
);
|
||||
@@ -427,7 +422,6 @@ class _ChannelFeedState extends State<_ChannelFeed>
|
||||
const pageSide = 14.0;
|
||||
const cardGap = 9.0;
|
||||
const rowGap = 16.0;
|
||||
const cardBodyExtent = 44.0; // 封面下间距 + 剧名行 + 集数行
|
||||
// 两列时卡更大,卡间距略放宽。
|
||||
final spacing = crossAxisCount == 2 ? 12.0 : cardGap;
|
||||
return SliverPadding(
|
||||
@@ -437,7 +431,7 @@ class _ChannelFeedState extends State<_ChannelFeed>
|
||||
final itemWidth =
|
||||
(constraints.crossAxisExtent - spacing * (crossAxisCount - 1)) /
|
||||
crossAxisCount;
|
||||
final itemExtent = itemWidth * 157 / 110 + cardBodyExtent;
|
||||
final itemExtent = itemWidth * 157 / 110 + homePosterCardBodyExtent;
|
||||
return SliverGrid(
|
||||
key: Key('recommend-category-grid-${widget.categoryId}'),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
@@ -446,17 +440,14 @@ class _ChannelFeedState extends State<_ChannelFeed>
|
||||
mainAxisSpacing: rowGap,
|
||||
mainAxisExtent: itemExtent,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final drama = dramas[index];
|
||||
return HomePosterCard(
|
||||
key: Key('recommend-category-card-${drama.id}'),
|
||||
drama: drama,
|
||||
onTap: () => widget.onOpenDrama(drama),
|
||||
);
|
||||
},
|
||||
childCount: dramas.length,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate((context, index) {
|
||||
final drama = dramas[index];
|
||||
return HomePosterCard(
|
||||
key: Key('recommend-category-card-${drama.id}'),
|
||||
drama: drama,
|
||||
onTap: () => widget.onOpenDrama(drama),
|
||||
);
|
||||
}, childCount: dramas.length),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -464,14 +455,11 @@ class _ChannelFeedState extends State<_ChannelFeed>
|
||||
}
|
||||
}
|
||||
|
||||
/// 判定接口下发的短剧标签是否为「上新」。
|
||||
/// 「上新」标签语义码,对应后台 content_tag.code。
|
||||
const String newArrivalTagCode = 'NEW_ARRIVAL';
|
||||
|
||||
/// 判定短剧是否属于「上新」板块。
|
||||
///
|
||||
/// 完全以后端返回的 `tag` / `tagLocales`(经 [Drama.localizedTag] 本地化)
|
||||
/// 为准;兼容「上新 / NEW / BARU」三种文案,并兼容「本周上新」这类带前后缀的写法。
|
||||
bool _isNewArrivalTag(Drama drama, String lang, String localizedBadge) {
|
||||
final tag = drama.localizedTag(lang).trim();
|
||||
if (tag.isEmpty) return false;
|
||||
if (tag == localizedBadge) return true;
|
||||
final lower = tag.toLowerCase();
|
||||
return tag.contains('上新') || lower == 'new' || lower == 'baru';
|
||||
}
|
||||
/// 只看语义码:码由后台标签管理配置,文案怎么改、切什么语言都不影响。
|
||||
bool isNewArrivalTag(Drama drama) =>
|
||||
drama.tagCode.trim().toUpperCase() == newArrivalTagCode;
|
||||
@@ -46,15 +46,25 @@ class _HomeHeroCarouselState extends State<HomeHeroCarousel> {
|
||||
static const double _dotGap = 9;
|
||||
static const double _ctaHeight = 38;
|
||||
|
||||
final PageController _controller = PageController();
|
||||
/// 无限轮播:PageView 用虚拟大页码,取模映射到真实 banner,首尾天然相接。
|
||||
static const int _basePage = 1000;
|
||||
|
||||
late final PageController _controller;
|
||||
Timer? _timer;
|
||||
int _page = 0;
|
||||
int _index = 0;
|
||||
bool _pressed = false;
|
||||
bool _tickerEnabled = true;
|
||||
|
||||
/// 起始页码取 banner 数量的大倍数,向左右滑都不会到头。
|
||||
static int _initialPageOf(int bannerCount) =>
|
||||
bannerCount > 1 ? bannerCount * _basePage : 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_page = _initialPageOf(widget.banners.length);
|
||||
_controller = PageController(initialPage: _page);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _syncTimer());
|
||||
}
|
||||
|
||||
@@ -72,8 +82,11 @@ class _HomeHeroCarouselState extends State<HomeHeroCarousel> {
|
||||
void didUpdateWidget(covariant HomeHeroCarousel oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.banners.length != widget.banners.length) {
|
||||
if (_index >= widget.banners.length) _index = 0;
|
||||
_syncTimer();
|
||||
// 首屏 banner 是异步到的:列表从空变有之后要把页码挪到虚拟起点,
|
||||
// 否则左滑第一下就到头了。
|
||||
_page = _initialPageOf(widget.banners.length);
|
||||
_index = 0;
|
||||
_jumpToBasePage();
|
||||
}
|
||||
if (oldWidget.banners.length != widget.banners.length ||
|
||||
oldWidget.active != widget.active) {
|
||||
@@ -91,7 +104,8 @@ class _HomeHeroCarouselState extends State<HomeHeroCarousel> {
|
||||
void _syncTimer() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
final canAdvance = mounted &&
|
||||
final canAdvance =
|
||||
mounted &&
|
||||
widget.active &&
|
||||
_tickerEnabled &&
|
||||
!_pressed &&
|
||||
@@ -102,12 +116,16 @@ class _HomeHeroCarouselState extends State<HomeHeroCarousel> {
|
||||
|
||||
void _advance() {
|
||||
if (!_controller.hasClients || widget.banners.length < 2) return;
|
||||
final next = (_index + 1) % widget.banners.length;
|
||||
_controller.animateToPage(
|
||||
next,
|
||||
duration: _pageDuration,
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
// 虚拟页码无限,直接翻下一页即可,不用在末尾跳回第 0 页。
|
||||
_controller.nextPage(duration: _pageDuration, curve: Curves.easeOutCubic);
|
||||
}
|
||||
|
||||
/// 把 PageView 静默挪到虚拟起点:视觉上是同一张图,不会闪。
|
||||
void _jumpToBasePage() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_controller.hasClients) return;
|
||||
if (_controller.page?.round() != _page) _controller.jumpToPage(_page);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -134,9 +152,17 @@ class _HomeHeroCarouselState extends State<HomeHeroCarousel> {
|
||||
children: [
|
||||
PageView.builder(
|
||||
controller: _controller,
|
||||
itemCount: widget.banners.length,
|
||||
onPageChanged: (value) => setState(() => _index = value),
|
||||
itemBuilder: (context, index) => _bannerPage(context, index),
|
||||
// 数量大于 1 时用无限页码做无缝循环,取模映射到真实 banner。
|
||||
itemCount: widget.banners.length > 1
|
||||
? null
|
||||
: widget.banners.length,
|
||||
onPageChanged: (value) => setState(() {
|
||||
_page = value;
|
||||
final count = widget.banners.length;
|
||||
_index = count == 0 ? 0 : value % count;
|
||||
}),
|
||||
itemBuilder: (context, page) =>
|
||||
_bannerPage(context, page % widget.banners.length),
|
||||
),
|
||||
Positioned(left: 0, right: 0, bottom: 14, child: _dots(context)),
|
||||
],
|
||||
@@ -288,16 +314,18 @@ class _HeroCopy extends StatelessWidget {
|
||||
color: AppColors.textPrimary.withValues(alpha: 0.95),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w400,
|
||||
shadows: const [
|
||||
Shadow(color: Color(0x80000000), blurRadius: 8),
|
||||
],
|
||||
shadows: const [Shadow(color: Color(0x80000000), blurRadius: 8)],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_WatchButton(height: ctaHeight, label: l10n.watchNowAction, onTap: onWatch),
|
||||
_WatchButton(
|
||||
height: ctaHeight,
|
||||
label: l10n.watchNowAction,
|
||||
onTap: onWatch,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_FavoriteCircle(size: ctaHeight, drama: drama),
|
||||
],
|
||||
|
||||
@@ -15,6 +15,12 @@ const double _cardCoverHeight = 157;
|
||||
const double _cardGap = 9;
|
||||
const double _cardRadius = 12;
|
||||
|
||||
/// 海报卡封面以下的高度:上间距 8 + 两行剧名 + 2 + 集数行 + 余量。
|
||||
///
|
||||
/// 横向列表和网格都要给一个有限的卡片高度,这里是唯一来源,
|
||||
/// 两行剧名撑高时只改这一处,不会出现某个入口高度不够而溢出。
|
||||
const double homePosterCardBodyExtent = 62;
|
||||
|
||||
/// 上新横滑卡板块。无板块标题,卡片左上角带「上新」角标。
|
||||
/// 内层横向 ListView 会自行识别横向手势,不与外层纵向滚动冲突。
|
||||
class HomeNewArrivalRow extends StatelessWidget {
|
||||
@@ -32,8 +38,7 @@ class HomeNewArrivalRow extends StatelessWidget {
|
||||
if (dramas.isEmpty) return const SizedBox.shrink();
|
||||
return SizedBox(
|
||||
key: const Key('home-new-arrival-row'),
|
||||
height:
|
||||
_cardCoverHeight + 8 + 20 + 2 + 16, // 封面 + 剧名行 + 集数行 + 余量
|
||||
height: _cardCoverHeight + homePosterCardBodyExtent,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: _pageSide),
|
||||
@@ -130,7 +135,7 @@ class HomePosterCard extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
drama.localizedTitle(lang),
|
||||
maxLines: 1,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
@@ -192,7 +197,11 @@ class HomeEditorPickBanner extends StatelessWidget {
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [Color(0xE6000000), Color(0x4D000000), Color(0x1A000000)],
|
||||
colors: [
|
||||
Color(0xE6000000),
|
||||
Color(0x4D000000),
|
||||
Color(0x1A000000),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
+1
-9
@@ -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:
|
||||
@@ -253,7 +245,7 @@ packages:
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
intl:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: intl
|
||||
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
|
||||
|
||||
+4
-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
|
||||
@@ -48,6 +45,8 @@ dependencies:
|
||||
sdk: flutter
|
||||
path_provider: 2.1.5
|
||||
html: ^0.15.6
|
||||
# 播放量等数字的本地化格式(NumberFormat);版本由 flutter_localizations 钉住
|
||||
intl: any
|
||||
# 画中画:Android 原生 PiP,OnLeavePiP 支持退后台自动进小窗
|
||||
floating: ^6.0.0
|
||||
# 运行时权限:systemAlertWindow(「显示在其他应用上层」)
|
||||
@@ -90,7 +89,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:
|
||||
|
||||
@@ -46,6 +46,23 @@ void main() {
|
||||
expect(drama.episodeCount, 12);
|
||||
});
|
||||
|
||||
test('should_parse_landscape_cover_for_horizontal_slots', () {
|
||||
final card = Drama.fromCardJson(const {
|
||||
'id': 5,
|
||||
'title': '闪婚后',
|
||||
'cover': '/files/cover.jpg',
|
||||
'coverLandscape': '/files/cover-wide.jpg',
|
||||
});
|
||||
final legacy = Drama.fromDetailJson(const {
|
||||
'id': 6,
|
||||
'title': '替嫁新娘',
|
||||
'cover': '/files/cover-2.jpg',
|
||||
});
|
||||
|
||||
expect(card.coverLandscape, '/files/cover-wide.jpg');
|
||||
expect(legacy.coverLandscape, isEmpty);
|
||||
});
|
||||
|
||||
test('should_count_shipped_episodes_when_detail_response_is_parsed', () {
|
||||
final drama = Drama.fromDetailJson(const {
|
||||
'id': 5,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -384,7 +384,7 @@ void main() {
|
||||
|
||||
expect(refresh.edgeOffset, 52);
|
||||
expect(
|
||||
tester.widget<ListView>(_verticalFeed).physics,
|
||||
tester.widget<CustomScrollView>(_verticalFeed).physics,
|
||||
AppPullToRefresh.physics,
|
||||
);
|
||||
await tester.drag(_verticalFeed, const Offset(0, 240));
|
||||
@@ -396,7 +396,8 @@ void main() {
|
||||
}
|
||||
|
||||
final Finder _verticalFeed = find.byWidgetPredicate(
|
||||
(widget) => widget is ListView && widget.scrollDirection == Axis.vertical,
|
||||
(widget) =>
|
||||
widget is CustomScrollView && widget.scrollDirection == Axis.vertical,
|
||||
);
|
||||
|
||||
Future<void> _pumpTheater(
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||
import 'package:fruitshorts/core/theme/app_colors.dart';
|
||||
import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart';
|
||||
import 'package:fruitshorts/pages/discover/discover_view.dart';
|
||||
import 'package:fruitshorts/pages/discover/widgets/drama_grid.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||
@@ -234,25 +235,26 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final list = find.byKey(const Key('new-releases-list'));
|
||||
expect(tester.widget<ListView>(list).physics, AppPullToRefresh.physics);
|
||||
await tester.drag(list, const Offset(0, -400));
|
||||
await tester.pump();
|
||||
expect(_countKeys('home-drama-card-'), 20);
|
||||
expect(
|
||||
tester.widget<CustomScrollView>(list).physics,
|
||||
AppPullToRefresh.physics,
|
||||
);
|
||||
|
||||
// 网格懒加载后只保留可见行,页数直接看传给网格的剧目数。
|
||||
List<Drama> gridDramas() => tester
|
||||
.widget<SliverDramaGrid>(find.byKey(const Key('new-releases-all-grid')))
|
||||
.dramas;
|
||||
expect(gridDramas(), hasLength(20));
|
||||
|
||||
// jumpTo 不发滚动通知,滑到底后再拖一下才会触发加载。
|
||||
final position = _scrollPosition(tester, const Key('new-releases-list'));
|
||||
position.jumpTo(position.maxScrollExtent);
|
||||
await tester.pump();
|
||||
expect(find.byKey(const Key('new-releases-load-more')), findsOneWidget);
|
||||
await tester.pump(const Duration(milliseconds: 299));
|
||||
expect(_countKeys('home-drama-card-'), 20);
|
||||
await tester.pump(const Duration(milliseconds: 1));
|
||||
await tester.drag(list, const Offset(0, -60));
|
||||
await tester.pump();
|
||||
expect(_countKeys('home-drama-card-'), 40);
|
||||
|
||||
expect(find.byKey(const Key('new-releases-load-more')), findsOneWidget);
|
||||
await tester.pumpAndSettle();
|
||||
position.jumpTo(position.maxScrollExtent);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('new-releases-no-more')), findsNothing);
|
||||
expect(gridDramas(), hasLength(40));
|
||||
|
||||
position.jumpTo(0);
|
||||
await tester.pump();
|
||||
@@ -262,10 +264,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(repository.fetchCalls, beforeRefresh + 1);
|
||||
expect(position.pixels, 0);
|
||||
|
||||
await tester.drag(list, const Offset(0, -400));
|
||||
await tester.pump();
|
||||
expect(_countKeys('home-drama-card-'), 20);
|
||||
expect(gridDramas(), hasLength(20));
|
||||
});
|
||||
|
||||
testWidgets('should_refresh_reserve_without_load_more', (tester) async {
|
||||
@@ -327,14 +326,6 @@ ScrollPosition _scrollPosition(WidgetTester tester, Key listKey) {
|
||||
return tester.state<ScrollableState>(scrollable.first).position;
|
||||
}
|
||||
|
||||
int _countKeys(String prefix) => find
|
||||
.byWidgetPredicate((widget) {
|
||||
final key = widget.key;
|
||||
return key is ValueKey<String> && key.value.startsWith(prefix);
|
||||
})
|
||||
.evaluate()
|
||||
.length;
|
||||
|
||||
class _CountingCatalogRepository extends FakeCatalogRepository {
|
||||
_CountingCatalogRepository({super.dramas});
|
||||
|
||||
|
||||
@@ -23,6 +23,28 @@ import '../support/fake_interaction_repositories.dart';
|
||||
import '../support/widget_player.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('should_defer_watch_history_until_following_tab_is_opened', (
|
||||
tester,
|
||||
) async {
|
||||
SharedPreferencesAsyncPlatform.instance =
|
||||
InMemorySharedPreferencesAsync.empty();
|
||||
final catalog = FakeCatalogRepository();
|
||||
final history = _IdentityWatchHistoryRepository(
|
||||
device: [_historyProgress('the-signal-demo', 'Device history')],
|
||||
account: const [],
|
||||
);
|
||||
await tester.pumpWidget(_app(catalog: catalog, history: history));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 首页默认停在推荐 tab:追剧页在启动时还没被构建,不该提前拉观看历史。
|
||||
expect(history.recentCalls, 0);
|
||||
|
||||
await tester.tap(find.byKey(const Key('tab-follow')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(history.recentCalls, greaterThan(0));
|
||||
});
|
||||
|
||||
testWidgets('should_render_following_tabs_grid_and_edit_actions', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
|
||||
@@ -293,7 +293,10 @@ void main() {
|
||||
expect(find.byKey(const Key('home-feed-synopsis-more')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const Key('home-feed-synopsis-more')));
|
||||
await tester.pump();
|
||||
expect(find.byKey(const Key('home-feed-synopsis-expanded')), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('home-feed-synopsis-expanded')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<Text>(find.byKey(const Key('home-feed-synopsis-expanded')))
|
||||
@@ -371,7 +374,7 @@ void main() {
|
||||
find.byKey(const Key('home-feed-progress-inactive')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byKey(const Key('home-feed-progress-thumb')), findsNothing);
|
||||
expect(find.byKey(const Key('home-feed-progress-played')), findsNothing);
|
||||
await tester.tap(find.byKey(const Key('rail-share')));
|
||||
expect(shares, 1);
|
||||
});
|
||||
@@ -575,26 +578,36 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final bar = tester.getRect(find.byKey(const Key('home-feed-full-series')));
|
||||
expect(bar.height, 44);
|
||||
expect(bar.height, 38);
|
||||
final overlayRect = tester.getRect(
|
||||
find.byKey(const Key('home-feed-gesture')),
|
||||
);
|
||||
// 通栏观看条铺满浮层宽度,不再左右内缩。
|
||||
expect(bar.left, closeTo(overlayRect.left, 0.1));
|
||||
expect(bar.width, closeTo(overlayRect.width, 0.1));
|
||||
// 进度线中心比观看条顶沿高 8pt。
|
||||
// 观看条与底部进度条共用同一组左右边距(各 20pt),宽度一致。
|
||||
expect(bar.left, closeTo(overlayRect.left + 20, 0.1));
|
||||
expect(bar.width, closeTo(overlayRect.width - 40, 0.1));
|
||||
expect(
|
||||
bar.top -
|
||||
tester
|
||||
.getCenter(find.byKey(const Key('home-feed-progress-track')))
|
||||
.dy,
|
||||
closeTo(8, 0.1),
|
||||
bar.left,
|
||||
closeTo(
|
||||
tester.getRect(find.byKey(const Key('home-feed-progress'))).left,
|
||||
0.1,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
bar.width,
|
||||
closeTo(
|
||||
tester.getRect(find.byKey(const Key('home-feed-progress'))).width,
|
||||
0.1,
|
||||
),
|
||||
);
|
||||
// 进度线贴着浮层底部(线心距底 4pt),且位于观看条下方。
|
||||
final trackCenter = tester.getCenter(
|
||||
find.byKey(const Key('home-feed-progress-track')),
|
||||
);
|
||||
expect(overlayRect.bottom - trackCenter.dy, closeTo(4, 0.1));
|
||||
expect(trackCenter.dy, greaterThan(bar.bottom));
|
||||
expect(
|
||||
tester
|
||||
.widget<Material>(
|
||||
find.byKey(const Key('home-feed-full-series-bg')),
|
||||
)
|
||||
.widget<Material>(find.byKey(const Key('home-feed-full-series-bg')))
|
||||
.color,
|
||||
AppColors.textPrimary.withValues(alpha: 0.15),
|
||||
);
|
||||
@@ -602,13 +615,9 @@ void main() {
|
||||
tester.getRect(find.textContaining('Watch full series')).center.dy,
|
||||
closeTo(bar.center.dy, 2),
|
||||
);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))),
|
||||
const Size(6, 6),
|
||||
);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('home-feed-progress'))).height,
|
||||
greaterThanOrEqualTo(32),
|
||||
greaterThanOrEqualTo(16),
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
@@ -620,19 +629,6 @@ void main() {
|
||||
.getRect(find.byKey(const Key('home-feed-progress-track')))
|
||||
.center
|
||||
.dy;
|
||||
expect(
|
||||
tester
|
||||
.getRect(find.byKey(const Key('home-feed-progress-thumb')))
|
||||
.center
|
||||
.dy,
|
||||
closeTo(
|
||||
tester
|
||||
.getRect(find.byKey(const Key('home-feed-progress-track')))
|
||||
.center
|
||||
.dy,
|
||||
0.5,
|
||||
),
|
||||
);
|
||||
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byKey(const Key('home-feed-progress'))),
|
||||
@@ -704,10 +700,6 @@ void main() {
|
||||
closeTo(trackRect.right - 12, 0.1),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 130));
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))),
|
||||
const Size(14, 14),
|
||||
);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('home-feed-progress-track'))).height,
|
||||
6,
|
||||
@@ -719,19 +711,6 @@ void main() {
|
||||
.dy,
|
||||
closeTo(idleTrackCenter, 0.1),
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.getRect(find.byKey(const Key('home-feed-progress-thumb')))
|
||||
.center
|
||||
.dy,
|
||||
closeTo(
|
||||
tester
|
||||
.getRect(find.byKey(const Key('home-feed-progress-track')))
|
||||
.center
|
||||
.dy,
|
||||
0.5,
|
||||
),
|
||||
);
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(scrubVisibility.last, isFalse);
|
||||
@@ -746,10 +725,6 @@ void main() {
|
||||
);
|
||||
expect(seeked, isNotNull);
|
||||
expect(seeked!.inSeconds, greaterThan(0));
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))),
|
||||
const Size(6, 6),
|
||||
);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('home-feed-progress-track'))).height,
|
||||
2,
|
||||
@@ -825,11 +800,11 @@ void main() {
|
||||
isBuffering: true,
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
expect(find.byKey(const Key('home-feed-progress-thumb')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-progress-played')), findsOneWidget);
|
||||
expect(find.byKey(const Key('progress-loading')), findsNothing);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
expect(find.byKey(const Key('home-feed-progress-thumb')), findsNothing);
|
||||
expect(find.byKey(const Key('home-feed-progress-played')), findsNothing);
|
||||
expect(find.byKey(const Key('progress-loading')), findsOneWidget);
|
||||
|
||||
controller.value = const VideoPlayerValue(
|
||||
@@ -838,7 +813,7 @@ void main() {
|
||||
isInitialized: true,
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byKey(const Key('home-feed-progress-thumb')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-progress-played')), findsOneWidget);
|
||||
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byKey(const Key('home-feed-progress'))),
|
||||
@@ -853,7 +828,7 @@ void main() {
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
|
||||
expect(find.byKey(const Key('home-feed-progress-thumb')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-progress-played')), findsOneWidget);
|
||||
expect(find.byKey(const Key('progress-loading')), findsNothing);
|
||||
|
||||
controller.value = const VideoPlayerValue(
|
||||
@@ -872,7 +847,7 @@ void main() {
|
||||
isBuffering: true,
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(find.byKey(const Key('home-feed-progress-thumb')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-progress-played')), findsOneWidget);
|
||||
expect(find.byKey(const Key('progress-loading')), findsNothing);
|
||||
});
|
||||
|
||||
|
||||
@@ -89,6 +89,25 @@ void main() {
|
||||
expect(cache.latestForDrama('drama-a')?.episodeId, 'episode-2');
|
||||
});
|
||||
|
||||
test('should_evict_oldest_entry_when_capacity_exceeded', () {
|
||||
var now = DateTime(2026, 10, 22, 12);
|
||||
final cache = PlaybackPositionCache(now: () => now, maxEntries: 2);
|
||||
|
||||
for (final episodeId in ['episode-1', 'episode-2', 'episode-3']) {
|
||||
cache.save(
|
||||
episodeId: episodeId,
|
||||
dramaId: 'drama-a',
|
||||
position: const Duration(seconds: 30),
|
||||
duration: const Duration(minutes: 3),
|
||||
);
|
||||
now = now.add(const Duration(minutes: 1));
|
||||
}
|
||||
|
||||
expect(cache.read('episode-1', dramaId: 'drama-a'), isNull);
|
||||
expect(cache.read('episode-2', dramaId: 'drama-a'), isNotNull);
|
||||
expect(cache.read('episode-3', dramaId: 'drama-a'), isNotNull);
|
||||
});
|
||||
|
||||
test('should_reject_expired_cached_position', () {
|
||||
var now = DateTime(2026, 10, 22, 12);
|
||||
final cache = PlaybackPositionCache(now: () => now);
|
||||
|
||||
@@ -158,6 +158,18 @@ void main() {
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_share_one_request_when_resume_point_loads_concurrently', () async {
|
||||
final history = FakeWatchHistory()..saved = const Duration(seconds: 20);
|
||||
final logic = _logic(history, drama: _drama('signal'));
|
||||
|
||||
// 进页与首次准备播放会同时要续看点,只应发一次请求。
|
||||
await Future.wait([logic.loadResumePoint(), logic.loadResumePoint()]);
|
||||
|
||||
expect(logic.resumeLoaded, isTrue);
|
||||
expect(history.reads, ['signal']);
|
||||
logic.dispose();
|
||||
});
|
||||
|
||||
test('should_not_seek_when_position_is_zero', () async {
|
||||
final history = FakeWatchHistory()
|
||||
..records['signal'] = const WatchProgress(
|
||||
|
||||
@@ -159,6 +159,31 @@ void main() {
|
||||
expect(loader.spriteCalls, hasLength(6));
|
||||
});
|
||||
|
||||
testWidgets('should_not_reload_three_slots_sprites_when_capacity_fits', (
|
||||
tester,
|
||||
) async {
|
||||
final loader = _FakeSpriteLoader(vtt: _spriteVtt);
|
||||
// 播放页三个槽位各预取 2 张,容量必须 >= 6,否则每轮重建都会重新下载解码。
|
||||
final service = SpriteThumbnailAxisService(
|
||||
loader: loader,
|
||||
spriteCacheCapacity: 8,
|
||||
);
|
||||
addTearDown(service.dispose);
|
||||
const urls = [
|
||||
'https://cdn.test/episode-0/thumbnails.vtt',
|
||||
'https://cdn.test/episode-1/thumbnails.vtt',
|
||||
'https://cdn.test/episode-2/thumbnails.vtt',
|
||||
];
|
||||
|
||||
for (var round = 0; round < 3; round++) {
|
||||
for (final url in urls) {
|
||||
await service.prepareAt(url, Duration.zero);
|
||||
}
|
||||
}
|
||||
|
||||
expect(loader.spriteCalls, hasLength(6));
|
||||
});
|
||||
|
||||
testWidgets('should_refresh_vtt_after_ten_minute_ttl', (tester) async {
|
||||
var now = DateTime(2026, 10, 22);
|
||||
final loader = _FakeSpriteLoader(vtt: _spriteVtt);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -35,12 +35,12 @@ void main() {
|
||||
Key('player-play-toggle'),
|
||||
]) {
|
||||
expect(tester.getSize(find.byKey(key)), const Size(68, 68));
|
||||
// 圆形底改为半透明黑色,不再是完全透明或蓝色实底。
|
||||
// 圆形深色底:压在亮画面上也能看见播放/暂停。
|
||||
final container = tester.widget<Container>(
|
||||
find.descendant(of: find.byKey(key), matching: find.byType(Container)),
|
||||
);
|
||||
final decoration = container.decoration! as BoxDecoration;
|
||||
expect(decoration.color, Colors.black26);
|
||||
expect(decoration.color, const Color(0x66000000));
|
||||
expect(decoration.shape, BoxShape.circle);
|
||||
final icon = tester.widget<Icon>(
|
||||
find.descendant(of: find.byKey(key), matching: find.byType(Icon)),
|
||||
|
||||
@@ -983,9 +983,11 @@ void main() {
|
||||
expect(find.byKey(const Key('player-video-tap')), findsOneWidget);
|
||||
expect(find.byKey(const Key('player-bottom-clear-screen')), findsOneWidget);
|
||||
expect(find.byTooltip('Exit clear screen'), findsOneWidget);
|
||||
// 清屏后左侧选集条一起收起,只留右下角的退出按钮。
|
||||
expect(find.byKey(const Key('player-episodes-pill')), findsNothing);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('player-bottom-clear-screen'))),
|
||||
const Size(40, 40),
|
||||
const Size(40, 34),
|
||||
);
|
||||
|
||||
await tester.tapAt(const Offset(80, 300));
|
||||
@@ -1100,8 +1102,8 @@ void main() {
|
||||
expect(find.byKey(const Key('player-speed-boost-hint')), findsNothing);
|
||||
expect(find.byKey(const Key('player-interaction-rail')), findsNothing);
|
||||
expect(find.byTooltip('Exit clear screen'), findsOneWidget);
|
||||
// 清屏态本来显示的选集胶囊与右下按钮,在加速结束后恢复。
|
||||
expect(find.byKey(const Key('player-episodes-pill')), findsOneWidget);
|
||||
// 清屏态只恢复右下退出按钮;左侧选集条在清屏下一直不显示。
|
||||
expect(find.byKey(const Key('player-episodes-pill')), findsNothing);
|
||||
expect(find.byKey(const Key('player-bottom-clear-screen')), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
@@ -1118,7 +1120,7 @@ void main() {
|
||||
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('player-back'))),
|
||||
const Size(36, 36),
|
||||
const Size(40, 40),
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
@@ -1160,17 +1162,12 @@ void main() {
|
||||
.child,
|
||||
isNotNull,
|
||||
);
|
||||
// 选集条给一层浅底色,标出可点区域。
|
||||
expect(
|
||||
tester
|
||||
.widget<Material>(find.byKey(const Key('player-episodes-pill')))
|
||||
.color,
|
||||
AppColors.playerOverlayPill,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<Material>(find.byKey(const Key('player-episodes-pill')))
|
||||
.borderRadius,
|
||||
BorderRadius.circular(7),
|
||||
AppColors.textPrimary.withValues(alpha: 0.14),
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
@@ -1186,9 +1183,14 @@ void main() {
|
||||
?.fontSize,
|
||||
13.5,
|
||||
);
|
||||
// 清屏按钮与左侧选集条等高。
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('player-bottom-clear-screen'))),
|
||||
const Size(40, 40),
|
||||
const Size(40, 34),
|
||||
);
|
||||
expect(
|
||||
tester.getSize(find.byKey(const Key('player-episodes-pill'))).height,
|
||||
34,
|
||||
);
|
||||
expect(find.byKey(const Key('progress-loading')), findsNothing);
|
||||
expect(find.byKey(const Key('progress-inactive')), findsOneWidget);
|
||||
@@ -1289,7 +1291,8 @@ void main() {
|
||||
matching: find.byIcon(Icons.keyboard_arrow_up_rounded),
|
||||
),
|
||||
);
|
||||
expect(pill.right - arrow.right, closeTo(14, 0.5));
|
||||
// 选集条左右各留 10pt 内边距,箭头不再贴边。
|
||||
expect(pill.right - arrow.right, closeTo(10, 0.5));
|
||||
expect(arrow.left - label.right, closeTo(8, 0.5));
|
||||
});
|
||||
|
||||
@@ -1551,7 +1554,8 @@ void main() {
|
||||
),
|
||||
);
|
||||
expect(chevron.left - title.right, lessThanOrEqualTo(6));
|
||||
expect(chevron.right, lessThanOrEqualTo(320 - 14));
|
||||
// 剧名和简介一样收在互动栏左侧,不再铺到屏幕最右边。
|
||||
expect(chevron.right, lessThanOrEqualTo(320 - 14 - 54));
|
||||
final synopsis = tester.widget<Text>(
|
||||
find.byKey(const Key('player-synopsis')),
|
||||
);
|
||||
@@ -1647,11 +1651,11 @@ void main() {
|
||||
await tester.tap(find.byKey(const Key('player-episodes')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('player-episodes-sheet')), findsOneWidget);
|
||||
expect(find.byKey(const Key('player-episodes-close')), findsOneWidget);
|
||||
expect(find.byKey(const Key('drama-info-sheet')), findsOneWidget);
|
||||
expect(find.byKey(const Key('drama-info-close')), findsOneWidget);
|
||||
final grid = tester.widget<GridView>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('player-episodes-sheet')),
|
||||
of: find.byKey(const Key('drama-info-sheet')),
|
||||
matching: find.byType(GridView),
|
||||
),
|
||||
);
|
||||
@@ -1659,14 +1663,14 @@ void main() {
|
||||
grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
|
||||
expect(delegate.crossAxisCount, 6);
|
||||
final overlay = tester.widget<AnnotatedRegion<SystemUiOverlayStyle>>(
|
||||
find.byKey(const Key('player-episodes-system-overlay')),
|
||||
find.byKey(const Key('drama-info-system-overlay')),
|
||||
);
|
||||
expect(overlay.value.systemNavigationBarColor, AppColors.card);
|
||||
expect(overlay.value.systemNavigationBarContrastEnforced, isFalse);
|
||||
final safeArea = tester.widget<SafeArea>(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byKey(const Key('player-episodes-sheet')),
|
||||
of: find.byKey(const Key('drama-info-sheet')),
|
||||
matching: find.byType(SafeArea),
|
||||
)
|
||||
.first,
|
||||
@@ -2030,7 +2034,7 @@ void _expectFullscreenOverlay(WidgetTester tester) {
|
||||
expect(find.byKey(const Key('player-synopsis')), findsOneWidget);
|
||||
expect(find.byKey(const Key('player-more')), findsOneWidget);
|
||||
for (final key in const [Key('player-back'), Key('player-more')]) {
|
||||
expect(tester.getSize(find.byKey(key)), const Size(36, 36));
|
||||
expect(tester.getSize(find.byKey(key)), const Size(40, 40));
|
||||
}
|
||||
expect(find.byKey(const Key('player-play-toggle')), findsNothing);
|
||||
expect(find.byKey(const Key('player-episodes')), findsOneWidget);
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
|
||||
@@ -103,7 +103,10 @@ void main() {
|
||||
await _pumpSession(tester);
|
||||
await tester.runAsync(() => logic.session.playbackSettled);
|
||||
|
||||
expect(find.byType(DramaDetailPage), findsOneWidget);
|
||||
expect(find.byType(DramaDetailPage), findsNothing);
|
||||
expect(find.byKey(const Key('drama-info-sheet')), findsOneWidget);
|
||||
expect(find.byKey(const Key('drama-info-tab-detail')), findsOneWidget);
|
||||
expect(find.byKey(const Key('drama-info-detail')), findsOneWidget);
|
||||
expect(logic.session.active, same(active));
|
||||
expect(logic.session.currentIndex, index);
|
||||
expect(logic.session.userWantsPlay, isTrue);
|
||||
@@ -118,7 +121,7 @@ void main() {
|
||||
const Duration(minutes: 1, seconds: 22),
|
||||
);
|
||||
|
||||
await tester.pageBack();
|
||||
await tester.tap(find.byKey(const Key('drama-info-close')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
await _pumpSession(tester);
|
||||
|
||||
@@ -37,11 +37,8 @@ void main() {
|
||||
expect(find.byKey(const Key('scrub-overlay')), findsNothing);
|
||||
expect(harness.scrubStarts, 1);
|
||||
expect(_sliderTheme(tester).trackHeight, 6);
|
||||
expect(
|
||||
(_sliderTheme(tester).thumbShape! as RoundSliderThumbShape)
|
||||
.enabledThumbRadius,
|
||||
8,
|
||||
);
|
||||
// 不画滑块圆点,进度只靠已播段表达。
|
||||
expect(_sliderTheme(tester).thumbShape, SliderComponentShape.noThumb);
|
||||
|
||||
await gesture.moveBy(const Offset(72, 0));
|
||||
await tester.pump();
|
||||
@@ -67,11 +64,7 @@ void main() {
|
||||
expect(harness.scrubEnds, 1);
|
||||
expect(harness.seeks, 1);
|
||||
expect(_sliderTheme(tester).trackHeight, 2);
|
||||
expect(
|
||||
(_sliderTheme(tester).thumbShape! as RoundSliderThumbShape)
|
||||
.enabledThumbRadius,
|
||||
6,
|
||||
);
|
||||
expect(_sliderTheme(tester).thumbShape, SliderComponentShape.noThumb);
|
||||
});
|
||||
|
||||
testWidgets('feed progress tap does not clear overlay content', (
|
||||
@@ -154,29 +147,27 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
testWidgets('feed progress thumb stays on the finger when dragged right', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpFeedOverlay(tester);
|
||||
final bar = find.byKey(const Key('home-feed-progress'));
|
||||
final gesture = await tester.startGesture(tester.getCenter(bar));
|
||||
testWidgets(
|
||||
'feed progress played segment follows the finger when dragged right',
|
||||
(tester) async {
|
||||
await _pumpFeedOverlay(tester);
|
||||
final bar = find.byKey(const Key('home-feed-progress'));
|
||||
final gesture = await tester.startGesture(tester.getCenter(bar));
|
||||
|
||||
await gesture.moveBy(const Offset(800, 0));
|
||||
await tester.pump();
|
||||
await gesture.moveBy(const Offset(800, 0));
|
||||
await tester.pump();
|
||||
|
||||
final thumbCenter = tester
|
||||
.getRect(find.byKey(const Key('home-feed-progress-thumb')))
|
||||
.center
|
||||
.dx;
|
||||
final playedRight = tester
|
||||
.getRect(find.byKey(const Key('home-feed-progress-played')))
|
||||
.right;
|
||||
expect(thumbCenter, closeTo(tester.getRect(bar).right, 0.5));
|
||||
expect(playedRight, closeTo(thumbCenter, 0.5));
|
||||
expect(
|
||||
tester
|
||||
.getRect(find.byKey(const Key('home-feed-progress-played')))
|
||||
.right,
|
||||
closeTo(tester.getRect(bar).right, 0.5),
|
||||
);
|
||||
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('full player drag clears chrome and release restores it', (
|
||||
tester,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||
import 'package:fruitshorts/core/theme/app_colors.dart';
|
||||
import 'package:fruitshorts/l10n/app_localizations.dart';
|
||||
import 'package:fruitshorts/pages/recommend/widgets/home_hero_carousel.dart';
|
||||
|
||||
const List<FeedBanner> _banners = [
|
||||
FeedBanner(id: '1', imageUrl: 'https://cdn.test/a.png'),
|
||||
FeedBanner(id: '2', imageUrl: 'https://cdn.test/b.png'),
|
||||
];
|
||||
|
||||
Widget _harness(List<FeedBanner> banners) {
|
||||
return MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
home: Scaffold(
|
||||
body: HomeHeroCarousel(
|
||||
banners: banners,
|
||||
dramaFor: (_) => null,
|
||||
onWatch: (_) {},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color? _dotColor(WidgetTester tester, int index) {
|
||||
final decoration =
|
||||
tester
|
||||
.widget<Container>(find.byKey(Key('home-hero-dot-$index')))
|
||||
.decoration
|
||||
as BoxDecoration?;
|
||||
return decoration?.color;
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('should_keep_looping_backwards_from_the_first_banner', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_harness(_banners));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_dotColor(tester, 0), AppColors.brandRed);
|
||||
|
||||
// 从第一张往右滑:无缝轮播应该接到最后一张,而不是被边界挡住。
|
||||
await tester.fling(find.byType(PageView), const Offset(700, 0), 1200);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(_dotColor(tester, 1), AppColors.brandRed);
|
||||
expect(_dotColor(tester, 0), isNot(AppColors.brandRed));
|
||||
});
|
||||
|
||||
testWidgets('should_wrap_to_first_banner_after_last_auto_advance', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(_harness(_banners));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 自动播两次:第 2 张之后应回到第 1 张,且页码继续往前走不回头。
|
||||
await tester.pump(const Duration(milliseconds: 4600));
|
||||
await tester.pumpAndSettle();
|
||||
expect(_dotColor(tester, 1), AppColors.brandRed);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 4600));
|
||||
await tester.pumpAndSettle();
|
||||
expect(_dotColor(tester, 0), AppColors.brandRed);
|
||||
});
|
||||
|
||||
testWidgets('should_not_loop_when_only_one_banner', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_harness(const [FeedBanner(id: '1', imageUrl: 'https://cdn.test/a.png')]),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('home-hero-dot-0')), findsNothing);
|
||||
await tester.pump(const Duration(milliseconds: 6000));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/app.dart';
|
||||
import 'package:fruitshorts/core/data/models/drama.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||
@@ -10,6 +11,7 @@ import 'package:fruitshorts/core/theme/app_colors.dart';
|
||||
import 'package:fruitshorts/core/theme/app_dimens.dart';
|
||||
import 'package:fruitshorts/core/widgets/app_bottom_nav.dart';
|
||||
import 'package:fruitshorts/pages/player/player_view.dart';
|
||||
import 'package:fruitshorts/pages/recommend/recommend_discover_page.dart';
|
||||
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
|
||||
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
|
||||
|
||||
@@ -34,7 +36,10 @@ void main() {
|
||||
expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget);
|
||||
expect(find.byKey(const Key('recommend-tab-playlist')), findsOneWidget);
|
||||
expect(find.byKey(const Key('recommend-tab-all')), findsOneWidget);
|
||||
expect(find.byKey(const Key('recommend-feed-player'), skipOffstage: false), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('recommend-feed-player'), skipOffstage: false),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byKey(const Key('recommend-top-gradient')), findsOneWidget);
|
||||
final recommendText = tester.widget<Text>(
|
||||
find.descendant(
|
||||
@@ -65,7 +70,9 @@ void main() {
|
||||
find.byKey(const Key('recommend-top-nav')),
|
||||
);
|
||||
// 右侧依次是搜索和金色礼物盒(钱包入口),礼物盒贴右边距 8。
|
||||
final searchRect = tester.getRect(find.byKey(const Key('recommend-search')));
|
||||
final searchRect = tester.getRect(
|
||||
find.byKey(const Key('recommend-search')),
|
||||
);
|
||||
final giftRect = tester.getRect(find.byKey(const Key('recommend-gift')));
|
||||
expect(giftRect.right, closeTo(topNavRect.right - 8, 0.1));
|
||||
expect(searchRect.right, lessThanOrEqualTo(giftRect.left));
|
||||
@@ -78,7 +85,12 @@ void main() {
|
||||
tester
|
||||
.getRect(find.byKey(const Key('recommend-top-nav')))
|
||||
.overlaps(
|
||||
tester.getRect(find.byKey(const Key('recommend-feed-player'), skipOffstage: false)),
|
||||
tester.getRect(
|
||||
find.byKey(
|
||||
const Key('recommend-feed-player'),
|
||||
skipOffstage: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
@@ -387,7 +399,10 @@ void main() {
|
||||
tester.getRect(find.byKey(const Key('all-dramas-filters'))).top,
|
||||
closeTo(fixedFilterTop, 0.1),
|
||||
);
|
||||
expect(find.byKey(const Key('all-filter-classification-all')), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('all-filter-classification-all')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byKey(const Key('all-filter-category-all')), findsOneWidget);
|
||||
expect(find.byKey(const Key('all-filter-tag-all')), findsOneWidget);
|
||||
expect(
|
||||
@@ -407,7 +422,10 @@ void main() {
|
||||
await tester.tap(find.byKey(const Key('recommend-tab-playlist')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget);
|
||||
expect(find.byKey(const Key('recommend-feed-player'), skipOffstage: false), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('recommend-feed-player'), skipOffstage: false),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byType(AppBottomNav), findsOneWidget);
|
||||
|
||||
await tester.ensureVisible(find.byKey(const Key('recommend-tab-all')));
|
||||
@@ -430,7 +448,10 @@ void main() {
|
||||
await tester.drag(pages, const Offset(-320, 0));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget);
|
||||
expect(find.byKey(const Key('recommend-feed-player'), skipOffstage: false), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('recommend-feed-player'), skipOffstage: false),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<Text>(
|
||||
@@ -454,7 +475,10 @@ void main() {
|
||||
|
||||
await tester.drag(pages, const Offset(640, 0));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('recommend-feed-player'), skipOffstage: false), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('recommend-feed-player'), skipOffstage: false),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget);
|
||||
_drainOverflows(tester);
|
||||
});
|
||||
@@ -497,7 +521,9 @@ void main() {
|
||||
await _pumpRecommend(tester);
|
||||
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byKey(const Key('recommend-feed-player'), skipOffstage: false)),
|
||||
tester.getCenter(
|
||||
find.byKey(const Key('recommend-feed-player'), skipOffstage: false),
|
||||
),
|
||||
);
|
||||
await gesture.moveBy(const Offset(0, 80));
|
||||
await tester.pump();
|
||||
@@ -505,7 +531,10 @@ void main() {
|
||||
final feedScroll = tester.state<ScrollableState>(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byKey(const Key('recommend-feed-player'), skipOffstage: false),
|
||||
of: find.byKey(
|
||||
const Key('recommend-feed-player'),
|
||||
skipOffstage: false,
|
||||
),
|
||||
matching: find.byType(Scrollable),
|
||||
)
|
||||
.first,
|
||||
@@ -544,6 +573,25 @@ void main() {
|
||||
expect(search.focusNode?.hasFocus, isTrue);
|
||||
_drainOverflows(tester);
|
||||
});
|
||||
|
||||
test('should_judge_new_arrival_only_by_tag_code', () {
|
||||
Drama tagged({String tagCode = '', String tag = ''}) => Drama(
|
||||
id: 'tagged',
|
||||
title: 'Tagged',
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '',
|
||||
tag: tag,
|
||||
tagCode: tagCode,
|
||||
);
|
||||
|
||||
expect(isNewArrivalTag(tagged(tagCode: 'NEW_ARRIVAL')), isTrue);
|
||||
expect(isNewArrivalTag(tagged(tagCode: ' new_arrival ')), isTrue);
|
||||
// 其它语义码(以及没有码的标签)都不算上新,文案不参与判断。
|
||||
expect(isNewArrivalTag(tagged(tagCode: 'HOT')), isFalse);
|
||||
expect(isNewArrivalTag(tagged(tagCode: 'NEW_RELEASE')), isFalse);
|
||||
expect(isNewArrivalTag(tagged(tag: '上新')), isFalse);
|
||||
expect(isNewArrivalTag(tagged()), isFalse);
|
||||
});
|
||||
}
|
||||
|
||||
/// 测试字体(等宽方块字形)比真实字体宽/高,卡片固定高度会报 2~4px
|
||||
|
||||
@@ -19,7 +19,7 @@ void main() {
|
||||
expect(AppDimens.sectionTitle, 16);
|
||||
expect(AppDimens.pageTitle, 20);
|
||||
expect(AppDimens.navLabel, 13);
|
||||
expect(AppDimens.navLabelGap, 5);
|
||||
expect(AppDimens.navLabelGap, 1);
|
||||
expect(AppDimens.navIcon, 30);
|
||||
expect(AppDimens.buttonHeight, 48);
|
||||
expect(AppDimens.primaryButtonHeight, 52);
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:fruitshorts/core/data/models/feed_page.dart';
|
||||
import 'package:fruitshorts/core/data/models/home_feed.dart';
|
||||
import 'package:fruitshorts/core/data/models/page_result.dart';
|
||||
import 'package:fruitshorts/core/data/models/watch_progress.dart';
|
||||
import 'package:fruitshorts/core/data/models/watch_record.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
|
||||
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
@@ -278,6 +279,25 @@ void main() {
|
||||
|
||||
expect(controller.error, '账号不存在,请先注册');
|
||||
});
|
||||
|
||||
test('should_prefer_page_landscape_cover_and_fall_back_to_portrait', () {
|
||||
WatchRecord record(String? landscape) => WatchRecord(
|
||||
progress: WatchProgress(
|
||||
dramaId: '1',
|
||||
episodeId: 'episode-1',
|
||||
position: const Duration(seconds: 12),
|
||||
duration: const Duration(seconds: 60),
|
||||
dramaCover: '/files/poster.webp',
|
||||
dramaCoverLandscape: landscape,
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
record('/files/wide.webp').displayLandscapeCover,
|
||||
'/files/wide.webp',
|
||||
);
|
||||
expect(record(null).displayLandscapeCover, '/files/poster.webp');
|
||||
});
|
||||
}
|
||||
|
||||
class ControlledWatchHistory implements WatchHistoryRepository {
|
||||
|
||||
Reference in new issue
Block a user