24 Commits
Author SHA1 Message Date
Vicky 26321e3348 feat(cover): 支持横版封面并收紧底栏图标间距
flutter-ci / test (push) Canceled after 0s
- Drama 新增 coverLandscape,WatchProgress 新增 dramaCoverLandscape
- 继续观看横幅优先使用横版封面,缺省回退竖版
- 底部导航栏图标与文字间距由 5dp 收紧至 1dp
2026-10-29 17:32:41 +08:00
Vicky 3590cd0c55 feat(recommend): 上新板块改按后端语义码判断,顶部轮播改为无缝循环
flutter-ci / test (push) Failing after 30m39s
Drama 新增 tagCode,推荐页只看 NEW_ARRIVAL 这个码决定上新横滑,
不再依赖可变的标签文案;分类网格高度改为复用卡片的统一常量。
头部轮播改用虚拟无限页码,首尾相接且能反向滑,自动播放不再倒滑回第一张。
2026-10-29 15:44:33 +08:00
Vicky c311985601 fix(ui): 列表剧名统一两行省略,收藏卡去掉观看进度副标题
筛选、追剧收藏、新片、榜单、缓存与推荐上新等列表卡都按两行剧名预留高度,
超长标题显示省略号;收藏卡不再显示「看到第 N 集」,文字区高度同步收窄。
2026-10-29 15:44:30 +08:00
Vicky 4dd37b2588 fix(i18n): 缩短我的页收藏、缓存与历史的英文和印尼语文案
My Favorites / Offline downloads / Watch History 在菜单里过长,
统一收成 Favorites / Downloads / History 与 Favorit / Unduhan / Riwayat。
2026-10-29 15:44:26 +08:00
Vicky 543d0aadd5 style(player): 播放页与首页浮层的控件样式统一
播放页顶部按钮与选集条去掉底色、进度条不再画滑块圆点并压到最底部;
剧名与简介收在互动栏左侧,清屏后只留右下角退出按钮。
首页观看条与进度条共用 20pt 页边距,进度槽底色与播放页一致。
2026-10-29 15:44:22 +08:00
Vicky d5bcda54d3 fix(player): 雪碧图缓存补齐槽位预取量,清屏按钮贴近进度条
三个渲染槽位各预取 2 张雪碧图,容量 3 装不下会导致「重建→重新下载解码→
notify→重建」的死循环,真机持续发烫掉帧;容量恢复为 8 并只在未命中时预取。
首页清屏态的退出按钮由距底 56 收到 24,贴着进度条上方。
2026-10-29 15:44:17 +08:00
Vicky 79463e65c2 feat(player): 剧名与选集合并为同一个弹窗
点剧名默认停在详情页签,选集入口直接停在选集页签,不再跳详情页;
详情页签补上简介、标签、收藏与相似推荐,原选集弹窗合并进来后删除。
2026-10-29 15:44:10 +08:00
Vicky ad7aba3daf chore(gitignore): 忽略 IDE 的 Widget Preview 生成目录
flutter-ci / test (push) Failing after 19m31s
.widget_preview/ 是 IDE 里 Flutter Widget Preview 生成的临时工程(含 .dart_tool、build),不入库。
2026-10-29 11:56:23 +08:00
Vicky 47fdd4a29d build(ios): 移除 CocoaPods 集成,插件统一走 Swift Package Manager
iOS 插件已全部支持 SPM,但工程仍保留 Podfile,每次构建都会提示移除 CocoaPods 集成;
按提示 pod deintegrate、删除 Podfile,并去掉两个 Flutter xcconfig 里对 Pods-Runner 配置的 include。
2026-10-29 11:55:35 +08:00
Vicky fe49527dd8 perf(discover): 剧场与新片页网格改为按行懒加载
flutter-ci / test (push) Failing after 19m39s
双列网格原来是两个 Column 一次性建出所有卡片,翻几页后上百张卡连同封面都留在树上;
现在改成按行的 SliverList,只构建可见行,剧场页/新片页/搜索结果的滚动容器一并换成 sliver。
搜索结果、加载失败等状态同步改成 sliver,滚动位置与下拉刷新行为保持不变。
2026-10-29 11:38:21 +08:00
Vicky 4aaa5bbd60 build(deps): 显式声明 intl 依赖,修掉 CI 零告警门禁的存量告警
flutter-ci / test (push) Failing after 19m29s
播放量格式化直接用了 NumberFormat,但 intl 只是 flutter_localizations 的传递依赖;
新增的 dart analyze --fatal-infos 流水线会因此失败,这里按 flutter_localizations 钉住的版本显式声明。
2026-10-29 11:27:53 +08:00
Vicky 5649e4c3d4 perf(home): 四个根 tab 改为首次可见才构建
原先 IndexedStack 冷启动就把推荐/发现/追剧/我的四棵子树全建出来,各自还会发请求;
现在只建当前 tab,切过去再建并常驻,冷启动少三页构建和一次观看历史请求。
2026-10-29 11:25:47 +08:00
Vicky 9f7301305e perf(following): 追剧页不可见时不再提前拉观看历史
首页用 IndexedStack 常驻四个 tab,追剧页一启动就会发一次观看历史请求,和首页 feed 抢网络;
改为切到该 tab 时再加载,收藏列表也只在首次可见且为空时补拉。
2026-10-29 11:25:47 +08:00
Vicky 63191b4692 perf(player): 续看点加载与进度写盘改为并行且复用同一请求
进播放页与首次准备播放会各要一次续看点,同一次请求跑两遍还串在首帧前;
现在并发请求复用同一 Future,并与上一条进度写盘并行等待,少一个网络往返的起播延迟。
剧场网格海报按布局宽度限制解码尺寸,半屏卡片不再按原图解码。
2026-10-29 11:25:47 +08:00
Vicky 0f777b19f2 perf(player): 限制雪碧图与海报预缓存内存并给播放位置缓存加容量上限
雪碧图解码从 2048 降到 1024、缓存从 8 张降到 3 张,避免擦洗预览驻留十几 MB 位图;
相邻集海报预缓存改用 ResizeImage 按 360/720 宽解码,不再缓存整张原图;
播放位置缓存按写入时间淘汰,最多保留 100 条,长会话不再只增不减;
观看记录详情失败日志加 kDebugMode 保护,release 下不再逐条打堆栈。
2026-10-29 11:25:47 +08:00
Zhou Weidong fb9001a02d ci: 新增 Gitea Actions 流水线(测试/分析/构建门禁)
flutter-ci / test (push) Failing after 23m25s
2026-10-29 10:47:36 +08:00
Zhou Weidong 1b80f841be chore(ios): 补齐 CocoaPods 集成配置文件
xcconfig 增加 Pods 引用(PiP 等插件依赖),Podfile 纳入版本管理,
保证任何机器拉取代码后可直接构建 iOS。
2026-10-29 10:46:42 +08:00
Zhou Weidong 3e89c71f50 docs(readme): 更新服务地址配置与双渠道打包说明
补上 dev/prod 配置文件的切换方式,并把生产打包拆成 Google Play 的 AAB
与国内市场的通用 APK,标注实测体积、符号表归档与签名待办。
2026-10-29 10:44:58 +08:00
Zhou Weidong 2ce92aa7dd build(android): 压缩生产包体积至 13MB 并统一双渠道产物
生产包九成体积是两套 ABI 的引擎与快照。release 固定 split-debug-info 与
dart-obfuscation,快照从 8MB 降到 1.1MB/ABI;原生库改为压缩存储;开启
R8 裁剪并补保号规则;只打包实际引用的登录背景,移除零引用的图标字体。
2026-10-29 10:44:54 +08:00
Zhou Weidong a6d6b66d31 refactor(config): 服务地址改为 dev/prod 配置文件与单个环境开关
启动时按环回、局域网、公网顺序探测地址,换网络后会静默切到别处,排查
困难。改为代码内一个布尔开关选择环境,地址分别放在 dev_config.dart 与
prod_config.dart,不再探测;调试条改地址后直接重载,不再重新探测。
release 构建若仍是开发开关会启动即报错,避免线上包连开发机。
2026-10-29 10:44:50 +08:00
Zhou Weidong 57f84c45bd fix(player): 解锁状态登录期内共享,重进播放页不再丢解锁
解锁记录原本是播放页实例的私有集合,退出再进入会先闪一次锁定态,还会
被 selectLocked 提前释放播放器。改为进程内共享的 UnlockedEpisodeRegistry,
由全局注入并在登录态变化时清空;播放器三处渲染分支统一走同一判定,
会话锁态只约束当前页,避免相邻预加载页被误判为锁定。
2026-10-29 10:44:44 +08:00
Zhou Weidong a4d08e7c1e refactor(paging): 抽取分页控制器基类收敛四个列表页
收藏、观看记录、剧单、公告各自手写 loading 互斥、请求序号、跨页去重与
错误映射,修一处要同步改四份。统一到 PagedListController 后子类只提供
取页与条目标识;空页按已加载数收口,避免 total 虚大时反复请求。
2026-10-29 10:44:41 +08:00
Zhou Weidong 9c9a277217 refactor(feed): 合并首页与剧场频道控制器的重复分桶逻辑
两个控制器的缓存优先、请求序号防串台、分页去重、预取与 seed 完全同构,
此前各写一份。抽出 ChannelFeedController 后,子类只保留分桶 key 与取页
实现;顺带修掉刷新被翻页抢占时 loading 永久为 true 的问题。
2026-10-29 10:44:36 +08:00
Zhou Weidong 1be8ebf1f9 refactor(api): 统一响应解析并修复下载错误文案空白
各 *Api 类此前各自实现 data 结构判断,download_api 在结构异常时抛出空
message,界面会显示空白提示。抽出 ResponseDecoders 提供对象、可空对象、
对象数组三种语义,七个文件改为复用;下载侧异常文案与其他接口对齐。
2026-10-29 10:44:32 +08:00
100 changed files with 2995 additions and 2151 deletions

