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.* .env.*
!.env.example !.env.example
# IDE 的 Flutter Widget Preview 生成目录。
.widget_preview/
+34 -35
View File
@@ -71,42 +71,45 @@ flutter pub get
```bash ```bash
adb devices adb devices
adb reverse tcp:8081 tcp:8081 adb reverse tcp:8081 tcp:8081
flutter run -d <device> \ flutter run -d <device>
--dart-define=API_BASE_URL=http://127.0.0.1:8081
``` ```
`adb reverse` 将手机的 `127.0.0.1:8081` 转发到电脑;USB 断开或重新连接后需要 `adb reverse` 将手机的 `127.0.0.1:8081` 转发到电脑;USB 断开或重新连接后需要
重新执行。 重新执行。此时用调试条把地址改成 `http://127.0.0.1:8081`(见下节)。
Android 模拟器访问宿主机时使用: Android 模拟器访问宿主机时,同样在调试条里填 `http://10.0.2.2:8081`。
```bash
flutter run --dart-define=API_BASE_URL=http://10.0.2.2:8081
```
### iOS ### iOS
手机与 Mac 需在同一网络,首次启动允许“本地网络”权限: 手机与 Mac 需在同一网络,首次启动允许“本地网络”权限:
```bash ```bash
flutter run -d <udid> \ flutter run -d <udid>
--dart-define=API_BASE_URL=http://<Mac局域网IP>:8081
``` ```
### 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` 换 Wi-Fi 或换网段时只改 `dev_config.dart` 的 `baseUrl`。打生产包前把
(`lib/core/config/app_config.dart`)。debug 包启动时按 `useDevConfig` 改成 `false`;release 构建若仍为 `true` 会在启动时直接报错,
「环回 `127.0.0.1` → `devHostBaseUrl` → 公网」顺序探测可用地址;release 包不探测, 避免线上包连到开发机。
固定使用公网地址。换 Wi-Fi、路由器换网段后 `devHostBaseUrl` 会失效,此时要么传
`--dart-define`,要么改这个常量。release 包不显示地址入口,也不读取本地覆盖。 debug 包在找剧页加载失败时会显示调试条,点「修改服务器地址」可临时改成当前
电脑 IP,保存后立即重载、不需要重新打包,该覆盖保存在本地偏好里(仅 debug
生效,release 不读取)。
## 画中画 ## 画中画
@@ -151,30 +154,26 @@ xcodebuild -resolvePackageDependencies \
```bash ```bash
adb uninstall com.stormg.lumadrama adb uninstall com.stormg.lumadrama
flutter run -d <device> \ flutter run -d <device>
--dart-define=API_BASE_URL=http://127.0.0.1:8081
``` ```
## 生产打包(Android) ## 生产打包(Android)
生产包只包含以下两个 ABI,**打成一个 APK**,不做 split: 两个渠道出两种产物,源码同一套:
| ABI | 适用设备 | | 渠道 | 产物 | 命令 | 体积 |
| --- | --- | | --- | --- | --- | --- |
| `arm64-v8a` | 现代手机(64 位) | | Google Play | AAB(按设备下发) | `flutter build appbundle --release` | 上传 28MB,用户下载约 8MB |
| `armeabi-v7a` | 老 32 位机 | | 国内各大市场 | 通用 APK(arm64 + v7) | `flutter build apk --release` | 约 13MB |
```bash
flutter build apk --release --target-platform android-arm64,android-arm
# 产出单个:build/app/outputs/flutter-apk/app-release.apk(约 37MB)
```
说明: 说明:
- `android/app/build.gradle.kts` 的 `release` 已配置 `abiFilters`,只保留 `arm64-v8a` 与 `armeabi-v7a`,插件也不会再带入 `x86_64` 原生库。 - `android/app/build.gradle.kts` 的 `release` 已配置 `abiFilters`,只保留 `arm64-v8a` 与 `armeabi-v7a`,插件也不会再带入 `x86_64` 原生库。
- `x86_64` 只用于模拟器,不进入生产包。 - `android/gradle.properties` 里固定了 `split-debug-info=build/symbols` 与 `dart-obfuscation=true`,**IDE 里点 Build APK / Build App Bundle 也会生效**:`libapp.so` 从 ~8MB/ABI 降到 ~1.1MB/ABI。
- 不要直接运行 `flutter build apk --release`:默认会编译三套原生库,包体约 57MB。 - `useLegacyPackaging = true`:原生库以压缩形式存储,这是国内 APK 从 40MB 降到 13MB 的主要原因;代价是安装时解压。
- 只有需要进一步压缩单机下载体积时,才考虑 `--split-per-abi` 出分架构包(arm64 约 21MB、armv7 约 19MB),默认不用。 - **每次发版都要归档 `build/symbols`**,线上崩溃栈需要它还原。
- release 仍在用 debug 签名(`build.gradle.kts` 的 TODO),上架前必须换成正式 keystore。
- 不要用 `--split-per-abi`:与 `abiFilters` 冲突;Play 的 AAB 已经按设备拆好。
## 生产打包(iOS) ## 生产打包(iOS)
+10
View File
@@ -35,6 +35,13 @@ android {
ndk { ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a") 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. // TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works. // Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
@@ -52,6 +59,9 @@ kotlin {
androidComponents { androidComponents {
onVariants(selector().withBuildType("release")) { variant -> onVariants(selector().withBuildType("release")) { variant ->
variant.packaging.jniLibs.excludes.add("lib/x86_64/**") 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 android.newDsl=false
# This builtInKotlin flag was added by the Flutter template # This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false 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/event_tracker.dart';
import 'core/data/services/playback_position_cache.dart'; import 'core/data/services/playback_position_cache.dart';
import 'core/data/services/playback_session.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/video_player_port.dart';
import 'core/data/services/wallet_controller.dart'; import 'core/data/services/wallet_controller.dart';
import 'core/navigation/app_route_observer.dart'; import 'core/navigation/app_route_observer.dart';
@@ -207,6 +208,10 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
late final LocaleController _localeController = LocaleController(); late final LocaleController _localeController = LocaleController();
/// 登录期内共享的「已解锁收费集」:播放页进出、feed 与全屏切换都要沿用。
late final UnlockedEpisodeRegistry _unlockedEpisodes =
UnlockedEpisodeRegistry();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -226,6 +231,8 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
if (_auth.isSignedIn == _wasSignedIn) return; if (_auth.isSignedIn == _wasSignedIn) return;
_wasSignedIn = _auth.isSignedIn; _wasSignedIn = _auth.isSignedIn;
_interactionController.clear(); _interactionController.clear();
// 解锁结论属于账号:换人登录后不能沿用上一个人的可播判断。
_unlockedEpisodes.clear();
if (_auth.isSignedIn) { if (_auth.isSignedIn) {
unawaited(_favoriteController.load()); unawaited(_favoriteController.load());
unawaited(_walletController.load()); unawaited(_walletController.load());
@@ -271,6 +278,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
Provider<CatalogApi>.value(value: _catalogApi), Provider<CatalogApi>.value(value: _catalogApi),
Provider<PlaybackRepository>.value(value: _playback), Provider<PlaybackRepository>.value(value: _playback),
Provider<PlaybackPositionCache>.value(value: _playbackPositionCache), Provider<PlaybackPositionCache>.value(value: _playbackPositionCache),
Provider<UnlockedEpisodeRegistry>.value(value: _unlockedEpisodes),
Provider<WatchHistoryRepository>.value(value: _watchHistory), Provider<WatchHistoryRepository>.value(value: _watchHistory),
Provider<FavoriteRepository>.value(value: _favorites), Provider<FavoriteRepository>.value(value: _favorites),
Provider<CommentRepository>.value(value: _commentRepository), Provider<CommentRepository>.value(value: _commentRepository),
+24 -153
View File
@@ -1,173 +1,51 @@
// Author: fruit.com.cn // 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] 即可。 /// - `dev_config.dart`:[DevConfig](本机联调)
class AppConfig { /// - `prod_config.dart`:[ProdConfig](生产)
const AppConfig._(); ///
/// 切换环境只改下面这一个布尔值,不用传任何构建参数、也不做网络探测。
/// 开发调试默认服务地址,当前指向本机局域网 IP。 abstract final class AppConfig {
/// **环境开关**:true 用开发环境,false 用生产环境。
/// ///
/// 换网络时只改这一行,地址不要带结尾斜杠。 /// 打生产包前必须改成 false;release 构建若仍为 true 会在启动时直接报错。
static const String devHostBaseUrl = 'http://192.168.31.243:8081'; static const bool useDevConfig = true;
/// 本机后端地址:通过 `adb reverse tcp:8081 tcp:8081` 映射到开发机, /// 当前选中的环境地址。
/// 探测可达时 App 直连本机服务;不可达时回落到公网地址。 static String get baseUrl =>
static const String lanBaseUrl = 'http://127.0.0.1:8081'; useDevConfig ? DevConfig.baseUrl : ProdConfig.baseUrl;
/// 公网地址(远程/移动网络访问时使用)。
static const String publicBaseUrl = 'http://39.162.50.99:8081';
static const String _overrideBaseUrl = String.fromEnvironment('API_BASE_URL');
/// 调试面板手改的地址(见 `ServerEndpoint`);为空表示用上面的环境地址。
static String _runtimeBaseUrl = ''; static String _runtimeBaseUrl = '';
/// 启动期异步准备(iOS 无线数据权限弹窗),目录请求前等待它。
static Future<void>? _ready; static Future<void>? _ready;
/// 启动地址探测完成信号;默认已完成,避免影响既有测试与调用方。 /// 启动准备完成信号;默认已完成,避免影响既有测试与调用方。
static Future<void> get ready => _ready ?? Future<void>.value(); static Future<void> get ready => _ready ?? Future<void>.value();
static set ready(Future<void> value) => _ready = value; static set ready(Future<void> value) => _ready = value;
static const Duration _probeTimeout = Duration(seconds: 2); static const Duration requestTimeout = Duration(seconds: 8);
static const List<String> _probeCandidates = [
lanBaseUrl,
devHostBaseUrl,
if (devHostBaseUrl != publicBaseUrl) publicBaseUrl,
];
/// 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. /// 应用一个已经规范化的运行时地址;传 null 表示清除。
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.
static void applyRuntimeBaseUrl(String? value) { static void applyRuntimeBaseUrl(String? value) {
_runtimeBaseUrl = value ?? ''; _runtimeBaseUrl = value ?? '';
} }
/// Clears the in-memory runtime override, mainly for tests and restarts. /// 清除运行时地址,主要给测试与重启流程用。
static void clearRuntimeBaseUrl() { static void clearRuntimeBaseUrl() {
_runtimeBaseUrl = ''; _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/...),这里补上服务地址。 /// 后端返回的是相对路径(/files/...),这里补上服务地址。
static String mediaUrl(String path) { static String mediaUrl(String path) {
if (path.isEmpty) return ''; if (path.isEmpty) return '';
@@ -175,10 +53,3 @@ class AppConfig {
return '$apiBaseUrl$path'; 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'/+$'), ''); return value.replaceFirst(RegExp(r'/+$'), '');
} }
/// Resolves runtime > dart-define > default using normalized valid values. /// 运行时手改优先,其次构建期默认地址;两者都必须先通过规范校验。
static String resolve({ static String resolve({required String? stored, required String fallback}) {
required String? stored, return normalize(stored) ?? normalize(fallback) ?? fallback;
required String compiled,
required String fallback,
}) {
return normalize(stored) ??
normalize(compiled) ??
normalize(fallback) ??
fallback;
} }
/// Loads the persisted override before the app creates its ApiClient. /// Loads the persisted override before the app creates its ApiClient.
@@ -46,11 +39,7 @@ abstract final class ServerEndpoint {
final prefs = preferences ?? SharedPreferencesAsync(); final prefs = preferences ?? SharedPreferencesAsync();
final stored = await prefs.getString(preferenceKey); final stored = await prefs.getString(preferenceKey);
AppConfig.applyRuntimeBaseUrl( AppConfig.applyRuntimeBaseUrl(
resolve( resolve(stored: stored, fallback: AppConfig.baseUrl),
stored: stored,
compiled: AppConfig.compiledBaseUrl,
fallback: AppConfig.defaultBaseUrl,
),
); );
} catch (_) { } catch (_) {
AppConfig.clearRuntimeBaseUrl(); AppConfig.clearRuntimeBaseUrl();
+24 -3
View File
@@ -97,11 +97,13 @@ class Drama {
required this.title, required this.title,
required this.synopsis, required this.synopsis,
required this.posterUrl, required this.posterUrl,
this.coverLandscape = '',
this.episodes = const [], this.episodes = const [],
this.titleLocales = const {}, this.titleLocales = const {},
this.synopsisLocales = const {}, this.synopsisLocales = const {},
this.tag = '', this.tag = '',
this.tagLocales = const {}, this.tagLocales = const {},
this.tagCode = '',
this.tagColor = '', this.tagColor = '',
this.category = '', this.category = '',
this.categoryLocales = const {}, this.categoryLocales = const {},
@@ -127,8 +129,14 @@ class Drama {
final Map<String, String> synopsisLocales; final Map<String, String> synopsisLocales;
final String tag; final String tag;
final Map<String, String> tagLocales; final Map<String, String> tagLocales;
/// 标签语义码(如 `NEW_ARRIVAL`)。文案会随语言变,语义判断只认这个码。
final String tagCode;
final String tagColor; final String tagColor;
final String posterUrl; final String posterUrl;
/// 横版封面,用于继续观看横幅等横向展示位;为空时回退竖版 `posterUrl`。
final String coverLandscape;
final List<Episode> episodes; final List<Episode> episodes;
final String category; final String category;
final Map<String, String> categoryLocales; final Map<String, String> categoryLocales;
@@ -172,8 +180,10 @@ class Drama {
synopsisLocales: text.synopsisLocales, synopsisLocales: text.synopsisLocales,
tag: text.tag, tag: text.tag,
tagLocales: text.tagLocales, tagLocales: text.tagLocales,
tagCode: (json['tagCode'] as String? ?? '').trim(),
tagColor: json['tagColor'] as String? ?? '', tagColor: json['tagColor'] as String? ?? '',
posterUrl: json['cover'] as String? ?? '', posterUrl: json['cover'] as String? ?? '',
coverLandscape: json['coverLandscape'] as String? ?? '',
episodes: preview is Map<String, dynamic> episodes: preview is Map<String, dynamic>
? [Episode.fromJson(preview)] ? [Episode.fromJson(preview)]
: const [], : const [],
@@ -204,8 +214,10 @@ class Drama {
synopsisLocales: text.synopsisLocales, synopsisLocales: text.synopsisLocales,
tag: text.tag, tag: text.tag,
tagLocales: text.tagLocales, tagLocales: text.tagLocales,
tagCode: (json['tagCode'] as String? ?? '').trim(),
tagColor: json['tagColor'] as String? ?? '', tagColor: json['tagColor'] as String? ?? '',
posterUrl: json['cover'] as String? ?? '', posterUrl: json['cover'] as String? ?? '',
coverLandscape: json['coverLandscape'] as String? ?? '',
category: text.category, category: text.category,
categoryLocales: text.categoryLocales, categoryLocales: text.categoryLocales,
types: text.types, types: text.types,
@@ -315,7 +327,10 @@ class _DramaText {
final category = _firstText(categoryLocales, json['category'], types); final category = _firstText(categoryLocales, json['category'], types);
return _DramaText( return _DramaText(
title: _firstText(titleLocales, json['title']), title: _firstText(titleLocales, json['title']),
synopsis: _firstText(synopsisLocales, json['synopsis'] ?? json['description']), synopsis: _firstText(
synopsisLocales,
json['synopsis'] ?? json['description'],
),
titleLocales: titleLocales, titleLocales: titleLocales,
synopsisLocales: synopsisLocales, synopsisLocales: synopsisLocales,
tag: _firstText(tagLocales, json['tag']), 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'] ?? ''; final chinese = locales['zh'] ?? '';
if (chinese.isNotEmpty) return chinese; if (chinese.isNotEmpty) return chinese;
if (types != null && types.isNotEmpty) { 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) { 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(' / '); final joined = names.join(' / ');
if (joined.isNotEmpty) return joined; if (joined.isNotEmpty) return joined;
return fallback is String ? fallback : ''; return fallback is String ? fallback : '';
+6
View File
@@ -16,6 +16,7 @@ class WatchProgress {
this.dramaTitle, this.dramaTitle,
this.titleLocales = const {}, this.titleLocales = const {},
this.dramaCover, this.dramaCover,
this.dramaCoverLandscape,
this.episodeNumber, this.episodeNumber,
this.totalEpisodes, this.totalEpisodes,
this.finished = false, this.finished = false,
@@ -29,6 +30,9 @@ class WatchProgress {
final String? dramaTitle; final String? dramaTitle;
final Map<String, String> titleLocales; final Map<String, String> titleLocales;
final String? dramaCover; final String? dramaCover;
/// 横版封面,用于继续观看横幅等横向位;为空时回退 [dramaCover]。
final String? dramaCoverLandscape;
final int? episodeNumber; final int? episodeNumber;
final int? totalEpisodes; final int? totalEpisodes;
final Duration position; final Duration position;
@@ -60,6 +64,7 @@ class WatchProgress {
dramaTitle: json['dramaTitle']?.toString(), dramaTitle: json['dramaTitle']?.toString(),
titleLocales: localeMap(json['titleLocales']), titleLocales: localeMap(json['titleLocales']),
dramaCover: json['dramaCover']?.toString(), dramaCover: json['dramaCover']?.toString(),
dramaCoverLandscape: json['dramaCoverLandscape']?.toString(),
episodeNumber: json['episodeNumber'] == null episodeNumber: json['episodeNumber'] == null
? null ? null
: jsonToInt(json['episodeNumber']), : jsonToInt(json['episodeNumber']),
@@ -83,6 +88,7 @@ class WatchProgress {
if (dramaTitle != null) 'dramaTitle': dramaTitle, if (dramaTitle != null) 'dramaTitle': dramaTitle,
if (titleLocales.isNotEmpty) 'titleLocales': titleLocales, if (titleLocales.isNotEmpty) 'titleLocales': titleLocales,
if (dramaCover != null) 'dramaCover': dramaCover, if (dramaCover != null) 'dramaCover': dramaCover,
if (dramaCoverLandscape != null) 'dramaCoverLandscape': dramaCoverLandscape,
if (episodeNumber != null) 'episodeNumber': episodeNumber, if (episodeNumber != null) 'episodeNumber': episodeNumber,
if (totalEpisodes != null) 'totalEpisodes': totalEpisodes, if (totalEpisodes != null) 'totalEpisodes': totalEpisodes,
'positionMs': position.inMilliseconds, 'positionMs': position.inMilliseconds,
+8
View File
@@ -46,6 +46,14 @@ class WatchRecord {
return pageCover.isNotEmpty ? pageCover : drama?.posterUrl ?? ''; 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 => bool get hasDisplayMetadata =>
displayTitle.isNotEmpty || displayTitle.isNotEmpty ||
+6 -6
View File
@@ -4,6 +4,7 @@ import '../models/account_device.dart';
import '../models/account_export.dart'; import '../models/account_export.dart';
import '../models/account_profile.dart'; import '../models/account_profile.dart';
import 'api_client.dart'; import 'api_client.dart';
import 'response_decoders.dart';
/// 个人中心接口:`/app/me*`。 /// 个人中心接口:`/app/me*`。
class AccountApi { class AccountApi {
@@ -23,11 +24,9 @@ class AccountApi {
Future<List<AccountDevice>> devices() async { Future<List<AccountDevice>> devices() async {
final data = await _client.get('/app/me/devices'); final data = await _client.get('/app/me/devices');
if (data is! List) return const []; return ResponseDecoders.asMapList(
return [ data,
for (final item in data) ).map(AccountDevice.fromJson).toList();
if (item is Map) AccountDevice.fromJson(item.cast<String, dynamic>()),
];
} }
Future<AccountExport> export() async { Future<AccountExport> export() async {
@@ -47,6 +46,7 @@ class AccountApi {
await _client.post('/app/me/deactivate/cancel'); await _client.post('/app/me/deactivate/cancel');
} }
/// 个人资料允许缺失:后端未返回对象时按空对象解析,页面用默认值兜底。
Map<String, dynamic> _map(Object? data) => 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/playlist_detail.dart';
import '../models/search_result.dart'; import '../models/search_result.dart';
import 'api_client.dart'; import 'api_client.dart';
import 'response_decoders.dart';
/// 客户端目录接口,对应后端 `/app/**`。 /// 客户端目录接口,对应后端 `/app/**`。
class CatalogApi { class CatalogApi {
@@ -14,11 +15,15 @@ class CatalogApi {
final ApiClient _client; final ApiClient _client;
Future<HomeFeed> homeFeed() async { 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 { 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 为上一页末条。 /// 推荐流;types/categoryId 为空表示不过滤,cursor 为上一页末条。
@@ -29,7 +34,7 @@ class CatalogApi {
int size = 10, int size = 10,
}) async { }) async {
return FeedPage.fromJson( return FeedPage.fromJson(
_asMap( ResponseDecoders.asMap(
await _client.get( await _client.get(
'/app/feed', '/app/feed',
query: { query: {
@@ -58,7 +63,7 @@ class CatalogApi {
'pageSize': '$pageSize', 'pageSize': '$pageSize',
}, },
); );
return SearchResult.fromJson(_asMap(data)); return SearchResult.fromJson(ResponseDecoders.asMap(data));
} }
static const int playlistPageSize = 10; static const int playlistPageSize = 10;
@@ -86,7 +91,7 @@ class CatalogApi {
int pageSize = playlistDramaPageSize, int pageSize = playlistDramaPageSize,
}) async { }) async {
return PlaylistDetail.fromJson( return PlaylistDetail.fromJson(
_asMap( ResponseDecoders.asMap(
await _client.get( await _client.get(
'/app/playlists/$id', '/app/playlists/$id',
query: { query: {
@@ -138,20 +143,13 @@ class CatalogApi {
Future<Drama> dramaDetail(String dramaId) async { Future<Drama> dramaDetail(String dramaId) async {
return Drama.fromDetailJson( return Drama.fromDetailJson(
_asMap(await _client.get('/app/dramas/$dramaId')), ResponseDecoders.asMap(await _client.get('/app/dramas/$dramaId')),
); );
} }
List<ContentCategory> _categories(Object? data) { List<ContentCategory> _categories(Object? data) {
if (data is! List) return const []; return ResponseDecoders.asMapList(
return [ data,
for (final item in data) ).map(ContentCategory.fromJson).toList();
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, '返回数据格式异常');
} }
} }
+4 -5
View File
@@ -4,6 +4,7 @@ import '../models/content_category.dart';
import '../models/drama.dart'; import '../models/drama.dart';
import '../models/page_result.dart'; import '../models/page_result.dart';
import 'api_client.dart'; import 'api_client.dart';
import 'response_decoders.dart';
/// 内容分类接口,对应契约 `GET /app/categories`。 /// 内容分类接口,对应契约 `GET /app/categories`。
class CategoryApi { class CategoryApi {
@@ -16,11 +17,9 @@ class CategoryApi {
/// 拉取已启用分类,id 全程按字符串解析。 /// 拉取已启用分类,id 全程按字符串解析。
Future<List<ContentCategory>> list() async { Future<List<ContentCategory>> list() async {
final data = await _client.get('/app/categories'); final data = await _client.get('/app/categories');
if (data is! List) return const []; return ResponseDecoders.asMapList(
return [ data,
for (final item in data) ).map(ContentCategory.fromJson).toList();
if (item is Map<String, dynamic>) ContentCategory.fromJson(item),
];
} }
/// 分类下已上线短剧,pageIndex 从 1 开始。 /// 分类下已上线短剧,pageIndex 从 1 开始。
+7 -13
View File
@@ -4,6 +4,7 @@ import '../models/order.dart';
import '../models/product.dart'; import '../models/product.dart';
import '../models/wallet.dart'; import '../models/wallet.dart';
import 'api_client.dart'; import 'api_client.dart';
import 'response_decoders.dart';
/// 金币与解锁接口,对应契约 §1.8。 /// 金币与解锁接口,对应契约 §1.8。
class CommerceApi { class CommerceApi {
@@ -13,8 +14,7 @@ class CommerceApi {
Future<WalletSnapshot> wallet() async { Future<WalletSnapshot> wallet() async {
final data = await _client.get('/app/wallet'); final data = await _client.get('/app/wallet');
if (data is Map<String, dynamic>) return WalletSnapshot.fromJson(data); return WalletSnapshot.fromJson(ResponseDecoders.asMap(data));
throw const ApiException(-1, '返回数据格式异常');
} }
/// 上架商品列表;`type` 传 `COIN_PACK` 或 `VIP`。 /// 上架商品列表;`type` 传 `COIN_PACK` 或 `VIP`。
@@ -23,13 +23,9 @@ class CommerceApi {
'/app/products', '/app/products',
query: type == null ? null : {'type': type}, query: type == null ? null : {'type': type},
); );
if (data is List) { return ResponseDecoders.requiredMapList(
return data data,
.whereType<Map<String, dynamic>>() ).map(AppProduct.fromJson).toList();
.map(AppProduct.fromJson)
.toList();
}
throw const ApiException(-1, '返回数据格式异常');
} }
/// 创建充值订单,返回 `CREATED` 状态订单。 /// 创建充值订单,返回 `CREATED` 状态订单。
@@ -38,13 +34,11 @@ class CommerceApi {
'/app/orders', '/app/orders',
body: {'productId': productId}, body: {'productId': productId},
); );
if (data is Map<String, dynamic>) return AppOrder.fromJson(data); return AppOrder.fromJson(ResponseDecoders.asMap(data));
throw const ApiException(-1, '返回数据格式异常');
} }
Future<UnlockResult> unlock(String episodeId) async { Future<UnlockResult> unlock(String episodeId) async {
final data = await _client.post('/app/episodes/$episodeId/unlock'); final data = await _client.post('/app/episodes/$episodeId/unlock');
if (data is Map<String, dynamic>) return UnlockResult.fromJson(data); return UnlockResult.fromJson(ResponseDecoders.asMap(data));
throw const ApiException(-1, '返回数据格式异常');
} }
} }
+10 -14
View File
@@ -3,6 +3,7 @@
import '../models/download_license.dart'; import '../models/download_license.dart';
import '../models/download_policy.dart'; import '../models/download_policy.dart';
import 'api_client.dart'; import 'api_client.dart';
import 'response_decoders.dart';
/// 离线下载授权接口,对应契约 §1.6 的全部 8 个接口。 /// 离线下载授权接口,对应契约 §1.6 的全部 8 个接口。
/// ///
@@ -15,7 +16,7 @@ class DownloadApi {
/// 拉取配额和偏好策略。 /// 拉取配额和偏好策略。
Future<DownloadPolicy> policy() async { Future<DownloadPolicy> policy() async {
return DownloadPolicy.fromJson( 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 { Future<DownloadLicense> grant(String episodeId, {String? quality}) async {
return DownloadLicense.fromJson( return DownloadLicense.fromJson(
_asMap( ResponseDecoders.asMap(
await _client.post( await _client.post(
'/app/downloads', '/app/downloads',
body: {'episodeId': episodeId, 'quality': ?quality}, body: {'episodeId': episodeId, 'quality': ?quality},
@@ -42,17 +43,17 @@ class DownloadApi {
/// 拉取当前设备的授权列表。 /// 拉取当前设备的授权列表。
Future<List<DownloadLicense>> list() async { Future<List<DownloadLicense>> list() async {
final data = await _client.get('/app/downloads'); final data = await _client.get('/app/downloads');
if (data is! List) return const []; return ResponseDecoders.asMapList(
return [ data,
for (final item in data) ).map(DownloadLicense.fromJson).toList();
if (item is Map<String, dynamic>) DownloadLicense.fromJson(item),
];
} }
/// 续传前刷新短时效下载地址。 /// 续传前刷新短时效下载地址。
Future<DownloadLicense> refreshUrl(String episodeId) async { Future<DownloadLicense> refreshUrl(String episodeId) async {
return DownloadLicense.fromJson( 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, String? errorMsg,
}) async { }) async {
return DownloadLicense.fromJson( return DownloadLicense.fromJson(
_asMap( ResponseDecoders.asMap(
await _client.post( await _client.post(
'/app/downloads/$episodeId/progress', '/app/downloads/$episodeId/progress',
body: { body: {
@@ -93,9 +94,4 @@ class DownloadApi {
final data = await _client.delete('/app/downloads'); final data = await _client.delete('/app/downloads');
return data is int ? data : 0; 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/episode_stats.dart';
import '../models/play_ticket.dart'; import '../models/play_ticket.dart';
import 'api_client.dart'; import 'api_client.dart';
import 'response_decoders.dart';
/// 播放票据接口:`GET /app/episodes/{id}/play`(契约 §1.4)。 /// 播放票据接口:`GET /app/episodes/{id}/play`(契约 §1.4)。
/// ///
@@ -15,14 +16,12 @@ class PlaybackApi {
Future<PlayTicket> ticket(String episodeId) async { Future<PlayTicket> ticket(String episodeId) async {
final data = await _client.get('/app/episodes/$episodeId/play'); final data = await _client.get('/app/episodes/$episodeId/play');
if (data is Map<String, dynamic>) return PlayTicket.fromJson(data); return PlayTicket.fromJson(ResponseDecoders.asMap(data));
throw const ApiException(-1, '返回数据格式异常');
} }
/// 轻量刷新互动计数:不签发地址,也不占用播放票据缓存。 /// 轻量刷新互动计数:不签发地址,也不占用播放票据缓存。
Future<EpisodeStats> episodeStats(String episodeId) async { Future<EpisodeStats> episodeStats(String episodeId) async {
final data = await _client.get('/app/episodes/$episodeId/stats'); final data = await _client.get('/app/episodes/$episodeId/stats');
if (data is Map<String, dynamic>) return EpisodeStats.fromJson(data); return EpisodeStats.fromJson(ResponseDecoders.asMap(data));
throw const ApiException(-1, '返回数据格式异常');
} }
} }
@@ -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/page_result.dart';
import '../models/watch_progress.dart'; import '../models/watch_progress.dart';
import 'api_client.dart'; import 'api_client.dart';
import 'response_decoders.dart';
/// 观看进度接口:`/app/watch/*`(契约 §1.5)。支持账号或设备身份。 /// 观看进度接口:`/app/watch/*`(契约 §1.5)。支持账号或设备身份。
class WatchApi { class WatchApi {
@@ -28,7 +29,8 @@ class WatchApi {
'source': source, '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。 /// 某部剧的续看位置;没有记录时后端返回空 data。
@@ -37,7 +39,8 @@ class WatchApi {
'/app/watch/progress', '/app/watch/progress',
query: {'dramaId': dramaId}, 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`。 /// 最近观看分页:`pageIndex`/`pageSize`,解包 `PageResult`。
@@ -49,13 +52,9 @@ class WatchApi {
'/app/watch/recent', '/app/watch/recent',
query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'}, query: {'pageIndex': '$pageIndex', 'pageSize': '$pageSize'},
); );
if (data is! Map) { final map = ResponseDecoders.tryMap(data);
return const PageResult(); if (map == null) return const PageResult();
} return PageResult.fromJson(map, WatchProgress.fromJson);
return PageResult.fromJson(
Map<String, dynamic>.from(data),
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 // Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/drama.dart'; import '../models/drama.dart';
import '../models/page_result.dart';
import '../remote/api_client.dart'; import '../remote/api_client.dart';
import '../repositories/favorite_repository.dart'; import '../repositories/favorite_repository.dart';
import 'paged_list_controller.dart';
/// 收藏状态:**以服务端为准**。 /// 收藏状态:**以服务端为准**。
/// ///
/// 点击收藏先乐观更新(立刻变色),失败再回滚并给出提示 —— 网络抖动时不至于 /// 点击收藏先乐观更新(立刻变色),失败再回滚并给出提示 —— 网络抖动时不至于
/// "点了没反应"或"看起来收藏了其实没有"。 /// "点了没反应"或"看起来收藏了其实没有"。
class FavoriteController extends ChangeNotifier { class FavoriteController extends PagedListController<Drama> {
FavoriteController(this._repository, {this.pageSize = defaultPageSize}); FavoriteController(this._repository, {this.pageSize = defaultPageSize})
: super(fallbackErrorKey: 'favoritesError');
static const int defaultPageSize = 20; static const int defaultPageSize = 20;
@@ -19,17 +20,9 @@ class FavoriteController extends ChangeNotifier {
final int pageSize; final int pageSize;
final Set<String> _saved = {}; final Set<String> _saved = {};
final Map<String, int> _favoriteCounts = {}; final Map<String, int> _favoriteCounts = {};
List<Drama> cards = const [];
int total = 0; /// 收藏列表卡片(按服务端顺序)。
bool loading = false; List<Drama> get cards => items;
bool loadingMore = false;
bool hasMore = false;
String? error;
String? loadMoreError;
int? lastErrorCode;
int _pageIndex = 0;
int _requestSeq = 0;
bool _disposed = false;
Set<String> get savedIds => Set.unmodifiable(_saved); Set<String> get savedIds => Set.unmodifiable(_saved);
@@ -38,6 +31,33 @@ class FavoriteController extends ChangeNotifier {
/// 当前剧收藏数;包含本地 toggle 增量。 /// 当前剧收藏数;包含本地 toggle 增量。
int? favoriteCount(String dramaId) => _favoriteCounts[dramaId]; 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) { int? favoriteCountFor(Drama drama) {
final current = _favoriteCounts[drama.id]; final current = _favoriteCounts[drama.id];
@@ -82,81 +102,6 @@ class FavoriteController extends ChangeNotifier {
if (notify) this.notify(); 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] 是当前剧的卡片(播放页/详情页手上就有):收藏成功时直接放进列表, /// [card] 是当前剧的卡片(播放页/详情页手上就有):收藏成功时直接放进列表,
@@ -169,15 +114,13 @@ class FavoriteController extends ChangeNotifier {
: favoriteCountFor(card); : favoriteCountFor(card);
final previousCards = cards; final previousCards = cards;
final previousTotal = total; final previousTotal = total;
final previousHasMore = hasMore;
// 乐观更新 // 乐观更新
if (wasSaved) { if (wasSaved) {
_saved.remove(dramaId); _saved.remove(dramaId);
_stepCount(dramaId, -1); _stepCount(dramaId, -1);
if (cards.any((drama) => drama.id == dramaId)) { if (cards.any((drama) => drama.id == dramaId)) {
cards = cards.where((drama) => drama.id != dramaId).toList(); setItems(cards.where((drama) => drama.id != dramaId).toList());
total = total > 0 ? total - 1 : 0; setTotal(total - 1);
hasMore = cards.length < total;
} }
} else { } else {
_saved.add(dramaId); _saved.add(dramaId);
@@ -191,13 +134,11 @@ class FavoriteController extends ChangeNotifier {
_saved.add(dramaId); _saved.add(dramaId);
if (wasSaved) { if (wasSaved) {
_restoreCount(dramaId, previousCount); _restoreCount(dramaId, previousCount);
cards = previousCards; setItems(previousCards);
total = previousTotal; setTotal(previousTotal);
hasMore = previousHasMore;
} else if (card != null && !cards.any((drama) => drama.id == dramaId)) { } else if (card != null && !cards.any((drama) => drama.id == dramaId)) {
cards = [card, ...cards]; setItems([card, ...cards]);
total += 1; setTotal(total + 1);
hasMore = cards.length < total;
} }
} else { } else {
_saved.remove(dramaId); _saved.remove(dramaId);
@@ -205,18 +146,16 @@ class FavoriteController extends ChangeNotifier {
_restoreCount(dramaId, previousCount); _restoreCount(dramaId, previousCount);
} }
if (cards.any((drama) => drama.id == dramaId)) { if (cards.any((drama) => drama.id == dramaId)) {
cards = cards.where((drama) => drama.id != dramaId).toList(); setItems(cards.where((drama) => drama.id != dramaId).toList());
total = total > 0 ? total - 1 : 0; setTotal(total - 1);
hasMore = cards.length < total;
} }
} }
notify(); notify();
return true; return true;
} catch (failure) { } catch (failure) {
// 回滚 // 回滚
cards = previousCards; setItems(previousCards);
total = previousTotal; setTotal(previousTotal);
hasMore = previousHasMore;
if (wasSaved) { if (wasSaved) {
_saved.add(dramaId); _saved.add(dramaId);
} else { } else {
@@ -235,27 +174,24 @@ class FavoriteController extends ChangeNotifier {
if (ids.isEmpty) return true; if (ids.isEmpty) return true;
final previousCards = cards; final previousCards = cards;
final previousTotal = total; final previousTotal = total;
final previousHasMore = hasMore;
final removed = cards.where((drama) => ids.contains(drama.id)).toList(); final removed = cards.where((drama) => ids.contains(drama.id)).toList();
if (removed.isEmpty) return true; if (removed.isEmpty) return true;
final previousCounts = { final previousCounts = {
for (final drama in removed) drama.id: _favoriteCounts[drama.id], 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) { for (final id in ids) {
_saved.remove(id); _saved.remove(id);
_stepCount(id, -1); _stepCount(id, -1);
} }
total = total > removed.length ? total - removed.length : 0; setTotal(total - removed.length);
hasMore = cards.length < total;
notify(); notify();
try { try {
await _repository.removeMany(dramaIds); await _repository.removeMany(dramaIds);
return true; return true;
} catch (_) { } catch (_) {
cards = previousCards; setItems(previousCards);
total = previousTotal; setTotal(previousTotal);
hasMore = previousHasMore;
for (final drama in removed) { for (final drama in removed) {
_saved.add(drama.id); _saved.add(drama.id);
} }
@@ -276,9 +212,8 @@ class FavoriteController extends ChangeNotifier {
_saved.remove(dramaId); _saved.remove(dramaId);
_stepCount(dramaId, -1); _stepCount(dramaId, -1);
final next = [...cards]..removeAt(index); final next = [...cards]..removeAt(index);
cards = next; setItems(next);
total = total > 0 ? total - 1 : 0; setTotal(total - 1);
hasMore = cards.length < total;
notify(); notify();
try { try {
await _repository.remove(dramaId); await _repository.remove(dramaId);
@@ -289,11 +224,10 @@ class FavoriteController extends ChangeNotifier {
index > restored.length ? restored.length : index, index > restored.length ? restored.length : index,
removed, removed,
); );
cards = restored; setItems(restored);
_saved.add(dramaId); _saved.add(dramaId);
_restoreCount(dramaId, previousCount); _restoreCount(dramaId, previousCount);
total += 1; setTotal(total + 1);
hasMore = cards.length < total;
notify(); notify();
return false; return false;
} }
@@ -301,27 +235,11 @@ class FavoriteController extends ChangeNotifier {
/// 退出登录时清空:收藏状态属于账号,不能跨账号串联。 /// 退出登录时清空:收藏状态属于账号,不能跨账号串联。
void clear() { void clear() {
_requestSeq += 1; resetPaging();
_saved.clear(); _saved.clear();
cards = const [];
total = 0;
loading = false;
hasMore = false;
loadingMore = false;
loadMoreError = null;
error = null;
_pageIndex = 0;
notify(); 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) { void _rememberCardCounts(List<Drama> dramas) {
for (final drama in dramas) { for (final drama in dramas) {
final count = drama.favoriteCount; final count = drama.favoriteCount;
@@ -345,10 +263,4 @@ class FavoriteController extends ChangeNotifier {
_favoriteCounts[dramaId] = count; _favoriteCounts[dramaId] = count;
} }
} }
@override
void dispose() {
_disposed = true;
super.dispose();
}
} }
+17 -199
View File
@@ -1,223 +1,41 @@
// Author: fruit.com.cn // Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../../data/models/drama.dart'; import '../../data/models/drama.dart';
import '../../data/models/feed_page.dart';
import '../../data/repositories/catalog_repository.dart'; import '../../data/repositories/catalog_repository.dart';
import '../../../pages/home/home_feed_channel.dart'; import '../../../pages/home/home_feed_channel.dart';
import 'channel_feed_controller.dart';
/// 首页内容类型频道状态:每频道独立缓存、刷新和游标分页。 /// 首页内容类型频道状态:每频道独立缓存、刷新和游标分页。
class HomeFeedController extends ChangeNotifier { class HomeFeedController extends ChannelFeedController<HomeFeedChannel> {
HomeFeedController( HomeFeedController(this._repository, {super.requestTimeout})
this._repository, { : super(HomeFeedChannel.recommend);
this.requestTimeout = defaultRequestTimeout,
}) : _states = {
for (final channel in HomeFeedChannel.values)
channel: _ChannelFeedState(),
};
static const int pageSize = 20;
static const Duration defaultRequestTimeout = Duration(seconds: 10);
final CatalogRepository _repository; final CatalogRepository _repository;
final Duration requestTimeout;
final Map<HomeFeedChannel, _ChannelFeedState> _states;
HomeFeedChannel _channel = HomeFeedChannel.recommend;
bool _disposed = false;
HomeFeedChannel get channel => _channel; HomeFeedChannel get channel => currentKey;
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;
/// 用首页聚合接口打平的数据填充推荐首屏,不发起网络请求。 /// 用首页聚合接口打平的数据填充推荐首屏,不发起网络请求。
void seedRecommend(List<Drama> dramas) { void seedRecommend(List<Drama> dramas) =>
final state = _state(HomeFeedChannel.recommend); seedKey(HomeFeedChannel.recommend, dramas);
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);
}
/// 后台预取漫剧与真人剧首屏,不切换频道,也不影响当前列表。 /// 后台预取漫剧与真人剧首屏,不切换频道,也不影响当前列表。
Future<void> prefetchChannels() async { Future<void> prefetchChannels() async {
await Future.wait([ await Future.wait([
_prefetchChannel(HomeFeedChannel.anime), prefetchKey(HomeFeedChannel.anime),
_prefetchChannel(HomeFeedChannel.live), prefetchKey(HomeFeedChannel.live),
]); ]);
} }
/// 同步切到目标频道;有缓存先渲染,无缓存时复用或等待在飞请求。 /// 同步切到目标频道;有缓存先渲染,无缓存时复用或等待在飞请求。
Future<void> selectChannel(HomeFeedChannel next) async { Future<void> selectChannel(HomeFeedChannel next) => selectKey(next);
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();
}
@override @override
void dispose() { Future<FeedPage> fetchPage(HomeFeedChannel key, {String? cursor}) {
_disposed = true; return _repository.fetchFeed(
super.dispose(); 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 // Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../models/notice.dart'; import '../models/notice.dart';
import '../remote/api_client.dart'; import '../models/page_result.dart';
import '../repositories/notice_repository.dart'; import '../repositories/notice_repository.dart';
import 'paged_list_controller.dart';
/// Pagination and state controller for the anonymous notices page. /// 公告页状态:匿名可看,按页追加。
class NoticesController extends ChangeNotifier { class NoticesController extends PagedListController<AppNotice> {
NoticesController(this._repository, {this.pageSize = 20}); NoticesController(this._repository, {this.pageSize = 20})
: super(fallbackErrorKey: 'noticesLoadFailed');
final NoticeRepository _repository; final NoticeRepository _repository;
final int pageSize; 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. @override
bool get hasMore => _notices.length < _total; Future<PageResult<AppNotice>> fetchPage(int pageIndex) async {
final page = await _repository.page(
/// Loads the first page. pageIndex: pageIndex,
Future<void> load() async { pageSize: pageSize,
loading = true; );
error = null; return PageResult(total: page.total, list: page.notices);
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 @override
void dispose() { String itemKey(AppNotice item) => item.id;
_disposed = true;
super.dispose();
}
} }
@@ -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({ PlaybackPositionCache({
DateTime Function()? now, DateTime Function()? now,
this.maxAge = _defaultMaxAge, this.maxAge = _defaultMaxAge,
this.maxEntries = defaultMaxEntries,
}) : _now = now ?? DateTime.now; }) : _now = now ?? DateTime.now;
/// 最多保留的集数:长会话连看几十部剧也不至于让 Map 无限增长。
static const int defaultMaxEntries = 100;
final DateTime Function() _now; final DateTime Function() _now;
final Duration maxAge; final Duration maxAge;
final int maxEntries;
final Map<String, CachedPlaybackPosition> _positions = {}; final Map<String, CachedPlaybackPosition> _positions = {};
/// 保存最近有效播放位置;零位置不会覆盖已有记录。 /// 保存最近有效播放位置;零位置不会覆盖已有记录。
@@ -57,6 +62,24 @@ class PlaybackPositionCache {
duration: duration, duration: duration,
updatedAt: _now(), 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 'package:flutter/foundation.dart';
import '../models/page_result.dart';
import '../models/playlist_detail.dart'; import '../models/playlist_detail.dart';
import '../remote/catalog_api.dart'; import '../remote/catalog_api.dart';
import '../repositories/playlist_repository.dart'; import '../repositories/playlist_repository.dart';
import 'paged_list_controller.dart';
/// 推荐页剧单:已启用剧单及其短剧卡片。 /// 推荐页剧单:已启用剧单及其短剧卡片。
class PlaylistController extends ChangeNotifier { class PlaylistController extends PagedListController<PlaylistDetail> {
PlaylistController(this._repository); PlaylistController(this._repository)
: super(fallbackErrorKey: 'playlistsError', dedupe: false);
final PlaylistRepository _repository;
static const int pageSize = CatalogApi.playlistPageSize; static const int pageSize = CatalogApi.playlistPageSize;
List<PlaylistDetail> items = const []; final PlaylistRepository _repository;
bool loading = false;
bool loadingMore = false;
bool loaded = false;
bool hasMore = false;
String? error;
int _pageIndex = 1;
bool _disposed = false;
/// 从第一页重新拉取。失败保留错误态,成功后替换本地缓存。 @override
Future<void> load() async { Future<PageResult<PlaylistDetail>> fetchPage(int pageIndex) {
loading = true; return _repository.fetchPlaylists(pageIndex: pageIndex, pageSize: pageSize);
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<void> loadMore() async { String itemKey(PlaylistDetail item) => item.id;
if (loading || loadingMore || !hasMore) return;
loadingMore = true; /// 剧单页把失败统一收敛成固定 key,由页面翻译成文案。
_notify(); @override
try { String? errorKeyFor(Object failure) => 'playlistsError';
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();
}
}
/// 打开某一剧单时再取一次详情;失败则回落到列表里已有的那条。 /// 打开某一剧单时再取一次详情;失败则回落到列表里已有的那条。
Future<PlaylistDetail> detail( 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'; import '../../config/app_config.dart';
const int spriteThumbnailDecodeMaxDimension = 2048; /// 雪碧图解码上限:擦洗预览只占很小一块区域,2048 会让单张位图膨胀到 16MB。
const int spriteThumbnailDecodeMaxDimension = 1024;
/// VTT 资源拒绝访问,服务层据此清缓存并重试一次。 /// VTT 资源拒绝访问,服务层据此清缓存并重试一次。
class SpriteThumbnailForbiddenException implements Exception { class SpriteThumbnailForbiddenException implements Exception {
@@ -1,187 +1,27 @@
// Author: fruit.com.cn // Author: fruit.com.cn
import 'dart:async'; import '../../data/models/feed_page.dart';
import 'package:flutter/foundation.dart';
import '../../data/models/drama.dart';
import '../../data/repositories/catalog_repository.dart'; import '../../data/repositories/catalog_repository.dart';
import 'channel_feed_controller.dart';
/// 剧场页服务端分类列表:按 categoryId 独立缓存、刷新和游标分页。 /// 剧场页服务端分类列表:按 categoryId 独立缓存、刷新和游标分页。
class TheaterFeedController extends ChangeNotifier { class TheaterFeedController extends ChannelFeedController<String?> {
TheaterFeedController( TheaterFeedController(this._repository, {super.requestTimeout})
this._repository, { : super(null, initiallySelected: false);
this.requestTimeout = defaultRequestTimeout,
});
static const int pageSize = 20;
static const Duration defaultRequestTimeout = Duration(seconds: 10);
final CatalogRepository _repository; 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 categoryId => currentKey;
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;
/// 同步切换分类;有缓存先渲染,无缓存时等待或发起首屏请求。 /// 同步切换分类;有缓存先渲染,无缓存时等待或发起首屏请求。
Future<void> select(String? next) async { Future<void> select(String? next) => selectKey(next);
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();
}
@override @override
void dispose() { Future<FeedPage> fetchPage(String? key, {String? cursor}) {
_disposed = true; return _repository.fetchFeed(
super.dispose(); 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 'package:flutter/foundation.dart';
import '../models/drama.dart'; import '../models/drama.dart';
import '../models/page_result.dart';
import '../models/watch_progress.dart'; import '../models/watch_progress.dart';
import '../models/watch_record.dart'; import '../models/watch_record.dart';
import '../remote/api_client.dart'; import '../remote/api_client.dart';
import '../repositories/catalog_repository.dart'; import '../repositories/catalog_repository.dart';
import '../repositories/watch_history_repository.dart'; import '../repositories/watch_history_repository.dart';
import 'paged_list_controller.dart';
/// 独立观看记录页状态。 /// 独立观看记录页状态。
/// ///
/// 分页接口提供标题/封面/集号;详情按 dramaId 去重后补取,供点击播放使用。 /// 分页接口提供标题/封面/集号;详情按 dramaId 去重后补取,供点击播放使用。
class WatchRecordsController extends ChangeNotifier { class WatchRecordsController extends PagedListController<WatchRecord> {
factory WatchRecordsController({ factory WatchRecordsController({
required WatchHistoryRepository history, required WatchHistoryRepository history,
required CatalogRepository catalog, required CatalogRepository catalog,
int pageSize = defaultPageSize, int pageSize = defaultPageSize,
}) => WatchRecordsController._(history, catalog, pageSize); }) => 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 defaultPageSize = 20;
static const int detailConcurrency = 4; static const int detailConcurrency = 4;
@@ -29,98 +32,32 @@ class WatchRecordsController extends ChangeNotifier {
final int pageSize; final int pageSize;
final Map<String, Drama> _detailCache = {}; final Map<String, Drama> _detailCache = {};
List<WatchRecord> records = const [];
int total = 0;
bool loading = false;
bool loadingMore = false;
bool hasMore = false;
bool clearing = false; bool clearing = false;
String? error;
String? loadMoreError;
String? actionError; String? actionError;
int? lastErrorCode;
int _pageIndex = 0;
int _requestSeq = 0;
bool _disposed = false;
/// 加载最近记录并补齐详情;单条详情失败不拖垮整页。 /// 当前已加载记录(按服务端顺序)。
Future<void> load() async { List<WatchRecord> get records => items;
final request = ++_requestSeq;
loading = true; @override
loadingMore = false; Future<PageResult<WatchRecord>> fetchPage(int pageIndex) async {
error = null; final page = await _history.recent(
loadMoreError = null; pageIndex: pageIndex,
actionError = null; pageSize: pageSize,
lastErrorCode = null; );
_notify(); return PageResult(total: page.total, list: _uniqueRecords(page.list));
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();
}
}
} }
/// 追加下一页;刷新、加载中或无更多时忽略。 @override
Future<void> loadMore() async { String itemKey(WatchRecord item) => item.dramaId;
if (loading || loadingMore || !hasMore) return;
final request = ++_requestSeq; /// 第一页成功替换后清掉上一轮的操作错误(如删除失败提示)。
loadingMore = true; @override
loadMoreError = null; void onItemsReplaced() => actionError = null;
lastErrorCode = null;
_notify(); /// 只补缺标题的详情,单条失败不拖垮整页。
try { @override
final page = await _history.recent( Future<void> onPageLoaded(List<WatchRecord> added) =>
pageIndex: _pageIndex + 1, _hydrate(added.map((record) => record.dramaId).toList());
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();
}
}
}
/// 只重试一条详情失败记录。 /// 只重试一条详情失败记录。
Future<void> retryDetail(String dramaId) async { Future<void> retryDetail(String dramaId) async {
@@ -130,9 +67,9 @@ class WatchRecordsController extends ChangeNotifier {
index, index,
records[index].copyWith(detailLoading: true, detailFailed: false), records[index].copyWith(detailLoading: true, detailFailed: false),
); );
_notify(); notify();
await _loadDetail(dramaId); await _loadDetail(dramaId);
_notify(); notify();
} }
/// 点击有标题的记录时再按需加载详情,避免列表阶段产生 N+1。 /// 点击有标题的记录时再按需加载详情,避免列表阶段产生 N+1。
@@ -140,7 +77,7 @@ class WatchRecordsController extends ChangeNotifier {
final cached = _detailCache[dramaId]; final cached = _detailCache[dramaId];
if (cached != null) return cached; if (cached != null) return cached;
await _loadDetail(dramaId); await _loadDetail(dramaId);
_notify(); notify();
return _detailCache[dramaId]; return _detailCache[dramaId];
} }
@@ -157,62 +94,66 @@ class WatchRecordsController extends ChangeNotifier {
final index = _recordIndex(dramaId); final index = _recordIndex(dramaId);
if (index < 0) return true; if (index < 0) return true;
final removed = records[index]; final removed = records[index];
records = [...records]..removeAt(index); setItems([...records]..removeAt(index));
total = total > 0 ? total - 1 : 0; setTotal(total - 1);
hasMore = records.length < total;
actionError = null; actionError = null;
lastErrorCode = null; lastErrorCode = null;
_notify(); notify();
try { try {
await _history.remove(dramaId); await _history.remove(dramaId);
return true; return true;
} catch (failure) { } catch (failure) {
final next = [...records]; final next = [...items];
next.insert(index > next.length ? next.length : index, removed); next.insert(index > next.length ? next.length : index, removed);
records = next; setItems(next);
total += 1; setTotal(total + 1);
hasMore = records.length < total;
actionError = 'recordDeleteFailed'; actionError = 'recordDeleteFailed';
if (failure is ApiException) lastErrorCode = failure.code; if (failure is ApiException) lastErrorCode = failure.code;
return false; return false;
} finally { } finally {
_notify(); notify();
} }
} }
/// 清空全部记录,先更新 UI,失败恢复原列表。 /// 清空全部记录,先更新 UI,失败恢复原列表。
Future<bool> clear() async { Future<bool> clear() async {
if (records.isEmpty) return true; if (records.isEmpty) return true;
_requestSeq += 1; invalidateRequests();
final previous = records; final previous = records;
final previousTotal = total; final previousTotal = total;
final previousPageIndex = _pageIndex; final previousPageIndex = pageIndex;
records = const []; setItems(const []);
total = 0; setTotal(0);
hasMore = false; restorePageIndex(0);
_pageIndex = 0;
loadingMore = false; loadingMore = false;
clearing = true; clearing = true;
actionError = null; actionError = null;
lastErrorCode = null; lastErrorCode = null;
_notify(); notify();
try { try {
await _history.clear(); await _history.clear();
return true; return true;
} catch (failure) { } catch (failure) {
records = previous; setItems(previous);
total = previousTotal; setTotal(previousTotal);
_pageIndex = previousPageIndex; restorePageIndex(previousPageIndex);
hasMore = records.length < total;
actionError = 'recordClearFailed'; actionError = 'recordClearFailed';
if (failure is ApiException) lastErrorCode = failure.code; if (failure is ApiException) lastErrorCode = failure.code;
return false; return false;
} finally { } finally {
clearing = false; clearing = false;
_notify(); notify();
} }
} }
/// 切换账号/设备身份时清除内存数据,不删除服务端记录。
void reset() {
resetPaging();
clearing = false;
actionError = null;
notify();
}
List<WatchRecord> _uniqueRecords(List<WatchProgress> recent) { List<WatchRecord> _uniqueRecords(List<WatchProgress> recent) {
final seen = <String>{}; final seen = <String>{};
return [ return [
@@ -239,7 +180,7 @@ class WatchRecordsController extends ChangeNotifier {
? queue.length ? queue.length
: detailConcurrency; : detailConcurrency;
Future<void> worker() async { Future<void> worker() async {
while (queue.isNotEmpty && !_disposed) { while (queue.isNotEmpty && !isDisposed) {
await _loadDetail(queue.removeAt(0)); await _loadDetail(queue.removeAt(0));
} }
} }
@@ -272,7 +213,7 @@ class WatchRecordsController extends ChangeNotifier {
} }
try { try {
final detail = await _catalog.fetchDrama(dramaId); final detail = await _catalog.fetchDrama(dramaId);
if (_disposed) return null; if (isDisposed) return null;
_detailCache[dramaId] = detail; _detailCache[dramaId] = detail;
final current = _recordIndex(dramaId); final current = _recordIndex(dramaId);
if (current < 0) return detail; if (current < 0) return detail;
@@ -286,15 +227,18 @@ class WatchRecordsController extends ChangeNotifier {
); );
return detail; return detail;
} catch (failure, stackTrace) { } catch (failure, stackTrace) {
debugPrint( // 弱网时详情会成批失败,release 下不需要逐条打堆栈。
'[WatchRecordsController] detail failed dramaId=$dramaId ' if (kDebugMode) {
'${failure.runtimeType}: $failure', debugPrint(
); '[WatchRecordsController] detail failed dramaId=$dramaId '
debugPrintStack( '${failure.runtimeType}: $failure',
stackTrace: stackTrace, );
label: '[WatchRecordsController] detail failed dramaId=$dramaId', debugPrintStack(
); stackTrace: stackTrace,
if (_disposed) return null; label: '[WatchRecordsController] detail failed dramaId=$dramaId',
);
}
if (isDisposed) return null;
final current = _recordIndex(dramaId); final current = _recordIndex(dramaId);
if (current < 0) return null; if (current < 0) return null;
_replaceAt( _replaceAt(
@@ -315,33 +259,6 @@ class WatchRecordsController extends ChangeNotifier {
void _replaceAt(int index, WatchRecord record) { void _replaceAt(int index, WatchRecord record) {
final next = [...records]; final next = [...records];
next[index] = record; next[index] = record;
records = next; setItems(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();
} }
} }
+6 -6
View File
@@ -37,7 +37,7 @@ abstract final class AppDimens {
static const double previewSectionTitle = 14; static const double previewSectionTitle = 14;
static const double chipFont = 10; static const double chipFont = 10;
static const double navLabel = 13; static const double navLabel = 13;
static const double navLabelGap = 5; static const double navLabelGap = 1;
static const double buttonHeight = 48; static const double buttonHeight = 48;
static const double primaryButtonHeight = 52; static const double primaryButtonHeight = 52;
@@ -150,8 +150,8 @@ abstract final class AppDimens {
static const double recommendSectionTitle = 17; static const double recommendSectionTitle = 17;
static const double recommendSectionSubtitle = 11; static const double recommendSectionSubtitle = 11;
static const double recommendSectionMore = 12; static const double recommendSectionMore = 12;
// 封面 122*(4/3)≈163 + 间距6 + 两行标题39 + 间距6 + 标签13 ≈ 227。 // 封面 122*(4/3)≈163 + 间距6 + 两行标题≈42 + 间距6 + 标签≈13 ≈ 230,留 6 余量。
static const double recommendRowHeight = 228; static const double recommendRowHeight = 236;
static const double recommendCardWidth = 122; static const double recommendCardWidth = 122;
/// 首页热播榜。 /// 首页热播榜。
@@ -189,9 +189,9 @@ abstract final class AppDimens {
static const double followingAddIconStroke = 1.5; static const double followingAddIconStroke = 1.5;
static const double followingGridGap = 6; static const double followingGridGap = 6;
static const double followingGridRowGap = 12; static const double followingGridRowGap = 12;
// 单行标题 16*1.3=20.8 + 间距 6+4 + 副标题行高约 18.6 ≈ 49.4,留 0.6 余量;
// 按 48 估算在副标题行高偏高的字体下会溢出 1~2px。 /// 追剧网格卡文字区:间距 6 + 两行剧名 16*1.3*2=41.6 ≈ 48,留 4 余量。
static const double followingCardTextExtent = 50; static const double followingCardTextExtent = 52;
static const double followingSubtitleFont = 12; static const double followingSubtitleFont = 12;
static const double followingSubtitleGap = 4; static const double followingSubtitleGap = 4;
static const double followingProgressHeight = 2; static const double followingProgressHeight = 2;
@@ -15,12 +15,11 @@ const String _invalid = '请输入合法地址,例如 http://192.168.1.10:8081
const String _saveFailed = '服务地址保存失败'; const String _saveFailed = '服务地址保存失败';
const String _localNetworkHint = '手机需与电脑同一 Wi-Fi,并在 iOS 设置-隐私与安全性-本地网络 中允许本应用'; const String _localNetworkHint = '手机需与电脑同一 Wi-Fi,并在 iOS 设置-隐私与安全性-本地网络 中允许本应用';
/// Debug 下编辑服务地址,保存后重新探测并加载目录。 /// Debug 下编辑服务地址,保存后立即按新地址重新加载目录。
Future<void> showCatalogEndpointEditor( Future<void> showCatalogEndpointEditor(
BuildContext context, { BuildContext context, {
required Future<void> Function() reload, required Future<void> Function() reload,
Future<bool> Function(String value)? save, Future<bool> Function(String value)? save,
Future<void> Function()? probe,
}) async { }) async {
if (!kDebugMode) return; if (!kDebugMode) return;
final value = await showDialog<String>( final value = await showDialog<String>(
@@ -37,7 +36,6 @@ Future<void> showCatalogEndpointEditor(
} }
return; return;
} }
await (probe ?? () => AppConfig.probeAndApplyBaseUrl())();
await reload(); await reload();
} }
+5 -4
View File
@@ -316,6 +316,7 @@
} }
}, },
"episodes": "Episodes", "episodes": "Episodes",
"similarDramas": "More like this",
"synopsisTitle": "Synopsis", "synopsisTitle": "Synopsis",
"watchEpisodeOne": "Watch episode 1", "watchEpisodeOne": "Watch episode 1",
"continueEpisode": "Continue episode {number}", "continueEpisode": "Continue episode {number}",
@@ -432,7 +433,7 @@
"joinVip": "Join VIP", "joinVip": "Join VIP",
"earnRewards": "Earn Rewards", "earnRewards": "Earn Rewards",
"freeBonus": "Get Free Bonus", "freeBonus": "Get Free Bonus",
"watchHistory": "Watch History", "watchHistory": "History",
"settings": "Settings", "settings": "Settings",
"serverAddress": "Server address", "serverAddress": "Server address",
"serverSettings": "Server settings", "serverSettings": "Server settings",
@@ -492,7 +493,7 @@
"progressError": "Your watch position could not be loaded.", "progressError": "Your watch position could not be loaded.",
"favoritesError": "Your favorites could not be loaded.", "favoritesError": "Your favorites could not be loaded.",
"favoriteToggleFailed": "Could not update favorites. Please retry.", "favoriteToggleFailed": "Could not update favorites. Please retry.",
"myFavorites": "My favorites", "myFavorites": "Favorites",
"noFavorites": "No favorites yet.", "noFavorites": "No favorites yet.",
"downloads": "Downloads", "downloads": "Downloads",
"downloadsError": "Download settings could not be loaded.", "downloadsError": "Download settings could not be loaded.",
@@ -785,7 +786,7 @@
"visitor": "Visitor", "visitor": "Visitor",
"copyUid": "Copy UID", "copyUid": "Copy UID",
"uidCopied": "UID copied", "uidCopied": "UID copied",
"offlineDownloads": "Offline downloads", "offlineDownloads": "Downloads",
"watchPreferences": "Watch preferences", "watchPreferences": "Watch preferences",
"contactSupport": "Contact support", "contactSupport": "Contact support",
"vipBrandName": "FruitShorts VIP", "vipBrandName": "FruitShorts VIP",
@@ -803,7 +804,7 @@
"total": { "type": "int" } "total": { "type": "int" }
} }
}, },
"myFavorites": "My Favorites", "myFavorites": "Favorites",
"manage": "Manage", "manage": "Manage",
"continueWatching": "Continue Watching", "continueWatching": "Continue Watching",
"continueWatchingSubtitle": "Episode {episode} of {total}", "continueWatchingSubtitle": "Episode {episode} of {total}",
+5 -4
View File
@@ -295,6 +295,7 @@
} }
}, },
"episodes": "Episode", "episodes": "Episode",
"similarDramas": "Rekomendasi Serupa",
"synopsisTitle": "Sinopsis", "synopsisTitle": "Sinopsis",
"watchEpisodeOne": "Tonton episode 1", "watchEpisodeOne": "Tonton episode 1",
"continueEpisode": "Lanjut episode {number}", "continueEpisode": "Lanjut episode {number}",
@@ -411,7 +412,7 @@
"joinVip": "Gabung VIP", "joinVip": "Gabung VIP",
"earnRewards": "Dapatkan Hadiah", "earnRewards": "Dapatkan Hadiah",
"freeBonus": "Bonus Gratis", "freeBonus": "Bonus Gratis",
"watchHistory": "Riwayat Tontonan", "watchHistory": "Riwayat",
"settings": "Pengaturan", "settings": "Pengaturan",
"serverAddress": "Alamat server", "serverAddress": "Alamat server",
"serverSettings": "Pengaturan server", "serverSettings": "Pengaturan server",
@@ -471,7 +472,7 @@
"progressError": "Posisi tonton gagal dimuat.", "progressError": "Posisi tonton gagal dimuat.",
"favoritesError": "Daftar favorit gagal dimuat.", "favoritesError": "Daftar favorit gagal dimuat.",
"favoriteToggleFailed": "Gagal memperbarui favorit. Coba lagi.", "favoriteToggleFailed": "Gagal memperbarui favorit. Coba lagi.",
"myFavorites": "Favorit saya", "myFavorites": "Favorit",
"noFavorites": "Belum ada favorit.", "noFavorites": "Belum ada favorit.",
"downloads": "Unduhan", "downloads": "Unduhan",
"downloadsError": "Pengaturan unduhan gagal dimuat.", "downloadsError": "Pengaturan unduhan gagal dimuat.",
@@ -747,7 +748,7 @@
"visitor": "Pengunjung", "visitor": "Pengunjung",
"copyUid": "Salin UID", "copyUid": "Salin UID",
"uidCopied": "UID disalin", "uidCopied": "UID disalin",
"offlineDownloads": "Unduhan offline", "offlineDownloads": "Unduhan",
"watchPreferences": "Preferensi tontonan", "watchPreferences": "Preferensi tontonan",
"contactSupport": "Hubungi dukungan", "contactSupport": "Hubungi dukungan",
"vipBrandName": "FruitShorts VIP", "vipBrandName": "FruitShorts VIP",
@@ -765,7 +766,7 @@
"total": { "type": "int" } "total": { "type": "int" }
} }
}, },
"myFavorites": "Favorit Saya", "myFavorites": "Favorit",
"manage": "Kelola", "manage": "Kelola",
"continueWatching": "Lanjutkan", "continueWatching": "Lanjutkan",
"continueWatchingSubtitle": "Episode {episode} dari {total}", "continueWatchingSubtitle": "Episode {episode} dari {total}",
+9 -3
View File
@@ -1276,6 +1276,12 @@ abstract class AppLocalizations {
/// **'Episodes'** /// **'Episodes'**
String get 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. /// No description provided for @synopsisTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1771,7 +1777,7 @@ abstract class AppLocalizations {
/// No description provided for @watchHistory. /// No description provided for @watchHistory.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Watch History'** /// **'History'**
String get watchHistory; String get watchHistory;
/// No description provided for @settings. /// No description provided for @settings.
@@ -2131,7 +2137,7 @@ abstract class AppLocalizations {
/// No description provided for @myFavorites. /// No description provided for @myFavorites.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'My Favorites'** /// **'Favorites'**
String get myFavorites; String get myFavorites;
/// No description provided for @noFavorites. /// No description provided for @noFavorites.
@@ -3415,7 +3421,7 @@ abstract class AppLocalizations {
/// No description provided for @offlineDownloads. /// No description provided for @offlineDownloads.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Offline downloads'** /// **'Downloads'**
String get offlineDownloads; String get offlineDownloads;
/// No description provided for @watchPreferences. /// No description provided for @watchPreferences.
+6 -3
View File
@@ -644,6 +644,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get episodes => 'Episodes'; String get episodes => 'Episodes';
@override
String get similarDramas => 'More like this';
@override @override
String get synopsisTitle => 'Synopsis'; String get synopsisTitle => 'Synopsis';
@@ -903,7 +906,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get freeBonus => 'Get Free Bonus'; String get freeBonus => 'Get Free Bonus';
@override @override
String get watchHistory => 'Watch History'; String get watchHistory => 'History';
@override @override
String get settings => 'Settings'; String get settings => 'Settings';
@@ -1089,7 +1092,7 @@ class AppLocalizationsEn extends AppLocalizations {
'Could not update favorites. Please retry.'; 'Could not update favorites. Please retry.';
@override @override
String get myFavorites => 'My Favorites'; String get myFavorites => 'Favorites';
@override @override
String get noFavorites => 'No favorites yet.'; String get noFavorites => 'No favorites yet.';
@@ -1783,7 +1786,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get uidCopied => 'UID copied'; String get uidCopied => 'UID copied';
@override @override
String get offlineDownloads => 'Offline downloads'; String get offlineDownloads => 'Downloads';
@override @override
String get watchPreferences => 'Watch preferences'; String get watchPreferences => 'Watch preferences';
+6 -3
View File
@@ -645,6 +645,9 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get episodes => 'Episode'; String get episodes => 'Episode';
@override
String get similarDramas => 'Rekomendasi Serupa';
@override @override
String get synopsisTitle => 'Sinopsis'; String get synopsisTitle => 'Sinopsis';
@@ -906,7 +909,7 @@ class AppLocalizationsId extends AppLocalizations {
String get freeBonus => 'Bonus Gratis'; String get freeBonus => 'Bonus Gratis';
@override @override
String get watchHistory => 'Riwayat Tontonan'; String get watchHistory => 'Riwayat';
@override @override
String get settings => 'Pengaturan'; String get settings => 'Pengaturan';
@@ -1091,7 +1094,7 @@ class AppLocalizationsId extends AppLocalizations {
String get favoriteToggleFailed => 'Gagal memperbarui favorit. Coba lagi.'; String get favoriteToggleFailed => 'Gagal memperbarui favorit. Coba lagi.';
@override @override
String get myFavorites => 'Favorit Saya'; String get myFavorites => 'Favorit';
@override @override
String get noFavorites => 'Belum ada favorit.'; String get noFavorites => 'Belum ada favorit.';
@@ -1785,7 +1788,7 @@ class AppLocalizationsId extends AppLocalizations {
String get uidCopied => 'UID disalin'; String get uidCopied => 'UID disalin';
@override @override
String get offlineDownloads => 'Unduhan offline'; String get offlineDownloads => 'Unduhan';
@override @override
String get watchPreferences => 'Preferensi tontonan'; String get watchPreferences => 'Preferensi tontonan';
+3
View File
@@ -638,6 +638,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get episodes => '选集'; String get episodes => '选集';
@override
String get similarDramas => '相似推荐';
@override @override
String get synopsisTitle => '简介'; String get synopsisTitle => '简介';
+1
View File
@@ -281,6 +281,7 @@
"episodeCount": "{count} 集", "episodeCount": "{count} 集",
"technicalDemoCount": "免费试看 · {count} 集", "technicalDemoCount": "免费试看 · {count} 集",
"episodes": "选集", "episodes": "选集",
"similarDramas": "相似推荐",
"synopsisTitle": "简介", "synopsisTitle": "简介",
"watchEpisodeOne": "观看第 1 集", "watchEpisodeOne": "观看第 1 集",
"continueEpisode": "续看第 {number} 集", "continueEpisode": "续看第 {number} 集",
+5 -1
View File
@@ -13,10 +13,14 @@ import 'core/platform/wireless_data_prompt.dart';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// 生产包不能连开发机:忘了改开关时宁可启动即失败。
if (kReleaseMode && AppConfig.useDevConfig) {
throw StateError('release 构建请把 AppConfig.useDevConfig 改成 false');
}
await ServerEndpoint.load(); await ServerEndpoint.load();
AppConfig.ready = WirelessDataPrompt.awaitDecision( AppConfig.ready = WirelessDataPrompt.awaitDecision(
enabled: !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS, enabled: !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS,
).then((_) => AppConfig.probeAndApplyBaseUrl()); );
runApp(const LumaDramaApp()); runApp(const LumaDramaApp());
unawaited( unawaited(
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]), 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/models/drama.dart';
import '../../core/data/repositories/catalog_repository.dart'; import '../../core/data/repositories/catalog_repository.dart';
import '../../core/data/repositories/search_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/category_controller.dart';
import '../../core/data/services/theater_feed_controller.dart'; import '../../core/data/services/theater_feed_controller.dart';
import '../../core/formatting/error_text.dart'; import '../../core/formatting/error_text.dart';
@@ -154,14 +153,19 @@ class _DiscoverPageState extends State<DiscoverPage> {
edgeOffset: _stickyHeaderHeight + 8, edgeOffset: _stickyHeaderHeight + 8,
onRefresh: () => onRefresh: () =>
Future.wait([categories.load(), theater.refreshCurrent()]), Future.wait([categories.load(), theater.refreshCurrent()]),
child: ListView( child: CustomScrollView(
controller: logic.scroll, controller: logic.scroll,
physics: AppPullToRefresh.physics, physics: AppPullToRefresh.physics,
padding: EdgeInsets.only( slivers: [
top: showHeader ? _stickyHeaderHeight : 0, if (showHeader)
bottom: AppDimens.spaceXl, const SliverToBoxAdapter(
), child: SizedBox(height: _stickyHeaderHeight),
children: _content(l10n, categories), ),
..._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) { if (categories.loading && categories.categories.isEmpty) {
return [_block(const CircularProgressIndicator(strokeWidth: 2))]; return [_sliverBlock(const CircularProgressIndicator(strokeWidth: 2))];
} }
final error = categories.error; final error = categories.error;
if (error != null) { if (error != null) {
return [ return [
_block( _sliverBlock(
EmptyState( EmptyState(
key: const Key('home-categories-error'), key: const Key('home-categories-error'),
icon: Icons.cloud_off_rounded, icon: Icons.cloud_off_rounded,
@@ -254,12 +258,12 @@ class _DiscoverPageState extends State<DiscoverPage> {
]; ];
} }
if (theater.loadingFirstPage) { if (theater.loadingFirstPage) {
return [_block(const CircularProgressIndicator(strokeWidth: 2))]; return [_sliverBlock(const CircularProgressIndicator(strokeWidth: 2))];
} }
final theaterError = theater.lastError; final theaterError = theater.lastError;
if (theaterError != null && theater.dramas.isEmpty) { if (theaterError != null && theater.dramas.isEmpty) {
return [ return [
_block( _sliverBlock(
EmptyState( EmptyState(
key: const Key('home-categories-error'), key: const Key('home-categories-error'),
icon: Icons.cloud_off_rounded, icon: Icons.cloud_off_rounded,
@@ -283,38 +287,49 @@ class _DiscoverPageState extends State<DiscoverPage> {
List<Widget> _theaterBlocks(AppLocalizations l10n) { List<Widget> _theaterBlocks(AppLocalizations l10n) {
final dramas = theater.dramas; final dramas = theater.dramas;
return [ return [
_toolbar(), SliverToBoxAdapter(child: _toolbar()),
if (dramas.isEmpty) if (dramas.isEmpty)
Padding( SliverToBoxAdapter(
padding: const EdgeInsets.symmetric(vertical: 40), child: Padding(
child: EmptyState( padding: const EdgeInsets.symmetric(vertical: 40),
key: const Key('discover-empty'), child: EmptyState(
icon: Icons.movie_filter_outlined, key: const Key('discover-empty'),
title: l10n.noStories, icon: Icons.movie_filter_outlined,
subtitle: l10n.emptyHint, title: l10n.noStories,
subtitle: l10n.emptyHint,
),
), ),
) )
else 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) if (theater.loadingMore)
const Padding( const SliverToBoxAdapter(
padding: EdgeInsets.symmetric(vertical: 20), child: Padding(
child: Center( padding: EdgeInsets.symmetric(vertical: 20),
child: SizedBox.square( child: Center(
key: Key('theater-load-more'), child: SizedBox.square(
dimension: 22, key: Key('theater-load-more'),
child: CircularProgressIndicator(strokeWidth: 2), dimension: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
), ),
), ),
) )
else if (dramas.isNotEmpty && !theater.hasMore) else if (dramas.isNotEmpty && !theater.hasMore)
Padding( SliverToBoxAdapter(
key: const Key('paged-list-no-more'), child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16), key: const Key('paged-list-no-more'),
child: Text( padding: const EdgeInsets.symmetric(vertical: 16),
l10n.noMoreItems, child: Text(
textAlign: TextAlign.center, l10n.noMoreItems,
style: const TextStyle(color: AppColors.textHint, fontSize: 12), 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)); Navigator.of(context).push<void>(MaterialPageRoute(builder: (_) => page));
} }
Widget _sliverBlock(Widget child) => SliverToBoxAdapter(child: _block(child));
Widget _block(Widget child) => ConstrainedBox( Widget _block(Widget child) => ConstrainedBox(
constraints: const BoxConstraints(minHeight: 280), constraints: const BoxConstraints(minHeight: 280),
child: Center(child: child), child: Center(child: child),
); );
Future<void> _retryCategories(CategoryController categories) { Future<void> _retryCategories(CategoryController categories) {
return retryCatalogLoad(reload: categories.load); return categories.load();
} }
Future<void> _retryTheater() async { Future<void> _retryTheater() {
await retryCatalogLoad( return theater.refreshCurrent();
reload: () async {
await theater.refreshCurrent();
},
);
} }
Future<void> _editTheaterEndpoint() { Future<void> _editTheaterEndpoint() {
@@ -10,6 +10,8 @@ import '../../../l10n/app_localizations.dart';
import 'drama_grid.dart'; import 'drama_grid.dart';
/// 剧场搜索结果:加载、失败与空结果都留在同一页内。 /// 剧场搜索结果:加载、失败与空结果都留在同一页内。
///
/// 自身是 sliver,好让结果网格跟着剧场页一起懒加载。
class DiscoverSearchResults extends StatelessWidget { class DiscoverSearchResults extends StatelessWidget {
const DiscoverSearchResults({ const DiscoverSearchResults({
super.key, super.key,
@@ -26,16 +28,24 @@ class DiscoverSearchResults extends StatelessWidget {
final VoidCallback onRetry; final VoidCallback onRetry;
final ValueChanged<Drama> onOpen; final ValueChanged<Drama> onOpen;
static const EdgeInsets _topPadding = EdgeInsets.only(top: 12);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
return Padding( if (!loading && error == null && results.isNotEmpty) {
padding: const EdgeInsets.only(top: 12), return SliverPadding(
child: _content(l10n), 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) { if (loading) {
return const Padding( return const Padding(
padding: EdgeInsets.symmetric(vertical: 80), padding: EdgeInsets.symmetric(vertical: 80),
@@ -61,14 +71,11 @@ class DiscoverSearchResults extends StatelessWidget {
onAction: onRetry, onAction: onRetry,
); );
} }
if (results.isEmpty) { return EmptyState(
return EmptyState( key: const Key('discover-search-empty'),
key: const Key('discover-search-empty'), icon: Icons.search_off_rounded,
icon: Icons.search_off_rounded, title: l10n.noSearchResults,
title: l10n.noSearchResults, subtitle: l10n.emptyHint,
subtitle: l10n.emptyHint, );
);
}
return DramaGrid(l10n: l10n, dramas: results, onOpen: onOpen);
} }
} }
+56 -34
View File
@@ -7,9 +7,9 @@ import '../../../core/data/models/drama.dart';
import '../../../core/formatting/count_format.dart'; import '../../../core/formatting/count_format.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
const int _columns = 2;
const double _columnGap = 8; const double _columnGap = 8;
const double _cardGap = 14; const double _cardGap = 14;
const double _gridHorizontalPadding = 12; const double _gridHorizontalPadding = 12;
@@ -28,8 +28,10 @@ const Color _tagChipBackground = AppColors.raised;
final RegExp _tagSplitter = RegExp(r'[,/、·]'); final RegExp _tagSplitter = RegExp(r'[,/、·]');
/// 剧场/分类页共用的双列剧目网格。 /// 剧场/分类页共用的双列剧目网格。
class DramaGrid extends StatelessWidget { ///
const DramaGrid({ /// 按行懒加载:翻到第 N 页时只构建当前可见的几张卡,长列表不会把整本目录都留在树上。
class SliverDramaGrid extends StatelessWidget {
const SliverDramaGrid({
super.key, super.key,
required this.l10n, required this.l10n,
required this.dramas, required this.dramas,
@@ -42,33 +44,41 @@ class DramaGrid extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( final rowCount = (dramas.length + 1) ~/ 2;
key: const Key('home-all-dramas-section'), return SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: _gridHorizontalPadding), padding: const EdgeInsets.symmetric(horizontal: _gridHorizontalPadding),
child: Row( sliver: SliverList(
key: const Key('home-all-dramas-grid'), delegate: SliverChildBuilderDelegate((context, row) {
crossAxisAlignment: CrossAxisAlignment.start, final left = dramas[row * 2];
children: [ final rightIndex = row * 2 + 1;
Expanded(child: _column(0)), final right = rightIndex < dramas.length ? dramas[rightIndex] : null;
const SizedBox(width: _columnGap), return Padding(
Expanded(child: _column(1)), // 行间距沿用卡片间距,首行不需要。
], 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) { Widget _card(Drama drama) {
final children = <Widget>[]; return DramaGridCard(
for (var index = start; index < dramas.length; index += _columns) { l10n: l10n,
final drama = dramas[index]; drama: drama,
children.add( onTap: () => onOpen(drama),
DramaGridCard(l10n: l10n, drama: drama, onTap: () => onOpen(drama)), );
);
if (index + _columns < dramas.length) {
children.add(const SizedBox(height: _cardGap));
}
}
return Column(children: children);
} }
} }
@@ -218,16 +228,28 @@ class _DiscoverCoverImage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final url = AppConfig.mediaUrl(path); final url = AppConfig.mediaUrl(path);
if (url.isEmpty) return _placeholder(); if (url.isEmpty) return _placeholder();
return Image.network( return LayoutBuilder(
url, builder: (context, constraints) {
width: double.infinity, // 卡片只有半屏宽,按原图解码一张竖版海报就是数 MB,按布局宽度限一下。
height: double.infinity, final decodeWidth = constraints.hasBoundedWidth
fit: BoxFit.cover, ? resolveImageDecodePixels(
loadingBuilder: (context, child, progress) { constraints.maxWidth,
if (progress == null) return child; MediaQuery.devicePixelRatioOf(context),
return _placeholder(); )
: 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( Expanded(
child: Text( child: Text(
license.dramaTitle, license.dramaTitle,
maxLines: 1, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: AppColors.onSurface, color: AppColors.onSurface,
+3 -1
View File
@@ -23,7 +23,9 @@ const double _optionHorizontal = 5;
const double _optionVertical = 5; const double _optionVertical = 5;
const double _optionRunGap = 4; const double _optionRunGap = 4;
const double _cardGap = 14; const double _cardGap = 14;
const double _gridAspectRatio = 0.56;
/// 卡片比例:三列网格里要装下封面 + 两行剧名 + 元信息。
const double _gridAspectRatio = 0.53;
const double _groupFontSize = 13; const double _groupFontSize = 13;
const double _labelFontSize = 12; const double _labelFontSize = 12;
@@ -88,7 +88,7 @@ class FilterResultCard extends StatelessWidget {
Text( Text(
drama.title, drama.title,
key: Key('filter-title-${drama.id}'), key: Key('filter-title-${drama.id}'),
maxLines: 1, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: AppColors.onSurface, color: AppColors.onSurface,
@@ -167,18 +167,10 @@ class FollowingFavoritesGrid extends StatelessWidget {
bool isEditing, bool isEditing,
Set<String> picked, Set<String> picked,
) { ) {
final l10n = AppLocalizations.of(context)!;
if (index == controller.cards.length) { if (index == controller.cards.length) {
return FollowingAddCard(onTap: onGoHome); return FollowingAddCard(onTap: onGoHome);
} }
final drama = controller.cards[index]; 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( return FollowingGridCard(
dramaId: drama.id, dramaId: drama.id,
title: drama.localizedTitle(Localizations.localeOf(context).languageCode), title: drama.localizedTitle(Localizations.localeOf(context).languageCode),
@@ -186,10 +178,7 @@ class FollowingFavoritesGrid extends StatelessWidget {
viewCount: drama.viewCount, viewCount: drama.viewCount,
editing: isEditing, editing: isEditing,
selected: isEditing ? picked.contains(drama.id) : null, selected: isEditing ? picked.contains(drama.id) : null,
subtitle: subtitle, // TODO(backend): Drama 增加 hasUpdate 字段后,在此传 updateBadge。
// TODO(backend): Drama 增加 hasUpdate / latestEpisodeNumber / isFinished
// 字段后,在此传 updateBadge(l10n.hasUpdateBadge)、红色更新副标题
// (l10n.updatedToEpisode)或灰色完结副标题(l10n.finishedAllEpisodes)。
onTap: () => _openOrToggle(context, drama.id, isEditing), onTap: () => _openOrToggle(context, drama.id, isEditing),
); );
} }
+1 -18
View File
@@ -19,21 +19,18 @@ class FollowingGridCard extends StatelessWidget {
required this.title, required this.title,
required this.posterUrl, required this.posterUrl,
required this.viewCount, required this.viewCount,
required this.subtitle,
required this.onTap, required this.onTap,
this.progress, this.progress,
this.editing = false, this.editing = false,
this.selected, this.selected,
this.onRemove, this.onRemove,
this.updateBadge, this.updateBadge,
this.subtitleColor,
}); });
final String dramaId; final String dramaId;
final String title; final String title;
final String posterUrl; final String posterUrl;
final int viewCount; final int viewCount;
final String subtitle;
final VoidCallback onTap; final VoidCallback onTap;
final double? progress; final double? progress;
final bool editing; final bool editing;
@@ -44,9 +41,6 @@ class FollowingGridCard extends StatelessWidget {
/// 待后端在 Drama 增加更新状态字段后由收藏网格传入。 /// 待后端在 Drama 增加更新状态字段后由收藏网格传入。
final String? updateBadge; final String? updateBadge;
/// 副标题颜色;更新中用强调红,完结用默认灰。
final Color? subtitleColor;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return InkWell( return InkWell(
@@ -165,7 +159,7 @@ class FollowingGridCard extends StatelessWidget {
const SizedBox(height: AppDimens.cardCoverTitleGap), const SizedBox(height: AppDimens.cardCoverTitleGap),
Text( Text(
title, title,
maxLines: 1, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left, textAlign: TextAlign.left,
style: const TextStyle( style: const TextStyle(
@@ -175,17 +169,6 @@ class FollowingGridCard extends StatelessWidget {
height: AppDimens.cardTitleLineHeight, 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>(), history: context.read<WatchHistoryRepository>(),
catalog: context.read<CatalogRepository>(), catalog: context.read<CatalogRepository>(),
); );
unawaited(_history.load()); // IndexedStack 启动时就会建好本页:不可见时先不拉数据,等切到本 tab 再加载,
WidgetsBinding.instance.addPostFrameCallback((_) { // 免得冷启动和首页 feed 抢网络。
if (mounted) unawaited(widget.favoriteController.load()); if (widget.visible) {
}); unawaited(_history.load());
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(widget.favoriteController.load());
});
}
} }
void _onAuthChanged() { void _onAuthChanged() {
@@ -107,6 +111,13 @@ class _FollowingPageState extends State<FollowingPage> {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (!oldWidget.visible && widget.visible) { if (!oldWidget.visible && widget.visible) {
unawaited(_history.load()); unawaited(_history.load());
// 启动时本页不可见,收藏没在这里拉过;首次可见时补齐(已有数据就不重复请求)。
// 放在帧后,避免构建期间触发 controller 通知。
if (widget.favoriteController.cards.isEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(widget.favoriteController.load());
});
}
} }
if (oldWidget.historyRequest == widget.historyRequest) return; if (oldWidget.historyRequest == widget.historyRequest) return;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -33,13 +33,12 @@ class ContinueWatchingBanner extends StatelessWidget {
return SizedBox( return SizedBox(
height: AppDimens.followingBannerHeight, height: AppDimens.followingBannerHeight,
child: ClipRRect( child: ClipRRect(
borderRadius: borderRadius: BorderRadius.circular(AppDimens.followingBannerRadius),
BorderRadius.circular(AppDimens.followingBannerRadius),
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
RemoteImage( RemoteImage(
record.displayCover, record.displayLandscapeCover,
fit: BoxFit.cover, fit: BoxFit.cover,
showFallbackIcon: true, showFallbackIcon: true,
fallbackColor: AppColors.contentSurface, fallbackColor: AppColors.contentSurface,
@@ -82,8 +81,7 @@ class ContinueWatchingBanner extends StatelessWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: AppColors.bannerTitleWarm, color: AppColors.bannerTitleWarm,
fontSize: fontSize: AppDimens.followingBannerTitleFont,
AppDimens.followingBannerTitleFont,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
), ),
), ),
@@ -143,8 +141,7 @@ class _ContinueButton extends StatelessWidget {
height: AppDimens.followingBannerButtonHeight, height: AppDimens.followingBannerButtonHeight,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: horizontal: AppDimens.followingBannerButtonHorizontalPadding,
AppDimens.followingBannerButtonHorizontalPadding,
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -188,9 +185,7 @@ class _ProgressBar extends StatelessWidget {
height: AppDimens.followingBannerProgressHeight, height: AppDimens.followingBannerProgressHeight,
width: double.infinity, width: double.infinity,
child: DecoratedBox( child: DecoratedBox(
decoration: const BoxDecoration( decoration: const BoxDecoration(color: Color(0x66FFFFFF)),
color: Color(0x66FFFFFF),
),
child: Align( child: Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: FractionallySizedBox( child: FractionallySizedBox(
+47 -18
View File
@@ -6,7 +6,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../core/config/app_config.dart';
import '../../core/data/remote/catalog_api.dart'; import '../../core/data/remote/catalog_api.dart';
import '../../core/data/services/home_feed_controller.dart'; import '../../core/data/services/home_feed_controller.dart';
import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/catalog_controller.dart';
@@ -67,7 +66,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
) async { ) async {
_resumeRetryInFlight = true; _resumeRetryInFlight = true;
try { try {
await AppConfig.probeAndApplyBaseUrl(); // 地址是构建期常量,回前台只需要重试失败的数据请求。
await Future.wait([ await Future.wait([
if (catalog.error != null) catalog.load(), if (catalog.error != null) catalog.load(),
if (categories.error != null) categories.load(), if (categories.error != null) categories.load(),
@@ -80,10 +79,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final catalog = context.watch<CatalogController>(); final catalog = context.watch<CatalogController>();
return Theme( return Theme(data: AppTheme.dark(), child: _buildHome(context, catalog));
data: AppTheme.dark(),
child: _buildHome(context, catalog),
);
} }
Widget _buildHome(BuildContext context, CatalogController catalog) { Widget _buildHome(BuildContext context, CatalogController catalog) {
@@ -165,24 +161,18 @@ class _HomeTabsState extends State<_HomeTabs> {
} }
Widget _body(BuildContext context) { Widget _body(BuildContext context) {
final pages = <Widget>[ final builders = <WidgetBuilder>[
_recommend(context), _recommend,
_recommendDiscover(context), _recommendDiscover,
FollowingPage( (context) => FollowingPage(
onGoHome: () => logic.select(0), onGoHome: () => logic.select(0),
favoriteController: context.read<FavoriteController>(), favoriteController: context.read<FavoriteController>(),
historyRequest: logic.followingHistoryRequest, historyRequest: logic.followingHistoryRequest,
visible: logic.tab == 2, visible: logic.tab == 2,
), ),
_my(context), _my,
]; ];
return IndexedStack( return _LazyTabStack(index: logic.tab, builders: builders);
index: logic.tab,
children: [
for (var index = 0; index < pages.length; index++)
TickerMode(enabled: logic.tab == index, child: pages[index]),
],
);
} }
Widget _recommend(BuildContext context) { Widget _recommend(BuildContext context) {
@@ -252,3 +242,42 @@ class _HomeTabsState extends State<_HomeTabs> {
logic.select(tab); 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 int _filmHoles = 8;
const double _cardPadding = 8; const double _cardPadding = 8;
const double _cardWidth = 124; const double _cardWidth = 124;
const double _rowHeight = 204;
/// 横向卡片行高:封面 144 + 两行剧名 + 上映日期。
const double _rowHeight = 226;
const double _cardRadius = 12; const double _cardRadius = 12;
const double _loadMoreThreshold = 200; const double _loadMoreThreshold = 200;
@@ -135,64 +137,79 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
child: AppPullToRefresh( child: AppPullToRefresh(
key: const Key('new-releases-refresh'), key: const Key('new-releases-refresh'),
onRefresh: _refresh, onRefresh: _refresh,
child: ListView( child: CustomScrollView(
key: const Key('new-releases-list'), key: const Key('new-releases-list'),
controller: _scroll, controller: _scroll,
physics: AppPullToRefresh.physics, physics: AppPullToRefresh.physics,
padding: const EdgeInsets.only(bottom: 24), slivers: hero == null
children: hero == null
? [ ? [
SizedBox( SliverToBoxAdapter(
height: 420, child: SizedBox(
child: Center( height: 420,
child: Text( child: Center(
l10n.noStories, child: Text(
key: const Key('new-releases-empty'), l10n.noStories,
style: const TextStyle( key: const Key('new-releases-empty'),
color: AppColors.muted, style: const TextStyle(
color: AppColors.muted,
),
), ),
), ),
), ),
), ),
] ]
: [ : [
Padding( SliverToBoxAdapter(
padding: const EdgeInsets.symmetric( child: Padding(
horizontal: 16, padding: const EdgeInsets.symmetric(
horizontal: 16,
),
child: _heroCard(context, l10n, hero),
), ),
child: _heroCard(context, l10n, hero),
), ),
const SizedBox(height: 14), const SliverToBoxAdapter(
const _FilmStrip(), child: SizedBox(height: 14),
const SizedBox(height: 14), ),
const SliverToBoxAdapter(child: _FilmStrip()),
const SliverToBoxAdapter(
child: SizedBox(height: 14),
),
if (more.isNotEmpty) ...[ if (more.isNotEmpty) ...[
_moreHeader(l10n), SliverToBoxAdapter(child: _moreHeader(l10n)),
const SizedBox(height: 10), const SliverToBoxAdapter(
SizedBox( child: SizedBox(height: 10),
height: _rowHeight, ),
child: ListView.separated( SliverToBoxAdapter(
key: const Key('new-releases-row'), child: SizedBox(
scrollDirection: Axis.horizontal, height: _rowHeight,
padding: const EdgeInsets.symmetric( child: ListView.separated(
horizontal: 16, 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), const SliverToBoxAdapter(
_allHeader(l10n), child: SizedBox(height: 20),
const SizedBox(height: 10), ),
DramaGrid( SliverToBoxAdapter(child: _allHeader(l10n)),
const SliverToBoxAdapter(
child: SizedBox(height: 10),
),
SliverDramaGrid(
key: const Key('new-releases-all-grid'), key: const Key('new-releases-all-grid'),
l10n: l10n, l10n: l10n,
dramas: visible, dramas: visible,
@@ -200,12 +217,17 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
playDramaFromCatalog(context, drama), playDramaFromCatalog(context, drama),
), ),
), ),
PagedLoadFooter( SliverToBoxAdapter(
loading: pager.loading, child: Padding(
hasMore: pager.hasMore, padding: const EdgeInsets.only(bottom: 24),
hasItems: visible.isNotEmpty, child: PagedLoadFooter(
loadMoreKey: const Key( loading: pager.loading,
'new-releases-load-more', 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), const SizedBox(height: 4),
Text( Text(
drama.title, drama.title,
maxLines: 1, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: AppColors.onSurface, 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/android_picture_in_picture.dart';
import '../../core/data/services/download_controller.dart'; import '../../core/data/services/download_controller.dart';
import '../../core/data/services/playback_position_cache.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 '../../core/data/services/wallet_controller.dart';
import 'player_logic.dart'; import 'player_logic.dart';
@@ -13,6 +14,7 @@ class PlayerBinding {
required WatchHistoryRepository history, required WatchHistoryRepository history,
required PlaybackRepository playback, required PlaybackRepository playback,
required PlaybackPositionCache positionCache, required PlaybackPositionCache positionCache,
UnlockedEpisodeRegistry? unlockedEpisodes,
WalletController? wallet, WalletController? wallet,
DownloadController? downloads, DownloadController? downloads,
bool Function()? isSignedIn, bool Function()? isSignedIn,
@@ -20,6 +22,7 @@ class PlayerBinding {
history: history, history: history,
playback: playback, playback: playback,
positionCache: positionCache, positionCache: positionCache,
unlockedEpisodes: unlockedEpisodes,
pictureInPicture: AndroidPictureInPicture(), pictureInPicture: AndroidPictureInPicture(),
wallet: wallet, wallet: wallet,
downloads: downloads, 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_position_cache.dart';
import '../../core/data/services/playback_queue.dart'; import '../../core/data/services/playback_queue.dart';
import '../../core/data/services/playback_session.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/video_player_port.dart';
import '../../core/data/services/wallet_controller.dart'; import '../../core/data/services/wallet_controller.dart';
import '../../core/formatting/localized_content.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 '../../core/navigation/fade_page_route.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../discover/search_page.dart'; import '../discover/search_page.dart';
import '../drama_detail/drama_detail_view.dart';
import '../mine/mine_types.dart'; import '../mine/mine_types.dart';
import '../mine/mine_view.dart'; import '../mine/mine_view.dart';
import 'player_view.dart'; import 'player_view.dart';
import 'widgets/drama_comments_sheet.dart'; import 'widgets/drama_comments_sheet.dart';
import 'widgets/episode_picker_sheet.dart'; import 'widgets/drama_info_sheet.dart';
typedef _PlaybackSnapshot = ({ typedef _PlaybackSnapshot = ({
Duration position, Duration position,
@@ -64,11 +64,13 @@ class PlayerLogic extends ChangeNotifier {
required this.pictureInPicture, required this.pictureInPicture,
this.wallet, this.wallet,
this.downloads, this.downloads,
UnlockedEpisodeRegistry? unlockedEpisodes,
PlaybackPositionCache? positionCache, PlaybackPositionCache? positionCache,
Future<void> Function()? waitForFrame, Future<void> Function()? waitForFrame,
DateTime Function()? clock, DateTime Function()? clock,
bool Function()? isSignedIn, bool Function()? isSignedIn,
}) : positionCache = positionCache ?? PlaybackPositionCache(), }) : unlockedEpisodes = unlockedEpisodes ?? UnlockedEpisodeRegistry(),
positionCache = positionCache ?? PlaybackPositionCache(),
_waitForFrame = _waitForFrame =
waitForFrame ?? (() => WidgetsBinding.instance.endOfFrame), waitForFrame ?? (() => WidgetsBinding.instance.endOfFrame),
_clock = clock ?? DateTime.now, _clock = clock ?? DateTime.now,
@@ -79,6 +81,9 @@ class PlayerLogic extends ChangeNotifier {
final AndroidPictureInPicture pictureInPicture; final AndroidPictureInPicture pictureInPicture;
final WalletController? wallet; final WalletController? wallet;
final DownloadController? downloads; final DownloadController? downloads;
/// 已确认可播的收费集(登录期内共享):退出播放页再进来不必重新挡一次。
final UnlockedEpisodeRegistry unlockedEpisodes;
final PlaybackPositionCache positionCache; final PlaybackPositionCache positionCache;
final Future<void> Function() _waitForFrame; final Future<void> Function() _waitForFrame;
final DateTime Function() _clock; final DateTime Function() _clock;
@@ -104,6 +109,9 @@ class PlayerLogic extends ChangeNotifier {
ResumePoint? resumePoint; ResumePoint? resumePoint;
bool resumeLoaded = false; bool resumeLoaded = false;
/// 续看点加载中的请求:进页与首次准备播放会同时触发,复用同一次请求。
Future<void>? _resumePointLoad;
/// 票据三态:加载中 / 失败(可重试)/ 被后端判定为不可播放 /// 票据三态:加载中 / 失败(可重试)/ 被后端判定为不可播放
bool ticketLoading = false; bool ticketLoading = false;
String? ticketError; String? ticketError;
@@ -169,14 +177,15 @@ class PlayerLogic extends ChangeNotifier {
/// 当前设备是否为 iOS 真机环境(面板据此显示自动小窗开关)。 /// 当前设备是否为 iOS 真机环境(面板据此显示自动小窗开关)。
bool get iosDevice => !kIsWeb && Platform.isIOS; bool get iosDevice => !kIsWeb && Platform.isIOS;
/// PiP 状态是全局单通道回调,须广播给所有存活的 PlayerLogic: /// PiP 状态是全局单通道回调,须广播给所有存活的 PlayerLogic:
/// 推荐页每页一个 logic,若只保留最后一次注册的 handler, /// 推荐页每页一个 logic,若只保留最后一次注册的 handler,
/// 相邻预加载页会盖掉正在播放页的注册,导致小窗 UI 不隐藏且无声音。 /// 相邻预加载页会盖掉正在播放页的注册,导致小窗 UI 不隐藏且无声音。
static final Set<PlayerLogic> _pipSubscribers = <PlayerLogic>{}; static final Set<PlayerLogic> _pipSubscribers = <PlayerLogic>{};
/// 「记住上次选集」:dramaId → 集号。只在用户从选集面板主动挑集时写入 /// 「记住上次选集」:dramaId → 集号。只在用户从选集面板主动挑集时写入
/// (见 [_recordPickedEpisode]),feed 里同一部剧据此显示上次选中的集。 /// (见 [_recordPickedEpisode]),feed 里同一部剧据此显示上次选中的集。
static final Map<String, int> pickedEpisode = {}; static final Map<String, int> pickedEpisode = {};
final Set<String> _unlockedEpisodeIds = {};
final Map<String, EpisodeStats> _episodeStats = {}; final Map<String, EpisodeStats> _episodeStats = {};
final Map<String, DateTime> _episodeStatsFetchedAt = {}; final Map<String, DateTime> _episodeStatsFetchedAt = {};
final Map<String, Future<void>> _episodeStatsInFlight = {}; 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 entry = queue.entryAt(initialIndex);
final cached = positionCache.read( final cached = positionCache.read(
entry.episode.id, entry.episode.id,
@@ -562,7 +579,7 @@ class PlayerLogic extends ChangeNotifier {
final localPath = await downloads?.findLocalPath(episode.id); final localPath = await downloads?.findLocalPath(episode.id);
if (request != pageRequest) return false; if (request != pageRequest) return false;
if (localPath != null) { if (localPath != null) {
_unlockedEpisodeIds.add(episode.id); unlockedEpisodes.markUnlocked(episode.id);
blockedTicket = null; blockedTicket = null;
blockedEpisodeId = null; blockedEpisodeId = null;
qualities = const []; qualities = const [];
@@ -587,7 +604,7 @@ class PlayerLogic extends ChangeNotifier {
final playUrl = preferred ?? ticket.playUrl; final playUrl = preferred ?? ticket.playUrl;
if (preferred == null) selectedQuality = 'AUTO'; if (preferred == null) selectedQuality = 'AUTO';
if (ticket.accessible && playUrl.isNotEmpty) { if (ticket.accessible && playUrl.isNotEmpty) {
_unlockedEpisodeIds.add(episode.id); unlockedEpisodes.markUnlocked(episode.id);
// 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host) // 签名地址原样写回,不做任何拼接/改写(AppConfig.mediaUrl 只补 host)
sessionEpisodes[index] = episode.withVideoUrl( sessionEpisodes[index] = episode.withVideoUrl(
playUrl, playUrl,
@@ -860,7 +877,7 @@ class PlayerLogic extends ChangeNotifier {
unlockError = service.unlockError; unlockError = service.unlockError;
return false; return false;
} }
_unlockedEpisodeIds.add(episode.id); unlockedEpisodes.markUnlocked(episode.id);
final ready = await ensureTicket(index, forceRefresh: true); final ready = await ensureTicket(index, forceRefresh: true);
if (ready) { if (ready) {
await _selectVisible(index); await _selectVisible(index);
@@ -1274,9 +1291,9 @@ class PlayerLogic extends ChangeNotifier {
await _waitUntilFrameReady(player.controller); await _waitUntilFrameReady(player.controller);
return; return;
} }
await pendingWrite; // 上一条进度写盘与续看点拉取互不依赖,并行等,少一个网络往返的首帧延迟。
// 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播 // 服务端每剧只记一条:只有"记录里的那一集"才定位,其它集从头播
if (resumePoint == null) await loadResumePoint(); await Future.wait([pendingWrite, if (!resumeLoaded) loadResumePoint()]);
if (resumePositionFor(episode.id) == null) { if (resumePositionFor(episode.id) == null) {
final dramaId = _dramaIdForEpisode(episode.id); final dramaId = _dramaIdForEpisode(episode.id);
final cached = dramaId == null final cached = dramaId == null
@@ -1688,7 +1705,16 @@ class PlayerLogic extends ChangeNotifier {
/// 收费集只有在本会话确认可播或解锁成功后才解除门禁。 /// 收费集只有在本会话确认可播或解锁成功后才解除门禁。
bool isEpisodeLocked(Episode episode) { bool isEpisodeLocked(Episode episode) {
return (episode.locked || !episode.isFree) && 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(); if (context.mounted) Navigator.of(context).pop();
} }
Future<void> openDetail(BuildContext context) async { /// 剧名与选集共用同一个弹窗:点剧名默认停在详情页签。
session.setOverlayOpen(true); Future<void> openDetail(BuildContext context) =>
await saveCurrentProgress(); _showDramaSheet(context, DramaInfoTab.detail);
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();
}
String? _dramaIdForEpisode(String episodeId) { String? _dramaIdForEpisode(String episodeId) {
for (final entry in queue.entries) { for (final entry in queue.entries) {
@@ -1857,13 +1870,21 @@ class PlayerLogic extends ChangeNotifier {
await resumeAfterFullPlayer(); 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 entry = queue.entryAt(session.currentIndex);
final drama = await _withFullEpisodes(context, entry.drama); final drama = await _withFullEpisodes(context, entry.drama);
if (!context.mounted) return; if (!context.mounted) return;
final homeFeed = feedDramas != null; final homeFeed = feedDramas != null;
final currentNumber = entry.episode.number; final currentNumber = entry.episode.number;
session.setOverlayOpen(true); session.setOverlayOpen(true);
await saveCurrentProgress();
await session.playbackSettled; await session.playbackSettled;
if (!context.mounted) { if (!context.mounted) {
session.setOverlayOpen(false); session.setOverlayOpen(false);
@@ -1874,16 +1895,20 @@ class PlayerLogic extends ChangeNotifier {
context: context, context: context,
backgroundColor: AppColors.transparent, backgroundColor: AppColors.transparent,
isScrollControlled: true, isScrollControlled: true,
builder: (_) => EpisodePickerSheet( builder: (_) => DramaInfoSheet(
drama: drama, drama: drama,
currentNumber: currentNumber, currentNumber: currentNumber,
onSelect: (index) => initialTab: initialTab,
onSelectEpisode: (index) =>
_selectFromSheet(context, drama, index, homeFeed, currentNumber), _selectFromSheet(context, drama, index, homeFeed, currentNumber),
onOpenRelated: (related) =>
unawaited(openSeriesAt(context, related, 0)),
), ),
); );
} finally { } finally {
session.setOverlayOpen(false); 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/playback_session.dart';
import '../../core/data/services/sprite_thumbnail_axis.dart'; import '../../core/data/services/sprite_thumbnail_axis.dart';
import '../../core/data/services/playback_thumbnail_service.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/video_player_port.dart';
import '../../core/data/services/wallet_controller.dart'; import '../../core/data/services/wallet_controller.dart';
import '../../core/formatting/error_text.dart'; import '../../core/formatting/error_text.dart';
@@ -60,6 +61,17 @@ const double _playerLoadingSize = 24;
const double _playerLoadingStrokeWidth = 2; const double _playerLoadingStrokeWidth = 2;
const double _feedRefreshTopGap = 56; 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 { class PlayerPageHandoff {
PlayerPageHandoff({required this.sourceUserWantsPlay}); PlayerPageHandoff({required this.sourceUserWantsPlay});
@@ -167,6 +179,7 @@ class _PlayerPageState extends State<PlayerPage>
history: context.read<WatchHistoryRepository>(), history: context.read<WatchHistoryRepository>(),
playback: context.read<PlaybackRepository>(), playback: context.read<PlaybackRepository>(),
positionCache: context.read<PlaybackPositionCache>(), positionCache: context.read<PlaybackPositionCache>(),
unlockedEpisodes: _readUnlockedEpisodes(context),
wallet: widget.wallet, wallet: widget.wallet,
downloads: widget.downloads, downloads: widget.downloads,
isSignedIn: () => context.read<AuthController>().isSignedIn, isSignedIn: () => context.read<AuthController>().isSignedIn,
@@ -176,6 +189,9 @@ class _PlayerPageState extends State<PlayerPage>
_thumbnailService = PlaybackThumbnailService( _thumbnailService = PlaybackThumbnailService(
sourceResolver: _resolveThumbnailSource, sourceResolver: _resolveThumbnailSource,
); );
// 容量必须盖住「同时预取的数量」:渲染栈有上一集/当前集/下一集三个槽位,
// 每槽预取当前 cue + 1 个相邻 cue,约 6 张。容量小于这个数时,每次重建都会
// 把别处刚解码的雪碧图挤掉,形成「重建→重新下载解码→notify→重建」的死循环。
_spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 8) _spriteAxisService = SpriteThumbnailAxisService(spriteCacheCapacity: 8)
..addListener(_handleSpriteFrameChanged); ..addListener(_handleSpriteFrameChanged);
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
@@ -349,6 +365,16 @@ class _PlayerPageState extends State<PlayerPage>
setState(() => _playbackSpeed = value); setState(() => _playbackSpeed = value);
} }
/// 全局解锁登记表(登录期内共享);直接构造播放页的测试没有 Provider,
/// 返回 null 让 PlayerLogic 退化成页面自用的一份。
UnlockedEpisodeRegistry? _readUnlockedEpisodes(BuildContext context) {
try {
return context.read<UnlockedEpisodeRegistry>();
} on ProviderNotFoundException {
return null;
}
}
void _hideChrome() { void _hideChrome() {
if (!_chromeVisible) return; if (!_chromeVisible) return;
setState(() => _chromeVisible = false); setState(() => _chromeVisible = false);
@@ -495,9 +521,10 @@ class _PlayerPageState extends State<PlayerPage>
final imageUrl = AppConfig.mediaUrl(posterUrl); final imageUrl = AppConfig.mediaUrl(posterUrl);
if (_precachedPosterUrls.add(imageUrl)) { if (_precachedPosterUrls.add(imageUrl)) {
unawaited( unawaited(
precacheImage(NetworkImage(imageUrl), context).catchError(( precacheImage(
Object _, _posterProvider(imageUrl, width: _posterPreviewWidth),
) { context,
).catchError((Object _) {
_precachedPosterUrls.remove(imageUrl); _precachedPosterUrls.remove(imageUrl);
}), }),
); );
@@ -716,7 +743,15 @@ class _PlayerPageState extends State<PlayerPage>
if (logic.pip) { if (logic.pip) {
final poster = logic.trackedEpisode?.posterUrl?.trim(); final poster = logic.trackedEpisode?.posterUrl?.trim();
if (poster != null && poster.isNotEmpty) { if (poster != null && poster.isNotEmpty) {
unawaited(precacheImage(NetworkImage(poster), context)); unawaited(
precacheImage(
_posterProvider(
AppConfig.mediaUrl(poster),
width: _posterPipWidth,
),
context,
),
);
} }
} }
return Stack( return Stack(
@@ -956,10 +991,7 @@ class _PlayerPageState extends State<PlayerPage>
} }
final episode = _resolvedEpisode(index); final episode = _resolvedEpisode(index);
final selected = index == currentIndex; final selected = index == currentIndex;
final locked = final locked = logic.isEpisodeBlockedFor(episode, index);
logic.isEpisodeLocked(episode) ||
(selected && logic.session.isLocked) ||
logic.blockedEpisodeId == episode.id;
final port = locked final port = locked
? null ? null
: (selected ? logic.session.active : null) ?? : (selected ? logic.session.active : null) ??
@@ -982,19 +1014,19 @@ class _PlayerPageState extends State<PlayerPage>
final thumbnailReady = final thumbnailReady =
thumbnailsUrl != null && thumbnailsUrl != null &&
_spriteAxisService.cachedFrameAt(thumbnailsUrl, Duration.zero) != 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)); unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, Duration.zero));
} }
if (selected && if (selected &&
!locked && !locked &&
videoPort != null && videoPort != null &&
videoPort.controller.value.isInitialized) { videoPort.controller.value.isInitialized) {
unawaited( final position = videoPort.controller.value.position;
_spriteAxisService.prepareAt( if (_spriteAxisService.cachedFrameAt(thumbnailsUrl, position) == null) {
thumbnailsUrl, unawaited(_spriteAxisService.prepareAt(thumbnailsUrl, position));
videoPort.controller.value.position, }
),
);
} }
final pagePosition = indices.indexOf(index); final pagePosition = indices.indexOf(index);
return PlayerRenderSlotData( return PlayerRenderSlotData(
@@ -1042,10 +1074,7 @@ class _PlayerPageState extends State<PlayerPage>
final drama = entry.drama; final drama = entry.drama;
final episode = _resolvedEpisode(index); final episode = _resolvedEpisode(index);
final selected = index == logic.session.currentIndex; final selected = index == logic.session.currentIndex;
final locked = final locked = logic.isEpisodeBlockedFor(episode, index);
logic.isEpisodeLocked(episode) ||
logic.session.isLocked ||
logic.blockedEpisodeId == episode.id;
// Selection changes before the prepared slot is promoted. Use its controller // Selection changes before the prepared slot is promoted. Use its controller
// throughout the handoff instead of briefly falling back to a placeholder. // throughout the handoff instead of briefly falling back to a placeholder.
final port = locked final port = locked
@@ -1258,7 +1287,8 @@ class _PlayerPageState extends State<PlayerPage>
if (widget.homeMode && !_chromeVisible && !_boosting && !_scrubbing) if (widget.homeMode && !_chromeVisible && !_boosting && !_scrubbing)
Positioned( Positioned(
right: 12, right: 12,
bottom: 56, // 进度条触控带 16pt + 8pt 间距,退出清屏按钮贴着进度条上方。
bottom: 24,
child: ExitClearScreenButton( child: ExitClearScreenButton(
key: const Key('home-feed-exit-clear-screen'), key: const Key('home-feed-exit-clear-screen'),
tooltip: l10n.exitClearScreen, tooltip: l10n.exitClearScreen,
@@ -1294,10 +1324,7 @@ class _PlayerPageState extends State<PlayerPage>
final drama = entry.drama; final drama = entry.drama;
final episode = _resolvedEpisode(index); final episode = _resolvedEpisode(index);
final selected = index == logic.session.currentIndex; final selected = index == logic.session.currentIndex;
final locked = final locked = logic.isEpisodeBlockedFor(episode, index);
logic.isEpisodeLocked(episode) ||
logic.session.isLocked ||
logic.blockedEpisodeId == episode.id;
final port = locked final port = locked
? null ? null
: (selected ? logic.session.active : 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'; 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. /// Shared floating action used to leave clear-screen mode.
class ExitClearScreenButton extends StatelessWidget { class ExitClearScreenButton extends StatelessWidget {
const ExitClearScreenButton({ const ExitClearScreenButton({
@@ -25,11 +29,15 @@ class ExitClearScreenButton extends StatelessWidget {
onPressed: onPressed, onPressed: onPressed,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
style: IconButton.styleFrom( style: IconButton.styleFrom(
backgroundColor: AppColors.controlScrim, backgroundColor: AppColors.transparent,
foregroundColor: AppColors.onPrimary, foregroundColor: AppColors.onPrimary,
shape: const CircleBorder(), 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'; part 'home_feed_overlay_parts.dart';
const double _progressHeight = 2; const double _progressHeight = 2;
const double _progressHitHeight = 32;
/// 进度条触控带高度:没有滑块圆点后收到 16pt,够手指按住这条线拖动。
const double _progressHitHeight = 16;
const double _pageHorizontal = 20; const double _pageHorizontal = 20;
const double _railRight = 14; const double _railRight = 14;
const double _railGap = 12; const double _railGap = 12;
// 观看条高 44;2pt 进度线视觉上沿距观看条顶约 7pt(线中心距进度条盒底 8)。 // 进度线贴着屏幕底部(线心距底 4pt),观看条底边只留 8pt,视觉上就是线上一根条。
// 信息面板底边取 44+16=60,使简介↔进度线、进度线↔观看条两侧间距均为 7pt。 const double _watchAllBottomInset = 8;
const double _bottomInfoInset = homeFeedWatchAllHeight + 16; // 信息面板底边直接贴住观看条顶沿,中间不留缝。
const double _bottomInfoInset = _watchAllBottomInset + homeFeedWatchAllHeight;
const double _railBottomOffset = 40; const double _railBottomOffset = 40;
const Duration _overlayFadeDuration = Duration(milliseconds: 120); const Duration _overlayFadeDuration = Duration(milliseconds: 120);
const List<Shadow> _boostTextShadows = [ const List<Shadow> _boostTextShadows = [
@@ -152,7 +155,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
Positioned( Positioned(
left: 0, left: 0,
right: 0, right: 0,
bottom: homeFeedWatchAllHeight + 12, bottom: _watchAllBottomInset + homeFeedWatchAllHeight + 12,
child: AnimatedOpacity( child: AnimatedOpacity(
duration: _overlayFadeDuration, duration: _overlayFadeDuration,
opacity: widget.boosting ? 1 : 0, opacity: widget.boosting ? 1 : 0,
@@ -162,10 +165,30 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
), ),
), ),
), ),
// 观看条在下、进度条在上:进度条 32pt 触控盒要盖住观看条下沿的空白,
// 否则拖拽 seek 的起点会被观看条吞掉。两块的可见区域(文字/进度线)互不遮挡。
Positioned( Positioned(
left: _pageHorizontal, left: _pageHorizontal,
right: _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( child: _FeedProgressBar(
controller: widget.controller, controller: widget.controller,
locked: widget.locked, locked: widget.locked,
@@ -182,27 +205,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
onSeek: widget.onSeek, 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( Positioned(
left: _pageHorizontal, left: _pageHorizontal,
right: _railRight, right: _railRight,
@@ -2,10 +2,9 @@
part of 'home_feed_overlay.dart'; part of 'home_feed_overlay.dart';
const double _thumbSize = 6;
const double _thumbActiveSize = 14;
const double _trackActiveHeight = 6; const double _trackActiveHeight = 6;
const double _lineCenterFromBottom = 8; // 进度线贴住屏幕底部(线心距底 4pt);播放进度只靠蓝色已播段表达,不画滑块圆点。
const double _lineCenterFromBottom = 4;
const double _timeBubbleWidth = 96; const double _timeBubbleWidth = 96;
const double _timeBubbleMargin = 12; const double _timeBubbleMargin = 12;
const Key _inactiveTrackKey = Key('home-feed-progress-inactive'); const Key _inactiveTrackKey = Key('home-feed-progress-inactive');
@@ -305,7 +304,7 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
height: widget.height, height: widget.height,
child: ColoredBox( child: ColoredBox(
key: _inactiveTrackKey, key: _inactiveTrackKey,
color: AppColors.border, color: AppColors.inactiveTrack,
), ),
), ),
), ),
@@ -345,20 +344,11 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
Widget _track(double fraction, double width) { Widget _track(double fraction, double width) {
final dragging = _preview != null; final dragging = _preview != null;
final trackHeight = dragging ? _trackActiveHeight : widget.height; final trackHeight = dragging ? _trackActiveHeight : widget.height;
final size = dragging ? _thumbActiveSize : _thumbSize; final playedWidth = (width * fraction).clamp(0.0, width).toDouble();
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 trackSlotBottom = _lineCenterFromBottom - _trackActiveHeight / 2; final trackSlotBottom = _lineCenterFromBottom - _trackActiveHeight / 2;
final thumbSlotBottom = _lineCenterFromBottom - _thumbActiveSize / 2;
return SizedBox( return SizedBox(
height: widget.hitHeight, height: widget.hitHeight,
child: Stack( child: Stack(
clipBehavior: Clip.none,
children: [ children: [
Positioned( Positioned(
left: 0, left: 0,
@@ -374,7 +364,8 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
key: const Key('home-feed-progress-track'), key: const Key('home-feed-progress-track'),
width: width, width: width,
height: trackHeight, height: trackHeight,
color: AppColors.border, // 和剧集播放页进度条共用同一个槽底色,两个入口观感一致。
color: AppColors.inactiveTrack,
), ),
), ),
Align( 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 _seriesFont = 17;
const double _tagFont = 12; const double _tagFont = 12;
const double _synopsisFont = 14; 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 = [ const List<Shadow> _textShadows = [
Shadow(color: AppColors.shadow, blurRadius: 6, offset: Offset(0, 1)), 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 dramaTitle = localizedDramaTitle(l10n, widget.drama);
final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim(); final synopsis = localizedDramaSynopsis(l10n, widget.drama).trim();
final genres = _genreValues(Localizations.localeOf(context).languageCode); final genres = _genreValues(Localizations.localeOf(context).languageCode);
final tags = _tagChips(l10n, genres);
return Column( return Column(
key: const Key('home-feed-info-panel'), key: const Key('home-feed-info-panel'),
mainAxisSize: MainAxisSize.min, 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) ...[ if (synopsis.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: _blockGap),
_synopsis(l10n, synopsis), _synopsis(l10n, synopsis),
], ],
], ],
); );
} }
Widget _tagMetaRow(AppLocalizations l10n, List<String> genres) { List<Widget> _tagChips(AppLocalizations l10n, List<String> genres) {
final chips = <Widget>[ return [
if (isHotDrama(widget.drama)) if (isHotDrama(widget.drama))
_TagChip( _TagChip(
key: const Key('home-feed-hot-badge'), key: const Key('home-feed-hot-badge'),
@@ -125,6 +136,9 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
for (final genre in genres) for (final genre in genres)
_TagChip(key: Key('home-feed-tag-$genre'), label: genre), _TagChip(key: Key('home-feed-tag-$genre'), label: genre),
]; ];
}
Widget _tagRow(List<Widget> chips) {
return SizedBox( return SizedBox(
key: const Key('home-feed-tags'), key: const Key('home-feed-tags'),
height: 22, height: 22,
@@ -149,10 +163,7 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
); );
final span = TextSpan( final span = TextSpan(
children: [ children: [
TextSpan( TextSpan(text: prefix, style: baseStyle),
text: prefix,
style: baseStyle,
),
TextSpan( TextSpan(
text: ' | ', text: ' | ',
style: baseStyle.copyWith( style: baseStyle.copyWith(
@@ -368,7 +379,7 @@ class _TagChip extends StatelessWidget {
} }
} }
/// 底部通栏条:🎬 观看完整剧集 全 N 集。 /// 底部「观看完整剧集」条:浮在底部,不再铺满整屏宽度。
class HomeFeedWatchAllBar extends StatelessWidget { class HomeFeedWatchAllBar extends StatelessWidget {
const HomeFeedWatchAllBar({ const HomeFeedWatchAllBar({
super.key, super.key,
@@ -382,42 +393,45 @@ class HomeFeedWatchAllBar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
return Material( return ClipRRect(
key: const Key('home-feed-full-series-bg'), borderRadius: BorderRadius.circular(_watchAllRadius),
color: AppColors.textPrimary.withValues(alpha: 0.15), child: Material(
child: InkWell( key: const Key('home-feed-full-series-bg'),
key: const Key('home-feed-full-series'), color: AppColors.textPrimary.withValues(alpha: 0.15),
onTap: onTap, child: InkWell(
child: SizedBox( key: const Key('home-feed-full-series'),
height: _watchAllHeight, onTap: onTap,
child: Padding( child: SizedBox(
padding: const EdgeInsets.symmetric(horizontal: 20), height: _watchAllHeight,
child: Row( child: Padding(
children: [ padding: const EdgeInsets.symmetric(horizontal: _watchAllInset),
const Icon( child: Row(
Icons.movie_rounded, children: [
size: 22, const Icon(
color: AppColors.onPrimary, Icons.movie_rounded,
), size: 22,
const SizedBox(width: 10), color: AppColors.onPrimary,
Expanded( ),
child: Text( const SizedBox(width: 10),
l10n.watchFullSeries('${drama.episodeCount}'), Expanded(
maxLines: 1, child: Text(
overflow: TextOverflow.ellipsis, l10n.watchFullSeries('${drama.episodeCount}'),
style: const TextStyle( maxLines: 1,
color: AppColors.textPrimary, overflow: TextOverflow.ellipsis,
fontSize: 15, style: const TextStyle(
fontWeight: FontWeight.w500, color: AppColors.textPrimary,
fontSize: 15,
fontWeight: FontWeight.w500,
),
), ),
), ),
), const Icon(
const Icon( Icons.chevron_right_rounded,
Icons.chevron_right_rounded, size: 22,
size: 22, color: AppColors.textPrimary,
color: AppColors.textPrimary, ),
), ],
], ),
), ),
), ),
), ),
@@ -6,7 +6,9 @@ import '../../../core/theme/app_colors.dart';
const double _toggleSize = 68; const double _toggleSize = 68;
const double _iconSize = 36; const double _iconSize = 36;
const Color _toggleBackground = AppColors.controlSoftScrim;
/// 播放/暂停压在画面上,垫一层半透明深色圆底保证亮画面下也能看见。
const Color _toggleBackground = Color(0x66000000);
const List<Shadow> _iconShadows = [ const List<Shadow> _iconShadows = [
Shadow(color: AppColors.shadow, blurRadius: 12), Shadow(color: AppColors.shadow, blurRadius: 12),
]; ];
+82 -59
View File
@@ -19,8 +19,17 @@ import 'seek_progress_bar.dart';
part 'player_overlay_parts.dart'; part 'player_overlay_parts.dart';
const double _topButtonSize = 36; const double _topButtonSize = 40;
const double _topBarHeight = 58; const double _topBarHeight = 58;
/// 底部信息、选集条、进度条之间的统一行距。
const double _blockGap = 8;
/// 底部选集条与右侧清屏按钮的统一高度,两者必须等高。
const double _bottomActionHeight = 34;
/// 选集条内部左右留白。
const double _bottomActionInset = 10;
const double _boostHintIconSize = 16; const double _boostHintIconSize = 16;
const double _boostHintFontSize = 13; const double _boostHintFontSize = 13;
const double _synopsisRailInset = 54; const double _synopsisRailInset = 54;
@@ -107,11 +116,12 @@ class PlayerPlaybackOverlay extends StatelessWidget {
key: const Key('player-back'), key: const Key('player-back'),
tooltip: data.l10n.backToSeries, tooltip: data.l10n.backToSeries,
icon: Icons.chevron_left_rounded, icon: Icons.chevron_left_rounded,
iconSize: 30,
onPressed: actions.onBack!, onPressed: actions.onBack!,
) )
else else
const SizedBox(width: _topButtonSize), const SizedBox(width: _topButtonSize),
const SizedBox(width: 8), const SizedBox(width: 2),
Expanded( Expanded(
child: Text( child: Text(
data.l10n.episodeLabel(data.episode.number), data.l10n.episodeLabel(data.episode.number),
@@ -135,7 +145,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
_TopButton( _TopButton(
key: const Key('player-more'), key: const Key('player-more'),
tooltip: data.l10n.more, tooltip: data.l10n.more,
icon: Icons.more_vert_rounded, icon: Icons.more_horiz_rounded,
onPressed: actions.onMore, onPressed: actions.onMore,
), ),
], ],
@@ -226,19 +236,20 @@ class PlayerPlaybackOverlay extends StatelessWidget {
child: _bottomInfo(context), child: _bottomInfo(context),
), ),
if (data.boosting) _boostHint(), if (data.boosting) _boostHint(),
_bottomProgress(), // 加速时收起选集条。拖动进度时只隐藏,占位保留,底部不跳动。
// 加速时收起底栏。拖动进度时底栏只隐藏,占位保留,进度条不挪位。
scrubbing scrubbing
? IgnorePointer( ? IgnorePointer(
child: Opacity(opacity: 0, child: _bottomActions()), child: Opacity(opacity: 0, child: _bottomActions()),
) )
: Offstage(offstage: data.boosting, child: _bottomActions()), : Offstage(offstage: data.boosting, child: _bottomActions()),
// 进度条压在最底部,和首页 feed 一致。
_bottomProgress(),
], ],
), ),
); );
} }
/// 加速提示:橙色小三角 + 当前倍速文案,悬在进度条上方。 /// 加速提示:橙色小三角 + 当前倍速文案,压在底部信息区下方。
Widget _boostHint() { Widget _boostHint() {
return Padding( return Padding(
key: const Key('player-speed-boost-hint'), key: const Key('player-speed-boost-hint'),
@@ -270,25 +281,28 @@ class PlayerPlaybackOverlay extends StatelessWidget {
Widget _bottomInfo(BuildContext context) { Widget _bottomInfo(BuildContext context) {
final series = localizedDramaTitle(data.l10n, data.drama); final series = localizedDramaTitle(data.l10n, data.drama);
final synopsis = localizedDramaSynopsis(data.l10n, data.drama); final synopsis = localizedDramaSynopsis(data.l10n, data.drama);
// 右侧让出互动栏的宽度:剧名和简介一样,收在互动栏左侧,长标题也不会钻到按钮下面。
return Padding( return Padding(
key: const Key('player-controls-bottom'), 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( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_detailRow(series), _detailRow(series),
const SizedBox(height: 4), const SizedBox(height: 4),
Padding( _EpisodeSynopsis(
padding: const EdgeInsets.only(right: _synopsisRailInset), key: const Key('player-episode-synopsis'),
child: _EpisodeSynopsis( l10n: data.l10n,
key: const Key('player-episode-synopsis'), drama: data.drama,
l10n: data.l10n, episodeNumber: data.episode.number,
drama: data.drama, synopsis: synopsis,
episodeNumber: data.episode.number, authorNote: data.authorNote,
synopsis: synopsis,
authorNote: data.authorNote,
),
), ),
], ],
), ),
@@ -296,9 +310,10 @@ class PlayerPlaybackOverlay extends StatelessWidget {
} }
Widget _bottomProgress() { Widget _bottomProgress() {
// 进度条不贴底:底部留 16pt,线心离底边更远,也不再和选集条挤在一起。
return Padding( return Padding(
key: const Key('player-progress-container'), 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( child: SeekProgressBar(
key: const Key('player-progress'), key: const Key('player-progress'),
valueListenable: data.controller, valueListenable: data.controller,
@@ -345,10 +360,9 @@ class PlayerPlaybackOverlay extends StatelessWidget {
), ),
), ),
), ),
const SizedBox(width: 4),
const Icon( const Icon(
Icons.chevron_right_rounded, Icons.chevron_right_rounded,
color: AppColors.playerMuted, color: AppColors.onPrimary,
size: 20, size: 20,
shadows: _playerTextShadows, shadows: _playerTextShadows,
), ),
@@ -362,55 +376,63 @@ class PlayerPlaybackOverlay extends StatelessWidget {
Widget _bottomActions() { Widget _bottomActions() {
return Padding( return Padding(
key: const Key('player-bottom-actions'), key: const Key('player-bottom-actions'),
padding: const EdgeInsets.fromLTRB(14, 6, 14, 6), padding: const EdgeInsets.fromLTRB(14, 2, 14, 0),
child: Row( child: Row(
children: [ children: [
Expanded( // 清屏后只留右下角的退出按钮,左侧选集条一起收起。
child: Material( if (!controlsVisible)
key: const Key('player-episodes-pill'), const Spacer()
color: AppColors.playerOverlayPill, else ...[
borderRadius: BorderRadius.circular(7), Expanded(
clipBehavior: Clip.antiAlias, // 选集条给一层浅底色:视频画面亮时纯文字不好认,也顺带标出可点区域。
child: InkWell( child: Material(
key: const Key('player-episodes'), key: const Key('player-episodes-pill'),
onTap: actions.onEpisodes, color: AppColors.textPrimary.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(7), borderRadius: BorderRadius.circular(8),
child: Padding( clipBehavior: Clip.antiAlias,
padding: const EdgeInsets.symmetric( child: InkWell(
horizontal: 14, key: const Key('player-episodes'),
vertical: 8, onTap: actions.onEpisodes,
), borderRadius: BorderRadius.circular(8),
child: Row( child: Padding(
children: [ padding: const EdgeInsets.symmetric(
Expanded( horizontal: _bottomActionInset,
child: Text( ),
'${data.l10n.episodes} · ' child: SizedBox(
'${data.l10n.allEpisodesBadge(data.totalEpisodes)} · ' height: _bottomActionHeight,
'${data.l10n.freeWatch}', child: Row(
maxLines: 1, children: [
overflow: TextOverflow.ellipsis, Expanded(
style: const TextStyle( 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, color: AppColors.onPrimary,
fontSize: 13.5, size: 18,
fontWeight: FontWeight.w800,
shadows: _playerTextShadows, 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( _BottomIconButton(
key: const Key('player-bottom-clear-screen'), key: const Key('player-bottom-clear-screen'),
tooltip: controlsVisible tooltip: controlsVisible
@@ -419,6 +441,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
icon: controlsVisible icon: controlsVisible
? Icons.fullscreen_rounded ? Icons.fullscreen_rounded
: Icons.fullscreen_exit_rounded, : Icons.fullscreen_exit_rounded,
height: _bottomActionHeight,
onPressed: controlsVisible ? actions.onClear : actions.onExitClear, onPressed: controlsVisible ? actions.onClear : actions.onExitClear,
), ),
], ],
@@ -2,17 +2,20 @@
part of 'player_overlay.dart'; part of 'player_overlay.dart';
/// 顶部按钮:直接压在画面上,不加底色,靠文字/图标阴影保证可读性。
class _TopButton extends StatelessWidget { class _TopButton extends StatelessWidget {
const _TopButton({ const _TopButton({
super.key, super.key,
required this.tooltip, required this.tooltip,
required this.icon, required this.icon,
required this.onPressed, required this.onPressed,
this.iconSize = 22,
}); });
final String tooltip; final String tooltip;
final IconData icon; final IconData icon;
final VoidCallback onPressed; final VoidCallback onPressed;
final double iconSize;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -22,11 +25,11 @@ class _TopButton extends StatelessWidget {
tooltip: tooltip, tooltip: tooltip,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
style: IconButton.styleFrom( style: IconButton.styleFrom(
backgroundColor: AppColors.controlScrim, backgroundColor: AppColors.transparent,
foregroundColor: AppColors.onPrimary, foregroundColor: AppColors.onPrimary,
), ),
onPressed: onPressed, onPressed: onPressed,
icon: Icon(icon, size: 18), icon: Icon(icon, size: iconSize, shadows: _playerTextShadows),
), ),
); );
} }
@@ -41,7 +44,7 @@ class _SpeedEntry extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return Material(
color: AppColors.controlScrim, color: AppColors.transparent,
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
@@ -81,25 +84,30 @@ class _BottomIconButton extends StatelessWidget {
required this.tooltip, required this.tooltip,
required this.icon, required this.icon,
required this.onPressed, required this.onPressed,
this.height = 40,
}); });
final String tooltip; final String tooltip;
final IconData icon; final IconData icon;
final VoidCallback onPressed; final VoidCallback onPressed;
/// 与左侧选集条等高,两个控件底部齐平。
final double height;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SizedBox.square( return SizedBox(
dimension: 40, width: 40,
height: height,
child: IconButton( child: IconButton(
tooltip: tooltip, tooltip: tooltip,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
style: IconButton.styleFrom( style: IconButton.styleFrom(
backgroundColor: AppColors.playerOverlayPill, backgroundColor: AppColors.transparent,
foregroundColor: AppColors.onPrimary, foregroundColor: AppColors.onPrimary,
), ),
onPressed: onPressed, 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), const SizedBox(width: 2),
_toggle(widget.l10n.expandSynopsis, _toggle(
padding: const EdgeInsets.only(left: 6)), widget.l10n.expandSynopsis,
padding: const EdgeInsets.only(left: 6),
),
], ],
), ),
); );
@@ -291,12 +301,12 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
/// 分隔竖线:同字号 w400 + 45% 透明白,靠两侧空格留白。 /// 分隔竖线:同字号 w400 + 45% 透明白,靠两侧空格留白。
/// 必须用 TextSpan:WidgetSpan 占位盒的基线对齐会让第n集与简介错位。 /// 必须用 TextSpan:WidgetSpan 占位盒的基线对齐会让第n集与简介错位。
TextSpan _separatorSpan() => TextSpan( TextSpan _separatorSpan() => TextSpan(
text: ' | ', text: ' | ',
style: _lineStyle.copyWith( style: _lineStyle.copyWith(
color: AppColors.onPrimary.withValues(alpha: 0.45), color: AppColors.onPrimary.withValues(alpha: 0.45),
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
), ),
); );
Widget _toggle(String label, {required EdgeInsets padding}) { Widget _toggle(String label, {required EdgeInsets padding}) {
return GestureDetector( return GestureDetector(
@@ -321,10 +321,8 @@ class _SeekProgressBarState extends State<SeekProgressBar> {
trackHeight: trackHeight, trackHeight: trackHeight,
activeTrackColor: AppColors.blue, activeTrackColor: AppColors.blue,
inactiveTrackColor: widget.inactiveTrackColor, inactiveTrackColor: widget.inactiveTrackColor,
thumbColor: AppColors.onPrimary, // 播放页进度条不画滑块圆点:进度只靠蓝色已播段表达。
thumbShape: RoundSliderThumbShape( thumbShape: SliderComponentShape.noThumb,
enabledThumbRadius: scrubbing ? 8 : 6,
),
overlayShape: RoundSliderOverlayShape( overlayShape: RoundSliderOverlayShape(
overlayRadius: widget.compact ? 12 : 18, overlayRadius: widget.compact ? 12 : 18,
), ),
+1 -1
View File
@@ -464,7 +464,7 @@ class _RankingRow extends StatelessWidget {
children: [ children: [
Text( Text(
drama.title, drama.title,
maxLines: 1, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: AppColors.textPrimary, color: AppColors.textPrimary,
@@ -138,8 +138,7 @@ class _RecommendDiscoverPageState extends State<RecommendDiscoverPage>
} }
} }
Future<void> _openDrama(Drama drama) => Future<void> _openDrama(Drama drama) => playDramaFromCatalog(context, drama);
playDramaFromCatalog(context, drama);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -163,9 +162,7 @@ class _RecommendDiscoverPageState extends State<RecommendDiscoverPage>
Positioned.fill( Positioned.fill(
child: categoryList.isEmpty child: categoryList.isEmpty
? const Center( ? const Center(
child: CircularProgressIndicator( child: CircularProgressIndicator(color: AppColors.brandRed),
color: AppColors.brandRed,
),
) )
: PageView.builder( : PageView.builder(
key: const Key('recommend-channel-pager'), key: const Key('recommend-channel-pager'),
@@ -184,7 +181,8 @@ class _RecommendDiscoverPageState extends State<RecommendDiscoverPage>
carouselActive: widget.active && index == selectedIndex, carouselActive: widget.active && index == selectedIndex,
mediaTop: mediaTop, mediaTop: mediaTop,
onRequest: _ensureCategory, onRequest: _ensureCategory,
onRefresh: () => _refreshCategory(category.id, index == 0), onRefresh: () =>
_refreshCategory(category.id, index == 0),
onOpenDrama: _openDrama, onOpenDrama: _openDrama,
); );
}, },
@@ -294,11 +292,7 @@ class _ChannelFeedState extends State<_ChannelFeed>
List<Drama> newArrivals = const []; List<Drama> newArrivals = const [];
List<Drama> rest = dramas; List<Drama> rest = dramas;
if (widget.isFeatured && dramas.isNotEmpty) { if (widget.isFeatured && dramas.isNotEmpty) {
final lang = Localizations.localeOf(context).languageCode; newArrivals = dramas.where(isNewArrivalTag).toList(growable: false);
final badge = AppLocalizations.of(context)!.newArrivalBadge;
newArrivals = dramas
.where((drama) => _isNewArrivalTag(drama, lang, badge))
.toList(growable: false);
if (newArrivals.isNotEmpty) { if (newArrivals.isNotEmpty) {
final newIds = newArrivals.map((drama) => drama.id).toSet(); final newIds = newArrivals.map((drama) => drama.id).toSet();
rest = dramas rest = dramas
@@ -326,7 +320,9 @@ class _ChannelFeedState extends State<_ChannelFeed>
hasScrollBody: false, hasScrollBody: false,
child: Padding( child: Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
top: showCarousel ? 0 : widget.mediaTop + RecommendChannelNav.height, top: showCarousel
? 0
: widget.mediaTop + RecommendChannelNav.height,
), ),
child: Center(child: _placeholder(slot.loading)), child: Center(child: _placeholder(slot.loading)),
), ),
@@ -334,12 +330,11 @@ class _ChannelFeedState extends State<_ChannelFeed>
); );
} else if (widget.isFeatured) { } else if (widget.isFeatured) {
// 无轮播图时内容需避开顶部悬浮导航。 // 无轮播图时内容需避开顶部悬浮导航。
final contentTop = final contentTop = showCarousel
showCarousel ? 24.0 : widget.mediaTop + RecommendChannelNav.height + 16; ? 24.0
: widget.mediaTop + RecommendChannelNav.height + 16;
if (newArrivals.isNotEmpty) { if (newArrivals.isNotEmpty) {
sliverList.add( sliverList.add(SliverToBoxAdapter(child: SizedBox(height: contentTop)));
SliverToBoxAdapter(child: SizedBox(height: contentTop)),
);
sliverList.add( sliverList.add(
SliverToBoxAdapter( SliverToBoxAdapter(
child: HomeNewArrivalRow( child: HomeNewArrivalRow(
@@ -348,9 +343,7 @@ class _ChannelFeedState extends State<_ChannelFeed>
), ),
), ),
); );
sliverList.add( sliverList.add(const SliverToBoxAdapter(child: SizedBox(height: 28)));
const SliverToBoxAdapter(child: SizedBox(height: 28)),
);
} }
sliverList.add( sliverList.add(
_gridSliver( _gridSliver(
@@ -363,7 +356,9 @@ class _ChannelFeedState extends State<_ChannelFeed>
sliverList.add( sliverList.add(
_gridSliver( _gridSliver(
dramas, dramas,
topPadding: showCarousel ? 20 : widget.mediaTop + RecommendChannelNav.height + 16, topPadding: showCarousel
? 20
: widget.mediaTop + RecommendChannelNav.height + 16,
crossAxisCount: 3, crossAxisCount: 3,
), ),
); );
@@ -427,7 +422,6 @@ class _ChannelFeedState extends State<_ChannelFeed>
const pageSide = 14.0; const pageSide = 14.0;
const cardGap = 9.0; const cardGap = 9.0;
const rowGap = 16.0; const rowGap = 16.0;
const cardBodyExtent = 44.0; // 封面下间距 + 剧名行 + 集数行
// 两列时卡更大,卡间距略放宽。 // 两列时卡更大,卡间距略放宽。
final spacing = crossAxisCount == 2 ? 12.0 : cardGap; final spacing = crossAxisCount == 2 ? 12.0 : cardGap;
return SliverPadding( return SliverPadding(
@@ -437,7 +431,7 @@ class _ChannelFeedState extends State<_ChannelFeed>
final itemWidth = final itemWidth =
(constraints.crossAxisExtent - spacing * (crossAxisCount - 1)) / (constraints.crossAxisExtent - spacing * (crossAxisCount - 1)) /
crossAxisCount; crossAxisCount;
final itemExtent = itemWidth * 157 / 110 + cardBodyExtent; final itemExtent = itemWidth * 157 / 110 + homePosterCardBodyExtent;
return SliverGrid( return SliverGrid(
key: Key('recommend-category-grid-${widget.categoryId}'), key: Key('recommend-category-grid-${widget.categoryId}'),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
@@ -446,17 +440,14 @@ class _ChannelFeedState extends State<_ChannelFeed>
mainAxisSpacing: rowGap, mainAxisSpacing: rowGap,
mainAxisExtent: itemExtent, mainAxisExtent: itemExtent,
), ),
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate((context, index) {
(context, index) { final drama = dramas[index];
final drama = dramas[index]; return HomePosterCard(
return HomePosterCard( key: Key('recommend-category-card-${drama.id}'),
key: Key('recommend-category-card-${drama.id}'), drama: drama,
drama: drama, onTap: () => widget.onOpenDrama(drama),
onTap: () => widget.onOpenDrama(drama), );
); }, childCount: dramas.length),
},
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) =>
bool _isNewArrivalTag(Drama drama, String lang, String localizedBadge) { drama.tagCode.trim().toUpperCase() == newArrivalTagCode;
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';
}
@@ -46,15 +46,25 @@ class _HomeHeroCarouselState extends State<HomeHeroCarousel> {
static const double _dotGap = 9; static const double _dotGap = 9;
static const double _ctaHeight = 38; static const double _ctaHeight = 38;
final PageController _controller = PageController(); /// 无限轮播:PageView 用虚拟大页码,取模映射到真实 banner,首尾天然相接。
static const int _basePage = 1000;
late final PageController _controller;
Timer? _timer; Timer? _timer;
int _page = 0;
int _index = 0; int _index = 0;
bool _pressed = false; bool _pressed = false;
bool _tickerEnabled = true; bool _tickerEnabled = true;
/// 起始页码取 banner 数量的大倍数,向左右滑都不会到头。
static int _initialPageOf(int bannerCount) =>
bannerCount > 1 ? bannerCount * _basePage : 0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_page = _initialPageOf(widget.banners.length);
_controller = PageController(initialPage: _page);
WidgetsBinding.instance.addPostFrameCallback((_) => _syncTimer()); WidgetsBinding.instance.addPostFrameCallback((_) => _syncTimer());
} }
@@ -72,8 +82,11 @@ class _HomeHeroCarouselState extends State<HomeHeroCarousel> {
void didUpdateWidget(covariant HomeHeroCarousel oldWidget) { void didUpdateWidget(covariant HomeHeroCarousel oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.banners.length != widget.banners.length) { if (oldWidget.banners.length != widget.banners.length) {
if (_index >= widget.banners.length) _index = 0; // 首屏 banner 是异步到的:列表从空变有之后要把页码挪到虚拟起点,
_syncTimer(); // 否则左滑第一下就到头了。
_page = _initialPageOf(widget.banners.length);
_index = 0;
_jumpToBasePage();
} }
if (oldWidget.banners.length != widget.banners.length || if (oldWidget.banners.length != widget.banners.length ||
oldWidget.active != widget.active) { oldWidget.active != widget.active) {
@@ -91,7 +104,8 @@ class _HomeHeroCarouselState extends State<HomeHeroCarousel> {
void _syncTimer() { void _syncTimer() {
_timer?.cancel(); _timer?.cancel();
_timer = null; _timer = null;
final canAdvance = mounted && final canAdvance =
mounted &&
widget.active && widget.active &&
_tickerEnabled && _tickerEnabled &&
!_pressed && !_pressed &&
@@ -102,12 +116,16 @@ class _HomeHeroCarouselState extends State<HomeHeroCarousel> {
void _advance() { void _advance() {
if (!_controller.hasClients || widget.banners.length < 2) return; if (!_controller.hasClients || widget.banners.length < 2) return;
final next = (_index + 1) % widget.banners.length; // 虚拟页码无限,直接翻下一页即可,不用在末尾跳回第 0 页。
_controller.animateToPage( _controller.nextPage(duration: _pageDuration, curve: Curves.easeOutCubic);
next, }
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 @override
@@ -134,9 +152,17 @@ class _HomeHeroCarouselState extends State<HomeHeroCarousel> {
children: [ children: [
PageView.builder( PageView.builder(
controller: _controller, controller: _controller,
itemCount: widget.banners.length, // 数量大于 1 时用无限页码做无缝循环,取模映射到真实 banner。
onPageChanged: (value) => setState(() => _index = value), itemCount: widget.banners.length > 1
itemBuilder: (context, index) => _bannerPage(context, index), ? 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)), 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), color: AppColors.textPrimary.withValues(alpha: 0.95),
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
shadows: const [ shadows: const [Shadow(color: Color(0x80000000), blurRadius: 8)],
Shadow(color: Color(0x80000000), blurRadius: 8),
],
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
_WatchButton(height: ctaHeight, label: l10n.watchNowAction, onTap: onWatch), _WatchButton(
height: ctaHeight,
label: l10n.watchNowAction,
onTap: onWatch,
),
const SizedBox(width: 12), const SizedBox(width: 12),
_FavoriteCircle(size: ctaHeight, drama: drama), _FavoriteCircle(size: ctaHeight, drama: drama),
], ],
@@ -15,6 +15,12 @@ const double _cardCoverHeight = 157;
const double _cardGap = 9; const double _cardGap = 9;
const double _cardRadius = 12; const double _cardRadius = 12;
/// 海报卡封面以下的高度:上间距 8 + 两行剧名 + 2 + 集数行 + 余量。
///
/// 横向列表和网格都要给一个有限的卡片高度,这里是唯一来源,
/// 两行剧名撑高时只改这一处,不会出现某个入口高度不够而溢出。
const double homePosterCardBodyExtent = 62;
/// 上新横滑卡板块。无板块标题,卡片左上角带「上新」角标。 /// 上新横滑卡板块。无板块标题,卡片左上角带「上新」角标。
/// 内层横向 ListView 会自行识别横向手势,不与外层纵向滚动冲突。 /// 内层横向 ListView 会自行识别横向手势,不与外层纵向滚动冲突。
class HomeNewArrivalRow extends StatelessWidget { class HomeNewArrivalRow extends StatelessWidget {
@@ -32,8 +38,7 @@ class HomeNewArrivalRow extends StatelessWidget {
if (dramas.isEmpty) return const SizedBox.shrink(); if (dramas.isEmpty) return const SizedBox.shrink();
return SizedBox( return SizedBox(
key: const Key('home-new-arrival-row'), key: const Key('home-new-arrival-row'),
height: height: _cardCoverHeight + homePosterCardBodyExtent,
_cardCoverHeight + 8 + 20 + 2 + 16, // 封面 + 剧名行 + 集数行 + 余量
child: ListView.separated( child: ListView.separated(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: _pageSide), padding: const EdgeInsets.symmetric(horizontal: _pageSide),
@@ -130,7 +135,7 @@ class HomePosterCard extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
drama.localizedTitle(lang), drama.localizedTitle(lang),
maxLines: 1, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: AppColors.textPrimary, color: AppColors.textPrimary,
@@ -192,7 +197,11 @@ class HomeEditorPickBanner extends StatelessWidget {
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.centerLeft, begin: Alignment.centerLeft,
end: Alignment.centerRight, 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" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.0.2" 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: dio:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -253,7 +245,7 @@ packages:
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
intl: intl:
dependency: transitive dependency: "direct main"
description: description:
name: intl name: intl
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
+4 -4
View File
@@ -33,9 +33,6 @@ dependencies:
flutter: flutter:
sdk: 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(统一拦截器/超时/错误映射);纯 Dart 无原生插件,仍然不引代码生成与图片缓存库
dio: ^5.7.0 dio: ^5.7.0
provider: ^6.1.5+1 provider: ^6.1.5+1
@@ -48,6 +45,8 @@ dependencies:
sdk: flutter sdk: flutter
path_provider: 2.1.5 path_provider: 2.1.5
html: ^0.15.6 html: ^0.15.6
# 播放量等数字的本地化格式(NumberFormat);版本由 flutter_localizations 钉住
intl: any
# 画中画:Android 原生 PiP,OnLeavePiP 支持退后台自动进小窗 # 画中画:Android 原生 PiP,OnLeavePiP 支持退后台自动进小窗
floating: ^6.0.0 floating: ^6.0.0
# 运行时权限:systemAlertWindow(「显示在其他应用上层」) # 运行时权限:systemAlertWindow(「显示在其他应用上层」)
@@ -90,7 +89,8 @@ flutter:
- assets/icon/splash_logo.png - assets/icon/splash_logo.png
- assets/icon/splash_logo_android12.png - assets/icon/splash_logo_android12.png
- assets/icon/rail/ - assets/icon/rail/
- assets/auth/ # 只打包代码实际引用的那张登录背景(其余 3 张留在仓库但不进包)。
- assets/auth/bg_3.jpg
# To add assets to your application, add an assets section, like this: # To add assets to your application, add an assets section, like this:
# assets: # assets:
+17
View File
@@ -46,6 +46,23 @@ void main() {
expect(drama.episodeCount, 12); 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', () { test('should_count_shipped_episodes_when_detail_response_is_parsed', () {
final drama = Drama.fromDetailJson(const { final drama = Drama.fromDetailJson(const {
'id': 5, '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:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.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:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import 'package:fruitshorts/core/config/app_config.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'; import 'package:fruitshorts/core/config/server_endpoint.dart';
void main() { void main() {
@@ -35,11 +37,10 @@ void main() {
expect(ServerEndpoint.normalize('http://'), isNull); expect(ServerEndpoint.normalize('http://'), isNull);
}); });
test('should_prioritize_runtime_over_dart_define_over_default', () { test('should_prefer_valid_stored_url_over_build_default', () {
expect( expect(
ServerEndpoint.resolve( ServerEndpoint.resolve(
stored: ' http://runtime.test:8081/ ', stored: ' http://runtime.test:8081/ ',
compiled: 'http://define.test:8081',
fallback: 'http://default.test:8081', fallback: 'http://default.test:8081',
), ),
'http://runtime.test:8081', 'http://runtime.test:8081',
@@ -47,173 +48,57 @@ void main() {
expect( expect(
ServerEndpoint.resolve( ServerEndpoint.resolve(
stored: 'ftp://invalid', 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( expect(
ServerEndpoint.resolve( ServerEndpoint.resolve(
stored: null, stored: null,
compiled: '', fallback: 'http://default.test:8081',
fallback: 'http://default.test:8081/',
), ),
'http://default.test:8081', 'http://default.test:8081',
); );
}); });
test('should_use_the_configured_local_address_for_device_default', () { test('should_pick_address_by_environment_switch', () {
final uri = Uri.parse(AppConfig.devHostBaseUrl); // 仓库里的开关默认指向开发环境。
expect(uri.scheme, 'http'); expect(AppConfig.useDevConfig, isTrue);
expect(uri.host, isNotEmpty); expect(AppConfig.baseUrl, DevConfig.baseUrl);
expect(uri.port, 8081); expect(AppConfig.apiBaseUrl, DevConfig.baseUrl);
expect(uri.path, isEmpty); expect(Uri.parse(AppConfig.baseUrl).port, 8081);
}); });
test('should_skip_probe_when_compiled_override_is_set', () async { test('should_keep_dev_and_prod_addresses_in_separate_files', () {
const compiled = 'http://compiled.test:8081'; final dev = Uri.parse(DevConfig.baseUrl);
var probeCalls = 0; final prod = Uri.parse(ProdConfig.baseUrl);
await AppConfig.probeAndApplyBaseUrl( expect(dev.scheme, 'http');
compiledOverride: compiled, expect(dev.port, 8081);
probe: (_) async { expect(dev.path, isEmpty);
probeCalls++; expect(prod.scheme, 'http');
return true; expect(prod.port, 8081);
}, expect(prod.path, isEmpty);
); expect(DevConfig.baseUrl, isNot(ProdConfig.baseUrl));
expect(probeCalls, 0);
expect(AppConfig.apiBaseUrl, compiled);
}); });
test('should_keep_runtime_endpoint_when_compiled_override_is_set', () async { test('should_prefer_runtime_override_over_environment_switch', () {
const runtime = 'http://runtime.test:8081'; AppConfig.applyRuntimeBaseUrl('http://runtime.test:8081');
const compiled = 'http://compiled.test:8081';
var probeCalls = 0;
AppConfig.applyRuntimeBaseUrl(runtime);
await AppConfig.probeAndApplyBaseUrl( expect(AppConfig.apiBaseUrl, 'http://runtime.test:8081');
compiledOverride: compiled, expect(AppConfig.baseUrl, DevConfig.baseUrl);
probe: (_) async {
probeCalls++;
return true;
},
);
expect(probeCalls, 0);
expect(AppConfig.apiBaseUrl, runtime);
}); });
test('should_keep_probing_when_compiled_override_is_empty', () async { test('should_append_host_to_relative_media_paths', () {
final attempts = <String>[]; expect(AppConfig.mediaUrl(''), '');
expect(
await AppConfig.probeAndApplyBaseUrl( AppConfig.mediaUrl('https://cdn.test/a.mp4'),
compiledOverride: '', 'https://cdn.test/a.mp4',
probe: (baseUrl) async {
attempts.add(baseUrl);
return baseUrl == AppConfig.publicBaseUrl;
},
); );
expect(
expect(attempts, [ AppConfig.mediaUrl('/files/a.mp4'),
AppConfig.lanBaseUrl, '${AppConfig.apiBaseUrl}/files/a.mp4',
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(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 { test('should_apply_stored_url_only_when_debug_is_enabled', () async {
+4 -4
View File
@@ -136,19 +136,19 @@ void main() {
}); });
test( test(
'should_use_probed_endpoint_when_client_was_created_before_probe', 'should_follow_runtime_endpoint_applied_after_client_creation',
() async { () async {
const probedBaseUrl = 'http://probed.test:8081'; const runtimeBaseUrl = 'http://runtime.test:8081';
final adapter = FakeHttpAdapter( final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(envelope(null)), (_) async => FakeHttpResponse(envelope(null)),
); );
final client = ApiClient(adapter: adapter); final client = ApiClient(adapter: adapter);
AppConfig.applyRuntimeBaseUrl(probedBaseUrl); AppConfig.applyRuntimeBaseUrl(runtimeBaseUrl);
addTearDown(AppConfig.clearRuntimeBaseUrl); addTearDown(AppConfig.clearRuntimeBaseUrl);
await client.get('/app/home/feed'); 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(); 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 { class _PagedPlaylists implements PlaylistRepository {
_PagedPlaylists({required this.total}); _PagedPlaylists({required this.total, this.repeatLastOfFirstPage = false});
final int total; final int total;
final bool repeatLastOfFirstPage;
@override @override
Future<PageResult<PlaylistDetail>> fetchPlaylists({ Future<PageResult<PlaylistDetail>> fetchPlaylists({
@@ -44,7 +57,7 @@ class _PagedPlaylists implements PlaylistRepository {
list: [ list: [
for (var index = start; index < end; index++) for (var index = start; index < end; index++)
PlaylistDetail( PlaylistDetail(
id: '$index', id: '${repeatLastOfFirstPage && index == pageSize ? start - 1 : index}',
name: '剧单$index', name: '剧单$index',
description: '', description: '',
dramaCount: 0, 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(refresh.edgeOffset, 52);
expect( expect(
tester.widget<ListView>(_verticalFeed).physics, tester.widget<CustomScrollView>(_verticalFeed).physics,
AppPullToRefresh.physics, AppPullToRefresh.physics,
); );
await tester.drag(_verticalFeed, const Offset(0, 240)); await tester.drag(_verticalFeed, const Offset(0, 240));
@@ -396,7 +396,8 @@ void main() {
} }
final Finder _verticalFeed = find.byWidgetPredicate( final Finder _verticalFeed = find.byWidgetPredicate(
(widget) => widget is ListView && widget.scrollDirection == Axis.vertical, (widget) =>
widget is CustomScrollView && widget.scrollDirection == Axis.vertical,
); );
Future<void> _pumpTheater( 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/theme/app_colors.dart';
import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart'; import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart';
import 'package:fruitshorts/pages/discover/discover_view.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/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
@@ -234,25 +235,26 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final list = find.byKey(const Key('new-releases-list')); final list = find.byKey(const Key('new-releases-list'));
expect(tester.widget<ListView>(list).physics, AppPullToRefresh.physics); expect(
await tester.drag(list, const Offset(0, -400)); tester.widget<CustomScrollView>(list).physics,
await tester.pump(); AppPullToRefresh.physics,
expect(_countKeys('home-drama-card-'), 20); );
// 网格懒加载后只保留可见行,页数直接看传给网格的剧目数。
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')); final position = _scrollPosition(tester, const Key('new-releases-list'));
position.jumpTo(position.maxScrollExtent); position.jumpTo(position.maxScrollExtent);
await tester.pump(); await tester.pump();
expect(find.byKey(const Key('new-releases-load-more')), findsOneWidget); await tester.drag(list, const Offset(0, -60));
await tester.pump(const Duration(milliseconds: 299));
expect(_countKeys('home-drama-card-'), 20);
await tester.pump(const Duration(milliseconds: 1));
await tester.pump(); await tester.pump();
expect(_countKeys('home-drama-card-'), 40); expect(find.byKey(const Key('new-releases-load-more')), findsOneWidget);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
position.jumpTo(position.maxScrollExtent); expect(gridDramas(), hasLength(40));
await tester.pumpAndSettle();
expect(find.byKey(const Key('new-releases-no-more')), findsNothing);
position.jumpTo(0); position.jumpTo(0);
await tester.pump(); await tester.pump();
@@ -262,10 +264,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(repository.fetchCalls, beforeRefresh + 1); expect(repository.fetchCalls, beforeRefresh + 1);
expect(position.pixels, 0); expect(position.pixels, 0);
expect(gridDramas(), hasLength(20));
await tester.drag(list, const Offset(0, -400));
await tester.pump();
expect(_countKeys('home-drama-card-'), 20);
}); });
testWidgets('should_refresh_reserve_without_load_more', (tester) async { 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; 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 { class _CountingCatalogRepository extends FakeCatalogRepository {
_CountingCatalogRepository({super.dramas}); _CountingCatalogRepository({super.dramas});
+22
View File
@@ -23,6 +23,28 @@ import '../support/fake_interaction_repositories.dart';
import '../support/widget_player.dart'; import '../support/widget_player.dart';
void main() { 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', ( testWidgets('should_render_following_tabs_grid_and_edit_actions', (
tester, tester,
) async { ) async {
+3 -27
View File
@@ -6,7 +6,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/config/app_config.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/theme/app_colors.dart';
import 'package:fruitshorts/core/widgets/catalog_endpoint_editor.dart'; import 'package:fruitshorts/core/widgets/catalog_endpoint_editor.dart';
import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/l10n/app_localizations.dart';
@@ -55,20 +54,7 @@ void main() {
); );
}); });
test('should_probe_before_reloading_catalog', () async { testWidgets('should_save_and_reload_after_valid_endpoint', (tester) 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 {
AppConfig.applyRuntimeBaseUrl('http://127.0.0.1:8081'); AppConfig.applyRuntimeBaseUrl('http://127.0.0.1:8081');
final steps = <String>[]; final steps = <String>[];
await _pumpEndpointEditor( await _pumpEndpointEditor(
@@ -77,7 +63,6 @@ void main() {
steps.add('save:$value'); steps.add('save:$value');
return true; return true;
}, },
probe: () async => steps.add('probe'),
reload: () async => steps.add('load'), reload: () async => steps.add('load'),
); );
@@ -101,12 +86,11 @@ void main() {
await tester.tap(find.byKey(const Key('catalog-debug-endpoint-save'))); await tester.tap(find.byKey(const Key('catalog-debug-endpoint-save')));
await tester.pumpAndSettle(); 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 { testWidgets('should_reject_invalid_endpoint_without_saving', (tester) async {
var saves = 0; var saves = 0;
var probes = 0;
var loads = 0; var loads = 0;
await _pumpEndpointEditor( await _pumpEndpointEditor(
tester, tester,
@@ -114,7 +98,6 @@ void main() {
saves++; saves++;
return true; return true;
}, },
probe: () async => probes++,
reload: () async => loads++, reload: () async => loads++,
); );
@@ -134,7 +117,6 @@ void main() {
findsOneWidget, findsOneWidget,
); );
expect(saves, 0); expect(saves, 0);
expect(probes, 0);
expect(loads, 0); expect(loads, 0);
}); });
} }
@@ -142,7 +124,6 @@ void main() {
Future<void> _pumpEndpointEditor( Future<void> _pumpEndpointEditor(
WidgetTester tester, { WidgetTester tester, {
required Future<bool> Function(String value) save, required Future<bool> Function(String value) save,
required Future<void> Function() probe,
required Future<void> Function() reload, required Future<void> Function() reload,
}) async { }) async {
await tester.pumpWidget( await tester.pumpWidget(
@@ -152,12 +133,7 @@ Future<void> _pumpEndpointEditor(
body: TextButton( body: TextButton(
key: const Key('open-endpoint-editor'), key: const Key('open-endpoint-editor'),
onPressed: () => unawaited( onPressed: () => unawaited(
showCatalogEndpointEditor( showCatalogEndpointEditor(context, save: save, reload: reload),
context,
save: save,
probe: probe,
reload: reload,
),
), ),
child: const Text('open'), child: const Text('open'),
), ),
+34 -59
View File
@@ -293,7 +293,10 @@ void main() {
expect(find.byKey(const Key('home-feed-synopsis-more')), findsOneWidget); expect(find.byKey(const Key('home-feed-synopsis-more')), findsOneWidget);
await tester.tap(find.byKey(const Key('home-feed-synopsis-more'))); await tester.tap(find.byKey(const Key('home-feed-synopsis-more')));
await tester.pump(); await tester.pump();
expect(find.byKey(const Key('home-feed-synopsis-expanded')), findsOneWidget); expect(
find.byKey(const Key('home-feed-synopsis-expanded')),
findsOneWidget,
);
expect( expect(
tester tester
.widget<Text>(find.byKey(const Key('home-feed-synopsis-expanded'))) .widget<Text>(find.byKey(const Key('home-feed-synopsis-expanded')))
@@ -371,7 +374,7 @@ void main() {
find.byKey(const Key('home-feed-progress-inactive')), find.byKey(const Key('home-feed-progress-inactive')),
findsOneWidget, 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'))); await tester.tap(find.byKey(const Key('rail-share')));
expect(shares, 1); expect(shares, 1);
}); });
@@ -575,26 +578,36 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
final bar = tester.getRect(find.byKey(const Key('home-feed-full-series'))); final bar = tester.getRect(find.byKey(const Key('home-feed-full-series')));
expect(bar.height, 44); expect(bar.height, 38);
final overlayRect = tester.getRect( final overlayRect = tester.getRect(
find.byKey(const Key('home-feed-gesture')), find.byKey(const Key('home-feed-gesture')),
); );
// 通栏观看条铺满浮层宽度,不再左右内缩。 // 观看条与底部进度条共用同一组左右边距(各 20pt),宽度一致。
expect(bar.left, closeTo(overlayRect.left, 0.1)); expect(bar.left, closeTo(overlayRect.left + 20, 0.1));
expect(bar.width, closeTo(overlayRect.width, 0.1)); expect(bar.width, closeTo(overlayRect.width - 40, 0.1));
// 进度线中心比观看条顶沿高 8pt。
expect( expect(
bar.top - bar.left,
tester closeTo(
.getCenter(find.byKey(const Key('home-feed-progress-track'))) tester.getRect(find.byKey(const Key('home-feed-progress'))).left,
.dy, 0.1,
closeTo(8, 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( expect(
tester tester
.widget<Material>( .widget<Material>(find.byKey(const Key('home-feed-full-series-bg')))
find.byKey(const Key('home-feed-full-series-bg')),
)
.color, .color,
AppColors.textPrimary.withValues(alpha: 0.15), AppColors.textPrimary.withValues(alpha: 0.15),
); );
@@ -602,13 +615,9 @@ void main() {
tester.getRect(find.textContaining('Watch full series')).center.dy, tester.getRect(find.textContaining('Watch full series')).center.dy,
closeTo(bar.center.dy, 2), closeTo(bar.center.dy, 2),
); );
expect(
tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))),
const Size(6, 6),
);
expect( expect(
tester.getSize(find.byKey(const Key('home-feed-progress'))).height, tester.getSize(find.byKey(const Key('home-feed-progress'))).height,
greaterThanOrEqualTo(32), greaterThanOrEqualTo(16),
); );
expect( expect(
tester tester
@@ -620,19 +629,6 @@ void main() {
.getRect(find.byKey(const Key('home-feed-progress-track'))) .getRect(find.byKey(const Key('home-feed-progress-track')))
.center .center
.dy; .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( final gesture = await tester.startGesture(
tester.getCenter(find.byKey(const Key('home-feed-progress'))), tester.getCenter(find.byKey(const Key('home-feed-progress'))),
@@ -704,10 +700,6 @@ void main() {
closeTo(trackRect.right - 12, 0.1), closeTo(trackRect.right - 12, 0.1),
); );
await tester.pump(const Duration(milliseconds: 130)); await tester.pump(const Duration(milliseconds: 130));
expect(
tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))),
const Size(14, 14),
);
expect( expect(
tester.getSize(find.byKey(const Key('home-feed-progress-track'))).height, tester.getSize(find.byKey(const Key('home-feed-progress-track'))).height,
6, 6,
@@ -719,19 +711,6 @@ void main() {
.dy, .dy,
closeTo(idleTrackCenter, 0.1), 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 gesture.up();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(scrubVisibility.last, isFalse); expect(scrubVisibility.last, isFalse);
@@ -746,10 +725,6 @@ void main() {
); );
expect(seeked, isNotNull); expect(seeked, isNotNull);
expect(seeked!.inSeconds, greaterThan(0)); expect(seeked!.inSeconds, greaterThan(0));
expect(
tester.getSize(find.byKey(const Key('home-feed-progress-thumb'))),
const Size(6, 6),
);
expect( expect(
tester.getSize(find.byKey(const Key('home-feed-progress-track'))).height, tester.getSize(find.byKey(const Key('home-feed-progress-track'))).height,
2, 2,
@@ -825,11 +800,11 @@ void main() {
isBuffering: true, isBuffering: true,
); );
await tester.pump(const Duration(milliseconds: 200)); 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); expect(find.byKey(const Key('progress-loading')), findsNothing);
await tester.pump(const Duration(milliseconds: 200)); 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); expect(find.byKey(const Key('progress-loading')), findsOneWidget);
controller.value = const VideoPlayerValue( controller.value = const VideoPlayerValue(
@@ -838,7 +813,7 @@ void main() {
isInitialized: true, isInitialized: true,
); );
await tester.pump(); 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( final gesture = await tester.startGesture(
tester.getCenter(find.byKey(const Key('home-feed-progress'))), tester.getCenter(find.byKey(const Key('home-feed-progress'))),
@@ -853,7 +828,7 @@ void main() {
); );
await tester.pump(const Duration(milliseconds: 400)); 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); expect(find.byKey(const Key('progress-loading')), findsNothing);
controller.value = const VideoPlayerValue( controller.value = const VideoPlayerValue(
@@ -872,7 +847,7 @@ void main() {
isBuffering: true, isBuffering: true,
); );
await tester.pump(const Duration(milliseconds: 400)); 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); expect(find.byKey(const Key('progress-loading')), findsNothing);
}); });
@@ -89,6 +89,25 @@ void main() {
expect(cache.latestForDrama('drama-a')?.episodeId, 'episode-2'); 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', () { test('should_reject_expired_cached_position', () {
var now = DateTime(2026, 10, 22, 12); var now = DateTime(2026, 10, 22, 12);
final cache = PlaybackPositionCache(now: () => now); final cache = PlaybackPositionCache(now: () => now);
+12
View File
@@ -158,6 +158,18 @@ void main() {
logic.dispose(); 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 { test('should_not_seek_when_position_is_zero', () async {
final history = FakeWatchHistory() final history = FakeWatchHistory()
..records['signal'] = const WatchProgress( ..records['signal'] = const WatchProgress(
@@ -159,6 +159,31 @@ void main() {
expect(loader.spriteCalls, hasLength(6)); 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 { testWidgets('should_refresh_vtt_after_ten_minute_ttl', (tester) async {
var now = DateTime(2026, 10, 22); var now = DateTime(2026, 10, 22);
final loader = _FakeSpriteLoader(vtt: _spriteVtt); final loader = _FakeSpriteLoader(vtt: _spriteVtt);
+2 -3
View File
@@ -18,9 +18,8 @@ void main() {
); );
expect(uri.isAbsolute, isTrue); expect(uri.isAbsolute, isTrue);
// Uri.parse().toString() 会把 host 规范成小写,而 devHostBaseUrl 里的 // Uri.parse().toString() 会把 host 规范成小写,而开发地址可能带大写
// Bonjour 名字带大写(jiangdeMacBook-Air.local)—— 比较时统一小写, // (Bonjour 名字),比较时统一小写,否则会依赖运行平台。
// 否则会依赖运行平台(flutter test 里 defaultTargetPlatform 默认是 android)
expect( expect(
uri.toString().toLowerCase(), uri.toString().toLowerCase(),
'${AppConfig.apiBaseUrl}/files/episode/202610/clip.mp4'.toLowerCase(), '${AppConfig.apiBaseUrl}/files/episode/202610/clip.mp4'.toLowerCase(),
+2 -2
View File
@@ -35,12 +35,12 @@ void main() {
Key('player-play-toggle'), Key('player-play-toggle'),
]) { ]) {
expect(tester.getSize(find.byKey(key)), const Size(68, 68)); expect(tester.getSize(find.byKey(key)), const Size(68, 68));
// 圆形底改为半透明黑色,不再是完全透明或蓝色实底。 // 圆形深色底:压在亮画面上也能看见播放/暂停。
final container = tester.widget<Container>( final container = tester.widget<Container>(
find.descendant(of: find.byKey(key), matching: find.byType(Container)), find.descendant(of: find.byKey(key), matching: find.byType(Container)),
); );
final decoration = container.decoration! as BoxDecoration; final decoration = container.decoration! as BoxDecoration;
expect(decoration.color, Colors.black26); expect(decoration.color, const Color(0x66000000));
expect(decoration.shape, BoxShape.circle); expect(decoration.shape, BoxShape.circle);
final icon = tester.widget<Icon>( final icon = tester.widget<Icon>(
find.descendant(of: find.byKey(key), matching: find.byType(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-video-tap')), findsOneWidget);
expect(find.byKey(const Key('player-bottom-clear-screen')), findsOneWidget); expect(find.byKey(const Key('player-bottom-clear-screen')), findsOneWidget);
expect(find.byTooltip('Exit clear screen'), findsOneWidget); expect(find.byTooltip('Exit clear screen'), findsOneWidget);
// 清屏后左侧选集条一起收起,只留右下角的退出按钮。
expect(find.byKey(const Key('player-episodes-pill')), findsNothing);
expect( expect(
tester.getSize(find.byKey(const Key('player-bottom-clear-screen'))), 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)); 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-speed-boost-hint')), findsNothing);
expect(find.byKey(const Key('player-interaction-rail')), findsNothing); expect(find.byKey(const Key('player-interaction-rail')), findsNothing);
expect(find.byTooltip('Exit clear screen'), findsOneWidget); 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.byKey(const Key('player-bottom-clear-screen')), findsOneWidget);
expect( expect(
find.descendant( find.descendant(
@@ -1118,7 +1120,7 @@ void main() {
expect( expect(
tester.getSize(find.byKey(const Key('player-back'))), tester.getSize(find.byKey(const Key('player-back'))),
const Size(36, 36), const Size(40, 40),
); );
expect(tester.takeException(), isNull); expect(tester.takeException(), isNull);
}); });
@@ -1160,17 +1162,12 @@ void main() {
.child, .child,
isNotNull, isNotNull,
); );
// 选集条给一层浅底色,标出可点区域。
expect( expect(
tester tester
.widget<Material>(find.byKey(const Key('player-episodes-pill'))) .widget<Material>(find.byKey(const Key('player-episodes-pill')))
.color, .color,
AppColors.playerOverlayPill, AppColors.textPrimary.withValues(alpha: 0.14),
);
expect(
tester
.widget<Material>(find.byKey(const Key('player-episodes-pill')))
.borderRadius,
BorderRadius.circular(7),
); );
expect( expect(
tester tester
@@ -1186,9 +1183,14 @@ void main() {
?.fontSize, ?.fontSize,
13.5, 13.5,
); );
// 清屏按钮与左侧选集条等高。
expect( expect(
tester.getSize(find.byKey(const Key('player-bottom-clear-screen'))), 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-loading')), findsNothing);
expect(find.byKey(const Key('progress-inactive')), findsOneWidget); expect(find.byKey(const Key('progress-inactive')), findsOneWidget);
@@ -1289,7 +1291,8 @@ void main() {
matching: find.byIcon(Icons.keyboard_arrow_up_rounded), 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)); expect(arrow.left - label.right, closeTo(8, 0.5));
}); });
@@ -1551,7 +1554,8 @@ void main() {
), ),
); );
expect(chevron.left - title.right, lessThanOrEqualTo(6)); 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>( final synopsis = tester.widget<Text>(
find.byKey(const Key('player-synopsis')), find.byKey(const Key('player-synopsis')),
); );
@@ -1647,11 +1651,11 @@ void main() {
await tester.tap(find.byKey(const Key('player-episodes'))); await tester.tap(find.byKey(const Key('player-episodes')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('player-episodes-sheet')), findsOneWidget); expect(find.byKey(const Key('drama-info-sheet')), findsOneWidget);
expect(find.byKey(const Key('player-episodes-close')), findsOneWidget); expect(find.byKey(const Key('drama-info-close')), findsOneWidget);
final grid = tester.widget<GridView>( final grid = tester.widget<GridView>(
find.descendant( find.descendant(
of: find.byKey(const Key('player-episodes-sheet')), of: find.byKey(const Key('drama-info-sheet')),
matching: find.byType(GridView), matching: find.byType(GridView),
), ),
); );
@@ -1659,14 +1663,14 @@ void main() {
grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
expect(delegate.crossAxisCount, 6); expect(delegate.crossAxisCount, 6);
final overlay = tester.widget<AnnotatedRegion<SystemUiOverlayStyle>>( 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.systemNavigationBarColor, AppColors.card);
expect(overlay.value.systemNavigationBarContrastEnforced, isFalse); expect(overlay.value.systemNavigationBarContrastEnforced, isFalse);
final safeArea = tester.widget<SafeArea>( final safeArea = tester.widget<SafeArea>(
find find
.descendant( .descendant(
of: find.byKey(const Key('player-episodes-sheet')), of: find.byKey(const Key('drama-info-sheet')),
matching: find.byType(SafeArea), matching: find.byType(SafeArea),
) )
.first, .first,
@@ -2030,7 +2034,7 @@ void _expectFullscreenOverlay(WidgetTester tester) {
expect(find.byKey(const Key('player-synopsis')), findsOneWidget); expect(find.byKey(const Key('player-synopsis')), findsOneWidget);
expect(find.byKey(const Key('player-more')), findsOneWidget); expect(find.byKey(const Key('player-more')), findsOneWidget);
for (final key in const [Key('player-back'), Key('player-more')]) { 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-play-toggle')), findsNothing);
expect(find.byKey(const Key('player-episodes')), findsOneWidget); 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/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/services/android_picture_in_picture.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/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/core/data/services/wallet_controller.dart';
import 'package:fruitshorts/pages/player/player_logic.dart'; import 'package:fruitshorts/pages/player/player_logic.dart';
import 'package:fruitshorts/pages/player/player_view.dart'; import 'package:fruitshorts/pages/player/player_view.dart';
@@ -120,6 +121,36 @@ void main() {
logic.dispose(); logic.dispose();
await _drain(logic); 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( PlayerLogic _logic(
@@ -127,11 +158,13 @@ PlayerLogic _logic(
bool signedIn = true, bool signedIn = true,
bool Function()? isSignedIn, bool Function()? isSignedIn,
WalletController? wallet, WalletController? wallet,
UnlockedEpisodeRegistry? unlockedEpisodes,
}) { }) {
return PlayerLogic( return PlayerLogic(
history: FakeWatchHistory(), history: FakeWatchHistory(),
playback: playback, playback: playback,
wallet: wallet, wallet: wallet,
unlockedEpisodes: unlockedEpisodes,
pictureInPicture: AndroidPictureInPicture( pictureInPicture: AndroidPictureInPicture(
channel: const MethodChannel('test/player_locked_auth_flow/pip'), channel: const MethodChannel('test/player_locked_auth_flow/pip'),
), ),
+5 -2
View File
@@ -103,7 +103,10 @@ void main() {
await _pumpSession(tester); await _pumpSession(tester);
await tester.runAsync(() => logic.session.playbackSettled); 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.active, same(active));
expect(logic.session.currentIndex, index); expect(logic.session.currentIndex, index);
expect(logic.session.userWantsPlay, isTrue); expect(logic.session.userWantsPlay, isTrue);
@@ -118,7 +121,7 @@ void main() {
const Duration(minutes: 1, seconds: 22), 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();
await tester.pump(const Duration(milliseconds: 400)); await tester.pump(const Duration(milliseconds: 400));
await _pumpSession(tester); await _pumpSession(tester);
+21 -30
View File
@@ -37,11 +37,8 @@ void main() {
expect(find.byKey(const Key('scrub-overlay')), findsNothing); expect(find.byKey(const Key('scrub-overlay')), findsNothing);
expect(harness.scrubStarts, 1); expect(harness.scrubStarts, 1);
expect(_sliderTheme(tester).trackHeight, 6); expect(_sliderTheme(tester).trackHeight, 6);
expect( // 不画滑块圆点,进度只靠已播段表达。
(_sliderTheme(tester).thumbShape! as RoundSliderThumbShape) expect(_sliderTheme(tester).thumbShape, SliderComponentShape.noThumb);
.enabledThumbRadius,
8,
);
await gesture.moveBy(const Offset(72, 0)); await gesture.moveBy(const Offset(72, 0));
await tester.pump(); await tester.pump();
@@ -67,11 +64,7 @@ void main() {
expect(harness.scrubEnds, 1); expect(harness.scrubEnds, 1);
expect(harness.seeks, 1); expect(harness.seeks, 1);
expect(_sliderTheme(tester).trackHeight, 2); expect(_sliderTheme(tester).trackHeight, 2);
expect( expect(_sliderTheme(tester).thumbShape, SliderComponentShape.noThumb);
(_sliderTheme(tester).thumbShape! as RoundSliderThumbShape)
.enabledThumbRadius,
6,
);
}); });
testWidgets('feed progress tap does not clear overlay content', ( testWidgets('feed progress tap does not clear overlay content', (
@@ -154,29 +147,27 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
}); });
testWidgets('feed progress thumb stays on the finger when dragged right', ( testWidgets(
tester, 'feed progress played segment follows the finger when dragged right',
) async { (tester) async {
await _pumpFeedOverlay(tester); await _pumpFeedOverlay(tester);
final bar = find.byKey(const Key('home-feed-progress')); final bar = find.byKey(const Key('home-feed-progress'));
final gesture = await tester.startGesture(tester.getCenter(bar)); final gesture = await tester.startGesture(tester.getCenter(bar));
await gesture.moveBy(const Offset(800, 0)); await gesture.moveBy(const Offset(800, 0));
await tester.pump(); await tester.pump();
final thumbCenter = tester expect(
.getRect(find.byKey(const Key('home-feed-progress-thumb'))) tester
.center .getRect(find.byKey(const Key('home-feed-progress-played')))
.dx; .right,
final playedRight = tester closeTo(tester.getRect(bar).right, 0.5),
.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));
await gesture.up(); await gesture.up();
await tester.pumpAndSettle(); await tester.pumpAndSettle();
}); },
);
testWidgets('full player drag clears chrome and release restores it', ( testWidgets('full player drag clears chrome and release restores it', (
tester, 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/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/app.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/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_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/theme/app_dimens.dart';
import 'package:fruitshorts/core/widgets/app_bottom_nav.dart'; import 'package:fruitshorts/core/widgets/app_bottom_nav.dart';
import 'package:fruitshorts/pages/player/player_view.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/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.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-recommend')), findsOneWidget);
expect(find.byKey(const Key('recommend-tab-playlist')), 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-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); expect(find.byKey(const Key('recommend-top-gradient')), findsOneWidget);
final recommendText = tester.widget<Text>( final recommendText = tester.widget<Text>(
find.descendant( find.descendant(
@@ -65,7 +70,9 @@ void main() {
find.byKey(const Key('recommend-top-nav')), find.byKey(const Key('recommend-top-nav')),
); );
// 右侧依次是搜索和金色礼物盒(钱包入口),礼物盒贴右边距 8。 // 右侧依次是搜索和金色礼物盒(钱包入口),礼物盒贴右边距 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'))); final giftRect = tester.getRect(find.byKey(const Key('recommend-gift')));
expect(giftRect.right, closeTo(topNavRect.right - 8, 0.1)); expect(giftRect.right, closeTo(topNavRect.right - 8, 0.1));
expect(searchRect.right, lessThanOrEqualTo(giftRect.left)); expect(searchRect.right, lessThanOrEqualTo(giftRect.left));
@@ -78,7 +85,12 @@ void main() {
tester tester
.getRect(find.byKey(const Key('recommend-top-nav'))) .getRect(find.byKey(const Key('recommend-top-nav')))
.overlaps( .overlaps(
tester.getRect(find.byKey(const Key('recommend-feed-player'), skipOffstage: false)), tester.getRect(
find.byKey(
const Key('recommend-feed-player'),
skipOffstage: false,
),
),
), ),
isTrue, isTrue,
); );
@@ -387,7 +399,10 @@ void main() {
tester.getRect(find.byKey(const Key('all-dramas-filters'))).top, tester.getRect(find.byKey(const Key('all-dramas-filters'))).top,
closeTo(fixedFilterTop, 0.1), 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-category-all')), findsOneWidget);
expect(find.byKey(const Key('all-filter-tag-all')), findsOneWidget); expect(find.byKey(const Key('all-filter-tag-all')), findsOneWidget);
expect( expect(
@@ -407,7 +422,10 @@ void main() {
await tester.tap(find.byKey(const Key('recommend-tab-playlist'))); await tester.tap(find.byKey(const Key('recommend-tab-playlist')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); 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); expect(find.byType(AppBottomNav), findsOneWidget);
await tester.ensureVisible(find.byKey(const Key('recommend-tab-all'))); 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.drag(pages, const Offset(-320, 0));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('recommend-section-hits')), findsOneWidget); 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( expect(
tester tester
.widget<Text>( .widget<Text>(
@@ -454,7 +475,10 @@ void main() {
await tester.drag(pages, const Offset(640, 0)); await tester.drag(pages, const Offset(640, 0));
await tester.pumpAndSettle(); 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); expect(find.byKey(const Key('recommend-tab-recommend')), findsOneWidget);
_drainOverflows(tester); _drainOverflows(tester);
}); });
@@ -497,7 +521,9 @@ void main() {
await _pumpRecommend(tester); await _pumpRecommend(tester);
final gesture = await tester.startGesture( 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 gesture.moveBy(const Offset(0, 80));
await tester.pump(); await tester.pump();
@@ -505,7 +531,10 @@ void main() {
final feedScroll = tester.state<ScrollableState>( final feedScroll = tester.state<ScrollableState>(
find find
.descendant( .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), matching: find.byType(Scrollable),
) )
.first, .first,
@@ -544,6 +573,25 @@ void main() {
expect(search.focusNode?.hasFocus, isTrue); expect(search.focusNode?.hasFocus, isTrue);
_drainOverflows(tester); _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 /// 测试字体(等宽方块字形)比真实字体宽/高,卡片固定高度会报 2~4px
+1 -1
View File
@@ -19,7 +19,7 @@ void main() {
expect(AppDimens.sectionTitle, 16); expect(AppDimens.sectionTitle, 16);
expect(AppDimens.pageTitle, 20); expect(AppDimens.pageTitle, 20);
expect(AppDimens.navLabel, 13); expect(AppDimens.navLabel, 13);
expect(AppDimens.navLabelGap, 5); expect(AppDimens.navLabelGap, 1);
expect(AppDimens.navIcon, 30); expect(AppDimens.navIcon, 30);
expect(AppDimens.buttonHeight, 48); expect(AppDimens.buttonHeight, 48);
expect(AppDimens.primaryButtonHeight, 52); 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/home_feed.dart';
import 'package:fruitshorts/core/data/models/page_result.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_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/catalog_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart'; import 'package:fruitshorts/core/data/remote/api_client.dart';
@@ -278,6 +279,25 @@ void main() {
expect(controller.error, '账号不存在,请先注册'); 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 { class ControlledWatchHistory implements WatchHistoryRepository {