No files matched your search

+25
View File
@@ -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
+3
View File
@@ -65,3 +65,6 @@ app.*.map.json
.env
.env.*
!.env.example
# IDE 的 Flutter Widget Preview 生成目录。
.widget_preview/
+34 -35
View File
@@ -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)
+10
View File
@@ -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
}
}
+11
View File
@@ -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.**
+5
View File
@@ -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
+8
View File
@@ -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
View File
@@ -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;
}
+9
View File
@@ -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';
}
+9
View File
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
/// 生产环境配置。
///
/// 发版前确认这里的 [baseUrl],地址不要带结尾斜杠。
abstract final class ProdConfig {
/// 生产后端地址。
static const String baseUrl = 'http://39.162.50.99:8081';
}
+4 -15
View File
@@ -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();
+24 -3
View File
@@ -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 : '';
+6
View File
@@ -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,
+8
View File
@@ -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 ||
+6 -6
View File
@@ -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 {};
}
+14 -16
View File
@@ -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 -5
View File
@@ -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 开始。
+7 -13
View File
@@ -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));
}
}
+10 -14
View File
@@ -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 -4
View File
@@ -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);
}
}
+8 -9
View File
@@ -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);
}
/// 删除某部剧的观看记录(记录页清单条用)。
-12
View File
@@ -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();
}
}
+54 -142
View File
@@ -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();
}
}
+17 -199
View File
@@ -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;
}
+16 -104
View File
@@ -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);
}
}
/// 读取同剧同集且未过期的最近位置。
+15 -64
View File
@@ -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);
}
}
+6 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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}",
+9 -3
View File
@@ -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.
+6 -3
View File
@@ -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';
+6 -3
View File
@@ -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';
+3
View File
@@ -638,6 +638,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get episodes => '选集';
@override
String get similarDramas => '相似推荐';
@override
String get synopsisTitle => '简介';
+1
View File
@@ -281,6 +281,7 @@
"episodeCount": "{count} 集",
"technicalDemoCount": "免费试看 · {count} 集",
"episodes": "选集",
"similarDramas": "相似推荐",
"synopsisTitle": "简介",
"watchEpisodeOne": "观看第 1 集",
"continueEpisode": "续看第 {number} 集",
+5 -1
View File
@@ -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]),
+54 -41
View File
@@ -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,
);
}
}
+56 -34
View File
@@ -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(),
);
}
+1 -1
View File
@@ -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,
+3 -1
View File
@@ -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),
);
}
+1 -18
View File
@@ -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,
),
),
],
),
);
+15 -4
View File
@@ -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(
+47 -18
View File
@@ -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(),
),
],
);
}
}
+71 -49
View File
@@ -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,
+3
View File
@@ -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,
+55 -30
View File
@@ -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();
}
/// 列表卡片只带一集可播预览,需要完整剧集时补一次详情;
+51 -24
View File
@@ -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,
),
),
);
}
+30 -27
View File
@@ -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),
];
+82 -59
View File
@@ -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,
),
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+17
View File
@@ -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,
+35 -150
View File
@@ -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 {
+4 -4
View File
@@ -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: '');
+129
View File
@@ -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';
}
+15 -2
View File
@@ -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,
+87
View File
@@ -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();
},
);
}
+3 -2
View File
@@ -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(
+16 -25
View File
@@ -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});
+22
View File
@@ -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 {
+3 -27
View File
@@ -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'),
),
+34 -59
View File
@@ -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);
+12
View File
@@ -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);
+2 -3
View File
@@ -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(),
+2 -2
View File
@@ -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)),
+24 -20
View File
@@ -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'),
),
+5 -2
View File
@@ -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);
+21 -30
View File
@@ -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);
});
}
+57 -9
View File
@@ -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
+1 -1
View File
@@ -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 {