33 Commits
Author SHA1 Message Date
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
Zhou Weidong 70be941755 chore(build): 忽略 android 构建产物目录 2026-10-29 09:18:47 +08:00
Zhou Weidong 351d0afd09 chore(deps): 补齐 pubspec.lock 中画中画相关依赖 2026-10-29 09:18:43 +08:00
Zhou Weidong 7d32324a2d docs(readme): 补画中画、下载落盘与 iOS 打包说明,更新接口清单
接口从 26 条路径更新到 47 条(补账号设备/注销、搜索、剧单、标签、剧集统计
与解锁、钱包商品订单、评论数);画中画结论由"iOS 未接入"改为双端已接入;
下载由"不落盘、不支持离线播放"改为已落盘可离线、但不支持断点续传;
门禁更新为 dart analyze + flutter test(775 例)。
2026-10-28 19:05:12 +08:00
Zhou Weidong 1d4ab5e0e6 test: 按新 UI 基线重写过期用例
覆盖 155 条失败与 4 处编译错误:入口改走推荐流 tab、弹框换 AppGlowDialog、
rail 尺寸与底部锚定、子页标题字重 w400、底栏选中品牌红、收藏文案改收藏短剧。
全量 flutter test 775 通过。
2026-10-28 18:48:03 +08:00
Zhou Weidong 799f3d1b7d feat(player): 选集面板挑集后记住上次选集 2026-10-28 18:47:51 +08:00
Zhou Weidong 08aa6903d2 fix(player): 推荐流播放器补钱包依赖并统一点赞选中色 2026-10-28 18:47:51 +08:00
Zhou Weidong 4de9417e27 fix(auth): 放大登录注册分段的可点击区域 2026-10-28 18:47:51 +08:00
Zhou Weidong e269d5dce5 fix(ui): 修正卡片正文预留高度,消除底部溢出 2026-10-28 18:47:51 +08:00
Zhou Weidong 78f464f2a4 fix(ui): 弹框富内容加高度约束,长文案恢复滚动 2026-10-28 18:47:51 +08:00
Zhou Weidong 9983d42015 fix(core): 非移动端跳过设备区域读取,避免注册流程卡住 2026-10-28 18:47:51 +08:00
Vicky bfe0df62dc Merge branch 'develop' 2026-10-28 17:14:52 +08:00
Vicky df2654c9d6 fix(player): iOS自动小窗仅在播放页活跃时拉起 2026-10-28 17:14:50 +08:00
Vicky 496b1cb797 Merge branch 'develop' 2026-10-28 16:39:11 +08:00
Vicky 85dedf20fa fix(player): 修复清晰度切换闪屏与还原黑屏 2026-10-28 16:36:55 +08:00
Vicky 43e60e29ec feat(player): 接入双端画中画播放 2026-10-28 16:36:55 +08:00
Vicky 5346be40a9 fix(ui): 打磨推荐追剧我的设置页间距与图标 2026-10-28 16:36:51 +08:00
Vicky de7da64886 feat(ui): 登录页重设计并统一弹框主题渐变外壳 2026-10-28 16:36:43 +08:00
Vicky 709972245f fix(config): release 包固定公网接口地址,不再探测开发机
- release 默认地址固定为 publicBaseUrl(39.162.50.99:8081)
- release 启动跳过环回/局域网探测,避免合并 develop 后请求到开发机 192.168 地址
- debug 行为不变,仍按环回/开发机探测并支持 dart-define 覆盖
2026-10-27 20:10:06 +08:00
Vicky d5d13549f5 chore(merge): 合并推荐频道页与沉浸式首页到 main
- 新增推荐(tab1)接口分类频道页:分类轮播切换、选中频道自动定位、下拉刷新与上拉分页
- 精选频道 banner 轮播 + 接口 tag 上新横滑 + 双列短剧网格,卡片标题统一白色
- 恢复首页(tab0)沉浸式全屏信息流
2026-10-27 20:02:53 +08:00
Vicky c027acc91f feat(recommend): 新增推荐频道页并恢复首页沉浸式信息流
- tab1 推荐页改为接口分类频道:频道导航可横滑,选中频道自动滚动到可视区,内容区左右滑动切换分类
- 精选频道:顶部固定高度 banner 轮播图 + 接口 tag 筛选的上新短剧横滑 + 其余短剧双列网格;其他分类三列网格
- 分类内容支持懒加载、下拉刷新与上拉分页加载
- 卡片角标文案与背景色取自接口 tag/tagColor,短剧标题统一白色
- 恢复 tab0 为沉浸式全屏视频首页及其三 inner-tab 顶导(搜索/礼物入口)
- l10n 新增原创出品、立即观看、上新、编辑精选、全N集等多语言文案
2026-10-27 19:57:42 +08:00
Zhou Weidong 76429628ec chore(merge): 合并播放器切集与双击点赞优化到 main 2026-10-26 19:31:47 +08:00
Zhou Weidong 43f3593218 chore(merge): 合并 iOS 无线数据选择框触发到 main 2026-10-26 09:35:29 +08:00
Zhou Weidong 0eca7ecc31 chore(merge): 合并 iOS 无线数据询问到 main 2026-10-25 21:23:26 +08:00
Zhou Weidong af5d8f9d80 chore(merge): 合并客户端多语言筛选到 main 2026-10-25 19:52:30 +08:00
Zhou Weidong b84a5368d6 chore(merge): 将 release/v1.0.0 同步到 main 2026-10-23 13:07:33 +08:00
Zhou Weidong 67e178c89d chore(merge): 将 develop 同步到 release/v1.0.0 2026-10-23 13:07:33 +08:00
156 changed files with 7834 additions and 4179 deletions

No files matched your search

+89 -56
View File
@@ -1,18 +1,22 @@
# FruitShorts(ReelShort)客户端 # FruitShorts(ReelShort)客户端
FruitShorts 是面向印尼市场的短剧 Flutter 客户端。当前版本已连接 FruitShorts 是面向印尼市场的短剧 Flutter 客户端,已连接 `FruitServer` 的
`FruitServer` 的 `/app/**` 接口,支持沉浸播放、找剧、账号、观看记录、收藏、 `/app/**` 接口。功能覆盖:四 tab 主页(推荐视频流 / 找剧 / 追剧 / 我的)、
下载授权、评论互动、站内通知和中英印尼三语界面。 沉浸式播放与全屏剧集播放器、清晰度切换、剧单与排行榜、新剧页、搜索、观看记录、
收藏短剧、评论与点赞、站内通知、钱包与商品、下载落盘与离线播放、中英印尼三语,
以及 Android / iOS 双端画中画(退到后台自动进小窗)。
## 技术栈 ## 技术栈
- Flutter 3.47.7 / Dart 3.13.5 - Flutter 3.47.7 / Dart 3.13.5(`environment.sdk: ^3.12.2`)
- Provider 状态管理与依赖注入 - Provider 状态管理与依赖注入
- dio 网络请求 - dio 网络请求
- video_player 播放 - video_player 播放
- shared_preferences 与 `SharedPreferencesAsync` 本地配置 - shared_preferences 与 `SharedPreferencesAsync` 本地配置
- path_provider + dart:io 下载落盘(先写 `.part`,完成后原子改名)
- flutter_localizations,支持 `en`、`zh`、`id` - flutter_localizations,支持 `en`、`zh`、`id`
- Android 系统画中画 - 画中画:Android 用 `floating` + `permission_handler`(需悬浮窗权限);
iOS 用原生 `AVPictureInPictureController` + 影子播放器(`ios/Runner/AppDelegate.swift`)
## 目录结构 ## 目录结构
@@ -22,7 +26,7 @@ lib/
config/ 地址、语言和运行时配置 config/ 地址、语言和运行时配置
data/ models、remote API、repository、controller data/ models、remote API、repository、controller
formatting/ 文案与日期格式化 formatting/ 文案与日期格式化
platform/ 触觉等平台能力 platform/ 平台能力:画中画通道、设备号、触觉
theme/ 颜色与主题 theme/ 颜色与主题
widgets/ 跨页面复用组件 widgets/ 跨页面复用组件
l10n/ 三语 ARB 与生成代码 l10n/ 三语 ARB 与生成代码
@@ -36,20 +40,21 @@ assets/ 图标、图片和本地媒体文件
## 已接接口 ## 已接接口
以下接口均在 `lib/core/data/remote/` 中有实际调用,共 26 个不同路径、 以下接口均在 `lib/core/data/remote/` 中有实际调用,共 47 个不同路径。
31 个 HTTP 方法级调用。
| 分组 | 契约 | 接口 | | 分组 | 接口 |
| --- | --- | --- | | --- | --- |
| 账号 | §1.1 | `POST /app/auth/register`、`/login`、`/refresh`、`/logout` | | 账号 | `POST /app/auth/register`、`/login`、`/refresh`、`/logout`、`/otp`;`GET /app/me`、`/app/me/devices`、`/app/me/export`;`POST /app/me/deactivate`、`/app/me/deactivate/cancel` |
| 内容 | §1.3、§1.4 | `GET /app/home/feed`、`/app/discover/feed`、`/app/categories`、`/app/dramas/{id}`、`/app/episodes/{id}/play` | | 内容 | `GET /app/home/feed`、`/app/feed`、`/app/discover/feed`、`/app/categories`、`/app/categories/{id}/dramas`、`/app/drama-categories`、`/app/dramas/page`、`/app/dramas/{id}`、`/app/tags`、`/app/search`、`/app/playlists`、`/app/playlists/{id}` |
| 观看记录 | §1.5 | `POST/GET /app/watch/progress`、`GET /app/watch/recent`、`DELETE /app/watch/progress/{dramaId}`、`DELETE /app/watch/progress` | | 播放 | `GET /app/episodes/{id}/play`、`/app/episodes/{id}/stats`;`POST /app/episodes/{id}/unlock` |
| 收藏 | §1.5 | `POST /app/favorites/toggle`、`GET /app/favorites`、`DELETE /app/favorites/{dramaId}` | | 观看记录 | `POST/GET /app/watch/progress`、`GET /app/watch/recent`、`DELETE /app/watch/progress/{dramaId}`、`DELETE /app/watch/progress` |
| 下载授权 | §1.6 | `GET /app/downloads/policy`、`PUT /app/downloads/settings`、`POST/GET /app/downloads`、`GET /app/downloads/{episodeId}/url`、`POST /app/downloads/{episodeId}/progress`、`DELETE /app/downloads/{episodeId}`、`DELETE /app/downloads` | | 收藏 | `POST /app/favorites/toggle`、`GET /app/favorites`、`DELETE /app/favorites/{dramaId}`、`POST /app/favorites/remove` |
| 互动 | §1.7 | `GET/POST /app/comments`、`DELETE /app/comments/{id}`、`POST /app/likes/toggle`、`POST /app/reports` | | 下载 | `GET /app/downloads/policy`、`PUT /app/downloads/settings`、`POST/GET /app/downloads`、`GET /app/downloads/{id}/url`、`POST /app/downloads/{id}/progress`、`DELETE /app/downloads/{id}`、`DELETE /app/downloads` |
| 公告 | §1.9.2 | `GET /app/notices` | | 互动 | `GET/POST /app/comments`、`DELETE /app/comments/{id}`、`GET /app/comments/count`、`POST /app/likes/toggle`、`POST /app/reports` |
| 商业化 | `GET /app/wallet`、`/app/products`、`/app/orders` |
| 公告 | `GET /app/notices` |
客户端没有调用 `/app/config` 或创作者入驻接口。 客户端没有调用 `/app/config` 和创作者入驻接口。
## 运行 ## 运行
@@ -66,39 +71,57 @@ 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 版本可在「我的 → 设置 → 服务器地址」修改后端地址,保存后重启 App 生效。 地址是代码里的一个开关,**不做任何网络探测**,也不用传构建参数。两套环境各占
地址优先级: 一个文件:
```text | 文件 | 用途 | 默认值 |
本地运行时设置 > --dart-define(API_BASE_URL) > 平台默认地址 | --- | --- | --- |
| `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 即用生产地址
``` ```
因此不带 `--dart-define` 构建的 debug APK 也可在安装后改成当前电脑 IP。 换 Wi-Fi 或换网段时只改 `dev_config.dart` 的 `baseUrl`。打生产包前把
release 版本不显示入口,也不会读取本地覆盖。 `useDevConfig` 改成 `false`;release 构建若仍为 `true` 会在启动时直接报错,
避免线上包连到开发机。
debug 包在找剧页加载失败时会显示调试条,点「修改服务器地址」可临时改成当前
电脑 IP,保存后立即重载、不需要重新打包,该覆盖保存在本地偏好里(仅 debug
生效,release 不读取)。
## 画中画
| 平台 | 行为 |
| --- | --- |
| Android | 首次点播放页小窗按钮时申请悬浮窗权限;授权后退出 App 自动进入系统小窗。沉浸流与独立播放页共用一份引用计数,任一处活跃都算「在播」,避免互相顶掉 |
| iOS | 原生影子播放器承载 `AVPictureInPictureController`,只有播放页处于活跃状态时退到后台才自动进小窗;停在剧单等页面退后台不会触发 |
- 小窗内切集直接换源,不走页面翻页,可连续自动播放
- iOS 从系统小窗还原时用原生过渡层盖住纹理黑帧,等首帧就绪后再淡出
- 播放页「更多」面板里有「退出自动小窗」开关(仅支持自动小窗的平台显示),关掉后
退到后台不再进入小窗
## 环境注意 ## 环境注意
@@ -131,46 +154,56 @@ 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)
```bash
# 正式签名(需要 iOS Distribution 证书 + App Store 描述文件)
flutter build ipa --release
# 只有 Apple Development 证书时:出开发签名 IPA,只能装已注册设备
flutter build ipa --release --export-method development
```
产物在 `build/ios/ipa/*.ipa`。画中画依赖 `UIBackgroundModes` 里的 `audio` 与
`AVPictureInPictureController`,改动 `ios/Runner/Info.plist` 或 `AppDelegate.swift`
后要重新构建,热重载不会生效。
## 门禁 ## 门禁
```bash ```bash
dart analyze lib test dart analyze
flutter test flutter test
flutter build apk --debug flutter build apk --debug
``` ```
测试文件按模块放在 `test/` 下,用例名使用 当前基线:`dart analyze` 仅 1 条既存 info(`intl` 未在 `pubspec.yaml` 声明);
`flutter test` 全量 775 例通过。测试文件按模块放在 `test/` 下,用例名使用
`should_<期望>_when_<条件>`。真实后端联调测试在服务未启动时会跳过。 `should_<期望>_when_<条件>`。真实后端联调测试在服务未启动时会跳过。
## 当前边界 ## 当前边界
- 下载仅管理服务端授权,不落盘、不续传、不支持离线播放。 - 下载会落盘并支持离线播放,但不支持断点续传:中途失败会丢弃 `.part` 重下。
- 没有独立搜索接口,搜索能力尚未形成完整闭环。 - 支付与金币:客户端已有钱包、商品、下单接口与页面,商店票据校验依赖服务端配置凭据。
- 站内通知没有已读/未读和红点状态。 - 站内通知没有已读/未读和红点状态。
- 支付、金币和会员权益尚未形成客户端闭环。 - 播放清晰度切换会保留旧画面 500ms 掩盖闪屏,切换瞬间的进度以相对位置续播。
- Android 支持系统画中画;iOS 画中画尚未接入。
+1
View File
@@ -1,4 +1,5 @@
/.gradle /.gradle
/build/
/captures/ /captures/
/local.properties /local.properties
GeneratedPluginRegistrant.java GeneratedPluginRegistrant.java
+13 -1
View File
@@ -8,7 +8,9 @@ plugins {
android { android {
namespace = "com.stormg.lumadrama" namespace = "com.stormg.lumadrama"
compileSdk = flutter.compileSdkVersion // permission_handler_android 14 的 AAR 元数据要求依赖方 compileSdk >= 37,
// 固定为 37(比 Flutter 默认的 36 高一档,只影响编译期 API 可见性)。
compileSdk = 37
ndkVersion = flutter.ndkVersion ndkVersion = flutter.ndkVersion
compileOptions { compileOptions {
@@ -33,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")
@@ -50,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.**
+3
View File
@@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission <uses-permission
android:name="android.permission.INTERNET"/> android:name="android.permission.INTERNET"/>
<!-- 画中画自动触发依赖的「显示在其他应用上层」权限(部分 ROM 用它管控 PiP) -->
<uses-permission
android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application <application
android:label="FruitShorts" android:label="FruitShorts"
android:name="${applicationName}" android:name="${applicationName}"
@@ -284,6 +284,7 @@ class MainActivity : FlutterActivity() {
} }
companion object { companion object {
private const val TAG = "FruitPip"
private const val thumbnailMaxWidth = 240 private const val thumbnailMaxWidth = 240
private const val thumbnailJpegQuality = 70 private const val thumbnailJpegQuality = 70
private const val thumbnailTimeoutMs = 1500L private const val thumbnailTimeoutMs = 1500L
+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
+399
View File
@@ -1,6 +1,7 @@
import Flutter import Flutter
import UIKit import UIKit
import AVFoundation import AVFoundation
import AVKit
private let thumbnailHTTPHeaderFieldsKey = "AVURLAssetHTTPHeaderFieldsKey" private let thumbnailHTTPHeaderFieldsKey = "AVURLAssetHTTPHeaderFieldsKey"
@@ -35,6 +36,318 @@ final class RunnerFlutterViewController: FlutterViewController {
} }
} }
/// iOS 影子播放器:独立 AVPlayer + AVPlayerLayer 专供画中画窗口。
/// Flutter 的 video_player 走纹理渲染,没有 AVPlayerLayer,无法直接创建
/// AVPictureInPictureController;小窗的画面与声音由影子接管,退出时把
/// 影子进度回传给 Dart,由 Flutter 播放器 seek 对齐后继续。
final class PipShadowCoordinator: NSObject, AVPictureInPictureControllerDelegate {
static let shared = PipShadowCoordinator()
private var player: AVPlayer?
private var playerLayer: AVPlayerLayer?
private var controller: AVPictureInPictureController?
private var endObserver: NSObjectProtocol?
private var possibleObservation: NSKeyValueObservation?
/// 画中画还原过渡层:原生窗口级红晕渐变,在系统还原动画开始时立即盖上,
/// 避免回前台瞬间 Flutter 表面未恢复显示黑帧;视频出帧后由 Dart 通知淡出。
private var restoreOverlay: UIView?
/// 系统要求 playerLayer 位于真实的 view 层级中才会判定画中画可用,
/// 因此用 1x1 透明宿主 view 承载,而不是直接挂在 window.layer 上。
private var hostView: UIView?
/// 影子图层就绪稍晚于创建,置位后由 KVO 自动拉起画中画。
private var pendingStart = false
private var startRetryWorkItems: [DispatchWorkItem] = []
weak var channel: FlutterMethodChannel?
/// active 变化回调;停止时把影子最终进度(毫秒)一并提供。
var onPipChanged: ((Bool, Int64) -> Void)?
/// 影子在画中画里播完当前集。
var onPlaybackEnded: (() -> Void)?
static var isSupported: Bool {
AVPictureInPictureController.isPictureInPictureSupported()
}
private var keyWindow: UIWindow? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
.first { $0.isKeyWindow }
}
/// 首次创建影子:1x1 图层挂在 keyWindow 的宿主 view 里(肉眼不可见),静音待命。
/// [autoEnter] 为 true 时退后台由系统自动拉起画中画(需影子在播,见 armAutoEnter)。
func prepare(urlString: String, positionMs: Int64, autoEnter: Bool) {
teardown()
log("prepare begin supported=\(PipShadowCoordinator.isSupported) autoEnter=\(autoEnter)")
guard Self.isSupported else { return }
guard let url = URL(string: urlString) else {
log("prepare failed bad url \(urlString)")
return
}
guard let window = keyWindow else {
log("prepare failed no key window")
return
}
let item = AVPlayerItem(url: url)
let p = AVPlayer(playerItem: item)
p.isMuted = true
p.actionAtItemEnd = .pause
let layer = AVPlayerLayer()
layer.player = p
let host = UIView(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
host.isUserInteractionEnabled = false
host.backgroundColor = .clear
window.addSubview(host)
layer.frame = host.bounds
host.layer.addSublayer(layer)
hostView = host
guard let c = AVPictureInPictureController(playerLayer: layer) else {
p.pause()
log("prepare failed no pip controller")
return
}
c.canStartPictureInPictureAutomaticallyFromInline = autoEnter
c.delegate = self
player = p
playerLayer = layer
controller = c
// 静音起播让 item 完成首帧解码,isPictureInPicturePossible 才会变 true。
p.play()
possibleObservation = c.observe(
\.isPictureInPicturePossible,
options: [.new, .initial]
) { [weak self] controller, change in
let value = change.newValue ?? controller.isPictureInPicturePossible
self?.log("possible changed to \(value)")
guard let self, self.pendingStart, controller.isPictureInPicturePossible else { return }
self.pendingStart = false
// 刚翻 true 的同一 runloop 里调用会被系统静默拒绝,推到下一拍再进。
DispatchQueue.main.async {
guard self.controller === controller, controller.isPictureInPicturePossible else {
self.log("start via kvo aborted (shadow replaced)")
return
}
self.log("start via kvo")
controller.startPictureInPicture()
}
}
log("shadow ready, seeking to \(positionMs)ms")
if positionMs > 0 {
p.seek(
to: CMTime(value: positionMs, timescale: 1000),
toleranceBefore: .zero,
toleranceAfter: .zero
)
}
registerEndObserver(for: item)
activateAudioSession()
}
/// 退出 App 前(inactive)把影子对齐到当前进度并静音起播:
/// auto PiP 只在「player 正在播放 + 图层离屏」时由系统触发。
func armAutoEnter(positionMs: Int64) {
guard let player, let controller else {
log("arm failed, shadow missing")
return
}
guard controller.canStartPictureInPictureAutomaticallyFromInline else {
log("arm failed, autoEnter disabled on shadow")
return
}
log("arm auto enter at \(positionMs)ms")
if positionMs > 0 {
player.seek(
to: CMTime(value: positionMs, timescale: 1000),
toleranceBefore: CMTime(value: 800, timescale: 1000),
toleranceAfter: CMTime(value: 800, timescale: 1000)
)
}
player.play()
}
/// 回前台后取消待命:影子暂停回静音,等待下一次退后台。
func disarmAutoEnter() {
guard let player else { return }
log("disarm auto enter")
player.pause()
player.isMuted = true
}
/// 进入画中画:图层就绪则立即进入,否则 KVO/延时重试在就绪后自动进入。
/// 仅当影子从未创建成功(prepare 失败)时返回 false。
func start() -> Bool {
guard let controller else {
log("start failed, shadow not created")
return false
}
log("start requested possible=\(controller.isPictureInPicturePossible)")
pendingStart = true
if controller.isPictureInPicturePossible {
pendingStart = false
controller.startPictureInPicture()
}
scheduleStartRetries()
return true
}
/// KVO 偶发不触发或流加载较慢时兜底:就绪后再试两次。
private func scheduleStartRetries() {
startRetryWorkItems.forEach { $0.cancel() }
startRetryWorkItems.removeAll()
for delay in [1.0, 2.5] {
let item = DispatchWorkItem { [weak self] in
guard
let self,
self.pendingStart,
let controller = self.controller,
controller.isPictureInPicturePossible
else { return }
self.log("start via retry after \(delay)s")
self.pendingStart = false
controller.startPictureInPicture()
}
startRetryWorkItems.append(item)
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item)
}
}
/// 画中画内换集:只换 AVPlayerItem,不动 player/layer,窗口不中断。
func replaceItem(urlString: String, positionMs: Int64) {
guard let player, let url = URL(string: urlString) else { return }
let item = AVPlayerItem(url: url)
player.replaceCurrentItem(with: item)
registerEndObserver(for: item)
if positionMs > 0 {
player.seek(
to: CMTime(value: positionMs, timescale: 1000),
toleranceBefore: .zero,
toleranceAfter: .zero
)
}
}
/// 进入画中画后解除静音并起播(此时 Flutter 播放器已被暂停)。
func unmuteAndPlay() {
player?.isMuted = false
player?.play()
}
func positionMs() -> Int64 {
Int64(max(0, player?.currentTime().seconds ?? 0) * 1000)
}
func stopPip() {
controller?.stopPictureInPicture()
}
func teardown() {
startRetryWorkItems.forEach { $0.cancel() }
startRetryWorkItems.removeAll()
possibleObservation?.invalidate()
possibleObservation = nil
pendingStart = false
if let endObserver {
NotificationCenter.default.removeObserver(endObserver)
}
endObserver = nil
player?.pause()
player?.replaceCurrentItem(with: nil)
playerLayer?.removeFromSuperlayer()
playerLayer = nil
hostView?.removeFromSuperview()
hostView = nil
controller?.delegate = nil
controller = nil
player = nil
}
/// 诊断日志:NSLog 之外同步转发到 Dart,便于在 flutter run 控制台观察。
private func log(_ message: String) {
NSLog("pip: %@", message)
channel?.invokeMethod("pipDebug", arguments: message)
}
private func registerEndObserver(for item: AVPlayerItem) {
if let endObserver {
NotificationCenter.default.removeObserver(endObserver)
}
endObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: item,
queue: .main
) { [weak self] _ in
self?.log("item did play to end")
self?.onPlaybackEnded?()
}
}
/// 画中画的声音走影子播放器,需要 playback 类别的音频会话保活。
private func activateAudioSession() {
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
try AVAudioSession.sharedInstance().setActive(true)
} catch {
// 会话激活失败只影响后台出声,不阻断画中画。
}
}
func pictureInPictureControllerWillStartPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
log("will start")
pendingStart = false
onPipChanged?(true, 0)
}
func pictureInPictureControllerWillStopPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
log("will stop, showing restore overlay")
showRestoreOverlay()
}
func pictureInPictureControllerDidStopPictureInPicture(
_ pictureInPictureController: AVPictureInPictureController
) {
log("did stop")
showRestoreOverlay()
onPipChanged?(false, positionMs())
}
/// 盖原生过渡层(幂等):主题红晕渐变,不依赖 Flutter 渲染。
func showRestoreOverlay() {
guard let window = keyWindow else { return }
restoreOverlay?.removeFromSuperview()
let overlay = UIView(frame: window.bounds)
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
let gradient = CAGradientLayer()
gradient.frame = overlay.bounds
gradient.colors = [
UIColor(red: 0.298, green: 0.086, blue: 0.102, alpha: 1).cgColor,
UIColor(red: 0.118, green: 0.043, blue: 0.047, alpha: 1).cgColor,
UIColor(red: 0.063, green: 0.063, blue: 0.071, alpha: 1).cgColor,
]
gradient.locations = [0, 0.22, 0.62]
gradient.startPoint = CGPoint(x: 0.5, y: 0)
gradient.endPoint = CGPoint(x: 0.5, y: 1)
overlay.layer.addSublayer(gradient)
window.addSubview(overlay)
restoreOverlay = overlay
}
/// 淡出并移除过渡层(视频首帧已确认后由 Dart 调用)。
func hideRestoreOverlay() {
guard let overlay = restoreOverlay else { return }
restoreOverlay = nil
UIView.animate(
withDuration: 0.25,
animations: { overlay.alpha = 0 }
) { _ in overlay.removeFromSuperview() }
}
}
@main @main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private let wirelessDataPrompt = WirelessDataPrompt() private let wirelessDataPrompt = WirelessDataPrompt()
@@ -64,6 +377,7 @@ final class RunnerFlutterViewController: FlutterViewController {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
registerWirelessDataChannel(engineBridge) registerWirelessDataChannel(engineBridge)
registerDeviceIdChannel(engineBridge) registerDeviceIdChannel(engineBridge)
registerPipChannel(engineBridge)
let channel = FlutterMethodChannel( let channel = FlutterMethodChannel(
name: "com.stormg.lumadrama/share", name: "com.stormg.lumadrama/share",
binaryMessenger: engineBridge.applicationRegistrar.messenger() binaryMessenger: engineBridge.applicationRegistrar.messenger()
@@ -145,6 +459,91 @@ final class RunnerFlutterViewController: FlutterViewController {
} }
} }
/// iOS 画中画:影子播放器通道,与 Android 共用同一 channel 名。
private func registerPipChannel(_ engineBridge: FlutterImplicitEngineBridge) {
let channel = FlutterMethodChannel(
name: "com.stormg.lumadrama/pip",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
)
let pip = PipShadowCoordinator.shared
pip.channel = channel
channel.setMethodCallHandler { call, result in
switch call.method {
case "isAvailable":
result(PipShadowCoordinator.isSupported)
case "enter":
guard let args = call.arguments as? [String: Any],
let url = args["url"] as? String else {
result(false)
return
}
let positionMs = (args["positionMs"] as? NSNumber)?.int64Value ?? 0
pip.prepare(urlString: url, positionMs: positionMs, autoEnter: false)
// 等一拍让 AVPlayerLayer 完成挂载再请求进入画中画。
DispatchQueue.main.async {
result(pip.start())
}
case "prepareShadow":
guard let args = call.arguments as? [String: Any],
let url = args["url"] as? String else {
result(nil)
return
}
pip.prepare(
urlString: url,
positionMs: (args["positionMs"] as? NSNumber)?.int64Value ?? 0,
autoEnter: (args["autoEnter"] as? NSNumber)?.boolValue ?? false
)
result(nil)
case "armAutoEnter":
pip.armAutoEnter(
positionMs: (call.arguments as? [String: Any])?["positionMs"] as? Int64 ?? 0
)
result(nil)
case "showRestoreOverlay":
pip.showRestoreOverlay()
result(nil)
case "hideRestoreOverlay":
pip.hideRestoreOverlay()
result(nil)
case "disarmAutoEnter":
pip.disarmAutoEnter()
result(nil)
case "unmute":
pip.unmuteAndPlay()
result(nil)
case "replaceItem":
guard let args = call.arguments as? [String: Any],
let url = args["url"] as? String else {
result(nil)
return
}
pip.replaceItem(
urlString: url,
positionMs: (args["positionMs"] as? NSNumber)?.int64Value ?? 0
)
result(nil)
case "stop":
pip.stopPip()
result(nil)
case "teardown":
pip.teardown()
result(nil)
default:
result(FlutterMethodNotImplemented)
}
}
pip.onPipChanged = { active, positionMs in
channel.invokeMethod(
"onPipChanged",
arguments: ["active": active, "positionMs": positionMs]
)
}
pip.onPlaybackEnded = {
channel.invokeMethod("onPipPlaybackEnded", arguments: nil)
}
}
/// 设备标识:IDFV 免权限,卸载本 App 不变,避免客户端自生成的 id 每次重装都变。 /// 设备标识:IDFV 免权限,卸载本 App 不变,避免客户端自生成的 id 每次重装都变。
/// 同通道顺带提供设备区域(ISO-2),注册时作为服务端 IP 解析失败后的兜底。 /// 同通道顺带提供设备区域(ISO-2),注册时作为服务端 IP 解析失败后的兜底。
private func registerDeviceIdChannel(_ engineBridge: FlutterImplicitEngineBridge) { private func registerDeviceIdChannel(_ engineBridge: FlutterImplicitEngineBridge) {
+4
View File
@@ -87,6 +87,10 @@
</array> </array>
</dict> </dict>
</dict> </dict>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
+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 -145
View File
@@ -1,165 +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);
static final String _defaultBaseUrl = _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;
}
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 '';
@@ -167,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();
+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,
);
} }
/// 删除某部剧的观看记录(记录页清单条用)。 /// 删除某部剧的观看记录(记录页清单条用)。
@@ -3,7 +3,8 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
/// Native Android activity PiP; the Flutter playback session keeps the player. /// 原生活动级画中画桥:Android 是 Activity PiP(画面即 Flutter 渲染),
/// iOS 是影子播放器接力(画面/声音来自原生 AVPlayer,退出时进度回传)。
class AndroidPictureInPicture { class AndroidPictureInPicture {
AndroidPictureInPicture({MethodChannel? channel}) AndroidPictureInPicture({MethodChannel? channel})
: _channel = channel ?? const MethodChannel('com.stormg.lumadrama/pip'); : _channel = channel ?? const MethodChannel('com.stormg.lumadrama/pip');
@@ -11,22 +12,147 @@ class AndroidPictureInPicture {
final MethodChannel _channel; final MethodChannel _channel;
bool get isPlatformSupported => bool get isPlatformSupported =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.android; !kIsWeb &&
(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS);
void setStateListener(ValueChanged<bool>? listener) { void Function(bool active, int finalPositionMs)? _pipChangedListener;
_channel.setMethodCallHandler( VoidCallback? _playbackEndedListener;
listener == null
? null /// PiP 状态回调:iOS 停止时 [finalPositionMs] 为影子最终进度(毫秒)。
: (call) async { void setStateListener(void Function(bool active, int finalPositionMs)? listener) {
if (call.method == 'onPipChanged') { _pipChangedListener = listener;
listener(call.arguments == true); _refreshHandler();
}
},
);
} }
Future<bool> enter() async { /// iOS 画中画内影子播完当前集(Android 不会触发)。
void setPlaybackEndedListener(VoidCallback? listener) {
_playbackEndedListener = listener;
_refreshHandler();
}
void _refreshHandler() {
if (_pipChangedListener == null && _playbackEndedListener == null) {
_channel.setMethodCallHandler(null);
return;
}
_channel.setMethodCallHandler((call) async {
switch (call.method) {
case 'pipDebug':
debugPrint('pip[native]: ${call.arguments}');
case 'onPipChanged':
final listener = _pipChangedListener;
if (listener == null) return;
final args = call.arguments;
if (args is Map) {
listener(
args['active'] == true,
(args['positionMs'] as num?)?.toInt() ?? 0,
);
} else {
// Android 只回传 bool。
listener(args == true, 0);
}
case 'onPipPlaybackEnded':
_playbackEndedListener?.call();
}
});
}
/// 进入画中画。Android 忽略片源参数;iOS 用 [url] 与 [positionMs]
/// 初始化影子播放器(对齐到当前进度、静音待命)。
Future<bool> enter({required String url, required int positionMs}) async {
if (!isPlatformSupported) return false; if (!isPlatformSupported) return false;
return await _channel.invokeMethod<bool>('enter') ?? false; return await _channel.invokeMethod<bool>('enter', {
'url': url,
'positionMs': positionMs,
}) ??
false;
}
/// 创建影子播放器但不起播(仅 iOS):
/// 自动小窗待命形态——静音暂停,退后台由 [armAutoEnter] 拉起。
Future<void> prepareShadow({
required String url,
required int positionMs,
bool autoEnter = false,
}) async {
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
try {
await _channel.invokeMethod<void>('prepareShadow', {
'url': url,
'positionMs': positionMs,
'autoEnter': autoEnter,
});
} catch (_) {}
}
/// 退后台瞬间把影子对齐到当前进度并静音起播,触发系统自动画中画(仅 iOS)。
Future<void> armAutoEnter(int positionMs) async {
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
try {
await _channel.invokeMethod<void>('armAutoEnter', {
'positionMs': positionMs,
});
} catch (_) {}
}
/// 回前台后影子回待命(暂停、静音)(仅 iOS)。
Future<void> disarmAutoEnter() async {
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
try {
await _channel.invokeMethod<void>('disarmAutoEnter');
} catch (_) {}
}
/// 显示原生还原过渡层(仅 iOS;还原动画开始时原生侧也会自动盖上)。
Future<void> showRestoreOverlay() async {
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
try {
await _channel.invokeMethod<void>('showRestoreOverlay');
} catch (_) {}
}
/// 淡出原生还原过渡层(仅 iOS;视频首帧确认后调用)。
Future<void> hideRestoreOverlay() async {
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
try {
await _channel.invokeMethod<void>('hideRestoreOverlay');
} catch (_) {}
}
/// 解除影子静音并起播(仅 iOS 有意义,进入 PiP 后声音交给影子)。
Future<void> unmute() async {
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
try {
await _channel.invokeMethod<void>('unmute');
} catch (_) {}
}
/// iOS 画中画内换集:影子只换播放项,窗口不中断。
Future<void> replaceItem({required String url, required int positionMs}) async {
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
try {
await _channel.invokeMethod<void>('replaceItem', {
'url': url,
'positionMs': positionMs,
});
} catch (_) {}
}
/// 请求退出画中画(iOS;触发 onPipChanged(false) 后由 Dart 收尾)。
Future<void> stop() async {
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
try {
await _channel.invokeMethod<void>('stop');
} catch (_) {}
}
/// 销毁影子播放器(仅 iOS)。
Future<void> teardown() async {
if (defaultTargetPlatform != TargetPlatform.iOS || kIsWeb) return;
try {
await _channel.invokeMethod<void>('teardown');
} catch (_) {}
} }
} }
-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();
}
}
@@ -38,6 +38,9 @@ class DeviceIdProvider {
/// Android 依次取网络运营商 / SIM 卡 / 系统语言的国家,iOS 取系统 region。 /// Android 依次取网络运营商 / SIM 卡 / 系统语言的国家,iOS 取系统 region。
Future<String?> deviceRegion() async { Future<String?> deviceRegion() async {
if (kIsWeb) return null; if (kIsWeb) return null;
// 只有两端原生侧实现了 region;其它平台(测试、桌面)上通道没有 handler,
// 调用会一直挂着,注册流程会卡在"转圈"。
if (!Platform.isAndroid && !Platform.isIOS) return null;
try { try {
final region = await _channel.invokeMethod<String>('region'); final region = await _channel.invokeMethod<String>('region');
final value = region?.trim().toUpperCase() ?? ''; final value = region?.trim().toUpperCase() ?? '';
+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();
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ part 'playback_neighbors.dart';
part 'playback_session_controls.dart'; part 'playback_session_controls.dart';
/// 新纹理第一帧常是黑的,旧画面只盖住这一小段。 /// 新纹理第一帧常是黑的,旧画面只盖住这一小段。
const Duration _qualityHandoffHold = Duration(milliseconds: 200); const Duration _qualityHandoffHold = Duration(milliseconds: 500);
typedef PlayerFactory = PlayerPort Function(Episode episode); typedef PlayerFactory = PlayerPort Function(Episode episode);
typedef PlayerPreparation = typedef PlayerPreparation =
+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();
}
} }
@@ -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,71 +77,83 @@ 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];
} }
/// 按剧集 id 找最近观看记录,供收藏网格显示「看到第n集」与继续观看横幅。
WatchRecord? recordForDrama(String dramaId) {
for (final record in records) {
if (record.dramaId == dramaId) return record;
}
return null;
}
/// 删除单条记录,先更新 UI,失败按原位置回滚。 /// 删除单条记录,先更新 UI,失败按原位置回滚。
Future<bool> remove(String dramaId) async { Future<bool> remove(String dramaId) async {
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 [
@@ -231,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));
} }
} }
@@ -264,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,7 +235,7 @@ class WatchRecordsController extends ChangeNotifier {
stackTrace: stackTrace, stackTrace: stackTrace,
label: '[WatchRecordsController] detail failed dramaId=$dramaId', label: '[WatchRecordsController] detail failed dramaId=$dramaId',
); );
if (_disposed) return null; if (isDisposed) return null;
final current = _recordIndex(dramaId); final current = _recordIndex(dramaId);
if (current < 0) return null; if (current < 0) return null;
_replaceAt( _replaceAt(
@@ -307,33 +256,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();
} }
} }
+25 -10
View File
@@ -27,13 +27,13 @@ abstract final class AppColors {
static const overlaySecondary = Color(0xFFC7C7CC); static const overlaySecondary = Color(0xFFC7C7CC);
static const overlayLabel = Color(0xFFE6E6EB); static const overlayLabel = Color(0xFFE6E6EB);
/// 主操作、选中态、播放进度(品牌橙)。 /// 主操作、选中态、播放进度。旧品牌橙已全量映射为品牌红(2026-10 改版)。
static const blue = Color(0xFFFF6B2C); static const blue = Color(0xFFF04E4E);
static const primary = blue; static const primary = blue;
static const actionRed = Color(0xFFFF3B30); static const actionRed = Color(0xFFFF3B30);
/// 主操作按下态。 /// 主操作按下态。
static const bluePressed = Color(0xFFE55A1F); static const bluePressed = Color(0xFFD64040);
/// 链接与次要高亮:统一改用中性浅灰,避免旧青色残留。 /// 链接与次要高亮:统一改用中性浅灰,避免旧青色残留。
static const blueSoft = Color(0xFFB0B0B0); static const blueSoft = Color(0xFFB0B0B0);
@@ -47,6 +47,10 @@ abstract final class AppColors {
static const homeGiftPink = Color(0xFFFF4D8D); static const homeGiftPink = Color(0xFFFF4D8D);
static const vipGoldStart = Color(0xFFF5D9A0); static const vipGoldStart = Color(0xFFF5D9A0);
static const vipGoldEnd = Color(0xFFE8B65A); static const vipGoldEnd = Color(0xFFE8B65A);
/// 「我的」页 VIP 卡的浅香槟金(见设计稿,接近纯色)。
static const vipCardStart = Color(0xFFF6E8C6);
static const vipCardEnd = Color(0xFFEDD9A8);
static const vipBadgeSurface = Color(0x1F3D2408); static const vipBadgeSurface = Color(0x1F3D2408);
static const bronze = Color(0xFFCD7F32); static const bronze = Color(0xFFCD7F32);
static const bronzeDeep = Color(0xFFA9713A); static const bronzeDeep = Color(0xFFA9713A);
@@ -75,6 +79,15 @@ abstract final class AppColors {
/// 首页/追剧/我的统一的纯黑内容背景。 /// 首页/追剧/我的统一的纯黑内容背景。
static const appBackground = Color(0xFF000000); static const appBackground = Color(0xFF000000);
/// 追剧页顶部的暗红光晕,向下淡入纯黑(见设计稿)。
static const followingGlow = Color(0xFF4C161A);
/// 光晕的过渡色:让暖色维持更久再压黑。
static const followingGlowMid = Color(0xFF1E0B0C);
/// 继续观看横幅剧名的暖白(见设计稿,非纯白)。
static const bannerTitleWarm = Color(0xFFF5EFE4);
/// 中性深灰卡片与控件表面。 /// 中性深灰卡片与控件表面。
static const contentCard = Color(0xFF16161A); static const contentCard = Color(0xFF16161A);
static const contentSurface = Color(0xFF1C1C1E); static const contentSurface = Color(0xFF1C1C1E);
@@ -96,7 +109,7 @@ abstract final class AppColors {
static const mineGradientTop = Color(0xFF3A1B0E); static const mineGradientTop = Color(0xFF3A1B0E);
static const mineGradientMiddle = Color(0xFF1A0C07); static const mineGradientMiddle = Color(0xFF1A0C07);
static const mineGradientBottom = Color(0xFF000000); static const mineGradientBottom = Color(0xFF000000);
static const mineGradientGlow = Color(0x33FF6B2C); static const mineGradientGlow = Color(0x33F04E4E);
static const mineGridSurface = Color(0x99101010); static const mineGridSurface = Color(0x99101010);
static const minePanelSurface = Color(0xFF101010); static const minePanelSurface = Color(0xFF101010);
static const mineMoreDivider = Color(0xFF1C1C1E); static const mineMoreDivider = Color(0xFF1C1C1E);
@@ -111,7 +124,7 @@ abstract final class AppColors {
static const mineIconLanguage = Color(0xFF22D3EE); static const mineIconLanguage = Color(0xFF22D3EE);
static const mineIconSettings = Color(0xFFA1A1AA); static const mineIconSettings = Color(0xFFA1A1AA);
static const mineIconNotices = Color(0xFFFF8A4C); static const mineIconNotices = Color(0xFFFF8A4C);
static const recommendPlaylistGlow = Color(0x26FF6B2C); static const recommendPlaylistGlow = Color(0x26F04E4E);
/// 次级文字与列表 chevron。 /// 次级文字与列表 chevron。
static const textPrimary = Color(0xFFFFFFFF); static const textPrimary = Color(0xFFFFFFFF);
@@ -133,9 +146,11 @@ abstract final class AppColors {
/// 底部导航底色。 /// 底部导航底色。
static const bar = ink; static const bar = ink;
/// 底部导航专用:纯黑背景、选中白与未选中灰。 /// 底部导航专用:纯黑背景、选中品牌红与未选中白。
static const navBackground = Color(0xFF000000); static const navBackground = Color(0xFF000000);
static const navSelected = Color(0xFFFFFFFF); static const brandRed = Color(0xFFF04E4E);
static const brandRedPressed = Color(0xFFD64040);
static const navSelected = brandRed;
/// 卡片阴影(深色几乎不用阴影)。 /// 卡片阴影(深色几乎不用阴影)。
static const cardShadow = Color(0x00000000); static const cardShadow = Color(0x00000000);
@@ -188,8 +203,8 @@ abstract final class AppColors {
/// 封面集数胶囊。 /// 封面集数胶囊。
static const badgeBeige = raised; static const badgeBeige = raised;
/// 导航未选中。 /// 导航未选中(设计稿为白色线框 + 白色文字)。
static const navUnselected = textSecondary; static const navUnselected = Color(0xFFFFFFFF);
/// 首页内容角标。 /// 首页内容角标。
static const badgeHot = Color(0xFFE5484D); static const badgeHot = Color(0xFFE5484D);
@@ -213,7 +228,7 @@ abstract final class AppColors {
static const bannerScrimEnd = Color(0xE6000000); static const bannerScrimEnd = Color(0xE6000000);
/// 票券虚线等主色描边。 /// 票券虚线等主色描边。
static const primaryBorder = Color(0x66FF6B2C); static const primaryBorder = Color(0x66F04E4E);
static const outlineDark = border; static const outlineDark = border;
static const outlineVariantDark = border; static const outlineVariantDark = border;
+70 -9
View File
@@ -146,10 +146,12 @@ abstract final class AppDimens {
static const double homeActionGap = 6; static const double homeActionGap = 6;
/// 推荐分区。 /// 推荐分区。
static const double recommendSectionGap = 12;
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;
static const double recommendRowHeight = 252; // 封面 122*(4/3)≈163 + 间距6 + 两行标题39 + 间距6 + 标签13 ≈ 227。
static const double recommendRowHeight = 228;
static const double recommendCardWidth = 122; static const double recommendCardWidth = 122;
/// 首页热播榜。 /// 首页热播榜。
@@ -169,25 +171,27 @@ abstract final class AppDimens {
/// 全部短剧筛选与追剧双列。 /// 全部短剧筛选与追剧双列。
static const double filterChipFont = 16; static const double filterChipFont = 16;
static const double filterChipHeight = 32; static const double filterChipHeight = 28;
static const double filterChipRadius = 16; static const double filterChipRadius = 14;
static const double filterChipHorizontalPadding = 12; static const double filterChipHorizontalPadding = 12;
static const double filterRowGap = 6; static const double filterRowGap = 6;
static const double filterItemGap = 10; static const double filterItemGap = 10;
static const double allDramasHorizontal = 12; static const double allDramasHorizontal = 12;
static const double allDramasGridGap = 6; static const double allDramasGridGap = 6;
static const double allDramasGridRowGap = 12; static const double allDramasGridRowGap = 6;
static const double allDramasFilterToGridGap = 12; static const double allDramasFilterToGridGap = 12;
static const double followingHeaderHeight = 52; static const double followingHeaderHeight = 52;
static const double followingTabFont = 18; static const double followingTabFont = 18;
static const double followingTabGap = 24; static const double followingTabGap = 24;
static const double followingEditIconSize = 20; static const double followingEditIconSize = 20;
static const double followingEditHitSize = 40; static const double followingEditHitSize = 40;
static const double followingAddIconSize = 28; static const double followingAddIconSize = 20;
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;
static const double followingCardTextExtent = 70; // 单行标题 16*1.3=20.8 + 间距 6+4 + 副标题行高约 18.6 ≈ 49.4,留 0.6 余量;
// 按 48 估算在副标题行高偏高的字体下会溢出 1~2px。
static const double followingCardTextExtent = 50;
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;
@@ -201,6 +205,47 @@ abstract final class AppDimens {
static const double followingHistoryProgressGap = 8; static const double followingHistoryProgressGap = 8;
static const double followingHistoryLabelGap = 4; static const double followingHistoryLabelGap = 4;
static const double followingHistoryActionHeight = 28; static const double followingHistoryActionHeight = 28;
/// 追剧页新版头部与收藏布局。
static const double followingTitleFont = 26;
static const double followingTitleRowHeight = 42;
static const double followingTitleTopGap = 8;
static const double followingSearchIcon = 26;
static const double followingManageFont = 16;
static const double followingHeaderActionGap = 8;
static const double followingSegmentFont = 16;
static const double followingSegmentGap = 28;
static const double followingSegmentRowHeight = 42;
static const double followingSegmentIndicatorWidth = 24;
static const double followingSegmentIndicatorHeight = 3;
static const double followingSegmentIndicatorRadius = 2;
static const double followingSegmentIndicatorTopGap = 6;
static const double followingBannerHeight = 176;
static const double followingBannerRadius = 16;
static const double followingBannerEdgeGap = 16;
static const double followingBannerInnerGap = 16;
static const double followingBannerTitleFont = 22;
static const double followingBannerSubtitleFont = 13;
static const double followingBannerMetaGap = 8;
static const double followingBannerButtonHeight = 40;
static const double followingBannerButtonRadius = 20;
static const double followingBannerButtonFont = 15;
static const double followingBannerButtonIcon = 18;
static const double followingBannerButtonGap = 6;
static const double followingBannerButtonHorizontalPadding = 20;
static const double followingBannerButtonTopGap = 14;
static const double followingBannerProgressHeight = 3;
static const double followingSectionTitleFont = 16;
static const double followingSectionCountFont = 13;
static const double followingSectionTopGap = 20;
static const double followingSectionBottomGap = 12;
static const double followingCardTitleFontNew = 16;
static const double followingCardSubtitleFontNew = 13;
static const double followingBadgeFont = 11;
static const double followingBadgeRadius = 6;
static const double followingBadgeInset = 8;
static const double followingBadgePaddingH = 8;
static const double followingBadgePaddingV = 3;
static const Duration followingTabAnimationDuration = Duration( static const Duration followingTabAnimationDuration = Duration(
milliseconds: 180, milliseconds: 180,
); );
@@ -211,8 +256,8 @@ abstract final class AppDimens {
/// 「我的」页中性视觉。 /// 「我的」页中性视觉。
static const double mineCardRadius = 16; static const double mineCardRadius = 16;
static const double mineSectionTitle = 15; static const double mineSectionTitle = 15;
static const double mineAvatar = 56; static const double mineAvatar = 64;
static const double mineUserName = 20; static const double mineUserName = 24;
static const double mineUserNameLineHeight = 1.15; static const double mineUserNameLineHeight = 1.15;
static const double mineUserNameUidGap = 2; static const double mineUserNameUidGap = 2;
static const double mineUid = 12; static const double mineUid = 12;
@@ -227,7 +272,23 @@ abstract final class AppDimens {
static const double mineGridDividerWidth = 1; static const double mineGridDividerWidth = 1;
static const double mineSectionTitleFont = 16; static const double mineSectionTitleFont = 16;
static const double mineSectionLinkFont = 12; static const double mineSectionLinkFont = 12;
static const double mineSectionGap = 20; static const double mineSectionGap = 12;
/// 「我的」页新版:快捷图标行与服务列表。
static const double mineQuickActionIcon = 28;
static const double mineQuickActionLabelFont = 14;
static const double mineQuickActionGap = 8;
static const double mineServiceRowHeight = 56;
static const double mineServiceIcon = 24;
static const double mineServiceTitleFont = 16;
static const double mineServiceValueFont = 14;
static const double mineVipBenefitFont = 14;
static const double mineVipBenefitIcon = 20;
static const double mineVipBenefitBox = 30;
static const double mineVipBenefitBoxRadius = 7;
static const double mineVipBenefitGlyph = 17;
static const double mineVipBenefitRowGap = 4;
static const double mineVipButtonHeight = 36;
static const double mineContinueCardWidth = 96; static const double mineContinueCardWidth = 96;
static const double mineContinueRowHeight = 172; static const double mineContinueRowHeight = 172;
static const double mineContinueTitleFont = 13; static const double mineContinueTitleFont = 13;
+11 -11
View File
@@ -40,7 +40,7 @@ abstract final class AppTheme {
/// 全局深色主题。 /// 全局深色主题。
static ThemeData dark() { static ThemeData dark() {
const scheme = ColorScheme.dark( const scheme = ColorScheme.dark(
primary: AppColors.blue, primary: AppColors.brandRed,
onPrimary: AppColors.onPrimary, onPrimary: AppColors.onPrimary,
secondary: AppColors.secondary, secondary: AppColors.secondary,
onSecondary: AppColors.ink, onSecondary: AppColors.ink,
@@ -54,7 +54,7 @@ abstract final class AppTheme {
outline: AppColors.border, outline: AppColors.border,
outlineVariant: AppColors.border, outlineVariant: AppColors.border,
surfaceTint: AppColors.transparent, surfaceTint: AppColors.transparent,
inversePrimary: AppColors.bluePressed, inversePrimary: AppColors.brandRedPressed,
error: AppColors.error, error: AppColors.error,
onError: AppColors.onPrimary, onError: AppColors.onPrimary,
); );
@@ -66,16 +66,16 @@ abstract final class AppTheme {
canvasColor: AppColors.ink, canvasColor: AppColors.ink,
fontFamily: 'PlusJakartaSans', fontFamily: 'PlusJakartaSans',
progressIndicatorTheme: const ProgressIndicatorThemeData( progressIndicatorTheme: const ProgressIndicatorThemeData(
color: AppColors.blue, color: AppColors.brandRed,
), ),
sliderTheme: const SliderThemeData( sliderTheme: const SliderThemeData(
activeTrackColor: AppColors.blue, activeTrackColor: AppColors.brandRed,
inactiveTrackColor: AppColors.border, inactiveTrackColor: AppColors.border,
thumbColor: AppColors.onPrimary, thumbColor: AppColors.onPrimary,
), ),
tabBarTheme: const TabBarThemeData( tabBarTheme: const TabBarThemeData(
labelColor: AppColors.blue, labelColor: AppColors.brandRed,
indicatorColor: AppColors.blue, indicatorColor: AppColors.brandRed,
unselectedLabelColor: AppColors.muted, unselectedLabelColor: AppColors.muted,
), ),
textButtonTheme: TextButtonThemeData( textButtonTheme: TextButtonThemeData(
@@ -138,12 +138,12 @@ abstract final class AppTheme {
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: _inputRadius, borderRadius: _inputRadius,
borderSide: const BorderSide(color: AppColors.blue), borderSide: const BorderSide(color: AppColors.brandRed),
), ),
), ),
chipTheme: ChipThemeData( chipTheme: ChipThemeData(
backgroundColor: AppColors.raised, backgroundColor: AppColors.raised,
selectedColor: AppColors.blue.withValues(alpha: 0.22), selectedColor: AppColors.brandRed.withValues(alpha: 0.22),
labelStyle: const TextStyle(color: AppColors.onSurface), labelStyle: const TextStyle(color: AppColors.onSurface),
secondaryLabelStyle: const TextStyle(color: AppColors.muted), secondaryLabelStyle: const TextStyle(color: AppColors.muted),
side: const BorderSide(color: AppColors.border), side: const BorderSide(color: AppColors.border),
@@ -216,7 +216,7 @@ abstract final class AppTheme {
), ),
trackColor: WidgetStateProperty.resolveWith( trackColor: WidgetStateProperty.resolveWith(
(states) => (states) =>
states.contains(WidgetState.selected) ? AppColors.blue : null, states.contains(WidgetState.selected) ? AppColors.brandRed : null,
), ),
), ),
snackBarTheme: const SnackBarThemeData( snackBarTheme: const SnackBarThemeData(
@@ -273,8 +273,8 @@ abstract final class AppTheme {
if (states.contains(WidgetState.pressed) || if (states.contains(WidgetState.pressed) ||
states.contains(WidgetState.hovered) || states.contains(WidgetState.hovered) ||
states.contains(WidgetState.focused)) { states.contains(WidgetState.focused)) {
return AppColors.bluePressed; return AppColors.brandRedPressed;
} }
return AppColors.blue; return AppColors.brandRed;
} }
} }
+121
View File
@@ -0,0 +1,121 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
import '../theme/app_dimens.dart';
/// 主题红晕渐变容器:弹框(居中)与底部弹层(半屏)共用的外壳。
/// 纯黑弹框统一升级为「顶部红光 → 近黑」的家族渐变。
class AppGlowPanel extends StatelessWidget {
const AppGlowPanel({
super.key,
required this.child,
this.borderRadius = const BorderRadius.all(Radius.circular(20)),
this.border = true,
});
final Widget child;
final BorderRadius borderRadius;
final bool border;
static const _gradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.followingGlow,
AppColors.followingGlowMid,
Color(0xFF101012),
],
stops: [0, 0.22, 0.62],
);
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
gradient: _gradient,
borderRadius: borderRadius,
border: border
? Border.all(color: AppColors.contentBorder)
: null,
),
child: child,
);
}
}
/// 主题渐变确认弹框:替代 AlertDialog 的家族化外壳。
/// 按钮沿用项目惯例:纯文字 TextButton,主操作品牌红。
class AppGlowDialog extends StatelessWidget {
const AppGlowDialog({
super.key,
this.title,
this.content,
this.contentChild,
this.actions,
this.contentTextStyle,
});
final String? title;
final String? content;
/// 富内容(表单/滚动区),与 [content] 二选一。
final Widget? contentChild;
final List<Widget>? actions;
final TextStyle? contentTextStyle;
@override
Widget build(BuildContext context) {
final bodyStyle = contentTextStyle ??
TextStyle(
color: AppColors.muted,
fontSize: AppDimens.body,
fontWeight: FontWeight.w400,
);
return Dialog(
backgroundColor: AppColors.transparent,
elevation: 0,
child: AppGlowPanel(
child: Padding(
padding: const EdgeInsets.fromLTRB(22, 22, 22, 10),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null) ...[
Text(
title!,
style: TextStyle(
color: AppColors.onSurface,
fontSize: AppDimens.sectionTitle,
fontWeight: FontWeight.w400,
),
),
const SizedBox(height: 14),
],
if (content != null)
Text(content!, style: bodyStyle)
else if (contentChild != null)
// 必须给富内容一个有界高度,否则内部 SingleChildScrollView 拿到无界
// 约束会直接撑高整块内容(长文本小屏下溢出且无法滚动)。
Flexible(child: contentChild!),
if (actions != null && actions!.isNotEmpty) ...[
const SizedBox(height: 18),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
for (var i = 0; i < actions!.length; i++) ...[
if (i > 0) const SizedBox(width: 4),
actions![i],
],
],
),
],
],
),
),
),
);
}
}
+6 -14
View File
@@ -7,18 +7,12 @@ import 'app_bottom_nav.dart';
/// 客户端根导航的固定顺序。 /// 客户端根导航的固定顺序。
enum AppRootTab { enum AppRootTab {
home( home(),
assetPath: 'assets/icon/film_reel.png',
selectedAssetPath: 'assets/icon/film_reel_filled.png',
),
recommend(), recommend(),
following(), following(),
mine(); mine();
const AppRootTab({this.assetPath, this.selectedAssetPath}); const AppRootTab();
final String? assetPath;
final String? selectedAssetPath;
} }
extension AppRootTabUi on AppRootTab { extension AppRootTabUi on AppRootTab {
@@ -30,15 +24,15 @@ extension AppRootTabUi on AppRootTab {
}; };
IconData get icon => switch (this) { IconData get icon => switch (this) {
AppRootTab.home => Icons.theaters_outlined, AppRootTab.home => Icons.home_outlined,
AppRootTab.recommend => Icons.smart_display_outlined, AppRootTab.recommend => Icons.celebration_outlined,
AppRootTab.following => Icons.bookmark_border_rounded, AppRootTab.following => Icons.bookmark_border_rounded,
AppRootTab.mine => Icons.person_outline_rounded, AppRootTab.mine => Icons.person_outline_rounded,
}; };
IconData get selectedIcon => switch (this) { IconData get selectedIcon => switch (this) {
AppRootTab.home => Icons.theaters, AppRootTab.home => Icons.home_rounded,
AppRootTab.recommend => Icons.smart_display_rounded, AppRootTab.recommend => Icons.celebration_rounded,
AppRootTab.following => Icons.bookmark_rounded, AppRootTab.following => Icons.bookmark_rounded,
AppRootTab.mine => Icons.person_rounded, AppRootTab.mine => Icons.person_rounded,
}; };
@@ -75,8 +69,6 @@ class AppRootBottomNav extends StatelessWidget {
tabKey: tab.tabKey, tabKey: tab.tabKey,
icon: tab.icon, icon: tab.icon,
selectedIcon: tab.selectedIcon, selectedIcon: tab.selectedIcon,
assetPath: tab.assetPath,
selectedAssetPath: tab.selectedAssetPath,
label: tab.label(l10n), label: tab.label(l10n),
selected: tab == selected, selected: tab == selected,
onTap: () => onSelected(tab), onTap: () => onSelected(tab),
+7 -5
View File
@@ -5,6 +5,7 @@ import '../../l10n/app_localizations.dart';
import '../../pages/auth/auth_view.dart'; import '../../pages/auth/auth_view.dart';
import '../data/services/auth_controller.dart'; import '../data/services/auth_controller.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import 'app_glow_panel.dart';
/// 打开登录 / 注册页(沿用现有 Navigator 风格,不引路由库) /// 打开登录 / 注册页(沿用现有 Navigator 风格,不引路由库)
Future<void> openAuthPage(BuildContext context, {bool signUpMode = false}) { Future<void> openAuthPage(BuildContext context, {bool signUpMode = false}) {
@@ -23,11 +24,11 @@ Future<bool> ensureSignedIn(BuildContext context) async {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final go = await showModalBottomSheet<bool>( final go = await showModalBottomSheet<bool>(
context: context, context: context,
backgroundColor: AppColors.card, backgroundColor: AppColors.transparent,
shape: const RoundedRectangleBorder( builder: (sheetContext) => AppGlowPanel(
borderRadius: BorderRadius.vertical(top: Radius.circular(18)), borderRadius: const BorderRadius.vertical(top: Radius.circular(18)),
), border: false,
builder: (sheetContext) => SafeArea( child: SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 20), padding: const EdgeInsets.fromLTRB(20, 18, 20, 20),
child: Column( child: Column(
@@ -71,6 +72,7 @@ Future<bool> ensureSignedIn(BuildContext context) async {
], ],
), ),
), ),
),
), ),
); );
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import '../config/app_config.dart'; import '../config/app_config.dart';
import '../config/server_endpoint.dart'; import '../config/server_endpoint.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import 'app_glow_panel.dart';
const String _title = '设置服务地址'; const String _title = '设置服务地址';
const String _save = '保存'; const String _save = '保存';
@@ -14,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>(
@@ -36,7 +36,6 @@ Future<void> showCatalogEndpointEditor(
} }
return; return;
} }
await (probe ?? () => AppConfig.probeAndApplyBaseUrl())();
await reload(); await reload();
} }
@@ -72,9 +71,9 @@ class _CatalogEndpointDialogState extends State<_CatalogEndpointDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return AppGlowDialog(
title: const Text(_title), title: _title,
content: SingleChildScrollView( contentChild: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
+13 -2
View File
@@ -3,7 +3,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../theme/app_theme.dart';
import 'subpage_header.dart'; import 'subpage_header.dart';
/// 二级页面通用骨架:返回栏 + 标题/副标题 + 可滚动内容,无底部导航。 /// 二级页面通用骨架:返回栏 + 标题/副标题 + 可滚动内容,无底部导航。
@@ -29,7 +28,19 @@ class PlainPageScaffold extends StatelessWidget {
key: pageKey, key: pageKey,
backgroundColor: AppColors.ink, backgroundColor: AppColors.ink,
body: DecoratedBox( body: DecoratedBox(
decoration: const BoxDecoration(gradient: AppTheme.pageGradient), // 与首页各 tab 同族:纯黑底 + 顶部暗红光晕。
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.followingGlow,
AppColors.followingGlowMid,
AppColors.appBackground,
],
stops: [0, 0.14, 0.3],
),
),
child: SafeArea( child: SafeArea(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
+7 -3
View File
@@ -74,9 +74,13 @@ class RemoteImage extends StatelessWidget {
: null); : null);
final cacheWidth = final cacheWidth =
decodeWidth ?? resolveImageDecodePixels(layoutWidth, pixelRatio); decodeWidth ?? resolveImageDecodePixels(layoutWidth, pixelRatio);
final cacheHeight = // cover/contain 本身会保持宽高比,此时若同时强制 cacheWidth 和
decodeHeight ?? // cacheHeight,解码会把竖版图强行拉成横版像素。只在显式指定了
(inferredHeight == null // decodeHeight、或 fit 不保持宽高比时才限定解码高度。
final keepsAspectRatio =
fit == BoxFit.cover || fit == BoxFit.contain;
final cacheHeight = decodeHeight ??
(keepsAspectRatio || inferredHeight == null
? null ? null
: resolveImageDecodePixels(inferredHeight, pixelRatio)); : resolveImageDecodePixels(inferredHeight, pixelRatio));
return Image.network( return Image.network(
+2 -2
View File
@@ -8,7 +8,7 @@ const double _height = 52;
const double _heightWithSubtitle = 64; const double _heightWithSubtitle = 64;
const double _horizontal = 14; const double _horizontal = 14;
const double _backSize = 40; const double _backSize = 40;
const double _backGap = 12; const double _backGap = 6;
const double _titleFontSize = 18; const double _titleFontSize = 18;
const double _subtitleFontSize = 12; const double _subtitleFontSize = 12;
const double _actionGap = 8; const double _actionGap = 8;
@@ -163,7 +163,7 @@ class AppSubpageHeader extends StatelessWidget {
style: TextStyle( style: TextStyle(
color: foreground, color: foreground,
fontSize: _titleFontSize, fontSize: _titleFontSize,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w400,
), ),
), ),
if (subtitle != null) ...[ if (subtitle != null) ...[
+73 -4
View File
@@ -20,7 +20,6 @@
"featured": "Featured", "featured": "Featured",
"recommended": "Recommended", "recommended": "Recommended",
"fruitShortsBrand": "FruitShorts", "fruitShortsBrand": "FruitShorts",
"authLandingSubtitle": "Sign in to sync progress, favorites and coins",
"comingSoon": "Coming soon", "comingSoon": "Coming soon",
"authLegal": "By continuing, you agree to the Terms of Service and Privacy Policy.", "authLegal": "By continuing, you agree to the Terms of Service and Privacy Policy.",
"authLegalLead": "By continuing, you agree to the", "authLegalLead": "By continuing, you agree to the",
@@ -55,12 +54,24 @@
"viewers": {"type": "String"} "viewers": {"type": "String"}
} }
}, },
"watchFullSeries": "Watch full series · {episodes} eps", "watchFullSeries": "Watch full series {episodes} eps",
"@watchFullSeries": { "@watchFullSeries": {
"placeholders": { "placeholders": {
"episodes": {"type": "String"} "episodes": {"type": "String"}
} }
}, },
"feedEpisodePrefix": "EP{episode}",
"@feedEpisodePrefix": {
"placeholders": {
"episode": {"type": "int"}
}
},
"feedWatchingCount": "{count} watching",
"@feedWatchingCount": {
"placeholders": {
"count": {"type": "String"}
}
},
"feedTabAnime": "Anime", "feedTabAnime": "Anime",
"feedTabReal": "Live-action", "feedTabReal": "Live-action",
"feedTabForYou": "For you", "feedTabForYou": "For you",
@@ -263,8 +274,21 @@
"shareFailed": "Could not open the share sheet.", "shareFailed": "Could not open the share sheet.",
"nowPlaying": "Playing now", "nowPlaying": "Playing now",
"pictureInPicture": "Picture in picture", "pictureInPicture": "Picture in picture",
"autoEnterPip": "Auto mini player on exit",
"pipUnavailable": "Picture in picture is unavailable on this device", "pipUnavailable": "Picture in picture is unavailable on this device",
"originalTag": "Original", "originalTag": "Original",
"originalProduction": "Original",
"watchNowAction": "Watch Now",
"newArrivalBadge": "NEW",
"editorPick": "Editor's Pick",
"totalEpisodesShort": "{count} eps",
"@totalEpisodesShort": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"dramaTag": "Short drama", "dramaTag": "Short drama",
"episodeLabel": "Episode {number}", "episodeLabel": "Episode {number}",
"@episodeLabel": { "@episodeLabel": {
@@ -418,6 +442,7 @@
"restoreDefault": "Restore default", "restoreDefault": "Restore default",
"invalidServerAddress": "Enter a valid http(s) server address.", "invalidServerAddress": "Enter a valid http(s) server address.",
"signOut": "Sign Out", "signOut": "Sign Out",
"signOutConfirm": "Sign out of your current account?",
"mockUserName": "Vicky sheng", "mockUserName": "Vicky sheng",
"mockUserId": "ID: M1CZWVYE", "mockUserId": "ID: M1CZWVYE",
"mockNotice": "Sample data only.", "mockNotice": "Sample data only.",
@@ -763,8 +788,14 @@
"offlineDownloads": "Offline downloads", "offlineDownloads": "Offline downloads",
"watchPreferences": "Watch preferences", "watchPreferences": "Watch preferences",
"contactSupport": "Contact support", "contactSupport": "Contact support",
"vipBrandName": "Netshort VIP", "vipBrandName": "FruitShorts VIP",
"mineVipSubtitle": "Enjoy exclusive benefits", "vipOpenNow": "Get VIP",
"vipBenefitFree": "Free full access",
"vipBenefitNoAds": "Ad-free viewing",
"vipBenefitHd": "1080P HD",
"vipBenefitFastCache": "Fast downloads",
"moreServices": "More",
"mineVipSubtitle": "Unlock excitement, binge freely",
"favoriteEpisodeProgress": "Episode {episode} / {total} total", "favoriteEpisodeProgress": "Episode {episode} / {total} total",
"@favoriteEpisodeProgress": { "@favoriteEpisodeProgress": {
"placeholders": { "placeholders": {
@@ -772,6 +803,44 @@
"total": { "type": "int" } "total": { "type": "int" }
} }
}, },
"myFavorites": "My Favorites",
"manage": "Manage",
"continueWatching": "Continue Watching",
"continueWatchingSubtitle": "Episode {episode} of {total}",
"@continueWatchingSubtitle": {
"placeholders": {
"episode": { "type": "int" },
"total": { "type": "int" }
}
},
"favoriteDramas": "Favorites",
"totalDramasCount": "{count} titles",
"@totalDramasCount": {
"placeholders": {
"count": { "type": "int" }
}
},
"watchedEpisodeTotal": "EP {episode} / {total}",
"@watchedEpisodeTotal": {
"placeholders": {
"episode": { "type": "int" },
"total": { "type": "int" }
}
},
"hasUpdateBadge": "UPDATED",
"updatedToEpisode": "Updated to EP {episode}",
"@updatedToEpisode": {
"placeholders": {
"episode": { "type": "int" }
}
},
"finishedAllEpisodes": "{total} eps · Completed",
"@finishedAllEpisodes": {
"placeholders": {
"total": { "type": "int" }
}
},
"noFavoritesYet": "No favorites yet",
"watchedToEpisode": "Watched to Episode {episode}", "watchedToEpisode": "Watched to Episode {episode}",
"@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } },
"homeWatchAll": "Watch all", "homeWatchAll": "Watch all",
+78 -4
View File
@@ -20,7 +20,6 @@
"featured": "Unggulan", "featured": "Unggulan",
"recommended": "Rekomendasi", "recommended": "Rekomendasi",
"fruitShortsBrand": "FruitShorts", "fruitShortsBrand": "FruitShorts",
"authLandingSubtitle": "Masuk untuk menyinkronkan progres, favorit, dan koin",
"comingSoon": "Segera hadir", "comingSoon": "Segera hadir",
"authLegal": "Dengan melanjutkan, Anda menyetujui Ketentuan Layanan dan Kebijakan Privasi.", "authLegal": "Dengan melanjutkan, Anda menyetujui Ketentuan Layanan dan Kebijakan Privasi.",
"authLegalLead": "Dengan melanjutkan, Anda menyetujui", "authLegalLead": "Dengan melanjutkan, Anda menyetujui",
@@ -49,7 +48,24 @@
"hotBadge": "Hits", "hotBadge": "Hits",
"filterAll": "Semua", "filterAll": "Semua",
"seriesMeta": "Serial · {episodes} eps | {viewers} menonton", "seriesMeta": "Serial · {episodes} eps | {viewers} menonton",
"watchFullSeries": "Tonton serial lengkap · {episodes} eps", "watchFullSeries": "Tonton serial lengkap {episodes} eps",
"@watchFullSeries": {
"placeholders": {
"episodes": {"type": "String"}
}
},
"feedEpisodePrefix": "EP{episode}",
"@feedEpisodePrefix": {
"placeholders": {
"episode": {"type": "int"}
}
},
"feedWatchingCount": "{count} menonton",
"@feedWatchingCount": {
"placeholders": {
"count": {"type": "String"}
}
},
"feedTabAnime": "Anime", "feedTabAnime": "Anime",
"feedTabReal": "Live-action", "feedTabReal": "Live-action",
"feedTabForYou": "Untukmu", "feedTabForYou": "Untukmu",
@@ -237,8 +253,21 @@
"shareFailed": "Tidak bisa membuka menu berbagi.", "shareFailed": "Tidak bisa membuka menu berbagi.",
"nowPlaying": "Sedang diputar", "nowPlaying": "Sedang diputar",
"pictureInPicture": "Gambar dalam gambar", "pictureInPicture": "Gambar dalam gambar",
"autoEnterPip": "Mini player otomatis saat keluar",
"pipUnavailable": "Gambar dalam gambar tidak tersedia di perangkat ini", "pipUnavailable": "Gambar dalam gambar tidak tersedia di perangkat ini",
"originalTag": "Original", "originalTag": "Original",
"originalProduction": "Original",
"watchNowAction": "Tonton Sekarang",
"newArrivalBadge": "BARU",
"editorPick": "Pilihan Editor",
"totalEpisodesShort": "{count} eps",
"@totalEpisodesShort": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"dramaTag": "Drama pendek", "dramaTag": "Drama pendek",
"episodeLabel": "Episode {number}", "episodeLabel": "Episode {number}",
"@episodeLabel": { "@episodeLabel": {
@@ -392,6 +421,7 @@
"restoreDefault": "Pulihkan default", "restoreDefault": "Pulihkan default",
"invalidServerAddress": "Masukkan alamat server http(s) yang valid.", "invalidServerAddress": "Masukkan alamat server http(s) yang valid.",
"signOut": "Keluar", "signOut": "Keluar",
"signOutConfirm": "Keluar dari akun saat ini?",
"mockUserName": "Vicky sheng", "mockUserName": "Vicky sheng",
"mockUserId": "ID: M1CZWVYE", "mockUserId": "ID: M1CZWVYE",
"mockNotice": "Hanya data contoh.", "mockNotice": "Hanya data contoh.",
@@ -720,8 +750,14 @@
"offlineDownloads": "Unduhan offline", "offlineDownloads": "Unduhan offline",
"watchPreferences": "Preferensi tontonan", "watchPreferences": "Preferensi tontonan",
"contactSupport": "Hubungi dukungan", "contactSupport": "Hubungi dukungan",
"vipBrandName": "Netshort VIP", "vipBrandName": "FruitShorts VIP",
"mineVipSubtitle": "Nikmati manfaat eksklusif", "vipOpenNow": "Buka VIP",
"vipBenefitFree": "Nonton semua gratis",
"vipBenefitNoAds": "Bebas iklan",
"vipBenefitHd": "HD 1080P",
"vipBenefitFastCache": "Unduhan cepat",
"moreServices": "Lainnya",
"mineVipSubtitle": "Buka keseruan, tonton sepuasnya",
"favoriteEpisodeProgress": "Episode {episode} / total {total}", "favoriteEpisodeProgress": "Episode {episode} / total {total}",
"@favoriteEpisodeProgress": { "@favoriteEpisodeProgress": {
"placeholders": { "placeholders": {
@@ -729,6 +765,44 @@
"total": { "type": "int" } "total": { "type": "int" }
} }
}, },
"myFavorites": "Favorit Saya",
"manage": "Kelola",
"continueWatching": "Lanjutkan",
"continueWatchingSubtitle": "Episode {episode} dari {total}",
"@continueWatchingSubtitle": {
"placeholders": {
"episode": { "type": "int" },
"total": { "type": "int" }
}
},
"favoriteDramas": "Koleksi",
"totalDramasCount": "{count} judul",
"@totalDramasCount": {
"placeholders": {
"count": { "type": "int" }
}
},
"watchedEpisodeTotal": "EP {episode} / {total}",
"@watchedEpisodeTotal": {
"placeholders": {
"episode": { "type": "int" },
"total": { "type": "int" }
}
},
"hasUpdateBadge": "UPDATE",
"updatedToEpisode": "Diperbarui ke EP {episode}",
"@updatedToEpisode": {
"placeholders": {
"episode": { "type": "int" }
}
},
"finishedAllEpisodes": "{total} eps · Selesai",
"@finishedAllEpisodes": {
"placeholders": {
"total": { "type": "int" }
}
},
"noFavoritesYet": "Belum ada favorit",
"watchedToEpisode": "Ditonton hingga Episode {episode}", "watchedToEpisode": "Ditonton hingga Episode {episode}",
"@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } },
"homeWatchAll": "Tonton semua", "homeWatchAll": "Tonton semua",
+148 -10
View File
@@ -220,12 +220,6 @@ abstract class AppLocalizations {
/// **'FruitShorts'** /// **'FruitShorts'**
String get fruitShortsBrand; String get fruitShortsBrand;
/// No description provided for @authLandingSubtitle.
///
/// In en, this message translates to:
/// **'Sign in to sync progress, favorites and coins'**
String get authLandingSubtitle;
/// No description provided for @comingSoon. /// No description provided for @comingSoon.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -397,9 +391,21 @@ abstract class AppLocalizations {
/// No description provided for @watchFullSeries. /// No description provided for @watchFullSeries.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Watch full series · {episodes} eps'** /// **'Watch full series {episodes} eps'**
String watchFullSeries(String episodes); String watchFullSeries(String episodes);
/// No description provided for @feedEpisodePrefix.
///
/// In en, this message translates to:
/// **'EP{episode}'**
String feedEpisodePrefix(int episode);
/// No description provided for @feedWatchingCount.
///
/// In en, this message translates to:
/// **'{count} watching'**
String feedWatchingCount(String count);
/// No description provided for @feedTabAnime. /// No description provided for @feedTabAnime.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1186,6 +1192,12 @@ abstract class AppLocalizations {
/// **'Picture in picture'** /// **'Picture in picture'**
String get pictureInPicture; String get pictureInPicture;
/// No description provided for @autoEnterPip.
///
/// In en, this message translates to:
/// **'Auto mini player on exit'**
String get autoEnterPip;
/// No description provided for @pipUnavailable. /// No description provided for @pipUnavailable.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1198,6 +1210,36 @@ abstract class AppLocalizations {
/// **'Original'** /// **'Original'**
String get originalTag; String get originalTag;
/// No description provided for @originalProduction.
///
/// In en, this message translates to:
/// **'Original'**
String get originalProduction;
/// No description provided for @watchNowAction.
///
/// In en, this message translates to:
/// **'Watch Now'**
String get watchNowAction;
/// No description provided for @newArrivalBadge.
///
/// In en, this message translates to:
/// **'NEW'**
String get newArrivalBadge;
/// No description provided for @editorPick.
///
/// In en, this message translates to:
/// **'Editor\'s Pick'**
String get editorPick;
/// No description provided for @totalEpisodesShort.
///
/// In en, this message translates to:
/// **'{count} eps'**
String totalEpisodesShort(int count);
/// No description provided for @dramaTag. /// No description provided for @dramaTag.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -1786,6 +1828,12 @@ abstract class AppLocalizations {
/// **'Sign Out'** /// **'Sign Out'**
String get signOut; String get signOut;
/// No description provided for @signOutConfirm.
///
/// In en, this message translates to:
/// **'Sign out of your current account?'**
String get signOutConfirm;
/// No description provided for @mockUserName. /// No description provided for @mockUserName.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@@ -2083,7 +2131,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'** /// **'My Favorites'**
String get myFavorites; String get myFavorites;
/// No description provided for @noFavorites. /// No description provided for @noFavorites.
@@ -3385,13 +3433,49 @@ abstract class AppLocalizations {
/// No description provided for @vipBrandName. /// No description provided for @vipBrandName.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Netshort VIP'** /// **'FruitShorts VIP'**
String get vipBrandName; String get vipBrandName;
/// No description provided for @vipOpenNow.
///
/// In en, this message translates to:
/// **'Get VIP'**
String get vipOpenNow;
/// No description provided for @vipBenefitFree.
///
/// In en, this message translates to:
/// **'Free full access'**
String get vipBenefitFree;
/// No description provided for @vipBenefitNoAds.
///
/// In en, this message translates to:
/// **'Ad-free viewing'**
String get vipBenefitNoAds;
/// No description provided for @vipBenefitHd.
///
/// In en, this message translates to:
/// **'1080P HD'**
String get vipBenefitHd;
/// No description provided for @vipBenefitFastCache.
///
/// In en, this message translates to:
/// **'Fast downloads'**
String get vipBenefitFastCache;
/// No description provided for @moreServices.
///
/// In en, this message translates to:
/// **'More'**
String get moreServices;
/// No description provided for @mineVipSubtitle. /// No description provided for @mineVipSubtitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Enjoy exclusive benefits'** /// **'Unlock excitement, binge freely'**
String get mineVipSubtitle; String get mineVipSubtitle;
/// No description provided for @favoriteEpisodeProgress. /// No description provided for @favoriteEpisodeProgress.
@@ -3400,6 +3484,60 @@ abstract class AppLocalizations {
/// **'Episode {episode} / {total} total'** /// **'Episode {episode} / {total} total'**
String favoriteEpisodeProgress(int episode, int total); String favoriteEpisodeProgress(int episode, int total);
/// No description provided for @manage.
///
/// In en, this message translates to:
/// **'Manage'**
String get manage;
/// No description provided for @continueWatchingSubtitle.
///
/// In en, this message translates to:
/// **'Episode {episode} of {total}'**
String continueWatchingSubtitle(int episode, int total);
/// No description provided for @favoriteDramas.
///
/// In en, this message translates to:
/// **'Favorites'**
String get favoriteDramas;
/// No description provided for @totalDramasCount.
///
/// In en, this message translates to:
/// **'{count} titles'**
String totalDramasCount(int count);
/// No description provided for @watchedEpisodeTotal.
///
/// In en, this message translates to:
/// **'EP {episode} / {total}'**
String watchedEpisodeTotal(int episode, int total);
/// No description provided for @hasUpdateBadge.
///
/// In en, this message translates to:
/// **'UPDATED'**
String get hasUpdateBadge;
/// No description provided for @updatedToEpisode.
///
/// In en, this message translates to:
/// **'Updated to EP {episode}'**
String updatedToEpisode(int episode);
/// No description provided for @finishedAllEpisodes.
///
/// In en, this message translates to:
/// **'{total} eps · Completed'**
String finishedAllEpisodes(int total);
/// No description provided for @noFavoritesYet.
///
/// In en, this message translates to:
/// **'No favorites yet'**
String get noFavoritesYet;
/// No description provided for @watchedToEpisode. /// No description provided for @watchedToEpisode.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
+92 -8
View File
@@ -69,10 +69,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get fruitShortsBrand => 'FruitShorts'; String get fruitShortsBrand => 'FruitShorts';
@override
String get authLandingSubtitle =>
'Sign in to sync progress, favorites and coins';
@override @override
String get comingSoon => 'Coming soon'; String get comingSoon => 'Coming soon';
@@ -162,7 +158,17 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String watchFullSeries(String episodes) { String watchFullSeries(String episodes) {
return 'Watch full series · $episodes eps'; return 'Watch full series $episodes eps';
}
@override
String feedEpisodePrefix(int episode) {
return 'EP$episode';
}
@override
String feedWatchingCount(String count) {
return '$count watching';
} }
@override @override
@@ -587,6 +593,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get pictureInPicture => 'Picture in picture'; String get pictureInPicture => 'Picture in picture';
@override
String get autoEnterPip => 'Auto mini player on exit';
@override @override
String get pipUnavailable => String get pipUnavailable =>
'Picture in picture is unavailable on this device'; 'Picture in picture is unavailable on this device';
@@ -594,6 +603,23 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get originalTag => 'Original'; String get originalTag => 'Original';
@override
String get originalProduction => 'Original';
@override
String get watchNowAction => 'Watch Now';
@override
String get newArrivalBadge => 'NEW';
@override
String get editorPick => 'Editor\'s Pick';
@override
String totalEpisodesShort(int count) {
return '$count eps';
}
@override @override
String get dramaTag => 'Short drama'; String get dramaTag => 'Short drama';
@@ -906,6 +932,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get signOut => 'Sign Out'; String get signOut => 'Sign Out';
@override
String get signOutConfirm => 'Sign out of your current account?';
@override @override
String get mockUserName => 'Vicky sheng'; String get mockUserName => 'Vicky sheng';
@@ -1060,7 +1089,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 => 'My Favorites';
@override @override
String get noFavorites => 'No favorites yet.'; String get noFavorites => 'No favorites yet.';
@@ -1763,16 +1792,71 @@ class AppLocalizationsEn extends AppLocalizations {
String get contactSupport => 'Contact support'; String get contactSupport => 'Contact support';
@override @override
String get vipBrandName => 'Netshort VIP'; String get vipBrandName => 'FruitShorts VIP';
@override @override
String get mineVipSubtitle => 'Enjoy exclusive benefits'; String get vipOpenNow => 'Get VIP';
@override
String get vipBenefitFree => 'Free full access';
@override
String get vipBenefitNoAds => 'Ad-free viewing';
@override
String get vipBenefitHd => '1080P HD';
@override
String get vipBenefitFastCache => 'Fast downloads';
@override
String get moreServices => 'More';
@override
String get mineVipSubtitle => 'Unlock excitement, binge freely';
@override @override
String favoriteEpisodeProgress(int episode, int total) { String favoriteEpisodeProgress(int episode, int total) {
return 'Episode $episode / $total total'; return 'Episode $episode / $total total';
} }
@override
String get manage => 'Manage';
@override
String continueWatchingSubtitle(int episode, int total) {
return 'Episode $episode of $total';
}
@override
String get favoriteDramas => 'Favorites';
@override
String totalDramasCount(int count) {
return '$count titles';
}
@override
String watchedEpisodeTotal(int episode, int total) {
return 'EP $episode / $total';
}
@override
String get hasUpdateBadge => 'UPDATED';
@override
String updatedToEpisode(int episode) {
return 'Updated to EP $episode';
}
@override
String finishedAllEpisodes(int total) {
return '$total eps · Completed';
}
@override
String get noFavoritesYet => 'No favorites yet';
@override @override
String watchedToEpisode(int episode) { String watchedToEpisode(int episode) {
return 'Watched to Episode $episode'; return 'Watched to Episode $episode';
+93 -9
View File
@@ -69,10 +69,6 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get fruitShortsBrand => 'FruitShorts'; String get fruitShortsBrand => 'FruitShorts';
@override
String get authLandingSubtitle =>
'Masuk untuk menyinkronkan progres, favorit, dan koin';
@override @override
String get comingSoon => 'Segera hadir'; String get comingSoon => 'Segera hadir';
@@ -162,7 +158,17 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String watchFullSeries(String episodes) { String watchFullSeries(String episodes) {
return 'Tonton serial lengkap · $episodes eps'; return 'Tonton serial lengkap $episodes eps';
}
@override
String feedEpisodePrefix(int episode) {
return 'EP$episode';
}
@override
String feedWatchingCount(String count) {
return '$count menonton';
} }
@override @override
@@ -289,7 +295,7 @@ class AppLocalizationsId extends AppLocalizations {
String get localDemoProfile => 'Profil demo lokal'; String get localDemoProfile => 'Profil demo lokal';
@override @override
String get continueWatching => 'Lanjut Tonton'; String get continueWatching => 'Lanjutkan';
@override @override
String episodeRemaining(int episode, int minutes) { String episodeRemaining(int episode, int minutes) {
@@ -588,6 +594,9 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get pictureInPicture => 'Gambar dalam gambar'; String get pictureInPicture => 'Gambar dalam gambar';
@override
String get autoEnterPip => 'Mini player otomatis saat keluar';
@override @override
String get pipUnavailable => String get pipUnavailable =>
'Gambar dalam gambar tidak tersedia di perangkat ini'; 'Gambar dalam gambar tidak tersedia di perangkat ini';
@@ -595,6 +604,23 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get originalTag => 'Original'; String get originalTag => 'Original';
@override
String get originalProduction => 'Original';
@override
String get watchNowAction => 'Tonton Sekarang';
@override
String get newArrivalBadge => 'BARU';
@override
String get editorPick => 'Pilihan Editor';
@override
String totalEpisodesShort(int count) {
return '$count eps';
}
@override @override
String get dramaTag => 'Drama pendek'; String get dramaTag => 'Drama pendek';
@@ -910,6 +936,9 @@ class AppLocalizationsId extends AppLocalizations {
@override @override
String get signOut => 'Keluar'; String get signOut => 'Keluar';
@override
String get signOutConfirm => 'Keluar dari akun saat ini?';
@override @override
String get mockUserName => 'Vicky sheng'; String get mockUserName => 'Vicky sheng';
@@ -1062,7 +1091,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 Saya';
@override @override
String get noFavorites => 'Belum ada favorit.'; String get noFavorites => 'Belum ada favorit.';
@@ -1765,16 +1794,71 @@ class AppLocalizationsId extends AppLocalizations {
String get contactSupport => 'Hubungi dukungan'; String get contactSupport => 'Hubungi dukungan';
@override @override
String get vipBrandName => 'Netshort VIP'; String get vipBrandName => 'FruitShorts VIP';
@override @override
String get mineVipSubtitle => 'Nikmati manfaat eksklusif'; String get vipOpenNow => 'Buka VIP';
@override
String get vipBenefitFree => 'Nonton semua gratis';
@override
String get vipBenefitNoAds => 'Bebas iklan';
@override
String get vipBenefitHd => 'HD 1080P';
@override
String get vipBenefitFastCache => 'Unduhan cepat';
@override
String get moreServices => 'Lainnya';
@override
String get mineVipSubtitle => 'Buka keseruan, tonton sepuasnya';
@override @override
String favoriteEpisodeProgress(int episode, int total) { String favoriteEpisodeProgress(int episode, int total) {
return 'Episode $episode / total $total'; return 'Episode $episode / total $total';
} }
@override
String get manage => 'Kelola';
@override
String continueWatchingSubtitle(int episode, int total) {
return 'Episode $episode dari $total';
}
@override
String get favoriteDramas => 'Koleksi';
@override
String totalDramasCount(int count) {
return '$count judul';
}
@override
String watchedEpisodeTotal(int episode, int total) {
return 'EP $episode / $total';
}
@override
String get hasUpdateBadge => 'UPDATE';
@override
String updatedToEpisode(int episode) {
return 'Diperbarui ke EP $episode';
}
@override
String finishedAllEpisodes(int total) {
return '$total eps · Selesai';
}
@override
String get noFavoritesYet => 'Belum ada favorit';
@override @override
String watchedToEpisode(int episode) { String watchedToEpisode(int episode) {
return 'Ditonton hingga Episode $episode'; return 'Ditonton hingga Episode $episode';
+92 -7
View File
@@ -66,10 +66,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get recommended => '推荐'; String get recommended => '推荐';
@override @override
String get fruitShortsBrand => '水果短剧'; String get fruitShortsBrand => 'FruitShorts';
@override
String get authLandingSubtitle => '登录后同步进度、收藏与金币权益';
@override @override
String get comingSoon => '即将上线'; String get comingSoon => '即将上线';
@@ -159,7 +156,17 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String watchFullSeries(String episodes) { String watchFullSeries(String episodes) {
return '观看完整剧集 · 全$episodes集'; return '观看完整剧集 全$episodes集';
}
@override
String feedEpisodePrefix(int episode) {
return '第$episode集';
}
@override
String feedWatchingCount(String count) {
return '共$count人在看';
} }
@override @override
@@ -581,12 +588,32 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get pictureInPicture => '画中画'; String get pictureInPicture => '画中画';
@override
String get autoEnterPip => '退出后自动小窗';
@override @override
String get pipUnavailable => '此设备无法使用画中画'; String get pipUnavailable => '此设备无法使用画中画';
@override @override
String get originalTag => '原创'; String get originalTag => '原创';
@override
String get originalProduction => '原创出品';
@override
String get watchNowAction => '立即观看';
@override
String get newArrivalBadge => '上新';
@override
String get editorPick => '编辑精选';
@override
String totalEpisodesShort(int count) {
return '全$count集';
}
@override @override
String get dramaTag => '短剧'; String get dramaTag => '短剧';
@@ -895,6 +922,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get signOut => '退出登录'; String get signOut => '退出登录';
@override
String get signOutConfirm => '确定要退出当前账号吗?';
@override @override
String get mockUserName => 'Vicky sheng'; String get mockUserName => 'Vicky sheng';
@@ -1737,16 +1767,71 @@ class AppLocalizationsZh extends AppLocalizations {
String get contactSupport => '联系客服'; String get contactSupport => '联系客服';
@override @override
String get vipBrandName => 'Netshort VIP'; String get vipBrandName => 'FruitShorts VIP';
@override @override
String get mineVipSubtitle => '享受专属权益'; String get vipOpenNow => '立即开通';
@override
String get vipBenefitFree => '全剧免费看';
@override
String get vipBenefitNoAds => '免广告打扰';
@override
String get vipBenefitHd => '1080P高清';
@override
String get vipBenefitFastCache => '极速缓存';
@override
String get moreServices => '更多服务';
@override
String get mineVipSubtitle => '解锁精彩,畅看好剧';
@override @override
String favoriteEpisodeProgress(int episode, int total) { String favoriteEpisodeProgress(int episode, int total) {
return '第 $episode 集 / 共 $total 集'; return '第 $episode 集 / 共 $total 集';
} }
@override
String get manage => '管理';
@override
String continueWatchingSubtitle(int episode, int total) {
return '看到第 $episode 集 · 全 $total 集';
}
@override
String get favoriteDramas => '收藏短剧';
@override
String totalDramasCount(int count) {
return '共 $count 部';
}
@override
String watchedEpisodeTotal(int episode, int total) {
return '看到第 $episode 集 / 全 $total 集';
}
@override
String get hasUpdateBadge => '有更新';
@override
String updatedToEpisode(int episode) {
return '更新至第 $episode 集';
}
@override
String finishedAllEpisodes(int total) {
return '全 $total 集 · 已完结';
}
@override
String get noFavoritesYet => '还没有收藏的短剧';
@override @override
String watchedToEpisode(int episode) { String watchedToEpisode(int episode) {
return '已看至 第 $episode 集'; return '已看至 第 $episode 集';
+79 -5
View File
@@ -19,8 +19,7 @@
"searchCancel": "取消", "searchCancel": "取消",
"featured": "精选", "featured": "精选",
"recommended": "推荐", "recommended": "推荐",
"fruitShortsBrand": "水果短剧", "fruitShortsBrand": "FruitShorts",
"authLandingSubtitle": "登录后同步进度、收藏与金币权益",
"comingSoon": "即将上线", "comingSoon": "即将上线",
"authLegal": "继续即代表同意《用户协议》与《隐私政策》", "authLegal": "继续即代表同意《用户协议》与《隐私政策》",
"authLegalLead": "继续即代表同意", "authLegalLead": "继续即代表同意",
@@ -49,7 +48,24 @@
"hotBadge": "爆剧", "hotBadge": "爆剧",
"filterAll": "全部", "filterAll": "全部",
"seriesMeta": "系列剧 · 共{episodes}集 | 共{viewers}人在追", "seriesMeta": "系列剧 · 共{episodes}集 | 共{viewers}人在追",
"watchFullSeries": "观看完整剧集 · 全{episodes}集", "watchFullSeries": "观看完整剧集 全{episodes}集",
"@watchFullSeries": {
"placeholders": {
"episodes": {"type": "String"}
}
},
"feedEpisodePrefix": "第{episode}集",
"@feedEpisodePrefix": {
"placeholders": {
"episode": {"type": "int"}
}
},
"feedWatchingCount": "共{count}人在看",
"@feedWatchingCount": {
"placeholders": {
"count": {"type": "String"}
}
},
"feedTabAnime": "漫剧", "feedTabAnime": "漫剧",
"feedTabReal": "真人剧", "feedTabReal": "真人剧",
"feedTabForYou": "推荐", "feedTabForYou": "推荐",
@@ -237,8 +253,21 @@
"shareFailed": "无法打开系统分享面板。", "shareFailed": "无法打开系统分享面板。",
"nowPlaying": "正在播放", "nowPlaying": "正在播放",
"pictureInPicture": "画中画", "pictureInPicture": "画中画",
"autoEnterPip": "退出后自动小窗",
"pipUnavailable": "此设备无法使用画中画", "pipUnavailable": "此设备无法使用画中画",
"originalTag": "原创", "originalTag": "原创",
"originalProduction": "原创出品",
"watchNowAction": "立即观看",
"newArrivalBadge": "上新",
"editorPick": "编辑精选",
"totalEpisodesShort": "全{count}集",
"@totalEpisodesShort": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"dramaTag": "短剧", "dramaTag": "短剧",
"episodeLabel": "第 {number} 集", "episodeLabel": "第 {number} 集",
"@episodeLabel": { "@episodeLabel": {
@@ -354,6 +383,7 @@
"restoreDefault": "恢复默认", "restoreDefault": "恢复默认",
"invalidServerAddress": "请输入有效的 http(s) 服务器地址。", "invalidServerAddress": "请输入有效的 http(s) 服务器地址。",
"signOut": "退出登录", "signOut": "退出登录",
"signOutConfirm": "确定要退出当前账号吗?",
"mockUserName": "Vicky sheng", "mockUserName": "Vicky sheng",
"mockUserId": "ID: M1CZWVYE", "mockUserId": "ID: M1CZWVYE",
"mockNotice": "当前是示例数据。", "mockNotice": "当前是示例数据。",
@@ -682,8 +712,14 @@
"offlineDownloads": "离线缓存", "offlineDownloads": "离线缓存",
"watchPreferences": "观看偏好", "watchPreferences": "观看偏好",
"contactSupport": "联系客服", "contactSupport": "联系客服",
"vipBrandName": "Netshort VIP", "vipBrandName": "FruitShorts VIP",
"mineVipSubtitle": "享受专属权益", "vipOpenNow": "立即开通",
"vipBenefitFree": "全剧免费看",
"vipBenefitNoAds": "免广告打扰",
"vipBenefitHd": "1080P高清",
"vipBenefitFastCache": "极速缓存",
"moreServices": "更多服务",
"mineVipSubtitle": "解锁精彩,畅看好剧",
"favoriteEpisodeProgress": "第 {episode} 集 / 共 {total} 集", "favoriteEpisodeProgress": "第 {episode} 集 / 共 {total} 集",
"@favoriteEpisodeProgress": { "@favoriteEpisodeProgress": {
"placeholders": { "placeholders": {
@@ -691,6 +727,44 @@
"total": { "type": "int" } "total": { "type": "int" }
} }
}, },
"myFavorites": "我的收藏",
"manage": "管理",
"continueWatching": "继续观看",
"continueWatchingSubtitle": "看到第 {episode} 集 · 全 {total} 集",
"@continueWatchingSubtitle": {
"placeholders": {
"episode": { "type": "int" },
"total": { "type": "int" }
}
},
"favoriteDramas": "收藏短剧",
"totalDramasCount": "共 {count} 部",
"@totalDramasCount": {
"placeholders": {
"count": { "type": "int" }
}
},
"watchedEpisodeTotal": "看到第 {episode} 集 / 全 {total} 集",
"@watchedEpisodeTotal": {
"placeholders": {
"episode": { "type": "int" },
"total": { "type": "int" }
}
},
"hasUpdateBadge": "有更新",
"updatedToEpisode": "更新至第 {episode} 集",
"@updatedToEpisode": {
"placeholders": {
"episode": { "type": "int" }
}
},
"finishedAllEpisodes": "全 {total} 集 · 已完结",
"@finishedAllEpisodes": {
"placeholders": {
"total": { "type": "int" }
}
},
"noFavoritesYet": "还没有收藏的短剧",
"watchedToEpisode": "已看至 第 {episode} 集", "watchedToEpisode": "已看至 第 {episode} 集",
"@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } },
"homeWatchAll": "观看全集", "homeWatchAll": "观看全集",
+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]),
@@ -11,6 +11,7 @@ import '../../core/formatting/localized_date_time.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/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_glow_panel.dart';
import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/plain_page_scaffold.dart'; import '../../core/widgets/plain_page_scaffold.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@@ -172,15 +173,15 @@ class _AccountDeactivatePageState extends State<AccountDeactivatePage> {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AppGlowDialog(
title: Text(l10n.accountConfirmDeactivateTitle), title: l10n.accountConfirmDeactivateTitle,
content: Text(l10n.accountConfirmDeactivateMessage), content: l10n.accountConfirmDeactivateMessage,
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false), onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(l10n.cancel), child: Text(l10n.cancel),
), ),
FilledButton( TextButton(
key: const Key('account-deactivate-confirm'), key: const Key('account-deactivate-confirm'),
onPressed: () => Navigator.of(dialogContext).pop(true), onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(l10n.accountConfirmDeactivate), child: Text(l10n.accountConfirmDeactivate),
+2 -3
View File
@@ -45,8 +45,7 @@ class AccountEntryCard extends StatelessWidget {
]; ];
return Material( return Material(
key: const Key('mine-account-card'), key: const Key('mine-account-card'),
color: AppColors.card, color: AppColors.contentSurface,
borderRadius: BorderRadius.circular(AppDimens.cardRadius),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: Column( child: Column(
children: [ children: [
@@ -55,7 +54,7 @@ class AccountEntryCard extends StatelessWidget {
const Divider( const Divider(
height: AppDimens.mineGridDividerWidth, height: AppDimens.mineGridDividerWidth,
thickness: AppDimens.mineGridDividerWidth, thickness: AppDimens.mineGridDividerWidth,
color: AppColors.outlineVariantDark, color: AppColors.contentBorder,
), ),
_AccountListTile(row: rows[index]), _AccountListTile(row: rows[index]),
], ],
+35 -49
View File
@@ -61,7 +61,7 @@ class _AuthFields extends StatelessWidget {
onChanged: callbacks.onEmailChanged, onChanged: callbacks.onEmailChanged,
onSubmitted: (_) => state.fields.passwordFocus.requestFocus(), onSubmitted: (_) => state.fields.passwordFocus.requestFocus(),
style: const TextStyle(color: AppColors.onSurface, fontSize: 15), style: const TextStyle(color: AppColors.onSurface, fontSize: 15),
cursorColor: AppColors.blue, cursorColor: AppColors.brandRed,
decoration: _decoration( decoration: _decoration(
context: context, context: context,
hint: 'name@example.com', hint: 'name@example.com',
@@ -93,7 +93,7 @@ class _AuthFields extends StatelessWidget {
onChanged: callbacks.onPasswordChanged, onChanged: callbacks.onPasswordChanged,
onSubmitted: (_) => callbacks.onSubmit(), onSubmitted: (_) => callbacks.onSubmit(),
style: const TextStyle(color: AppColors.onSurface, fontSize: 15), style: const TextStyle(color: AppColors.onSurface, fontSize: 15),
cursorColor: AppColors.blue, cursorColor: AppColors.brandRed,
decoration: _decoration( decoration: _decoration(
context: context, context: context,
hint: l10n.authPasswordHint, hint: l10n.authPasswordHint,
@@ -128,7 +128,7 @@ class _AuthFields extends StatelessWidget {
style: const TextStyle( style: const TextStyle(
color: AppColors.muted, color: AppColors.muted,
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w400,
), ),
); );
@@ -142,10 +142,7 @@ class _AuthFields extends StatelessWidget {
}) { }) {
final border = OutlineInputBorder( final border = OutlineInputBorder(
borderRadius: BorderRadius.circular(_fieldRadius), borderRadius: BorderRadius.circular(_fieldRadius),
borderSide: const BorderSide( borderSide: BorderSide.none,
color: AppColors.border,
width: _fieldBorderWidth,
),
); );
final errorColor = Theme.of(context).colorScheme.error; final errorColor = Theme.of(context).colorScheme.error;
final errorBorder = OutlineInputBorder( final errorBorder = OutlineInputBorder(
@@ -158,7 +155,7 @@ class _AuthFields extends StatelessWidget {
prefixIcon: Icon( prefixIcon: Icon(
icon, icon,
size: 21, size: 21,
color: focused ? AppColors.blue : AppColors.muted, color: focused ? AppColors.brandRed : AppColors.muted,
), ),
prefixIconConstraints: const BoxConstraints( prefixIconConstraints: const BoxConstraints(
minWidth: _minTapTarget, minWidth: _minTapTarget,
@@ -166,7 +163,7 @@ class _AuthFields extends StatelessWidget {
), ),
suffixIcon: suffix, suffixIcon: suffix,
filled: true, filled: true,
fillColor: AppColors.card, fillColor: Colors.white.withValues(alpha: 0.05),
isDense: true, isDense: true,
contentPadding: const EdgeInsets.only(right: 16), contentPadding: const EdgeInsets.only(right: 16),
border: border, border: border,
@@ -175,7 +172,7 @@ class _AuthFields extends StatelessWidget {
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(_fieldRadius), borderRadius: BorderRadius.circular(_fieldRadius),
borderSide: const BorderSide( borderSide: const BorderSide(
color: AppColors.blue, color: AppColors.brandRed,
width: _fieldBorderWidth, width: _fieldBorderWidth,
), ),
), ),
@@ -204,17 +201,17 @@ class _AuthSubmitButton extends StatelessWidget {
style: ButtonStyle( style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith((states) { backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) { if (states.contains(WidgetState.disabled)) {
return AppColors.blue.withValues(alpha: 0.5); return AppColors.brandRed.withValues(alpha: 0.5);
} }
if (states.contains(WidgetState.pressed)) { if (states.contains(WidgetState.pressed)) {
return AppColors.bluePressed; return AppColors.brandRedPressed;
} }
return AppColors.blue; return AppColors.brandRed;
}), }),
foregroundColor: const WidgetStatePropertyAll(AppColors.onPrimary), foregroundColor: const WidgetStatePropertyAll(AppColors.onPrimary),
elevation: const WidgetStatePropertyAll(2), elevation: const WidgetStatePropertyAll(2),
shadowColor: WidgetStatePropertyAll( shadowColor: WidgetStatePropertyAll(
AppColors.blue.withValues(alpha: 0.22), AppColors.brandRed.withValues(alpha: 0.22),
), ),
padding: const WidgetStatePropertyAll(EdgeInsets.zero), padding: const WidgetStatePropertyAll(EdgeInsets.zero),
shape: WidgetStatePropertyAll( shape: WidgetStatePropertyAll(
@@ -235,7 +232,7 @@ class _AuthSubmitButton extends StatelessWidget {
status.signUpMode ? l10n.signUp : l10n.signIn, status.signUpMode ? l10n.signUp : l10n.signIn,
style: const TextStyle( style: const TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w600,
), ),
), ),
), ),
@@ -243,18 +240,16 @@ class _AuthSubmitButton extends StatelessWidget {
} }
} }
class _ModeSegment extends StatelessWidget { class _ModeTab extends StatelessWidget {
const _ModeSegment({ const _ModeTab({
required this.segmentKey, required this.segmentKey,
required this.label, required this.label,
required this.hiddenLabel,
required this.selected, required this.selected,
required this.onTap, required this.onTap,
}); });
final Key segmentKey; final Key segmentKey;
final String label; final String label;
final String hiddenLabel;
final bool selected; final bool selected;
final VoidCallback? onTap; final VoidCallback? onTap;
@@ -264,38 +259,29 @@ class _ModeSegment extends StatelessWidget {
key: segmentKey, key: segmentKey,
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: onTap, onTap: onTap,
child: AnimatedContainer( // 文字 + 指示条只有约 32px 高,补上下各 6px 把可点区域撑到 44。
duration: _segmentDuration, child: Padding(
alignment: Alignment.center, padding: const EdgeInsets.symmetric(vertical: 6),
decoration: BoxDecoration( child: Column(
color: selected ? AppColors.blue : AppColors.transparent, crossAxisAlignment: CrossAxisAlignment.start,
borderRadius: BorderRadius.circular(18), mainAxisSize: MainAxisSize.min,
),
child: Stack(
fit: StackFit.expand,
children: [ children: [
Center( Text(
child: Text( label,
label, style: TextStyle(
style: TextStyle( color: selected ? AppColors.textPrimary : AppColors.muted,
color: selected ? AppColors.onPrimary : AppColors.onSurface, fontSize: AppDimens.followingSegmentFont,
fontSize: 14, fontWeight: FontWeight.w400,
fontWeight: FontWeight.w700,
),
), ),
), ),
Positioned.fill( const SizedBox(height: AppDimens.followingSegmentIndicatorTopGap),
child: ExcludeSemantics( Container(
child: Opacity( width: AppDimens.followingSegmentIndicatorWidth,
opacity: 0, height: AppDimens.followingSegmentIndicatorHeight,
child: Center( decoration: BoxDecoration(
child: Text( color: selected ? AppColors.brandRed : AppColors.transparent,
hiddenLabel, borderRadius: BorderRadius.circular(
maxLines: 1, AppDimens.followingSegmentIndicatorRadius,
softWrap: false,
style: const TextStyle(fontSize: 14),
),
),
), ),
), ),
), ),
@@ -349,7 +335,7 @@ class _AuthBrand extends StatelessWidget {
style: const TextStyle( style: const TextStyle(
color: AppColors.onSurface, color: AppColors.onSurface,
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w700,
height: 1.1, height: 1.1,
), ),
), ),
+1 -1
View File
@@ -50,7 +50,7 @@ class _AuthLegalLinksState extends State<AuthLegalLinks> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final muted = TextStyle(color: AppColors.muted, fontSize: _legalFontSize, height: 1.6); final muted = TextStyle(color: AppColors.muted, fontSize: _legalFontSize, height: 1.6);
final link = muted.copyWith(color: AppColors.blue); final link = muted.copyWith(color: AppColors.brandRed);
return Text.rich( return Text.rich(
TextSpan( TextSpan(
style: muted, style: muted,
+88 -64
View File
@@ -24,18 +24,14 @@ part 'auth_widgets.dart';
part 'auth_form_widgets.dart'; part 'auth_form_widgets.dart';
const double _minTapTarget = 44; const double _minTapTarget = 44;
const double _brandMarkSize = 72; const double _brandMarkSize = 88;
const double _compactBrandMarkSize = 48; const double _compactBrandMarkSize = 48;
const double _brandMarkRadius = 20; const double _brandMarkRadius = 26;
const double _compactBrandMarkRadius = 14; const double _compactBrandMarkRadius = 14;
const double _landingLogoTopGap = 28;
const String _appLogoAsset = 'assets/icon/fruitshorts.png'; const String _appLogoAsset = 'assets/icon/fruitshorts.png';
const double _contentMaxWidth = 440; const double _contentMaxWidth = 440;
// Auth keeps its approved 20px card silhouette.
const double _cardRadius = AppDimens.authCardRadius;
const double _cardPadding = 20;
const double _fieldHeight = 52; const double _fieldHeight = 52;
const double _fieldRadius = 14; const double _fieldRadius = 12;
const double _fieldBorderWidth = 1; const double _fieldBorderWidth = 1;
const double _buttonHeight = 50; const double _buttonHeight = 50;
const double _buttonRadius = 25; const double _buttonRadius = 25;
@@ -47,14 +43,11 @@ const double _bottomContentPadding = 28;
const double _compactTopContentPadding = 40; const double _compactTopContentPadding = 40;
const double _compactHeightBreakpoint = 720; const double _compactHeightBreakpoint = 720;
const double _compactBrandGap = 16; const double _compactBrandGap = 16;
const double _glowSize = 280; const double _entryButtonHeight = 52;
const double _warmGlowOpacity = 0.18; const double _entryButtonRadius = 26;
const double _entryButtonHeight = 50;
const double _entryButtonRadius = 25;
const double _entryButtonFontSize = 15; const double _entryButtonFontSize = 15;
const double _entryIconSize = 20; const double _entryIconSize = 20;
const Duration _enterDuration = Duration(milliseconds: 220); const Duration _enterDuration = Duration(milliseconds: 220);
const Duration _segmentDuration = Duration(milliseconds: 160);
Widget _brandMarkWidget({ Widget _brandMarkWidget({
Key? key, Key? key,
@@ -267,45 +260,53 @@ class _AuthPageState extends State<AuthPage> {
child: IgnorePointer( child: IgnorePointer(
child: DecoratedBox( child: DecoratedBox(
key: Key('auth-page-background'), key: Key('auth-page-background'),
decoration: BoxDecoration(gradient: AppTheme.pageGradient),
),
),
),
Positioned(
top: -90,
right: -70,
width: _glowSize,
height: _glowSize,
child: IgnorePointer(
child: Container(
key: const Key('auth-background-glow'),
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, gradient: LinearGradient(
gradient: RadialGradient( begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [ colors: [
AppColors.surfaceTop, AppColors.followingGlow,
AppColors.surfaceTop.withValues(alpha: 0), AppColors.followingGlowMid,
AppColors.appBackground,
], ],
stops: [0, 0.14, 0.3],
), ),
), ),
), ),
), ),
), ),
Positioned( // 全屏海报背景:压暗保证文字可读,顶部再叠品牌红光。
left: -110, Positioned.fill(
bottom: -90, child: IgnorePointer(child: _AuthPosterBackdrop()),
width: _glowSize, ),
height: _glowSize, const Positioned.fill(
child: IgnorePointer( child: IgnorePointer(
child: Container( child: DecoratedBox(
key: const Key('auth-background-warm-glow'), key: Key('auth-poster-scrim'),
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, gradient: LinearGradient(
gradient: RadialGradient( begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [ colors: [
AppColors.gold.withValues(alpha: _warmGlowOpacity), Color(0x66000000),
AppColors.gold.withValues(alpha: 0), Color(0x14000000),
Color(0xE0000000),
], ],
stops: [0, 0.42, 1],
),
),
),
),
),
const Positioned.fill(
child: IgnorePointer(
child: DecoratedBox(
key: Key('auth-red-glow'),
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment(0, -1.15),
radius: 1.3,
colors: [AppColors.primaryBorder, AppColors.transparent],
), ),
), ),
), ),
@@ -347,7 +348,11 @@ class _AuthPageState extends State<AuthPage> {
constraints: const BoxConstraints( constraints: const BoxConstraints(
maxWidth: _contentMaxWidth, maxWidth: _contentMaxWidth,
), ),
child: _content(l10n, compact: compact), child: _content(
l10n,
compact: compact,
minHeight: minHeight,
),
), ),
), ),
), ),
@@ -387,7 +392,11 @@ class _AuthPageState extends State<AuthPage> {
); );
} }
Widget _content(AppLocalizations l10n, {required bool compact}) => Widget _content(
AppLocalizations l10n, {
required bool compact,
required double minHeight,
}) =>
TweenAnimationBuilder<double>( TweenAnimationBuilder<double>(
key: const Key('auth-enter-animation'), key: const Key('auth-enter-animation'),
tween: Tween(begin: 0, end: 1), tween: Tween(begin: 0, end: 1),
@@ -409,15 +418,18 @@ class _AuthPageState extends State<AuthPage> {
_authCard(l10n), _authCard(l10n),
], ],
) )
: _landing(l10n), : _landing(l10n, minHeight: minHeight),
); );
Widget _landing(AppLocalizations l10n) => Column( /// 落地页内容需要有限高度才能用 Spacer 三段分割,这里显式钉住可用高度。
Widget _landing(AppLocalizations l10n, {required double minHeight}) =>
SizedBox(
height: minHeight,
child: Column(
key: const Key('auth-landing'), key: const Key('auth-landing'),
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const SizedBox(height: _landingLogoTopGap), const Spacer(flex: 5),
Center( Center(
child: _brandMarkWidget( child: _brandMarkWidget(
key: const Key('auth-brand-logo'), key: const Key('auth-brand-logo'),
@@ -425,24 +437,19 @@ class _AuthPageState extends State<AuthPage> {
radius: _brandMarkRadius, radius: _brandMarkRadius,
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 10),
Text( Text(
l10n.fruitShortsBrand, l10n.fruitShortsBrand,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle( style: const TextStyle(
fontFamily: 'Outfit', fontFamily: 'Outfit',
color: AppColors.onSurface, color: AppColors.onSurface,
fontSize: 24, fontSize: 26,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w700,
letterSpacing: 2,
), ),
), ),
const SizedBox(height: 6), const Spacer(flex: 3),
Text(
l10n.authLandingSubtitle,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.muted, fontSize: 13),
),
const SizedBox(height: 28),
_AuthEntryButton( _AuthEntryButton(
key: const Key('auth-entry-phone'), key: const Key('auth-entry-phone'),
label: l10n.authPhoneSignIn, label: l10n.authPhoneSignIn,
@@ -458,10 +465,12 @@ class _AuthPageState extends State<AuthPage> {
primary: false, primary: false,
onTap: () => setState(() => _emailEntryOpen = true), onTap: () => setState(() => _emailEntryOpen = true),
), ),
const SizedBox(height: 18), const SizedBox(height: 14),
const AuthLegalLinks(), const Opacity(opacity: 0.75, child: AuthLegalLinks()),
], const Spacer(flex: 6),
); ],
),
);
Future<void> _openPhoneSheet() async { Future<void> _openPhoneSheet() async {
final signedIn = await showPhoneAuthSheet( final signedIn = await showPhoneAuthSheet(
@@ -526,15 +535,15 @@ class _AuthEntryButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final background = primary ? AppColors.blue : AppColors.card; // 主按钮纯品牌红;次按钮为无边框深色玻璃(白 6%),Apple 风格。
final background = primary
? AppColors.brandRed
: Colors.white.withValues(alpha: 0.06);
final foreground = primary ? AppColors.onPrimary : AppColors.onSurface; final foreground = primary ? AppColors.onPrimary : AppColors.onSurface;
return Material( return Material(
color: background, color: background,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_entryButtonRadius), borderRadius: BorderRadius.circular(_entryButtonRadius),
side: primary
? BorderSide.none
: const BorderSide(color: AppColors.border),
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: InkWell( child: InkWell(
@@ -556,7 +565,7 @@ class _AuthEntryButton extends StatelessWidget {
style: TextStyle( style: TextStyle(
color: foreground, color: foreground,
fontSize: _entryButtonFontSize, fontSize: _entryButtonFontSize,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w600,
), ),
), ),
), ),
@@ -567,3 +576,18 @@ class _AuthEntryButton extends StatelessWidget {
); );
} }
} }
/// 登录页全屏海报背景(单张,无拼缝)。
class _AuthPosterBackdrop extends StatelessWidget {
const _AuthPosterBackdrop();
@override
Widget build(BuildContext context) {
return Image.asset(
key: const Key('auth-poster-backdrop'),
'assets/auth/bg_3.jpg',
fit: BoxFit.cover,
alignment: Alignment.center,
);
}
}
+49 -80
View File
@@ -70,52 +70,45 @@ class _AuthCard extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = state.l10n; final l10n = state.l10n;
final status = state.status; final status = state.status;
return Container( // 不再做卡片盒子,表单直接排在红光晕背景上。
return Column(
key: const Key('auth-card'), key: const Key('auth-card'),
padding: const EdgeInsets.all(_cardPadding), crossAxisAlignment: CrossAxisAlignment.stretch,
decoration: BoxDecoration( children: [
color: AppColors.card, _AuthModeSwitch(
borderRadius: BorderRadius.circular(_cardRadius), l10n: l10n,
border: Border.all(color: AppColors.border), signUpMode: status.signUpMode,
), busy: status.busy,
child: Column( onSelected: callbacks.onModeSelected,
crossAxisAlignment: CrossAxisAlignment.stretch, ),
children: [ const SizedBox(height: 24),
_AuthModeSwitch( Text(
l10n: l10n, status.signUpMode ? l10n.authSignUpTitle : l10n.authSignInTitle,
signUpMode: status.signUpMode, style: const TextStyle(
busy: status.busy, color: AppColors.onSurface,
onSelected: callbacks.onModeSelected, fontSize: 24,
fontWeight: FontWeight.w600,
height: 1.2,
), ),
const SizedBox(height: 22), ),
Text( const SizedBox(height: 6),
status.signUpMode ? l10n.authSignUpTitle : l10n.authSignInTitle, Text(
style: const TextStyle( status.signUpMode ? l10n.authSignUpSubtitle : l10n.authSubtitle,
color: AppColors.onSurface, style: const TextStyle(
fontSize: 25, color: AppColors.muted,
fontWeight: FontWeight.w800, fontSize: AppDimens.body,
height: 1.2, height: 1.45,
),
), ),
const SizedBox(height: 6), ),
Text( const SizedBox(height: 20),
status.signUpMode ? l10n.authSignUpSubtitle : l10n.authSubtitle, _AuthFields(state: state, callbacks: callbacks),
style: const TextStyle( if (status.errorMessage != null) ...[
color: AppColors.muted, const SizedBox(height: 14),
fontSize: AppDimens.body, _AuthError(message: status.errorMessage!),
height: 1.45,
),
),
const SizedBox(height: 22),
_AuthFields(state: state, callbacks: callbacks),
if (status.errorMessage != null) ...[
const SizedBox(height: 14),
_AuthError(message: status.errorMessage!),
],
const SizedBox(height: 22),
_AuthSubmitButton(state: state, onSubmit: callbacks.onSubmit),
], ],
), const SizedBox(height: 22),
_AuthSubmitButton(state: state, onSubmit: callbacks.onSubmit),
],
); );
} }
} }
@@ -135,47 +128,23 @@ class _AuthModeSwitch extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return TextButton( return Row(
key: const Key('auth-switch-mode'), key: const Key('auth-switch-mode'),
onPressed: busy ? null : () => onSelected(!signUpMode), children: [
style: TextButton.styleFrom( _ModeTab(
minimumSize: const Size(0, _minTapTarget), segmentKey: const Key('auth-mode-sign-in'),
padding: EdgeInsets.zero, label: l10n.signIn,
overlayColor: AppColors.transparent, selected: !signUpMode,
splashFactory: NoSplash.splashFactory, onTap: busy ? null : () => onSelected(false),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)),
),
child: Container(
height: 36,
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: AppColors.raised,
borderRadius: BorderRadius.circular(18),
), ),
child: Row( const SizedBox(width: AppDimens.followingSegmentGap),
children: [ _ModeTab(
Expanded( segmentKey: const Key('auth-mode-sign-up'),
child: _ModeSegment( label: l10n.signUp,
segmentKey: const Key('auth-mode-sign-in'), selected: signUpMode,
label: l10n.signIn, onTap: busy ? null : () => onSelected(true),
hiddenLabel: l10n.authSwitchToSignIn,
selected: !signUpMode,
onTap: busy ? null : () => onSelected(false),
),
),
const SizedBox(width: 4),
Expanded(
child: _ModeSegment(
segmentKey: const Key('auth-mode-sign-up'),
label: l10n.signUp,
hiddenLabel: l10n.authSwitchToSignUp,
selected: signUpMode,
onTap: busy ? null : () => onSelected(true),
),
),
],
), ),
), ],
); );
} }
} }
+12 -10
View File
@@ -11,6 +11,7 @@ import '../../core/data/services/auth_controller.dart';
import '../../core/platform/haptics.dart'; import '../../core/platform/haptics.dart';
import '../../core/platform/keyboard_inset.dart'; import '../../core/platform/keyboard_inset.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/widgets/app_glow_panel.dart';
import '../../core/widgets/centered_toast.dart'; import '../../core/widgets/centered_toast.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@@ -252,15 +253,15 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
final bottom = effectiveKeyboardInset(context, _keyboardInset); final bottom = effectiveKeyboardInset(context, _keyboardInset);
return Padding( return Padding(
padding: EdgeInsets.only(bottom: bottom), padding: EdgeInsets.only(bottom: bottom),
child: Container( child: AppGlowPanel(
key: const Key('phone-auth-sheet'), borderRadius: const BorderRadius.vertical(
decoration: const BoxDecoration( top: Radius.circular(_sheetRadius),
color: AppColors.card,
borderRadius: BorderRadius.vertical(
top: Radius.circular(_sheetRadius),
),
), ),
child: SingleChildScrollView( border: false,
child: Container(
key: const Key('phone-auth-sheet'),
decoration: const BoxDecoration(color: AppColors.transparent),
child: SingleChildScrollView(
controller: _scrollController, controller: _scrollController,
padding: const EdgeInsets.fromLTRB(22, 10, 22, 26), padding: const EdgeInsets.fromLTRB(22, 10, 22, 26),
child: Column( child: Column(
@@ -313,6 +314,7 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
], ],
), ),
), ),
),
), ),
); );
} }
@@ -403,7 +405,7 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
key: const Key('auth-phone-submit'), key: const Key('auth-phone-submit'),
onPressed: _submitting ? null : _submit, onPressed: _submitting ? null : _submit,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: AppColors.blue, backgroundColor: AppColors.brandRed,
foregroundColor: AppColors.onPrimary, foregroundColor: AppColors.onPrimary,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_buttonHeight / 2), borderRadius: BorderRadius.circular(_buttonHeight / 2),
@@ -439,7 +441,7 @@ class _PhoneAuthSheetState extends State<PhoneAuthSheet> {
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(_fieldRadius), borderRadius: BorderRadius.circular(_fieldRadius),
borderSide: const BorderSide(color: AppColors.blue, width: 1.5), borderSide: const BorderSide(color: AppColors.brandRed, width: 1.5),
), ),
); );
} }
+3 -8
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';
@@ -408,15 +407,11 @@ class _DiscoverPageState extends State<DiscoverPage> {
); );
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() {
+1 -1
View File
@@ -139,7 +139,7 @@ class _SearchPageState extends State<SearchPage> {
color: AppColors.textSecondary, color: AppColors.textSecondary,
size: 18, size: 18,
), ),
const SizedBox(width: AppDimens.spaceSm), const SizedBox(width: 2),
Expanded( Expanded(
child: TextField( child: TextField(
key: const Key('discover-search'), key: const Key('discover-search'),
+6 -5
View File
@@ -16,6 +16,7 @@ import '../../core/platform/haptics.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/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_glow_panel.dart';
import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/app_pull_to_refresh.dart';
import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/empty_state.dart'; import '../../core/widgets/empty_state.dart';
@@ -181,15 +182,15 @@ class _DownloadsBody extends StatelessWidget {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AppGlowDialog(
title: Text(l10n.clearDownloadsTitle), title: l10n.clearDownloadsTitle,
content: Text(l10n.clearDownloadsMessage), content: l10n.clearDownloadsMessage,
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false), onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(l10n.cancel), child: Text(l10n.cancel),
), ),
FilledButton( TextButton(
onPressed: () => Navigator.of(dialogContext).pop(true), onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(l10n.confirmClear), child: Text(l10n.confirmClear),
), ),
@@ -435,7 +436,7 @@ class _LicenseTile extends StatelessWidget {
minHeight: AppDimens.progressHeight, minHeight: AppDimens.progressHeight,
value: progress.clamp(0.0, 1.0), value: progress.clamp(0.0, 1.0),
backgroundColor: AppColors.border, backgroundColor: AppColors.border,
valueColor: const AlwaysStoppedAnimation(AppColors.blueSoft), valueColor: const AlwaysStoppedAnimation(AppColors.brandRed),
), ),
), ),
if (license.expireAt != null) ...[ if (license.expireAt != null) ...[
@@ -95,7 +95,7 @@ class DramaResumeBar extends StatelessWidget {
value: fraction, value: fraction,
minHeight: 3, minHeight: 3,
backgroundColor: AppColors.border, backgroundColor: AppColors.border,
valueColor: const AlwaysStoppedAnimation(AppColors.blue), valueColor: const AlwaysStoppedAnimation(AppColors.brandRed),
), ),
), ),
if (label != null) ...[ if (label != null) ...[
@@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart'; import '../../../core/data/models/drama.dart';
import '../../../core/formatting/localized_content.dart'; import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/widgets/remote_image.dart'; import '../../../core/widgets/remote_image.dart';
import '../../../core/widgets/subpage_header.dart'; import '../../../core/widgets/subpage_header.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
@@ -54,7 +53,18 @@ class DramaDetailHeader extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
key: const Key('detail-hero'), key: const Key('detail-hero'),
decoration: const BoxDecoration(gradient: AppTheme.pageGradient), decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.followingGlow,
AppColors.followingGlowMid,
AppColors.appBackground,
],
stops: [0, 0.14, 0.3],
),
),
child: SafeArea( child: SafeArea(
bottom: false, bottom: false,
child: Padding( child: Padding(
+4 -86
View File
@@ -25,87 +25,6 @@ const double _barHorizontal = 16;
const double _barFontSize = 13; const double _barFontSize = 13;
const double _barButtonHeight = 30; const double _barButtonHeight = 30;
const double _removeDialogButtonHeight = 32; const double _removeDialogButtonHeight = 32;
const double _removeDialogRadius = 6;
const double _removeDialogBottomInset = 8;
const double _cancelFollowingDialogPadding = 20;
/// 播放历史移除确认。只把移除按钮压矮。
Future<bool> confirmHistoryRemove(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
return await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
actionsPadding: const EdgeInsets.fromLTRB(
24,
0,
16,
_removeDialogBottomInset,
),
content: Text(l10n.confirmRemove),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(l10n.cancel),
),
FilledButton(
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(0, _removeDialogButtonHeight),
padding: const EdgeInsets.symmetric(horizontal: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_removeDialogRadius),
),
),
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(l10n.remove),
),
],
),
) ??
false;
}
/// 确认后返回是否取消已选追剧。
Future<bool> confirmCancelFollowing(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
return await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
contentPadding: const EdgeInsets.all(_cancelFollowingDialogPadding),
actionsPadding: const EdgeInsets.fromLTRB(
_cancelFollowingDialogPadding,
0,
_cancelFollowingDialogPadding,
_cancelFollowingDialogPadding,
),
content: Text(l10n.confirmCancelFollowing),
actions: [
TextButton(
style: TextButton.styleFrom(
visualDensity: VisualDensity.standard,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(0, _removeDialogButtonHeight),
padding: const EdgeInsets.symmetric(horizontal: 12),
),
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(l10n.cancel),
),
FilledButton(
style: FilledButton.styleFrom(
visualDensity: VisualDensity.standard,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(0, _removeDialogButtonHeight),
padding: const EdgeInsets.symmetric(horizontal: 12),
),
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(l10n.cancelFollowing),
),
],
),
) ??
false;
}
/// 只在追剧编辑中显示底栏。 /// 只在追剧编辑中显示底栏。
class FollowingEditSlot extends StatelessWidget { class FollowingEditSlot extends StatelessWidget {
@@ -195,6 +114,7 @@ class FollowingEditBar extends StatelessWidget {
TextButton( TextButton(
key: const Key('following-select-all'), key: const Key('following-select-all'),
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: AppColors.brandRed,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap, tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(0, _barButtonHeight), minimumSize: const Size(0, _barButtonHeight),
@@ -214,16 +134,14 @@ class FollowingEditBar extends StatelessWidget {
), ),
), ),
const Spacer(), const Spacer(),
FilledButton( TextButton(
key: const Key('following-cancel-follow'), key: const Key('following-cancel-follow'),
style: FilledButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: AppColors.brandRed,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap, tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(0, _removeDialogButtonHeight), minimumSize: const Size(0, _removeDialogButtonHeight),
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_removeDialogRadius),
),
textStyle: const TextStyle( textStyle: const TextStyle(
fontSize: _barFontSize, fontSize: _barFontSize,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -5,12 +5,18 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/data/models/watch_record.dart';
import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/watch_records_controller.dart';
import '../../core/formatting/error_text.dart'; import '../../core/formatting/error_text.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../player/open_player.dart'; import '../player/open_player.dart';
import 'following_grid_card.dart'; import 'following_grid_card.dart';
import 'following_paged_grid.dart'; import 'following_paged_grid.dart';
import 'widgets/continue_watching_banner.dart';
/// 追剧编辑中的选中状态。 /// 追剧编辑中的选中状态。
class FollowingFavoriteSelection { class FollowingFavoriteSelection {
@@ -30,15 +36,19 @@ class FollowingFavoritesGrid extends StatelessWidget {
const FollowingFavoritesGrid({ const FollowingFavoritesGrid({
super.key, super.key,
required this.controller, required this.controller,
required this.history,
required this.selection, required this.selection,
required this.scrollController, required this.scrollController,
required this.onGoHome, required this.onGoHome,
required this.onContinueRecord,
}); });
final FavoriteController controller; final FavoriteController controller;
final WatchRecordsController history;
final FollowingFavoriteSelection selection; final FollowingFavoriteSelection selection;
final ScrollController scrollController; final ScrollController scrollController;
final VoidCallback onGoHome; final VoidCallback onGoHome;
final ValueChanged<WatchRecord> onContinueRecord;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -49,7 +59,7 @@ class FollowingFavoritesGrid extends StatelessWidget {
valueListenable: selection.selected, valueListenable: selection.selected,
builder: (context, picked, _) { builder: (context, picked, _) {
return ListenableBuilder( return ListenableBuilder(
listenable: controller, listenable: Listenable.merge([controller, history]),
builder: (context, _) => _grid(context, isEditing, picked), builder: (context, _) => _grid(context, isEditing, picked),
); );
}, },
@@ -60,14 +70,15 @@ class FollowingFavoritesGrid extends StatelessWidget {
Widget _grid(BuildContext context, bool isEditing, Set<String> picked) { Widget _grid(BuildContext context, bool isEditing, Set<String> picked) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final extra = isEditing ? 0 : 1; final hasFavorites = controller.cards.isNotEmpty;
final extra = !isEditing && hasFavorites ? 1 : 0;
return FollowingPagedGrid( return FollowingPagedGrid(
key: const Key('following-favorites-grid'), key: const Key('following-favorites-grid'),
scrollController: scrollController, scrollController: scrollController,
loading: controller.loading, loading: controller.loading,
loadingMore: controller.loadingMore, loadingMore: controller.loadingMore,
hasMore: controller.hasMore, hasMore: controller.hasMore,
hasItems: controller.cards.isNotEmpty, hasItems: hasFavorites,
showInitialLoading: false, showInitialLoading: false,
errorMessage: controller.error == null errorMessage: controller.error == null
? null ? null
@@ -78,23 +89,96 @@ class FollowingFavoritesGrid extends StatelessWidget {
onRefresh: controller.load, onRefresh: controller.load,
onLoadMore: controller.loadMore, onLoadMore: controller.loadMore,
itemCount: controller.cards.length + extra, itemCount: controller.cards.length + extra,
empty: const SizedBox.shrink(), topSlivers: _topSlivers(context),
empty: EmptyState(
key: const Key('following-favorites-empty'),
icon: Icons.bookmark_border_rounded,
title: l10n.noFavoritesYet,
),
itemBuilder: (context, index) => _item(context, index, isEditing, picked), itemBuilder: (context, index) => _item(context, index, isEditing, picked),
); );
} }
List<Widget> _topSlivers(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final continueRecord = _continueRecord();
return [
if (continueRecord != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppDimens.feedPageHorizontal,
4,
AppDimens.feedPageHorizontal,
0,
),
child: ContinueWatchingBanner(
record: continueRecord,
languageCode: Localizations.localeOf(context).languageCode,
onTap: () => onContinueRecord(continueRecord),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppDimens.feedPageHorizontal,
AppDimens.followingSectionTopGap,
AppDimens.feedPageHorizontal,
AppDimens.followingSectionBottomGap,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
l10n.favoriteDramas,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.followingSectionTitleFont,
fontWeight: FontWeight.w400,
),
),
const Spacer(),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
l10n.totalDramasCount(controller.total),
style: const TextStyle(
color: AppColors.textTabInactive,
fontSize: AppDimens.followingSectionCountFont,
),
),
),
],
),
),
),
];
}
/// 最近一条观看记录(records 已按最近观看排序,与观看历史页首条一致)。
WatchRecord? _continueRecord() {
return history.records.isEmpty ? null : history.records.first;
}
Widget _item( Widget _item(
BuildContext context, BuildContext context,
int index, int index,
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 l10n = AppLocalizations.of(context)!;
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 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),
@@ -102,7 +186,10 @@ 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: l10n.favoriteEpisodeProgress(episode, drama.episodeCount), subtitle: subtitle,
// 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),
); );
} }
+44 -9
View File
@@ -25,6 +25,8 @@ class FollowingGridCard extends StatelessWidget {
this.editing = false, this.editing = false,
this.selected, this.selected,
this.onRemove, this.onRemove,
this.updateBadge,
this.subtitleColor,
}); });
final String dramaId; final String dramaId;
@@ -38,6 +40,13 @@ class FollowingGridCard extends StatelessWidget {
final bool? selected; final bool? selected;
final VoidCallback? onRemove; final VoidCallback? onRemove;
/// 左上角「有更新」角标文案;null 不显示。
/// 待后端在 Drama 增加更新状态字段后由收藏网格传入。
final String? updateBadge;
/// 副标题颜色;更新中用强调红,完结用默认灰。
final Color? subtitleColor;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return InkWell( return InkWell(
@@ -109,6 +118,32 @@ class FollowingGridCard extends StatelessWidget {
count: viewCount, count: viewCount,
), ),
), ),
if (updateBadge != null && selected == null)
Positioned(
left: AppDimens.followingBadgeInset,
top: AppDimens.followingBadgeInset,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.followingBadgePaddingH,
vertical: AppDimens.followingBadgePaddingV,
),
decoration: BoxDecoration(
color: AppColors.brandRed,
borderRadius: BorderRadius.circular(
AppDimens.followingBadgeRadius,
),
),
child: Text(
updateBadge!,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: AppDimens.followingBadgeFont,
fontWeight: FontWeight.w400,
height: 1,
),
),
),
),
if (progress != null) if (progress != null)
Positioned( Positioned(
left: 0, left: 0,
@@ -120,7 +155,7 @@ class FollowingGridCard extends StatelessWidget {
minHeight: AppDimens.followingProgressHeight, minHeight: AppDimens.followingProgressHeight,
backgroundColor: AppColors.inactiveTrack, backgroundColor: AppColors.inactiveTrack,
valueColor: const AlwaysStoppedAnimation<Color>( valueColor: const AlwaysStoppedAnimation<Color>(
AppColors.primary, AppColors.brandRed,
), ),
), ),
), ),
@@ -130,13 +165,13 @@ class FollowingGridCard extends StatelessWidget {
const SizedBox(height: AppDimens.cardCoverTitleGap), const SizedBox(height: AppDimens.cardCoverTitleGap),
Text( Text(
title, title,
maxLines: 2, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left, textAlign: TextAlign.left,
style: const TextStyle( style: const TextStyle(
color: AppColors.textPrimary, color: AppColors.textPrimary,
fontSize: AppDimens.cardTitleFont, fontSize: AppDimens.followingCardTitleFontNew,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w400,
height: AppDimens.cardTitleLineHeight, height: AppDimens.cardTitleLineHeight,
), ),
), ),
@@ -146,9 +181,9 @@ class FollowingGridCard extends StatelessWidget {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left, textAlign: TextAlign.left,
style: const TextStyle( style: TextStyle(
color: AppColors.textTertiary, color: subtitleColor ?? AppColors.textTertiary,
fontSize: AppDimens.followingSubtitleFont, fontSize: AppDimens.followingCardSubtitleFontNew,
), ),
), ),
], ],
@@ -169,9 +204,9 @@ class _SelectMark extends StatelessWidget {
height: _selectMarkSize, height: _selectMarkSize,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: selected ? AppColors.blue : AppColors.transparent, color: selected ? AppColors.brandRed : AppColors.transparent,
border: Border.all( border: Border.all(
color: selected ? AppColors.blue : AppColors.textPrimary, color: selected ? AppColors.brandRed : AppColors.textPrimary,
width: _selectBorderWidth, width: _selectBorderWidth,
), ),
), ),
@@ -158,7 +158,7 @@ class FollowingHistoryCard extends StatelessWidget {
minHeight: AppDimens.followingHistoryProgressHeight, minHeight: AppDimens.followingHistoryProgressHeight,
backgroundColor: AppColors.inactiveTrack, backgroundColor: AppColors.inactiveTrack,
valueColor: const AlwaysStoppedAnimation<Color>( valueColor: const AlwaysStoppedAnimation<Color>(
AppColors.primary, AppColors.brandRed,
), ),
), ),
), ),
+161 -97
View File
@@ -17,28 +17,31 @@ import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_dimens.dart';
import '../../core/widgets/empty_state.dart'; import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../discover/search_page.dart';
import '../player/open_player.dart'; import '../player/open_player.dart';
import '../recommend/widgets/search_gift_glyphs.dart';
import 'following_edit_bar.dart'; import 'following_edit_bar.dart';
import 'following_favorites_grid.dart'; import 'following_favorites_grid.dart';
import 'following_history_card.dart'; import 'following_history_card.dart';
import 'following_keep_alive_tab.dart'; import 'following_keep_alive_tab.dart';
import 'following_paged_grid.dart'; import 'following_paged_grid.dart';
const double _doneFontSize = 14;
const double _doneTextGap = 12;
class FollowingPage extends StatefulWidget { class FollowingPage extends StatefulWidget {
const FollowingPage({ const FollowingPage({
super.key, super.key,
required this.onGoHome, required this.onGoHome,
required this.favoriteController, required this.favoriteController,
this.historyRequest = 0, this.historyRequest = 0,
this.visible = true,
}); });
final VoidCallback onGoHome; final VoidCallback onGoHome;
final FavoriteController favoriteController; final FavoriteController favoriteController;
final int historyRequest; final int historyRequest;
/// 当前是否为首页选中的 tab;重新可见时刷新观看历史,保证横幅是最新进度。
final bool visible;
@override @override
State<FollowingPage> createState() => _FollowingPageState(); State<FollowingPage> createState() => _FollowingPageState();
} }
@@ -102,6 +105,9 @@ class _FollowingPageState extends State<FollowingPage> {
@override @override
void didUpdateWidget(covariant FollowingPage oldWidget) { void didUpdateWidget(covariant FollowingPage oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (!oldWidget.visible && widget.visible) {
unawaited(_history.load());
}
if (oldWidget.historyRequest == widget.historyRequest) return; if (oldWidget.historyRequest == widget.historyRequest) return;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _selectTab(FollowingTab.history); if (mounted) _selectTab(FollowingTab.history);
@@ -111,9 +117,21 @@ class _FollowingPageState extends State<FollowingPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
return ColoredBox( return DecoratedBox(
key: const Key('following-page'), key: const Key('following-page'),
color: AppColors.appBackground, // 纯黑底 + 顶部暗红光晕,与设计稿一致。
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.followingGlow,
AppColors.followingGlowMid,
AppColors.appBackground,
],
stops: [0, 0.14, 0.3],
),
),
child: SafeArea( child: SafeArea(
bottom: false, bottom: false,
child: Column( child: Column(
@@ -128,6 +146,7 @@ class _FollowingPageState extends State<FollowingPage> {
FollowingKeepAliveTab( FollowingKeepAliveTab(
child: FollowingFavoritesGrid( child: FollowingFavoritesGrid(
controller: widget.favoriteController, controller: widget.favoriteController,
history: _history,
selection: FollowingFavoriteSelection( selection: FollowingFavoriteSelection(
editing: _editing, editing: _editing,
selected: _selected, selected: _selected,
@@ -135,6 +154,9 @@ class _FollowingPageState extends State<FollowingPage> {
), ),
scrollController: _favoriteScroll, scrollController: _favoriteScroll,
onGoHome: widget.onGoHome, onGoHome: widget.onGoHome,
onContinueRecord: (record) => unawaited(
_openHistory(context, _history, record),
),
), ),
), ),
FollowingKeepAliveTab( FollowingKeepAliveTab(
@@ -165,119 +187,165 @@ class _FollowingPageState extends State<FollowingPage> {
} }
Widget _header(AppLocalizations l10n) { Widget _header(AppLocalizations l10n) {
return SizedBox( return Padding(
key: const Key('following-header'), key: const Key('following-header'),
height: AppDimens.followingHeaderHeight, padding: const EdgeInsets.fromLTRB(
child: Padding( AppDimens.feedPageHorizontal,
padding: const EdgeInsets.symmetric( AppDimens.followingTitleTopGap,
horizontal: AppDimens.feedPageHorizontal, AppDimens.feedPageHorizontal,
), 0,
child: ValueListenableBuilder<FollowingTab>( ),
valueListenable: _tab, child: Column(
builder: (context, selectedTab, _) { children: [
return ValueListenableBuilder<bool>( SizedBox(
valueListenable: _editing, height: AppDimens.followingTitleRowHeight,
builder: (context, editing, _) { child: Row(
children: [
Expanded(
child: Text(
l10n.followingTab,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.followingTitleFont,
fontWeight: FontWeight.w400,
height: 1.0,
),
),
),
_searchButton(l10n),
const SizedBox(width: AppDimens.followingHeaderActionGap),
_manageButton(l10n),
],
),
),
SizedBox(
height: AppDimens.followingSegmentRowHeight,
child: ValueListenableBuilder<FollowingTab>(
valueListenable: _tab,
builder: (context, selectedTab, _) {
return Row( return Row(
children: [ children: [
Expanded( ListenableBuilder(
child: Row( listenable: widget.favoriteController,
children: [ builder: (context, _) {
Flexible( return _segment(
fit: FlexFit.loose, FollowingTab.favorites,
child: _tabButton( Text(
FollowingTab.favorites, '${l10n.myFavorites} ${widget.favoriteController.total}',
l10n.followingTab, maxLines: 1,
'favorites',
selectedTab == FollowingTab.favorites,
),
), ),
const SizedBox(width: AppDimens.followingTabGap), 'favorites',
Flexible( selectedTab == FollowingTab.favorites,
fit: FlexFit.loose, );
child: _tabButton( },
FollowingTab.history, ),
l10n.watchHistory, const SizedBox(width: AppDimens.followingSegmentGap),
'history', _segment(
selectedTab == FollowingTab.history, FollowingTab.history,
), Text(l10n.watchHistory, maxLines: 1),
), 'history',
], selectedTab == FollowingTab.history,
),
), ),
if (editing)
InkWell(
key: const Key('following-edit'),
onTap: _toggleEditing,
child: Padding(
padding: const EdgeInsets.only(left: _doneTextGap),
child: Text(
l10n.done,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _doneFontSize,
fontWeight: FontWeight.w500,
),
),
),
)
else
Semantics(
button: true,
label: l10n.edit,
child: SizedBox(
width: AppDimens.followingEditHitSize,
height: AppDimens.followingEditHitSize,
child: InkWell(
key: const Key('following-edit'),
onTap: _toggleEditing,
child: const Align(
alignment: Alignment.centerRight,
child: Icon(
Icons.edit_outlined,
size: AppDimens.followingEditIconSize,
color: AppColors.textTabInactive,
),
),
),
),
),
], ],
); );
}, },
); ),
}, ),
],
),
);
}
Widget _searchButton(AppLocalizations l10n) {
return Semantics(
button: true,
label: l10n.search,
child: InkWell(
key: const Key('following-search'),
borderRadius: BorderRadius.circular(8),
onTap: () {
Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const SearchPage()),
);
},
child: Padding(
padding: EdgeInsets.all(4),
child: SearchGlyph(
size: AppDimens.followingSearchIcon,
color: AppColors.textPrimary,
),
), ),
), ),
); );
} }
Widget _tabButton( Widget _manageButton(AppLocalizations l10n) {
return ValueListenableBuilder<bool>(
valueListenable: _editing,
builder: (context, editing, _) {
return InkWell(
key: const Key('following-edit'),
borderRadius: BorderRadius.circular(8),
onTap: _toggleEditing,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 8),
child: Text(
editing ? l10n.done : l10n.manage,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.followingManageFont,
fontWeight: FontWeight.w400,
height: 1.0,
),
),
),
);
},
);
}
Widget _segment(
FollowingTab tab, FollowingTab tab,
String label, Widget label,
String keyName, String keyName,
bool selected, bool selected,
) { ) {
return InkWell( return InkWell(
key: Key('following-tab-$keyName'), key: Key('following-tab-$keyName'),
onTap: () => _selectTab(tab), onTap: () => _selectTab(tab),
child: SizedBox( borderRadius: BorderRadius.circular(8),
height: AppDimens.followingHeaderHeight, child: Column(
child: Align( mainAxisAlignment: MainAxisAlignment.center,
alignment: Alignment.centerLeft, children: [
widthFactor: 1, AnimatedDefaultTextStyle(
child: AnimatedDefaultTextStyle(
duration: AppDimens.followingTabAnimationDuration, duration: AppDimens.followingTabAnimationDuration,
style: TextStyle( style: TextStyle(
color: selected color: selected
? AppColors.textPrimary ? AppColors.textPrimary
: AppColors.textTabInactive, : AppColors.textTabInactive,
fontSize: AppDimens.followingTabFont, fontSize: AppDimens.followingSegmentFont,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400, fontWeight: FontWeight.w400,
), ),
child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), child: label,
), ),
), const SizedBox(height: AppDimens.followingSegmentIndicatorTopGap),
AnimatedContainer(
duration: AppDimens.followingTabAnimationDuration,
curve: Curves.easeOut,
width: selected
? AppDimens.followingSegmentIndicatorWidth
: 0,
height: AppDimens.followingSegmentIndicatorHeight,
decoration: BoxDecoration(
color: AppColors.brandRed,
borderRadius: BorderRadius.circular(
AppDimens.followingSegmentIndicatorRadius,
),
),
),
],
), ),
); );
} }
@@ -329,10 +397,7 @@ class _FollowingPageState extends State<FollowingPage> {
Future<void> _cancelSelected() async { Future<void> _cancelSelected() async {
final ids = _selected.value.toList(); final ids = _selected.value.toList();
if (ids.isEmpty || !mounted || !await confirmCancelFollowing(context)) { if (ids.isEmpty || !mounted) return;
return;
}
if (!mounted) return;
final removed = await widget.favoriteController.removeMany(ids); final removed = await widget.favoriteController.removeMany(ids);
if (!removed || !mounted) return; if (!removed || !mounted) return;
_selected.value = {}; _selected.value = {};
@@ -424,8 +489,7 @@ class _HistoryList extends StatelessWidget {
onTap: openRecord, onTap: openRecord,
onContinue: openRecord, onContinue: openRecord,
onRemove: () async { onRemove: () async {
if (await confirmHistoryRemove(context) && if (await controller.remove(record.dramaId) &&
await controller.remove(record.dramaId) &&
controller.records.isEmpty) { controller.records.isEmpty) {
onListEmptied(); onListEmptied();
} }
@@ -28,6 +28,7 @@ class FollowingPagedGrid extends StatelessWidget {
this.singleColumn = false, this.singleColumn = false,
this.errorMessage, this.errorMessage,
this.loadMoreError, this.loadMoreError,
this.topSlivers = const [],
}); });
static const double _loadMoreThreshold = 240; static const double _loadMoreThreshold = 240;
@@ -44,6 +45,10 @@ class FollowingPagedGrid extends StatelessWidget {
final String? loadMoreError; final String? loadMoreError;
final bool showInitialLoading; final bool showInitialLoading;
final bool singleColumn; final bool singleColumn;
/// 列表顶部的非分页内容(继续观看横幅、区块标题等)。
/// 必须是 sliver;空列表时也会保留,因此可携带空态上方的固定区块。
final List<Widget> topSlivers;
final Future<void> Function() onRefresh; final Future<void> Function() onRefresh;
final Future<void> Function() onLoadMore; final Future<void> Function() onLoadMore;
@@ -81,6 +86,7 @@ class FollowingPagedGrid extends StatelessWidget {
controller: scrollController, controller: scrollController,
physics: AppPullToRefresh.physics, physics: AppPullToRefresh.physics,
slivers: [ slivers: [
...topSlivers,
if (itemCount == 0) if (itemCount == 0)
SliverFillRemaining( SliverFillRemaining(
hasScrollBody: false, hasScrollBody: false,
@@ -0,0 +1,207 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/watch_record.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
/// 追剧页「继续观看」大横幅:取最近一条观看记录,点击续播。
class ContinueWatchingBanner extends StatelessWidget {
const ContinueWatchingBanner({
super.key,
required this.record,
required this.languageCode,
required this.onTap,
});
final WatchRecord record;
final String languageCode;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final episode = record.episodeNumber;
final total =
record.progress.totalEpisodes ?? record.drama?.episodeCount ?? 0;
final title = record.localizedDisplayTitle(languageCode);
final showMeta = episode > 0 && total > 0;
return SizedBox(
height: AppDimens.followingBannerHeight,
child: ClipRRect(
borderRadius:
BorderRadius.circular(AppDimens.followingBannerRadius),
child: Stack(
fit: StackFit.expand,
children: [
RemoteImage(
record.displayCover,
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackColor: AppColors.contentSurface,
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Color(0xCC000000),
Color(0x99000000),
Color(0x00000000),
],
stops: [0, 0.45, 1],
),
),
),
Material(
type: MaterialType.transparency,
child: InkWell(
key: const Key('following-continue-banner'),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(
AppDimens.followingBannerInnerGap,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title.isNotEmpty)
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.bannerTitleWarm,
fontSize:
AppDimens.followingBannerTitleFont,
fontWeight: FontWeight.w400,
),
),
if (showMeta) ...[
const SizedBox(
height: AppDimens.followingBannerMetaGap,
),
Text(
l10n.continueWatchingSubtitle(episode, total),
maxLines: 1,
style: const TextStyle(
color: Color(0xB3F5EFE4),
fontSize:
AppDimens.followingBannerSubtitleFont,
),
),
],
const SizedBox(
height: AppDimens.followingBannerButtonTopGap,
),
_ContinueButton(label: l10n.continueWatching),
],
),
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: _ProgressBar(fraction: record.progress.fraction),
),
],
),
),
),
),
],
),
),
);
}
}
class _ContinueButton extends StatelessWidget {
const _ContinueButton({required this.label});
final String label;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: AppColors.brandRed,
borderRadius: BorderRadius.circular(
AppDimens.followingBannerButtonRadius,
),
),
child: SizedBox(
height: AppDimens.followingBannerButtonHeight,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal:
AppDimens.followingBannerButtonHorizontalPadding,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.play_arrow_rounded,
size: AppDimens.followingBannerButtonIcon,
color: AppColors.onPrimary,
),
const SizedBox(width: AppDimens.followingBannerButtonGap),
Text(
label,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: AppDimens.followingBannerButtonFont,
fontWeight: FontWeight.w400,
),
),
],
),
),
),
);
}
}
class _ProgressBar extends StatelessWidget {
const _ProgressBar({required this.fraction});
final double fraction;
@override
Widget build(BuildContext context) {
// SizedBox 给紧约束让 DecoratedBox 轨道撑满整宽;
// 不能用无子 ColoredBox 直接放 Stack(会缩成 0×0 隐形)。
return ClipRRect(
borderRadius: BorderRadius.circular(
AppDimens.followingBannerProgressHeight,
),
child: SizedBox(
height: AppDimens.followingBannerProgressHeight,
width: double.infinity,
child: DecoratedBox(
decoration: const BoxDecoration(
color: Color(0x66FFFFFF),
),
child: Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: fraction.clamp(0.0, 1.0).toDouble(),
// 无 child 的 ColoredBox 在松约束下会缩成 0×0,高度必须显式占满。
heightFactor: 1.0,
child: const ColoredBox(color: AppColors.brandRed),
),
),
),
),
);
}
}
+17 -48
View File
@@ -6,13 +6,11 @@ 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';
import '../../core/data/services/category_controller.dart'; import '../../core/data/services/category_controller.dart';
import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/wallet_controller.dart';
import '../../core/platform/haptics.dart'; import '../../core/platform/haptics.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
@@ -20,16 +18,13 @@ import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/app_root_tab.dart'; import '../../core/widgets/app_root_tab.dart';
import '../discover/search_page.dart'; import '../discover/search_page.dart';
import '../following/following_page.dart'; import '../following/following_page.dart';
import '../filter/filter_page.dart';
import '../mine/mine_types.dart'; import '../mine/mine_types.dart';
import '../recommend/recommend_page.dart'; import '../recommend/recommend_page.dart';
import '../recommend/recommend_discover_page.dart';
import '../mine/mine_view.dart'; import '../mine/mine_view.dart';
import '../store/vip_page.dart';
import '../wallet/wallet_page.dart'; import '../wallet/wallet_page.dart';
import 'home_binding.dart'; import 'home_binding.dart';
import 'home_logic.dart'; import 'home_logic.dart';
import 'widgets/home_feed_grid.dart';
import 'widgets/home_grid_header.dart';
class HomePage extends StatefulWidget { class HomePage extends StatefulWidget {
const HomePage({super.key}); const HomePage({super.key});
@@ -71,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(),
@@ -84,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) {
@@ -147,7 +139,7 @@ class _HomeTabsState extends State<_HomeTabs> {
} }
Widget _scaffold(BuildContext context) { Widget _scaffold(BuildContext context) {
final mediaTab = logic.tab == 0 || logic.tab == 1; // 各 tab 均自绘背景(媒体 tab 纯黑,追剧/我的带顶部红光晕),无需外层渐变。
final content = PopScope( final content = PopScope(
canPop: false, canPop: false,
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {
@@ -164,24 +156,19 @@ class _HomeTabsState extends State<_HomeTabs> {
); );
return AnnotatedRegion<SystemUiOverlayStyle>( return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light, value: SystemUiOverlayStyle.light,
child: mediaTab child: content,
? content
: DecoratedBox(
key: const Key('home-page-gradient'),
decoration: const BoxDecoration(gradient: AppTheme.pageGradient),
child: content,
),
); );
} }
Widget _body(BuildContext context) { Widget _body(BuildContext context) {
final pages = <Widget>[ final pages = <Widget>[
_homeGrid(context),
_recommend(context), _recommend(context),
_recommendDiscover(context),
FollowingPage( 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,
), ),
_my(context), _my(context),
]; ];
@@ -194,27 +181,23 @@ class _HomeTabsState extends State<_HomeTabs> {
); );
} }
Widget _homeGrid(BuildContext context) {
return HomeFeedGrid(
feedController: widget.feedController,
callbacks: HomeGridCallbacks(
onSearch: () => _openSearch(context),
onOpenVip: () => unawaited(_openVip(context)),
onOpenWallet: () => unawaited(_openWallet(context)),
onOpenFilter: () => _pushPage(context, const FilterPage()),
),
onOpenDrama: (drama) => unawaited(logic.playDrama(context, drama)),
);
}
Widget _recommend(BuildContext context) { Widget _recommend(BuildContext context) {
return RecommendPage( return RecommendPage(
feedController: widget.feedController, feedController: widget.feedController,
catalogApi: _catalogApi(context), catalogApi: _catalogApi(context),
active: logic.tab == 1, active: logic.tab == 0,
videoOnly: logic.videoImmersive, videoOnly: logic.videoImmersive,
onImmersiveChanged: logic.onImmersiveChanged, onImmersiveChanged: logic.onImmersiveChanged,
onSearch: () => _openSearch(context), onSearch: () => _openSearch(context),
onOpenWallet: () => unawaited(_openWallet(context)),
);
}
Widget _recommendDiscover(BuildContext context) {
return RecommendDiscoverPage(
active: logic.tab == 1,
onSearch: () => _openSearch(context),
onOpenWallet: () => unawaited(_openWallet(context)),
); );
} }
@@ -232,20 +215,6 @@ class _HomeTabsState extends State<_HomeTabs> {
).push<void>(MaterialPageRoute(builder: (_) => const SearchPage())); ).push<void>(MaterialPageRoute(builder: (_) => const SearchPage()));
} }
void _pushPage(BuildContext context, Widget page) {
Navigator.of(context).push<void>(MaterialPageRoute(builder: (_) => page));
}
Future<void> _openVip(BuildContext context) async {
if (!await ensureSignedIn(context) || !context.mounted) return;
final wallet = context.read<WalletController>();
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => VipPage(onPurchased: () => unawaited(wallet.load())),
),
);
}
Future<void> _openWallet(BuildContext context) async { Future<void> _openWallet(BuildContext context) async {
if (!await ensureSignedIn(context) || !context.mounted) return; if (!await ensureSignedIn(context) || !context.mounted) return;
await Navigator.of( await Navigator.of(
+1 -1
View File
@@ -50,7 +50,7 @@ class HomeFeedCard extends StatelessWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left, textAlign: TextAlign.left,
style: const TextStyle( style: const TextStyle(
color: AppColors.navSelected, color: AppColors.textPrimary,
fontSize: AppDimens.cardTitleFont, fontSize: AppDimens.cardTitleFont,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
height: AppDimens.cardTitleLineHeight, height: AppDimens.cardTitleLineHeight,
@@ -3,6 +3,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../core/data/models/interaction.dart'; import '../../../core/data/models/interaction.dart';
import '../../../core/widgets/app_glow_panel.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
const int _maxDescriptionLength = 300; const int _maxDescriptionLength = 300;
@@ -67,9 +68,9 @@ class _ReportDialogState extends State<_ReportDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
return AlertDialog( return AppGlowDialog(
title: Text(widget.title), title: widget.title,
content: SingleChildScrollView( contentChild: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
+84 -146
View File
@@ -6,39 +6,33 @@ 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/data/models/drama.dart';
import '../../core/data/models/watch_progress.dart';
import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/auth_controller.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/locale_controller.dart'; import '../../core/data/services/locale_controller.dart';
import '../../core/data/services/wallet_controller.dart'; import '../../core/data/services/wallet_controller.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/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_glow_panel.dart';
import '../../core/widgets/app_root_tab.dart'; import '../../core/widgets/app_root_tab.dart';
import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/plain_page_scaffold.dart'; import '../../core/widgets/plain_page_scaffold.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../downloads/downloads_page.dart'; import '../downloads/downloads_page.dart';
import '../account/account_profile_page.dart'; import '../account/account_profile_page.dart';
import '../favorites/favorites_page.dart';
import '../filter/filter_page.dart'; import '../filter/filter_page.dart';
import '../notices/notices_page.dart'; import '../notices/notices_page.dart';
import '../player/open_player.dart';
import '../settings/settings_page.dart'; import '../settings/settings_page.dart';
import '../store/vip_page.dart'; import '../store/vip_page.dart';
import '../wallet/wallet_page.dart'; import '../wallet/wallet_page.dart';
import '../watch_records/watch_records_page.dart'; import '../watch_records/watch_records_page.dart';
import 'about_page.dart';
import 'mine_binding.dart'; import 'mine_binding.dart';
import 'mine_logic.dart'; import 'mine_logic.dart';
import 'mine_types.dart'; import 'mine_types.dart';
import 'widgets/mine_continue_watching.dart';
import 'widgets/mine_header.dart'; import 'widgets/mine_header.dart';
import 'widgets/mine_menu.dart'; import 'widgets/mine_menu.dart';
import 'widgets/mine_more_services.dart';
import 'widgets/mine_stats_panel.dart';
/// K28-B 我的页。 /// K28-B 我的页。
class MyPage extends StatefulWidget { class MyPage extends StatefulWidget {
@@ -136,41 +130,27 @@ class _MyPageState extends State<MyPage> {
Widget _page(BuildContext context) { Widget _page(BuildContext context) {
final content = DecoratedBox( final content = DecoratedBox(
key: const Key('mine-page-surface'), key: const Key('mine-page-surface'),
// 与追剧页同款:纯黑底 + 顶部暗红光晕。
decoration: const BoxDecoration( decoration: const BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: [
AppColors.mineGradientTop, AppColors.followingGlow,
AppColors.mineGradientMiddle, AppColors.followingGlowMid,
AppColors.mineGradientBottom, AppColors.appBackground,
], ],
stops: [0, 0.4, 0.75], stops: [0, 0.14, 0.3],
), ),
), ),
child: Stack( child: SafeArea(
fit: StackFit.expand, bottom: false,
children: [ child: Column(
const DecoratedBox( children: [
key: Key('mine-page-glow'), Expanded(child: _body(context)),
decoration: BoxDecoration( if (!embedded) _bottomNav(),
gradient: RadialGradient( ],
center: Alignment(0, -0.95), ),
radius: 0.9,
colors: [AppColors.mineGradientGlow, AppColors.transparent],
),
),
),
SafeArea(
bottom: false,
child: Column(
children: [
Expanded(child: _body(context)),
if (!embedded) _bottomNav(),
],
),
),
],
), ),
); );
return AnnotatedRegion<SystemUiOverlayStyle>( return AnnotatedRegion<SystemUiOverlayStyle>(
@@ -185,22 +165,7 @@ class _MyPageState extends State<MyPage> {
} }
Widget _body(BuildContext context) { Widget _body(BuildContext context) {
final auth = context.watch<AuthController>(); final l10n = AppLocalizations.of(context)!;
final favorites = context.watch<FavoriteController>();
final wallet = context.watch<WalletController>();
final continueWatching = logic.recent.take(10).toList(growable: false);
final followingCount =
favorites.error == null && !(favorites.loading && favorites.total == 0)
? favorites.total
: null;
final watchRecordCount =
logic.recentError == null &&
!(logic.recentLoading && logic.watchRecordTotal == null)
? logic.watchRecordTotal
: null;
final coinBalance = auth.isSignedIn && wallet.snapshot != null
? wallet.balance
: null;
return ListView( return ListView(
key: const Key('mine-page-scroll'), key: const Key('mine-page-scroll'),
padding: const EdgeInsets.only(bottom: AppDimens.spaceXl), padding: const EdgeInsets.only(bottom: AppDimens.spaceXl),
@@ -214,6 +179,7 @@ class _MyPageState extends State<MyPage> {
onEdit: () => _push(const AccountProfilePage()), onEdit: () => _push(const AccountProfilePage()),
), ),
), ),
const SizedBox(height: AppDimens.spaceMd),
Padding( Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: AppDimens.pageHorizontal, horizontal: AppDimens.pageHorizontal,
@@ -222,23 +188,16 @@ class _MyPageState extends State<MyPage> {
onOpen: () => _openDestination(MineDestination.vip), onOpen: () => _openDestination(MineDestination.vip),
), ),
), ),
if (continueWatching.isNotEmpty) ...[
const SizedBox(height: AppDimens.mineSectionGap),
MineContinueWatching(
records: continueWatching,
onOpenRecord: _openContinueRecord,
onOpenAll: _openHistoryTab,
),
],
const SizedBox(height: AppDimens.mineSectionGap), const SizedBox(height: AppDimens.mineSectionGap),
Padding( Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: AppDimens.mineGridHorizontal, horizontal: AppDimens.pageHorizontal,
), ),
child: MineStatsPanel( child: MineQuickActions(
followingCount: followingCount, onWallet: () => _openDestination(MineDestination.wallet),
watchRecordCount: watchRecordCount, onFavorites: () => _push(const FavoritesPage()),
coinBalance: coinBalance, onDownloads: () => _openDestination(MineDestination.downloads),
onHistory: () => _openDestination(MineDestination.history),
), ),
), ),
const SizedBox(height: AppDimens.mineSectionGap), const SizedBox(height: AppDimens.mineSectionGap),
@@ -246,25 +205,40 @@ class _MyPageState extends State<MyPage> {
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: AppDimens.mineGridHorizontal, horizontal: AppDimens.mineGridHorizontal,
), ),
child: MineMenuList( child: Column(
onOpen: _openDestination, crossAxisAlignment: CrossAxisAlignment.start,
onLanguage: _openLanguage, children: [
), Text(
), l10n.moreServices,
const SizedBox(height: AppDimens.mineSectionGap), style: const TextStyle(
Padding( color: AppColors.textTertiary,
padding: const EdgeInsets.symmetric( fontSize: AppDimens.mineSectionTitle,
horizontal: AppDimens.mineGridHorizontal, ),
), ),
child: MineMoreServices( const SizedBox(height: AppDimens.spaceSm),
onCommonQuestions: _pushPlaceholder, MineServiceList(
onAbout: () => _push(const MineAboutPage()), onOpen: _openDestination,
onLanguage: _openLanguage,
languageName: _languageName(context),
),
],
), ),
), ),
], ],
); );
} }
/// 语言设置行右侧展示的当前语言名。
String _languageName(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final code = context.read<LocaleController>().locale?.languageCode;
return switch (code) {
'en' => l10n.english,
'id' => l10n.bahasaIndonesia,
_ => l10n.simplifiedChinese,
};
}
void _openDestination(MineDestination destination) { void _openDestination(MineDestination destination) {
switch (destination) { switch (destination) {
case MineDestination.history: case MineDestination.history:
@@ -290,45 +264,6 @@ class _MyPageState extends State<MyPage> {
unawaited(openAuthPage(context)); unawaited(openAuthPage(context));
} }
void _openHistoryTab() {
final callback = widget.callbacks.onOpenHistoryTab;
if (callback != null) {
callback();
return;
}
unawaited(_openHistoryPage());
}
/// 继续观看卡片:补齐剧集后直接进全屏播放页,播放器自己会定位到记录的秒数。
Future<void> _openContinueRecord(WatchProgress record) async {
final catalog = context.read<CatalogController>();
// 续看记录只有剧的概要信息,先拼一个占位对象让目录去取完整详情
final summary = Drama(
id: record.dramaId,
title: record.dramaTitle ?? '',
synopsis: '',
posterUrl: record.dramaCover ?? '',
);
Drama drama;
try {
drama = await catalog.requireDetail(summary);
} catch (_) {
if (!mounted) return;
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.detailLoadFailed)));
return;
}
if (!mounted) return;
await playDramaFromCatalog(
context,
drama,
episodeId: record.episodeId.isEmpty ? null : record.episodeId,
);
if (mounted) unawaited(logic.refreshRecent());
}
Future<void> _openHistoryPage() async { Future<void> _openHistoryPage() async {
await _push(const WatchRecordsPage()); await _push(const WatchRecordsPage());
if (mounted) unawaited(logic.refreshRecent()); if (mounted) unawaited(logic.refreshRecent());
@@ -341,7 +276,25 @@ class _MyPageState extends State<MyPage> {
} }
void _pushPlaceholder() { void _pushPlaceholder() {
unawaited(_push(const _MinePlaceholderPage())); final l10n = AppLocalizations.of(context)!;
unawaited(
_push(
PlainPageScaffold(
pageKey: const Key('mine-placeholder-page'),
backKey: const Key('mine-placeholder-back'),
title: l10n.contactSupport,
child: Center(
child: Text(
l10n.comingSoon,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: AppDimens.body,
),
),
),
),
),
);
} }
Future<void> _openProtected(Widget page) async { Future<void> _openProtected(Widget page) async {
@@ -410,14 +363,16 @@ class _LanguageSheet extends StatelessWidget {
(const Locale('en'), const Key('mine-language-en'), l10n.english), (const Locale('en'), const Key('mine-language-en'), l10n.english),
(const Locale('id'), const Key('mine-language-id'), l10n.bahasaIndonesia), (const Locale('id'), const Key('mine-language-id'), l10n.bahasaIndonesia),
]; ];
return Material( return AppGlowPanel(
key: const Key('mine-language-sheet'),
color: AppColors.contentCard,
borderRadius: const BorderRadius.vertical( borderRadius: const BorderRadius.vertical(
top: Radius.circular(AppDimens.mineCardRadius), top: Radius.circular(AppDimens.mineCardRadius),
), ),
clipBehavior: Clip.antiAlias, border: false,
child: SafeArea( child: Material(
key: const Key('mine-language-sheet'),
type: MaterialType.transparency,
clipBehavior: Clip.antiAlias,
child: SafeArea(
top: false, top: false,
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 20), padding: const EdgeInsets.fromLTRB(20, 14, 20, 20),
@@ -440,7 +395,13 @@ class _LanguageSheet extends StatelessWidget {
IconButton( IconButton(
tooltip: l10n.close, tooltip: l10n.close,
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close_rounded), visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
icon: const Icon(Icons.close_rounded, size: 20),
), ),
], ],
), ),
@@ -461,29 +422,6 @@ class _LanguageSheet extends StatelessWidget {
), ),
), ),
), ),
);
}
}
class _MinePlaceholderPage extends StatelessWidget {
const _MinePlaceholderPage();
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final title = l10n.helpFeedback;
return PlainPageScaffold(
pageKey: const Key('mine-placeholder-page'),
backKey: const Key('mine-placeholder-back'),
title: title,
child: Center(
child: Text(
l10n.comingSoon,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: AppDimens.body,
),
),
), ),
); );
} }
+31 -20
View File
@@ -1,6 +1,7 @@
// Author: fruit.com.cn // Author: fruit.com.cn
import 'dart:async'; import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -13,8 +14,7 @@ import '../../../core/widgets/centered_toast.dart';
import '../../../core/widgets/remote_image.dart'; import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
const double _buttonHeight = 34; const double _buttonHeight = 40;
const double _buttonRadius = 6;
/// 「我的」页用户区。 /// 「我的」页用户区。
class MineHeader extends StatelessWidget { class MineHeader extends StatelessWidget {
@@ -35,7 +35,7 @@ class MineHeader extends StatelessWidget {
final uid = signedIn ? user?.uid.trim() ?? '' : ''; final uid = signedIn ? user?.uid.trim() ?? '' : '';
return Padding( return Padding(
key: const Key('mine-user-row'), key: const Key('mine-user-row'),
padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceMd), padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceSm),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
@@ -55,7 +55,7 @@ class MineHeader extends StatelessWidget {
fontFamily: 'Outfit', fontFamily: 'Outfit',
color: AppColors.textPrimary, color: AppColors.textPrimary,
fontSize: AppDimens.mineUserName, fontSize: AppDimens.mineUserName,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w400,
height: AppDimens.mineUserNameLineHeight, height: AppDimens.mineUserNameLineHeight,
), ),
), ),
@@ -121,18 +121,29 @@ class MineHeader extends StatelessWidget {
key: const Key('mine-avatar'), key: const Key('mine-avatar'),
width: AppDimens.mineAvatar, width: AppDimens.mineAvatar,
height: AppDimens.mineAvatar, height: AppDimens.mineAvatar,
decoration: const BoxDecoration( decoration: BoxDecoration(
color: AppColors.contentSurface,
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(
color: AppColors.textTertiary,
width: 1.2,
),
),
child: ClipOval(
child: BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
color: AppColors.textPrimary.withValues(alpha: 0.06),
alignment: Alignment.center,
child: path.isEmpty
? const Icon(
Icons.person_outline_rounded,
color: AppColors.textPrimary,
size: 30,
)
: RemoteImage(path, fit: BoxFit.cover),
),
),
), ),
clipBehavior: Clip.antiAlias,
child: path.isEmpty
? const Icon(
Icons.person_rounded,
color: AppColors.textPrimary,
size: 28,
)
: RemoteImage(path, fit: BoxFit.cover),
); );
} }
@@ -141,20 +152,20 @@ class MineHeader extends StatelessWidget {
required String label, required String label,
required VoidCallback onTap, required VoidCallback onTap,
}) { }) {
return OutlinedButton( return TextButton(
key: key, key: key,
onPressed: onTap, onPressed: onTap,
style: OutlinedButton.styleFrom( style: TextButton.styleFrom(
minimumSize: const Size(0, _buttonHeight), minimumSize: const Size(0, _buttonHeight),
padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd), padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd),
foregroundColor: AppColors.textPrimary, backgroundColor: AppColors.brandRed,
side: const BorderSide(color: AppColors.mineOutline), foregroundColor: AppColors.onPrimary,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_buttonRadius), borderRadius: BorderRadius.circular(_buttonHeight / 2),
), ),
textStyle: const TextStyle( textStyle: const TextStyle(
fontSize: AppDimens.body, fontSize: AppDimens.body,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w400,
), ),
), ),
child: Row( child: Row(
+307 -215
View File
@@ -7,11 +7,9 @@ import '../../../core/theme/app_dimens.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import '../mine_types.dart'; import '../mine_types.dart';
const double _vipRadius = 12; const double _vipRadius = 14;
const double _vipBadgeSize = 36;
const double _vipButtonHeight = 34;
/// 金色会员横幅。 /// 「我的」页金色 VIP 卡:品牌行 + 立即开通 + 2×2 权益宫格。
class MineVipBanner extends StatelessWidget { class MineVipBanner extends StatelessWidget {
const MineVipBanner({super.key, required this.onOpen}); const MineVipBanner({super.key, required this.onOpen});
@@ -24,7 +22,9 @@ class MineVipBanner extends StatelessWidget {
key: const Key('mine-vip'), key: const Key('mine-vip'),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient( gradient: const LinearGradient(
colors: [AppColors.vipGoldStart, AppColors.vipGoldEnd], begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.vipCardStart, AppColors.vipCardEnd],
), ),
borderRadius: BorderRadius.circular(_vipRadius), borderRadius: BorderRadius.circular(_vipRadius),
), ),
@@ -35,69 +35,89 @@ class MineVipBanner extends StatelessWidget {
child: InkWell( child: InkWell(
onTap: onOpen, onTap: onOpen,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.fromLTRB(
horizontal: AppDimens.cardPadding, AppDimens.cardPadding,
vertical: AppDimens.spaceMd, AppDimens.spaceSm + 2,
AppDimens.cardPadding,
AppDimens.spaceSm + 2,
), ),
child: Row( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( Row(
width: _vipBadgeSize, children: [
height: _vipBadgeSize, const Icon(
decoration: const BoxDecoration( Icons.diamond_outlined,
color: AppColors.vipBadgeSurface, color: AppColors.onGold,
shape: BoxShape.circle, size: 24,
), ),
child: const Icon( const SizedBox(width: AppDimens.spaceSm),
Icons.workspace_premium_rounded, Expanded(
color: AppColors.onGold, child: Text(
size: 22,
),
),
const SizedBox(width: AppDimens.spaceMd),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.vipBrandName, l10n.vipBrandName,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: AppColors.onGold, color: AppColors.onGold,
fontSize: AppDimens.sectionTitle, fontSize: 18,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w400,
), ),
), ),
const SizedBox(height: 2), ),
Text( const SizedBox(width: AppDimens.spaceSm),
l10n.mineVipSubtitle, _openButton(l10n),
maxLines: 1, ],
overflow: TextOverflow.ellipsis, ),
style: const TextStyle( Transform.translate(
color: AppColors.onGold, offset: const Offset(0, -7),
fontSize: AppDimens.caption, child: Text(
), l10n.mineVipSubtitle,
), maxLines: 1,
], overflow: TextOverflow.ellipsis,
style: TextStyle(
color: AppColors.onGold.withValues(alpha: 0.7),
fontSize: AppDimens.caption,
),
), ),
), ),
const SizedBox(width: AppDimens.spaceSm), const SizedBox(height: AppDimens.mineVipBenefitRowGap),
TextButton( Container(
key: const Key('mine-vip-open'), height: 1,
onPressed: onOpen, color: AppColors.onGold.withValues(alpha: 0.15),
style: TextButton.styleFrom( ),
minimumSize: const Size(0, _vipButtonHeight), const SizedBox(height: AppDimens.mineVipBenefitRowGap),
padding: const EdgeInsets.symmetric( Table(
horizontal: AppDimens.spaceMd, defaultColumnWidth: const FlexColumnWidth(),
children: [
TableRow(
children: [
_benefit(
key: const Key('mine-vip-benefit-0'),
icon: Icons.play_circle_outline_rounded,
label: l10n.vipBenefitFree,
),
_benefit(
key: const Key('mine-vip-benefit-1'),
icon: Icons.ads_click_rounded,
label: l10n.vipBenefitNoAds,
),
],
), ),
backgroundColor: AppColors.onGold, TableRow(
foregroundColor: AppColors.textPrimary, children: [
shape: RoundedRectangleBorder( _benefit(
borderRadius: BorderRadius.circular(_vipButtonHeight), key: const Key('mine-vip-benefit-2'),
icon: Icons.hd_outlined,
label: l10n.vipBenefitHd,
),
_benefit(
key: const Key('mine-vip-benefit-3'),
icon: Icons.download_for_offline_outlined,
label: l10n.vipBenefitFastCache,
),
],
), ),
), ],
child: Text(l10n.joinVip),
), ),
], ],
), ),
@@ -106,184 +126,256 @@ class MineVipBanner extends StatelessWidget {
), ),
); );
} }
Widget _openButton(AppLocalizations l10n) {
return TextButton(
key: const Key('mine-vip-open'),
onPressed: onOpen,
style: TextButton.styleFrom(
minimumSize: const Size(0, AppDimens.mineVipButtonHeight),
padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd),
backgroundColor: AppColors.onGold,
foregroundColor: AppColors.vipGoldStart,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppDimens.mineVipButtonHeight / 2,
),
),
textStyle: const TextStyle(
fontSize: AppDimens.body,
fontWeight: FontWeight.w400,
),
),
child: Text(l10n.vipOpenNow),
);
}
Widget _benefit({required Key key, required IconData icon, required String label}) {
return Padding(
key: key,
padding: const EdgeInsets.symmetric(vertical: AppDimens.mineVipBenefitRowGap),
child: Row(
children: [
Container(
width: AppDimens.mineVipBenefitBox,
height: AppDimens.mineVipBenefitBox,
decoration: BoxDecoration(
border: Border.all(
color: AppColors.onGold.withValues(alpha: 0.6),
width: 1.2,
),
borderRadius: BorderRadius.circular(
AppDimens.mineVipBenefitBoxRadius,
),
),
child: Icon(
icon,
color: AppColors.onGold,
size: AppDimens.mineVipBenefitGlyph,
),
),
const SizedBox(width: AppDimens.spaceSm),
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onGold,
fontSize: AppDimens.mineVipBenefitFont,
),
),
),
],
),
);
}
} }
/// 「我的」页 4 列功能入口。 /// 快捷图标行:钱包 / 我的收藏 / 离线缓存 / 播放历史(设计稿的「奖励」暂不做)。
class MineMenuList extends StatelessWidget { class MineQuickActions extends StatelessWidget {
const MineMenuList({ const MineQuickActions({
super.key, super.key,
required this.onOpen, required this.onWallet,
required this.onLanguage, required this.onFavorites,
required this.onDownloads,
required this.onHistory,
}); });
final ValueChanged<MineDestination> onOpen; final VoidCallback onWallet;
final VoidCallback onLanguage; final VoidCallback onFavorites;
final VoidCallback onDownloads;
final VoidCallback onHistory;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
return Container( return Row(
key: const Key('mine-menu'), children: [
decoration: BoxDecoration( _action(
color: AppColors.mineGridSurface, key: const Key('mine-wallet'),
borderRadius: BorderRadius.circular(AppDimens.mineGridRadius), icon: Icons.wallet_outlined,
), label: l10n.wallet,
child: Material( onTap: onWallet,
color: AppColors.transparent, ),
borderRadius: BorderRadius.circular(AppDimens.mineGridRadius), _action(
clipBehavior: Clip.antiAlias, key: const Key('mine-favorites'),
child: Table( icon: Icons.star_border_rounded,
key: const Key('mine-menu-grid'), label: l10n.myFavorites,
defaultColumnWidth: const FlexColumnWidth(), onTap: onFavorites,
defaultVerticalAlignment: TableCellVerticalAlignment.middle, ),
border: const TableBorder( _action(
horizontalInside: BorderSide( key: const Key('mine-downloads'),
color: AppColors.mineGridDivider, icon: Icons.download_outlined,
width: AppDimens.mineGridDividerWidth, label: l10n.offlineDownloads,
), onTap: onDownloads,
verticalInside: BorderSide( ),
color: AppColors.mineGridDivider, _action(
width: AppDimens.mineGridDividerWidth, key: const Key('mine-history'),
), icon: Icons.history_rounded,
label: l10n.watchHistory,
onTap: onHistory,
),
],
);
}
Widget _action({
required Key key,
required IconData icon,
required String label,
required VoidCallback onTap,
}) {
return Expanded(
child: InkWell(
key: key,
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceSm),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: AppColors.textPrimary, size: AppDimens.mineQuickActionIcon),
const SizedBox(height: AppDimens.mineQuickActionGap),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.mineQuickActionLabelFont,
),
),
],
), ),
),
),
);
}
}
/// 「更多服务」列表行:白图标 + 标题 + 可选值 + chevron,行间细分隔线。
class MineServiceList extends StatelessWidget {
const MineServiceList({
super.key,
required this.onOpen,
required this.onLanguage,
required this.languageName,
});
final ValueChanged<MineDestination> onOpen;
final VoidCallback onLanguage;
final String languageName;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final rows = <Widget>[
_row(
key: const Key('mine-help'),
icon: Icons.headset_outlined,
label: l10n.contactSupport,
onTap: () => onOpen(MineDestination.help),
),
_divider(),
_row(
key: const Key('mine-language'),
icon: Icons.language_rounded,
label: l10n.language,
value: languageName,
onTap: onLanguage,
),
_divider(),
_row(
key: const Key('mine-settings'),
icon: Icons.settings_outlined,
label: l10n.settings,
onTap: () => onOpen(MineDestination.settings),
),
_divider(),
_row(
key: const Key('mine-notices'),
icon: Icons.notifications_none_rounded,
label: l10n.notices,
onTap: () => onOpen(MineDestination.notices),
),
];
return Column(key: const Key('mine-menu'), children: rows);
}
Widget _divider() => const Divider(
height: AppDimens.mineGridDividerWidth,
thickness: AppDimens.mineGridDividerWidth,
color: AppColors.contentBorder,
);
Widget _row({
required Key key,
required IconData icon,
required String label,
String? value,
required VoidCallback onTap,
}) {
return SizedBox(
height: AppDimens.mineServiceRowHeight,
child: InkWell(
key: key,
onTap: onTap,
child: Row(
children: [ children: [
TableRow( Icon(icon, color: AppColors.textPrimary, size: AppDimens.mineServiceIcon),
children: [ const SizedBox(width: AppDimens.spaceMd),
_cell( Expanded(
index: 0, child: Text(
actionKey: const Key('mine-wallet'), label,
icon: Icons.account_balance_wallet_outlined, maxLines: 1,
iconColor: AppColors.mineIconWallet, overflow: TextOverflow.ellipsis,
label: l10n.wallet, style: const TextStyle(
onTap: () => onOpen(MineDestination.wallet), color: AppColors.textPrimary,
fontSize: AppDimens.mineServiceTitleFont,
), ),
_cell( ),
index: 1,
actionKey: const Key('mine-downloads'),
icon: Icons.download_outlined,
iconColor: AppColors.mineIconDownloads,
label: l10n.offlineDownloads,
onTap: () => onOpen(MineDestination.downloads),
),
_cell(
index: 2,
actionKey: const Key('mine-history'),
icon: Icons.history_rounded,
iconColor: AppColors.mineIconHistory,
label: l10n.watchHistory,
onTap: () => onOpen(MineDestination.history),
),
_cell(
index: 3,
actionKey: const Key('mine-watch-preferences'),
icon: Icons.tune_rounded,
iconColor: AppColors.mineIconPreferences,
label: l10n.watchPreferences,
onTap: () => onOpen(MineDestination.preferences),
),
],
), ),
TableRow( if (value != null && value.isNotEmpty) ...[
children: [ const SizedBox(width: AppDimens.spaceSm),
_cell( Text(
index: 4, value,
actionKey: const Key('mine-help'), style: const TextStyle(
icon: Icons.support_agent_rounded, color: AppColors.textTertiary,
iconColor: AppColors.mineIconSupport, fontSize: AppDimens.mineServiceValueFont,
label: l10n.contactSupport,
onTap: () => onOpen(MineDestination.help),
), ),
_cell( ),
index: 5, ],
actionKey: const Key('mine-language'), const SizedBox(width: AppDimens.spaceXs),
icon: Icons.language_rounded, const Icon(
iconColor: AppColors.mineIconLanguage, Icons.chevron_right_rounded,
label: l10n.language, color: AppColors.textSecondary,
onTap: onLanguage, size: 22,
),
_cell(
index: 6,
actionKey: const Key('mine-settings'),
icon: Icons.settings_outlined,
iconColor: AppColors.mineIconSettings,
label: l10n.settings,
onTap: () => onOpen(MineDestination.settings),
),
_cell(
index: 7,
actionKey: const Key('mine-notices'),
icon: Icons.notifications_none_rounded,
iconColor: AppColors.mineIconNotices,
label: l10n.notices,
onTap: () => onOpen(MineDestination.notices),
),
],
), ),
], ],
), ),
), ),
); );
} }
Widget _cell({
required int index,
Key? actionKey,
IconData? icon,
Color? iconColor,
String? label,
VoidCallback? onTap,
}) {
return SizedBox(
key: Key('mine-menu-cell-$index'),
height: AppDimens.mineGridCellHeight,
child: actionKey == null
? const SizedBox.shrink()
: InkWell(
key: actionKey,
onTap: onTap,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: AppDimens.mineGridIconSurface,
height: AppDimens.mineGridIconSurface,
decoration: const BoxDecoration(
color: AppColors.mineGridIconSurface,
shape: BoxShape.circle,
),
child: DecoratedBox(
decoration: BoxDecoration(
color: iconColor?.withValues(alpha: 0.08),
shape: BoxShape.circle,
),
child: Icon(
icon,
color: iconColor,
size: AppDimens.mineGridIcon,
),
),
),
const SizedBox(height: AppDimens.spaceXs + 2),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
label ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
color: AppColors.mineGridLabel,
fontSize: AppDimens.mineGridLabel,
fontWeight: FontWeight.w500,
height: 1.2,
),
),
),
],
),
),
),
);
}
} }
@@ -1,93 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../l10n/app_localizations.dart';
class MineMoreServices extends StatelessWidget {
const MineMoreServices({
super.key,
required this.onCommonQuestions,
required this.onAbout,
});
final VoidCallback onCommonQuestions;
final VoidCallback onAbout;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Container(
key: const Key('mine-more-services'),
decoration: BoxDecoration(
color: AppColors.minePanelSurface,
borderRadius: BorderRadius.circular(AppDimens.mineGridRadius),
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
_row(
key: const Key('mine-common-questions'),
label: l10n.commonQuestions,
onTap: onCommonQuestions,
),
const Divider(
height: AppDimens.mineGridDividerWidth,
thickness: AppDimens.mineGridDividerWidth,
color: AppColors.mineMoreDivider,
),
_row(
key: const Key('mine-about'),
label: l10n.aboutUs,
onTap: onAbout,
),
],
),
);
}
Widget _row({
required Key key,
required String label,
required VoidCallback onTap,
}) {
return Material(
color: AppColors.transparent,
child: InkWell(
key: key,
onTap: onTap,
child: SizedBox(
height: AppDimens.mineMoreRowHeight,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.minePanelPadding,
),
child: Row(
children: [
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.mineMoreFont,
fontWeight: FontWeight.w400,
),
),
),
const Icon(
Icons.chevron_right_rounded,
color: AppColors.textSecondary,
size: 18,
),
],
),
),
),
),
);
}
}
+12 -2
View File
@@ -10,7 +10,6 @@ import '../../core/data/models/drama.dart';
import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/catalog_controller.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_theme.dart';
import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/app_pull_to_refresh.dart';
import '../../core/widgets/paged_local_list.dart'; import '../../core/widgets/paged_local_list.dart';
import '../../core/widgets/remote_image.dart'; import '../../core/widgets/remote_image.dart';
@@ -108,7 +107,18 @@ class _NewReleasesPageState extends State<NewReleasesPage> {
key: const Key('new-releases-page'), key: const Key('new-releases-page'),
backgroundColor: AppColors.ink, backgroundColor: AppColors.ink,
body: DecoratedBox( body: DecoratedBox(
decoration: const BoxDecoration(gradient: AppTheme.pageGradient), decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.followingGlow,
AppColors.followingGlowMid,
AppColors.appBackground,
],
stops: [0, 0.14, 0.3],
),
),
child: SafeArea( child: SafeArea(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
+4 -3
View File
@@ -13,6 +13,7 @@ import '../../core/formatting/localized_date_time.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/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_glow_panel.dart';
import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/app_pull_to_refresh.dart';
import '../../core/widgets/empty_state.dart'; import '../../core/widgets/empty_state.dart';
import '../../core/widgets/plain_page_scaffold.dart'; import '../../core/widgets/plain_page_scaffold.dart';
@@ -191,9 +192,9 @@ class _NoticesPageState extends State<NoticesPage> {
: formatLocalizedDateTime(context, notice.publishAt!); : formatLocalizedDateTime(context, notice.publishAt!);
return showDialog<void>( return showDialog<void>(
context: context, context: context,
builder: (dialogContext) => AlertDialog( builder: (dialogContext) => AppGlowDialog(
title: Text(notice.title), title: notice.title,
content: SizedBox( contentChild: SizedBox(
width: double.maxFinite, width: double.maxFinite,
child: SingleChildScrollView( child: SingleChildScrollView(
child: Column( child: Column(
+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,
+459 -19
View File
@@ -1,12 +1,16 @@
// Author: fruit.com.cn // Author: fruit.com.cn
import 'dart:async'; import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:floating/floating.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../core/data/models/drama.dart'; import '../../core/data/models/drama.dart';
import '../../core/data/models/episode_stats.dart'; import '../../core/data/models/episode_stats.dart';
@@ -23,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';
@@ -44,17 +49,29 @@ typedef _PlaybackSnapshot = ({
}); });
class PlayerLogic extends ChangeNotifier { class PlayerLogic extends ChangeNotifier {
/// Android PiP 管理器(floating 插件);负责退后台自动进小窗。
static final Floating _floating = Floating();
/// 挂了自动小窗的播放器实例(引用计数):沉浸流与独立播放页可能
/// 同时在树上,只有全部不需要时才真正注销 OnLeavePiP。
static final Set<PlayerLogic> _pipOwners = <PlayerLogic>{};
/// OnLeavePiP 当前在原生侧的挂载状态(进程级,与 [_pipOwners] 对应)。
static bool _pipNativeArmed = false;
PlayerLogic({ PlayerLogic({
required this.history, required this.history,
required this.playback, required this.playback,
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,
@@ -65,6 +82,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;
@@ -112,8 +132,58 @@ class PlayerLogic extends ChangeNotifier {
VideoPlayerPort? scrubbingPlayer; VideoPlayerPort? scrubbingPlayer;
bool pip = false; bool pip = false;
bool pipPreparing = false; bool pipPreparing = false;
final Map<String, int> pickedEpisode = {}; bool _wasPlayingBeforeIosPip = false;
final Set<String> _unlockedEpisodeIds = {}; Timer? _pipStartTimer;
/// iOS 自动小窗开关(对齐红果:开启后退 App 自动进入画中画)。
static const _iosAutoPipPrefKey = 'player.ios.autoEnterPip';
bool iosAutoPipEnabled = false;
/// 播放页前台活跃标志:沉浸流切走 tab / 独立页关闭后置 false,
/// 此时退后台不再自动拉起画中画(影子只同步不拉起)。
bool pipPageActive = false;
/// 由播放视图同步活跃标志;离开播放页时撤销影子的自动小窗资格,
/// 重新进入时重建影子待命(切走期间视图可能被卸载、逻辑实例已换新)。
void setPipPageActive(bool active) {
debugPrint('pip: page-active -> $active');
if (pipPageActive == active) return;
pipPageActive = active;
if (!active && iosDevice) {
unawaited(pictureInPicture.disarmAutoEnter());
return;
}
if (active && iosDevice && iosAutoPipEnabled && !pip) {
final episode = trackedEpisode;
if (episode != null) {
debugPrint('pip: page re-entered, ensure shadow standby');
unawaited(_ensureIosShadowStandby(episode));
}
}
}
/// 画中画还原信号:自增通知视频表面回退占位重新等首帧(防回前台黑屏)。
int pipResumeToken = 0;
/// 画中画还原过渡期:为 true 时播放区盖当前集封面,等纹理出帧后淡出,
/// 盖住回前台瞬间的黑纹理帧。700ms 后自动复位。
bool pipRecovering = false;
Timer? _pipRecoverTimer;
/// iOS 影子播放器当前承载的集 id(全局单例状态);用于避免重复换源。
static String? iosShadowEpisodeId;
/// 当前设备是否为 iOS 真机环境(面板据此显示自动小窗开关)。
bool get iosDevice => !kIsWeb && Platform.isIOS;
/// PiP 状态是全局单通道回调,须广播给所有存活的 PlayerLogic:
/// 推荐页每页一个 logic,若只保留最后一次注册的 handler,
/// 相邻预加载页会盖掉正在播放页的注册,导致小窗 UI 不隐藏且无声音。
static final Set<PlayerLogic> _pipSubscribers = <PlayerLogic>{};
/// 「记住上次选集」:dramaId → 集号。只在用户从选集面板主动挑集时写入
/// (见 [_recordPickedEpisode]),feed 里同一部剧据此显示上次选中的集。
static final Map<String, int> pickedEpisode = {};
final 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 = {};
@@ -214,7 +284,10 @@ class PlayerLogic extends ChangeNotifier {
_started = true; _started = true;
_sessionFactory = factory; _sessionFactory = factory;
_sessionCoordinator = coordinator; _sessionCoordinator = coordinator;
pictureInPicture.setStateListener(onPipChanged); _pipSubscribers.add(this);
pictureInPicture.setStateListener(_dispatchPipChanged);
pictureInPicture.setPlaybackEndedListener(_dispatchPipPlaybackEnded);
unawaited(_loadIosAutoPipPref());
queue = feedDramas == null queue = feedDramas == null
? PlaybackQueue.series(pageDrama) ? PlaybackQueue.series(pageDrama)
: PlaybackQueue.home(feedDramas!); : PlaybackQueue.home(feedDramas!);
@@ -496,7 +569,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 [];
@@ -521,7 +594,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,
@@ -794,7 +867,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);
@@ -850,6 +923,11 @@ class PlayerLogic extends ChangeNotifier {
if (pip || pipPreparing) { if (pip || pipPreparing) {
pip = false; pip = false;
pipPreparing = false; pipPreparing = false;
if (iosDevice) {
// 部分回前台路径不回调 didStop:销毁影子、seek 刷纹理并恢复播放,
// 否则停在 PiP 期间的暂停态表现为黑屏;自动小窗开着则重建待命。
unawaited(_recoverFromIosPipFallback());
}
notify(); notify();
notifyImmersive(); notifyImmersive();
} }
@@ -873,6 +951,33 @@ class PlayerLogic extends ChangeNotifier {
state == AppLifecycleState.resumed || pip || pipPreparing, state == AppLifecycleState.resumed || pip || pipPreparing,
); );
if (state != AppLifecycleState.resumed) cancelScrub(); if (state != AppLifecycleState.resumed) cancelScrub();
// iOS 自动小窗:退后台瞬间把影子对齐到当前进度起播,系统据此自动进画中画;
// 回前台取消待命。手动小窗的恢复由 onPipChanged(false) 处理。
if (iosDevice && iosAutoPipEnabled && !pip) {
final video = session.active is VideoPlayerPort
? session.active as VideoPlayerPort
: null;
if (state == AppLifecycleState.inactive) {
debugPrint(
'pip: inactive gate pageActive=$pipPageActive '
'video=${video != null} userWantsPlay=${session.userWantsPlay}',
);
}
if (state == AppLifecycleState.inactive &&
pipPageActive &&
video != null &&
session.userWantsPlay) {
debugPrint('pip: lifecycle inactive -> arm auto enter');
unawaited(
pictureInPicture.armAutoEnter(
video.controller.value.position.inMilliseconds,
),
);
} else if (state == AppLifecycleState.resumed) {
debugPrint('pip: lifecycle resumed -> disarm auto enter');
unawaited(pictureInPicture.disarmAutoEnter());
}
}
} }
Future<void> _refreshTicketThenResume( Future<void> _refreshTicketThenResume(
@@ -904,7 +1009,13 @@ class PlayerLogic extends ChangeNotifier {
void dispose() { void dispose() {
if (_disposed) return; if (_disposed) return;
_disposed = true; _disposed = true;
pictureInPicture.setStateListener(null); _pipSubscribers.remove(this);
_pipStartTimer?.cancel();
if (_pipSubscribers.isEmpty) {
pictureInPicture.setStateListener(null);
pictureInPicture.setPlaybackEndedListener(null);
}
unawaited(syncPipAutoArm(false));
unawaited(saveCurrentProgress()); unawaited(saveCurrentProgress());
_detachTrackedPlayerListener(); _detachTrackedPlayerListener();
if (_started) { if (_started) {
@@ -918,31 +1029,117 @@ class PlayerLogic extends ChangeNotifier {
void notifyImmersive() => onImmersiveChanged?.call(chromeHidden); void notifyImmersive() => onImmersiveChanged?.call(chromeHidden);
/// 按 [want] 挂/摘退后台自动小窗(引用计数):
/// - 沉浸流:随所在 tab 的激活状态调用(active 时挂,切走时摘);
/// - 独立播放页:进入时挂、销毁时摘;
/// - 权限未授权时静默跳过(申请统一在首次点「画中画」时,见 [enterPip])。
Future<void> syncPipAutoArm(bool want) async {
if (!pictureInPicture.isPlatformSupported) return;
// iOS 无「退后台自动小窗」(系统不开放 Activity 级自动进入),走手动入口。
if (!kIsWeb && Platform.isIOS) return;
try {
if (want) {
_pipOwners.add(this);
} else {
_pipOwners.remove(this);
}
final arm = _pipOwners.isNotEmpty;
if (arm == _pipNativeArmed) return;
if (arm) {
if (!await _floating.isPipAvailable) return;
if (!await Permission.systemAlertWindow.isGranted) return;
await _floating.enable(const OnLeavePiP(aspectRatio: Rational(9, 16)));
_pipNativeArmed = true;
} else {
_pipNativeArmed = false;
await _floating.cancelOnLeavePiP();
}
} catch (_) {
// 自动小窗挂载失败不影响正常播放。
}
}
Future<void> enterPip(BuildContext context) async { Future<void> enterPip(BuildContext context) async {
if (pip || pipPreparing) return; if (pip || pipPreparing) {
if (session.active is! VideoPlayerPort) { debugPrint('pip: tap ignored (pip=$pip preparing=$pipPreparing)');
return;
}
final isIos = !kIsWeb && Platform.isIOS;
debugPrint('pip: tap platform=${isIos ? "ios" : "android"}');
final activeVideo = session.active is VideoPlayerPort
? session.active as VideoPlayerPort
: null;
if (activeVideo == null) {
debugPrint('pip: no active video port');
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.pipUnavailable)), SnackBar(content: Text(AppLocalizations.of(context)!.pipUnavailable)),
); );
return; return;
} }
// 首次点击「画中画」:申请「显示在其他应用上层」权限(特殊权限,
// 会跳系统设置页);拒绝则视为设备无法使用画中画。仅 Android 需要。
if (!isIos && pictureInPicture.isPlatformSupported) {
try {
if (!await Permission.systemAlertWindow.isGranted) {
await Permission.systemAlertWindow.request();
}
if (await Permission.systemAlertWindow.isGranted) {
_pipOwners.add(this);
if (!_pipNativeArmed && await _floating.isPipAvailable) {
await _floating.enable(
const OnLeavePiP(aspectRatio: Rational(9, 16)),
);
_pipNativeArmed = true;
}
} else {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.pipUnavailable),
),
);
return;
}
} catch (_) {
// 权限流程异常时按原有流程继续尝试直接进入。
}
}
pipPreparing = true; pipPreparing = true;
notify(); notify();
notifyImmersive(); notifyImmersive();
await WidgetsBinding.instance.endOfFrame; await WidgetsBinding.instance.endOfFrame;
final url = VideoPlayerPort.resolveUri(
sessionEpisodes[session.currentIndex],
).toString();
final positionMs = activeVideo.controller.value.position.inMilliseconds;
debugPrint('pip: invoke enter pos=$positionMs');
bool entered = false; bool entered = false;
try { try {
entered = await pictureInPicture.enter(); entered = await pictureInPicture.enter(url: url, positionMs: positionMs);
} catch (_) { } catch (e) {
debugPrint('pip: enter threw $e');
// Android reports unavailable for devices without PiP support. // Android reports unavailable for devices without PiP support.
} }
debugPrint('pip: entered=$entered');
if (!context.mounted) return; if (!context.mounted) return;
if (entered) { if (entered) {
pip = true; if (isIos) {
pipPreparing = false; // iOS 等影子流就绪后自动进入小窗(原生 pendingStart 兜底不会过期);
notify(); // 超时仅复位按钮状态,避免长时间卡在「准备中」。
notifyImmersive(); _pipStartTimer?.cancel();
session.setAppResumed(true); _pipStartTimer = Timer(const Duration(seconds: 6), () {
if (!isMounted() || pip || !pipPreparing) return;
pipPreparing = false;
notify();
notifyImmersive();
});
} else {
pip = true;
pipPreparing = false;
notify();
notifyImmersive();
session.setAppResumed(true);
}
} else { } else {
pipPreparing = false; pipPreparing = false;
notify(); notify();
@@ -953,10 +1150,28 @@ class PlayerLogic extends ChangeNotifier {
} }
} }
void onPipChanged(bool enabled) { static void _dispatchPipChanged(bool enabled, int finalPositionMs) {
for (final logic in List.of(_pipSubscribers)) {
logic.onPipChanged(enabled, finalPositionMs: finalPositionMs);
}
}
static void _dispatchPipPlaybackEnded() {
for (final logic in List.of(_pipSubscribers)) {
logic._handleIosPipPlaybackEnded();
}
}
void onPipChanged(bool enabled, {int finalPositionMs = 0}) {
if (!isMounted()) return; if (!isMounted()) return;
if (!kIsWeb && Platform.isIOS) {
_onIosPipChanged(enabled, finalPositionMs);
return;
}
pip = enabled; pip = enabled;
pipPreparing = false; pipPreparing = false;
_pipStartTimer?.cancel();
_pipStartTimer = null;
notify(); notify();
notifyImmersive(); notifyImmersive();
session.setAppResumed( session.setAppResumed(
@@ -965,6 +1180,94 @@ class PlayerLogic extends ChangeNotifier {
); );
} }
/// iOS 影子接力:进入小窗后画面/声音交给原生影子播放器,
/// Flutter 播放器暂停(避免双声);退出时按影子进度 seek 对齐恢复。
void _onIosPipChanged(bool enabled, int finalPositionMs) {
final video = session.active is VideoPlayerPort
? session.active as VideoPlayerPort
: null;
pip = enabled;
pipPreparing = false;
_pipStartTimer?.cancel();
_pipStartTimer = null;
if (enabled) {
_wasPlayingBeforeIosPip = session.userWantsPlay;
session.setUserWantsPlay(false);
if (video != null) unawaited(video.pause());
unawaited(pictureInPicture.unmute());
session.setAppResumed(true);
} else {
unawaited(pictureInPicture.teardown());
PlayerLogic.iosShadowEpisodeId = null;
pipResumeToken++;
_beginPipRecover();
if (video != null) {
final target = finalPositionMs > 0
? clampResumePosition(
Duration(milliseconds: finalPositionMs),
video.controller.value.duration,
)
: video.controller.value.position;
// seek 同时兜住进度对齐与回前台纹理黑屏(强制 AVPlayer 重新出帧)。
unawaited(video.seekTo(target));
}
session.setUserWantsPlay(_wasPlayingBeforeIosPip);
session.setAppResumed(
WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed,
);
// 自动小窗开着时影子回待命,等待下次退后台。
if (iosAutoPipEnabled) {
final episode = trackedEpisode;
if (episode != null) unawaited(_ensureIosShadowStandby(episode));
}
}
notify();
notifyImmersive();
}
/// iOS 画中画里影子播完当前集:小窗内直接换下一集片源无缝连播(不翻页),
/// 同时用无动画跳页把 App 内位置同步到新集,还原后正好停在新集;
/// 没有下一集或下一集锁定时退出画中画回到 App。
Future<void> _handleIosPipPlaybackEnded() async {
if (!isMounted() || !pip || !(!kIsWeb && Platform.isIOS)) return;
final index = session.currentIndex;
final positionInVisible = visibleIndices.indexOf(index) + 1;
if (positionInVisible <= 0 || positionInVisible >= visibleIndices.length) {
await pictureInPicture.stop();
return;
}
final nextIndex = visibleIndices[positionInVisible];
if (nextIndex < 0 || nextIndex >= sessionEpisodes.length) {
await pictureInPicture.stop();
return;
}
if (isEpisodeLocked(sessionEpisodes[nextIndex])) {
await pictureInPicture.stop();
return;
}
final episode = sessionEpisodes[nextIndex];
debugPrint('pip: auto next episode=${episode.id}');
// 影子直接切下一集从头播,窗口不中断。
try {
await pictureInPicture.replaceItem(
url: VideoPlayerPort.resolveUri(episode).toString(),
positionMs: 0,
);
await pictureInPicture.unmute();
PlayerLogic.iosShadowEpisodeId = episode.id;
} catch (_) {
await pictureInPicture.stop();
return;
}
// 同步 App 内会话与页面位置(无动画),还原后停在当前连播到的集。
final controller = pages;
try {
if (controller.hasClients) controller.jumpToPage(positionInVisible);
} catch (_) {
// 页面可能已离树,影子连播不受影响。
}
}
Future<void> preparePlayer(Episode episode, PlayerPort player) async { Future<void> preparePlayer(Episode episode, PlayerPort player) async {
if (player is! VideoPlayerPort) return; if (player is! VideoPlayerPort) return;
try { try {
@@ -1020,6 +1323,121 @@ class PlayerLogic extends ChangeNotifier {
_completionProgressSaved = false; _completionProgressSaved = false;
lastProgressWrite = DateTime.now(); lastProgressWrite = DateTime.now();
player.controller.addListener(_handleTrackedPlayerChanged); player.controller.addListener(_handleTrackedPlayerChanged);
// iOS:小窗内换集则影子直切新片源继续播;自动小窗开启时影子换源保持待命。
// 影子已在该集(连播刚换过源)时跳过,避免重置回片头。
if (!kIsWeb && Platform.isIOS) {
if (pip) {
if (PlayerLogic.iosShadowEpisodeId != episode.id) {
unawaited(_syncIosShadowToEpisode(episode, playWhenPip: true));
}
} else if (iosAutoPipEnabled) {
unawaited(_ensureIosShadowStandby(episode));
}
}
}
Future<void> _syncIosShadowToEpisode(
Episode episode, {
required bool playWhenPip,
}) async {
debugPrint('pip: shadow sync episode=${episode.id} play=$playWhenPip');
try {
await pictureInPicture.replaceItem(
url: VideoPlayerPort.resolveUri(episode).toString(),
positionMs: 0,
);
PlayerLogic.iosShadowEpisodeId = episode.id;
if (playWhenPip) await pictureInPicture.unmute();
} catch (_) {
// 影子切换失败时保持小窗停在旧集末帧,不阻断 Flutter 侧换集。
}
}
/// 读取自动小窗开关;开启时影子待命由集激活钩子建立。
Future<void> _loadIosAutoPipPref() async {
if (!iosDevice) return;
try {
iosAutoPipEnabled =
await SharedPreferencesAsync().getBool(_iosAutoPipPrefKey) ?? false;
} catch (_) {
iosAutoPipEnabled = false;
}
}
/// 切换自动小窗开关(更多面板):开启即建影子待命,关闭即销毁。
Future<void> setIosAutoPip(bool enabled) async {
debugPrint('pip: auto-pip switch -> $enabled');
if (!iosDevice || iosAutoPipEnabled == enabled) return;
iosAutoPipEnabled = enabled;
try {
await SharedPreferencesAsync().setBool(_iosAutoPipPrefKey, enabled);
} catch (_) {
// 偏好写失败只影响下次启动的记忆,本次会话照常生效。
}
if (enabled) {
final index = session.currentIndex;
if (index >= 0 && index < sessionEpisodes.length) {
await _ensureIosShadowStandby(sessionEpisodes[index]);
}
} else {
await pictureInPicture.teardown();
PlayerLogic.iosShadowEpisodeId = null;
}
}
/// 还原过渡:盖封面直至视频真正出帧(onVideoVisible 时收起),兜底 900ms。
void _beginPipRecover() {
_pipRecoverTimer?.cancel();
pipRecovering = true;
_pipRecoverTimer = Timer(const Duration(milliseconds: 900), endPipRecover);
}
/// 视频首帧确认后立即收起还原封面(由播放视图回调)。
void endPipRecover() {
_pipRecoverTimer?.cancel();
if (pipRecovering) {
pipRecovering = false;
notify();
}
// 原生过渡层在还原动画开始时已盖上,这里等表面恢复后淡出。
unawaited(pictureInPicture.hideRestoreOverlay());
}
/// 回前台兜底恢复:销毁影子、seek 强制重新出帧并恢复播放,
/// 修复部分还原路径不回调 didStop 导致的黑屏 + 暂停。
Future<void> _recoverFromIosPipFallback() async {
pipResumeToken++;
_beginPipRecover();
await pictureInPicture.teardown();
PlayerLogic.iosShadowEpisodeId = null;
final video = session.active is VideoPlayerPort
? session.active as VideoPlayerPort
: null;
if (video != null) {
await video.seekTo(video.controller.value.position);
session.setUserWantsPlay(true);
}
session.setAppResumed(true);
final episode = trackedEpisode;
if (iosAutoPipEnabled && episode != null) {
await _ensureIosShadowStandby(episode);
}
}
/// 影子待命形态:静音暂停在片头,退后台时由系统自动切画中画。
Future<void> _ensureIosShadowStandby(Episode episode) async {
if (!iosDevice || pip) return;
try {
debugPrint('pip: prepare shadow standby (autoEnter)');
await pictureInPicture.prepareShadow(
url: VideoPlayerPort.resolveUri(episode).toString(),
positionMs: 0,
autoEnter: true,
);
PlayerLogic.iosShadowEpisodeId = episode.id;
} catch (e) {
debugPrint('pip: prepare standby failed: $e');
}
} }
void _detachTrackedPlayerListener() { void _detachTrackedPlayerListener() {
@@ -1199,6 +1617,7 @@ class PlayerLogic extends ChangeNotifier {
_pendingSelectionIndex = null; _pendingSelectionIndex = null;
_pendingSelectionRevision = null; _pendingSelectionRevision = null;
await selectEpisode(index, revision: revision); await selectEpisode(index, revision: revision);
_recordPickedEpisode(index);
} }
} finally { } finally {
_selectionDrain = null; _selectionDrain = null;
@@ -1211,6 +1630,18 @@ class PlayerLogic extends ChangeNotifier {
} }
} }
/// 记录用户从选集面板主动挑的集,供 feed 侧「记住上次选集」展示。
/// 只在切集确实落到该集时记录,避免快速连点时把没播过的集记成"上次选集"。
void _recordPickedEpisode(int index) {
if (!_started || _disposed || session.currentIndex != index) return;
if (index < 0 || index >= sessionEpisodes.length) return;
final episode = sessionEpisodes[index];
final dramaId = _dramaIdForEpisode(episode.id);
if (dramaId != null) {
pickedEpisode[dramaId] = episode.number;
}
}
Future<void> selectEpisode( Future<void> selectEpisode(
int index, { int index, {
int? revision, int? revision,
@@ -1264,7 +1695,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;
} }
/// 按可见队列选择一集,并预热上一集与下一集。 /// 按可见队列选择一集,并预热上一集与下一集。
+81 -12
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';
@@ -167,6 +168,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,
@@ -200,6 +202,9 @@ class _PlayerPageState extends State<PlayerPage>
); );
} }
_pageController = logic.pages; _pageController = logic.pages;
// 自动小窗:独立播放页进入即挂;沉浸流随所在 tab 的激活状态挂/摘。
unawaited(logic.syncPipAutoArm(widget.embedded ? widget.active : true));
logic.setPipPageActive(widget.embedded ? widget.active : true);
if (!widget.embedded && if (!widget.embedded &&
!logic.isEpisodeLocked(logic.session.currentEpisode)) { !logic.isEpisodeLocked(logic.session.currentEpisode)) {
_loadingVisible = true; _loadingVisible = true;
@@ -217,6 +222,11 @@ class _PlayerPageState extends State<PlayerPage>
void didUpdateWidget(covariant PlayerPage oldWidget) { void didUpdateWidget(covariant PlayerPage oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
logic.attach(widget, isMounted: () => mounted); logic.attach(widget, isMounted: () => mounted);
// 沉浸流所在 tab 切换时同步自动小窗(激活挂、切走摘)。
if (widget.embedded && oldWidget.active != widget.active) {
unawaited(logic.syncPipAutoArm(widget.active));
logic.setPipPageActive(widget.active);
}
if (oldWidget.scrubCancelRequest != widget.scrubCancelRequest) { if (oldWidget.scrubCancelRequest != widget.scrubCancelRequest) {
_cancelFeedScrub(); _cancelFeedScrub();
} }
@@ -327,6 +337,7 @@ class _PlayerPageState extends State<PlayerPage>
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
appRouteObserver.unsubscribe(this); appRouteObserver.unsubscribe(this);
logic.setPipPageActive(false);
_heartOverlayController.dispose(); _heartOverlayController.dispose();
_spriteAxisService _spriteAxisService
..removeListener(_handleSpriteFrameChanged) ..removeListener(_handleSpriteFrameChanged)
@@ -340,6 +351,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);
@@ -703,6 +724,13 @@ class _PlayerPageState extends State<PlayerPage>
onRefresh: _refreshHomeFeed, onRefresh: _refreshHomeFeed,
child: pages, child: pages,
); );
// 进画中画时预加载当前集海报:还原过渡封面立即可显示,避免再黑一次。
if (logic.pip) {
final poster = logic.trackedEpisode?.posterUrl?.trim();
if (poster != null && poster.isNotEmpty) {
unawaited(precacheImage(NetworkImage(poster), context));
}
}
return Stack( return Stack(
fit: StackFit.expand, fit: StackFit.expand,
clipBehavior: Clip.none, clipBehavior: Clip.none,
@@ -711,7 +739,13 @@ class _PlayerPageState extends State<PlayerPage>
pageController: _pageController, pageController: _pageController,
slots: _renderSlots(indices), slots: _renderSlots(indices),
onCurrentVideoVisible: _handleCurrentVideoVisible, onCurrentVideoVisible: _handleCurrentVideoVisible,
resumeSignal: logic.pipResumeToken,
), ),
if (logic.pipRecovering)
Positioned.fill(
key: const Key('player-pip-recover-cover'),
child: _PipRecoverCover(episode: logic.trackedEpisode),
),
if (!widget.embedded && !_loadingRemoved) if (!widget.embedded && !_loadingRemoved)
Center( Center(
child: IgnorePointer( child: IgnorePointer(
@@ -804,6 +838,9 @@ class _PlayerPageState extends State<PlayerPage>
void _handleCurrentVideoVisible(int? index) { void _handleCurrentVideoVisible(int? index) {
if (!mounted || _loadingRemoved) return; if (!mounted || _loadingRemoved) return;
// 收起还原封面要放在 early-return 之前:恢复时集未变,
// _videoReadyIndex 已相等,只有这里能接到淡入完成的回调。
logic.endPipRecover();
final currentIndex = logic.session.currentIndex; final currentIndex = logic.session.currentIndex;
if (index == null || if (index == null ||
index != currentIndex || index != currentIndex ||
@@ -931,10 +968,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) ??
@@ -1017,10 +1051,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
@@ -1200,6 +1231,7 @@ class _PlayerPageState extends State<PlayerPage>
unawaited(_presentFullPlayer(context, index)); unawaited(_presentFullPlayer(context, index));
}, },
onEpisodes: () => logic.showEpisodes(context), onEpisodes: () => logic.showEpisodes(context),
onOpenSearch: widget.onSearch,
onMore: _openCurrentMore, onMore: _openCurrentMore,
onScrubStart: videoReady onScrubStart: videoReady
? () => logic.beginScrub(videoPort!) ? () => logic.beginScrub(videoPort!)
@@ -1268,10 +1300,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) ??
@@ -1446,3 +1475,43 @@ class _PlayerPageState extends State<PlayerPage>
); );
} }
} }
/// 画中画还原过渡封面:盖住回前台瞬间的黑纹理帧,视频出帧后由
/// [PlayerLogic.endPipRecover] 收起。背景用主题渐变,海报预加载命中前也有过渡观感。
class _PipRecoverCover extends StatelessWidget {
const _PipRecoverCover({required this.episode});
final Episode? episode;
@override
Widget build(BuildContext context) {
final poster = episode?.posterUrl?.trim();
final thumbnail = episode?.thumbnailsUrl?.trim();
final url = poster != null && poster.isNotEmpty
? poster
: (thumbnail != null && thumbnail.isNotEmpty ? thumbnail : null);
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.followingGlow,
AppColors.followingGlowMid,
Color(0xFF101012),
],
stops: [0, 0.22, 0.62],
),
),
child: url == null
? const SizedBox.shrink()
: Image.network(
url,
key: const Key('player-pip-recover-cover-image'),
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
);
}
}
+6 -1
View File
@@ -37,9 +37,14 @@ extension _PlayerViewActions on _PlayerPageState {
onQualityChanged: (quality) => _switchQuality(context, l10n, quality), onQualityChanged: (quality) => _switchQuality(context, l10n, quality),
onClear: _hideChrome, onClear: _hideChrome,
onDownload: () => requestEpisodeDownload(context, episode.id), onDownload: () => requestEpisodeDownload(context, episode.id),
onPip: logic.pictureInPicture.isPlatformSupported // iOS 不显示手动「画中画」入口(走下方「退出后自动小窗」开关)。
onPip: logic.pictureInPicture.isPlatformSupported && !logic.iosDevice
? () => unawaited(logic.enterPip(context)) ? () => unawaited(logic.enterPip(context))
: null, : null,
autoPipEnabled: logic.iosAutoPipEnabled,
onAutoPipChanged: logic.iosDevice
? (value) => unawaited(logic.setIosAutoPip(value))
: null,
), ),
), ),
); );
@@ -14,6 +14,7 @@ import '../../../core/data/repositories/interaction_repository.dart';
import '../../../core/data/services/comment_controller.dart'; import '../../../core/data/services/comment_controller.dart';
import '../../../core/formatting/error_text.dart'; import '../../../core/formatting/error_text.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/app_glow_panel.dart';
import '../../../core/widgets/empty_state.dart'; import '../../../core/widgets/empty_state.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import 'comment_composer_sheet.dart'; import 'comment_composer_sheet.dart';
@@ -176,14 +177,14 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
child: SizedBox( child: SizedBox(
key: const Key('comment-sheet-constraints'), key: const Key('comment-sheet-constraints'),
height: sheetHeight, height: sheetHeight,
child: Container( child: AppGlowPanel(
decoration: const BoxDecoration( borderRadius: const BorderRadius.vertical(
color: AppColors.card, top: Radius.circular(_sheetRadius),
borderRadius: BorderRadius.vertical(
top: Radius.circular(_sheetRadius),
),
), ),
child: SafeArea( border: false,
child: Container(
decoration: const BoxDecoration(color: AppColors.transparent),
child: SafeArea(
top: false, top: false,
child: ListenableBuilder( child: ListenableBuilder(
listenable: _controller, listenable: _controller,
@@ -193,6 +194,7 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
child: _content(context), child: _content(context),
); );
}, },
),
), ),
), ),
), ),
@@ -238,7 +240,7 @@ class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
style: const TextStyle( style: const TextStyle(
color: AppColors.onSurface, color: AppColors.onSurface,
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w400,
), ),
), ),
Positioned( Positioned(
@@ -7,6 +7,7 @@ import '../../../core/data/models/drama.dart';
import '../../../core/formatting/localized_content.dart'; import '../../../core/formatting/localized_content.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/app_glow_panel.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import 'episode_picker_cell.dart'; import 'episode_picker_cell.dart';
@@ -47,16 +48,16 @@ class EpisodePickerSheet extends StatelessWidget {
systemNavigationBarIconBrightness: Brightness.light, systemNavigationBarIconBrightness: Brightness.light,
systemNavigationBarContrastEnforced: false, systemNavigationBarContrastEnforced: false,
), ),
child: Container( child: AppGlowPanel(
key: const Key('player-episodes-sheet'), borderRadius: const BorderRadius.vertical(
height: MediaQuery.sizeOf(context).height * _sheetHeightFactor, top: Radius.circular(_sheetRadius),
decoration: const BoxDecoration(
color: AppColors.card,
borderRadius: BorderRadius.vertical(
top: Radius.circular(_sheetRadius),
),
), ),
child: SafeArea( 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, top: false,
bottom: true, bottom: true,
child: Column( child: Column(
@@ -139,6 +140,7 @@ class EpisodePickerSheet extends StatelessWidget {
], ],
), ),
), ),
),
), ),
); );
} }
+66 -38
View File
@@ -22,11 +22,13 @@ part 'home_feed_overlay_parts.dart';
const double _progressHeight = 2; const double _progressHeight = 2;
const double _progressHitHeight = 32; const double _progressHitHeight = 32;
const double _pageHorizontal = 14; const double _pageHorizontal = 20;
const double _railRight = 8; const double _railRight = 14;
const double _railGap = 12; const double _railGap = 12;
const double _bottomInfoInset = 32; // 观看条高 44;2pt 进度线视觉上沿距观看条顶约 7pt(线中心距进度条盒底 8)。
const double _railBottomOffset = 60; // 信息面板底边取 44+16=60,使简介↔进度线、进度线↔观看条两侧间距均为 7pt。
const double _bottomInfoInset = homeFeedWatchAllHeight + 16;
const double _railBottomOffset = 40;
const Duration _overlayFadeDuration = Duration(milliseconds: 120); const Duration _overlayFadeDuration = Duration(milliseconds: 120);
const List<Shadow> _boostTextShadows = [ const List<Shadow> _boostTextShadows = [
Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)), Shadow(color: AppColors.shadow, blurRadius: 5, offset: Offset(0, 1)),
@@ -52,6 +54,7 @@ class HomeFeedOverlay extends StatefulWidget {
required this.onDetail, required this.onDetail,
required this.onOpenFullPlayer, required this.onOpenFullPlayer,
this.onEpisodes, this.onEpisodes,
this.onOpenSearch,
required this.onTogglePlay, required this.onTogglePlay,
required this.onBoostStart, required this.onBoostStart,
required this.onBoostEnd, required this.onBoostEnd,
@@ -92,6 +95,9 @@ class HomeFeedOverlay extends StatefulWidget {
final VoidCallback onDetail; final VoidCallback onDetail;
final VoidCallback onOpenFullPlayer; final VoidCallback onOpenFullPlayer;
final VoidCallback? onEpisodes; final VoidCallback? onEpisodes;
/// 底部热搜胶囊点击(降级版:跳搜索页)。
final VoidCallback? onOpenSearch;
final VoidCallback onTogglePlay; final VoidCallback onTogglePlay;
final VoidCallback onBoostStart; final VoidCallback onBoostStart;
final VoidCallback onBoostEnd; final VoidCallback onBoostEnd;
@@ -143,6 +149,60 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
onInteractionEnd: widget.onBoostEnd, onInteractionEnd: widget.onBoostEnd,
), ),
), ),
Positioned(
left: 0,
right: 0,
bottom: homeFeedWatchAllHeight + 12,
child: AnimatedOpacity(
duration: _overlayFadeDuration,
opacity: widget.boosting ? 1 : 0,
child: IgnorePointer(
ignoring: !widget.boosting,
child: _boostHint(),
),
),
),
Positioned(
left: _pageHorizontal,
right: _pageHorizontal,
bottom: homeFeedWatchAllHeight,
child: _FeedProgressBar(
controller: widget.controller,
locked: widget.locked,
height: _progressHeight,
hitHeight: _progressHitHeight,
cancelScrubRequest: widget.cancelScrubRequest,
thumbnailService: widget.thumbnailService,
spriteAxisService: widget.spriteAxisService,
thumbnailsUrl: widget.episode.thumbnailsUrl,
episodeId: widget.episode.id,
suppressBufferLoading: widget.suppressBufferLoading,
onScrubStart: _startScrub,
onScrubEnd: _endScrub,
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,
@@ -170,39 +230,6 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
), ),
), ),
), ),
Positioned(
left: 0,
right: 0,
bottom: 20,
child: AnimatedOpacity(
duration: _overlayFadeDuration,
opacity: widget.boosting ? 1 : 0,
child: IgnorePointer(
ignoring: !widget.boosting,
child: _boostHint(),
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _FeedProgressBar(
controller: widget.controller,
locked: widget.locked,
height: _progressHeight,
hitHeight: _progressHitHeight,
cancelScrubRequest: widget.cancelScrubRequest,
thumbnailService: widget.thumbnailService,
spriteAxisService: widget.spriteAxisService,
thumbnailsUrl: widget.episode.thumbnailsUrl,
episodeId: widget.episode.id,
suppressBufferLoading: widget.suppressBufferLoading,
onScrubStart: _startScrub,
onScrubEnd: _endScrub,
onSeek: widget.onSeek,
),
),
], ],
), ),
); );
@@ -273,7 +300,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
onSave: widget.onSave, onSave: widget.onSave,
onLike: widget.onLike, onLike: widget.onLike,
onComments: widget.onComments, onComments: widget.onComments,
onEpisodes: widget.onEpisodes ?? widget.onOpenFullPlayer, onShare: widget.onShare,
); );
} }
@@ -283,6 +310,7 @@ class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
episode: widget.episode, episode: widget.episode,
onDetail: widget.onDetail, onDetail: widget.onDetail,
onOpenFullPlayer: widget.onOpenFullPlayer, onOpenFullPlayer: widget.onOpenFullPlayer,
onOpenSearch: widget.onOpenSearch,
); );
} }
} }
@@ -6,7 +6,6 @@ const double _thumbSize = 6;
const double _thumbActiveSize = 14; const double _thumbActiveSize = 14;
const double _trackActiveHeight = 6; const double _trackActiveHeight = 6;
const double _lineCenterFromBottom = 8; const double _lineCenterFromBottom = 8;
const Duration _trackResizeDuration = Duration(milliseconds: 120);
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');
@@ -371,10 +370,8 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
children: [ children: [
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: AnimatedContainer( child: Container(
key: const Key('home-feed-progress-track'), key: const Key('home-feed-progress-track'),
duration: _trackResizeDuration,
curve: Curves.easeOut,
width: width, width: width,
height: trackHeight, height: trackHeight,
color: AppColors.border, color: AppColors.border,
@@ -382,10 +379,8 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
), ),
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: AnimatedContainer( child: Container(
key: const Key('home-feed-progress-played'), key: const Key('home-feed-progress-played'),
duration: dragging ? Duration.zero : _trackResizeDuration,
curve: Curves.easeOut,
width: playedWidth, width: playedWidth,
height: trackHeight, height: trackHeight,
color: AppColors.blue, color: AppColors.blue,
@@ -401,10 +396,8 @@ class _FeedProgressBarState extends State<_FeedProgressBar> {
child: SizedBox.square( child: SizedBox.square(
dimension: _thumbActiveSize, dimension: _thumbActiveSize,
child: Center( child: Center(
child: AnimatedContainer( child: Container(
key: const Key('home-feed-progress-thumb'), key: const Key('home-feed-progress-thumb'),
duration: _trackResizeDuration,
curve: Curves.easeOut,
width: size, width: size,
height: size, height: size,
decoration: const BoxDecoration( decoration: const BoxDecoration(
@@ -1,8 +1,7 @@
// Author: fruit.com.cn // Author: fruit.com.cn
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../core/data/models/drama.dart'; import '../../../core/data/models/drama.dart';
import '../../../core/formatting/localized_content.dart'; import '../../../core/formatting/localized_content.dart';
@@ -11,15 +10,22 @@ import '../../../l10n/app_localizations.dart';
import '../../home/widgets/home_drama_classification.dart'; import '../../home/widgets/home_drama_classification.dart';
import 'interaction_rail.dart'; import 'interaction_rail.dart';
const double _seriesFont = 19; const double _seriesFont = 17;
const double _tagFont = 13; const double _tagFont = 12;
const double _synopsisFont = 13; const double _synopsisFont = 14;
const double _watchAllHeight = 44; const double _watchAllHeight = 44;
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)),
]; ];
final RegExp _tagSplitter = RegExp(r'[,/、·]'); final RegExp _tagSplitter = RegExp(r'[,/、·]');
/// 通栏「观看完整剧集」半透明白底条高度,overlay 用它安排底部避让。
const double homeFeedWatchAllHeight = _watchAllHeight;
/// 播放量紧凑展示(跟随语言):zh 20万 / en 200K / id 200 rb。
String _compactViewers(int count, Locale locale) =>
NumberFormat.compact(locale: locale.languageCode).format(count);
class HomeFeedInfoPanel extends StatefulWidget { class HomeFeedInfoPanel extends StatefulWidget {
const HomeFeedInfoPanel({ const HomeFeedInfoPanel({
super.key, super.key,
@@ -27,6 +33,7 @@ class HomeFeedInfoPanel extends StatefulWidget {
required this.episode, required this.episode,
required this.onDetail, required this.onDetail,
required this.onOpenFullPlayer, required this.onOpenFullPlayer,
this.onOpenSearch,
}); });
final Drama drama; final Drama drama;
@@ -34,6 +41,9 @@ class HomeFeedInfoPanel extends StatefulWidget {
final VoidCallback onDetail; final VoidCallback onDetail;
final VoidCallback onOpenFullPlayer; final VoidCallback onOpenFullPlayer;
/// 热搜胶囊点击;为空或播放量为 0 时不显示胶囊。
final VoidCallback? onOpenSearch;
@override @override
State<HomeFeedInfoPanel> createState() => _HomeFeedInfoPanelState(); State<HomeFeedInfoPanel> createState() => _HomeFeedInfoPanelState();
} }
@@ -48,9 +58,22 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
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);
return Column( return Column(
key: const Key('home-feed-info-panel'),
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (widget.onOpenSearch != null && widget.drama.viewCount > 0) ...[
_HotSearchPill(
label: l10n.feedWatchingCount(
_compactViewers(
widget.drama.viewCount,
Localizations.localeOf(context),
),
),
onTap: widget.onOpenSearch!,
),
const SizedBox(height: 8),
],
GestureDetector( GestureDetector(
key: const Key('home-feed-series-name'), key: const Key('home-feed-series-name'),
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
@@ -67,221 +90,186 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
style: const TextStyle( style: const TextStyle(
color: AppColors.textPrimary, color: AppColors.textPrimary,
fontSize: _seriesFont, fontSize: _seriesFont,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w400,
shadows: _textShadows, shadows: _textShadows,
), ),
), ),
), ),
const SizedBox(width: 4), const SizedBox(width: 2),
const Icon( const Icon(
Icons.chevron_right_rounded, Icons.chevron_right_rounded,
size: 20, size: 18,
color: AppColors.textPrimary, color: AppColors.textPrimary,
), ),
], ],
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 6),
_tagMetaRow(l10n, genres), _tagMetaRow(l10n, genres),
if (synopsis.isNotEmpty) ...[ if (synopsis.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
_synopsis(l10n, synopsis), _synopsis(l10n, synopsis),
], ],
const SizedBox(height: 12),
_watchAll(context, l10n),
], ],
); );
} }
Widget _tagMetaRow(AppLocalizations l10n, List<String> genres) { Widget _tagMetaRow(AppLocalizations l10n, List<String> genres) {
return Row( final chips = <Widget>[
if (isHotDrama(widget.drama))
_TagChip(
key: const Key('home-feed-hot-badge'),
label: l10n.homeBadgeHot,
filled: true,
),
for (final genre in genres)
_TagChip(key: Key('home-feed-tag-$genre'), label: genre),
];
return SizedBox(
key: const Key('home-feed-tags'), key: const Key('home-feed-tags'),
children: [ height: 22,
if (isHotDrama(widget.drama)) ...[ child: ListView.separated(
Container( scrollDirection: Axis.horizontal,
key: const Key('home-feed-hot-badge'), physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), shrinkWrap: true,
decoration: BoxDecoration( itemCount: chips.length,
color: AppColors.error, separatorBuilder: (_, _) => const SizedBox(width: 6),
borderRadius: BorderRadius.circular(3), itemBuilder: (_, index) => chips[index],
), ),
child: Text(
l10n.homeBadgeHot,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 8),
],
Flexible(
fit: FlexFit.loose,
child: Text(
genres.join(' '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.overlayLabel,
fontSize: _tagFont,
),
),
),
const SizedBox(width: 8),
Flexible(
child: Text(
l10n.favoriteEpisodeProgress(
widget.episode.number,
widget.drama.episodeCount,
),
key: const Key('home-feed-episode-progress'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.overlayLabel,
fontSize: _tagFont,
),
),
),
],
); );
} }
Widget _synopsis(AppLocalizations l10n, String synopsis) { Widget _synopsis(AppLocalizations l10n, String synopsis) {
const style = TextStyle( final prefix = l10n.feedEpisodePrefix(widget.episode.number);
color: AppColors.overlaySecondary, final baseStyle = const TextStyle(
color: AppColors.textPrimary,
fontSize: _synopsisFont, fontSize: _synopsisFont,
height: 1.35, height: 1.3,
shadows: _textShadows, shadows: _textShadows,
); );
final span = TextSpan(
children: [
TextSpan(
text: prefix,
style: baseStyle,
),
TextSpan(
text: ' | ',
style: baseStyle.copyWith(
color: AppColors.textPrimary.withValues(alpha: 0.45),
fontSize: _synopsisFont - 2,
fontWeight: FontWeight.w400,
),
),
TextSpan(text: synopsis, style: baseStyle),
],
);
if (_synopsisExpanded) {
return _expandedSynopsis(span, baseStyle, l10n);
}
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final toggle = _synopsisToggle(
key: const Key('home-feed-synopsis-more'),
label: l10n.expandSynopsis,
padding: const EdgeInsets.only(left: 6),
);
final painter = TextPainter( final painter = TextPainter(
text: TextSpan(text: synopsis, style: style), text: span,
maxLines: 2, maxLines: 1,
textDirection: Directionality.of(context), textDirection: Directionality.of(context),
textScaler: MediaQuery.textScalerOf(context), textScaler: MediaQuery.textScalerOf(context),
)..layout(maxWidth: constraints.maxWidth); )..layout(maxWidth: constraints.maxWidth);
final overflows = painter.didExceedMaxLines; if (!painter.didExceedMaxLines) {
if (!overflows) { return Text.rich(
return Text( span,
synopsis,
key: const Key('home-feed-synopsis'), key: const Key('home-feed-synopsis'),
style: style, maxLines: 1,
style: baseStyle,
); );
} }
if (_synopsisExpanded) { // 设计稿:单行省略号 + 行内固定「展开」。
return _expandedSynopsis(constraints.maxWidth, synopsis, style, l10n); // 整行可点:真机上不必精确命中两个小字,点简介任意位置都能展开。
} return GestureDetector(
return Stack( behavior: HitTestBehavior.opaque,
children: [ onTap: () => setState(() => _synopsisExpanded = true),
Padding( child: Row(
padding: const EdgeInsets.only(right: 40), key: const Key('home-feed-synopsis-collapsed'),
child: Text( // 顶对齐:文字位置不变,toggle 的 bottom 留白把触控区向下扩到空白处。
synopsis, crossAxisAlignment: CrossAxisAlignment.start,
key: const Key('home-feed-synopsis'), children: [
maxLines: 2, Expanded(
overflow: TextOverflow.ellipsis, child: Text.rich(
style: style, span,
key: const Key('home-feed-synopsis'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: baseStyle,
),
), ),
), const SizedBox(width: 2),
Positioned( toggle,
right: 0, ],
bottom: 0, ),
child: _synopsisToggle(
key: const Key('home-feed-synopsis-more'),
label: l10n.expandSynopsis,
),
),
],
); );
}, },
); );
} }
Widget _expandedSynopsis( Widget _expandedSynopsis(
double maxWidth, InlineSpan span,
String synopsis,
TextStyle style, TextStyle style,
AppLocalizations l10n, AppLocalizations l10n,
) { ) {
return SizedBox( // 「收起」内联在简介最后一行末尾,避免独占一行导致多行简介与进度条间
width: maxWidth, // 出现大片空白;垂直方向只留少量触控留白(仅撑高最后一行)。
child: Column( final expandedSpan = TextSpan(
crossAxisAlignment: CrossAxisAlignment.stretch, style: style,
children: [ children: [
Text(synopsis, key: const Key('home-feed-synopsis'), style: style), span,
Align( WidgetSpan(
alignment: Alignment.centerRight, alignment: PlaceholderAlignment.baseline,
child: _synopsisToggle( baseline: TextBaseline.alphabetic,
key: const Key('home-feed-synopsis-more'), child: _synopsisToggle(
label: l10n.showLess, key: const Key('home-feed-synopsis-more'),
), label: l10n.showLess,
padding: const EdgeInsets.only(left: 8),
), ),
], ),
), ],
);
return Text.rich(
expandedSpan,
key: const Key('home-feed-synopsis-expanded'),
); );
} }
Widget _synopsisToggle({required Key key, required String label}) { Widget _synopsisToggle({
required Key key,
required String label,
required EdgeInsets padding,
}) {
return GestureDetector( return GestureDetector(
key: key, key: key,
// opaque + 留白扩大命中区;留白方向由调用方决定,
// 展开态不能加 top padding,否则「收起」距简介正文过远。
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _synopsisExpanded = !_synopsisExpanded), onTap: () => setState(() => _synopsisExpanded = !_synopsisExpanded),
child: Text( child: Padding(
label, padding: padding,
style: const TextStyle( child: Text(
color: AppColors.textPrimary, label,
fontSize: _synopsisFont, style: const TextStyle(
fontWeight: FontWeight.w600, color: AppColors.textPrimary,
fontSize: _synopsisFont,
fontWeight: FontWeight.w600,
shadows: _textShadows,
),
), ),
), ),
); );
} }
Widget _watchAll(BuildContext context, AppLocalizations l10n) {
final screenWidth = MediaQuery.sizeOf(context).width;
return LayoutBuilder(
builder: (context, constraints) {
final buttonWidth = math.min(screenWidth * 0.7, constraints.maxWidth);
return Align(
alignment: Alignment.centerLeft,
child: SizedBox(
width: buttonWidth,
height: _watchAllHeight,
child: DecoratedBox(
key: const Key('home-feed-full-series-bg'),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.actionRed, AppColors.blue],
),
borderRadius: BorderRadius.circular(8),
),
child: Material(
color: AppColors.transparent,
child: InkWell(
key: const Key('home-feed-full-series'),
onTap: widget.onOpenFullPlayer,
borderRadius: BorderRadius.circular(8),
child: Center(
child: Text(
l10n.homeWatchAll,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
),
);
},
);
}
List<String> _genreValues(String languageCode) { List<String> _genreValues(String languageCode) {
final localized = widget.drama.localizedTypeLabels(languageCode); final localized = widget.drama.localizedTypeLabels(languageCode);
if (localized.isNotEmpty) return localized.take(3).toList(growable: false); if (localized.isNotEmpty) return localized.take(3).toList(growable: false);
@@ -297,6 +285,147 @@ class _HomeFeedInfoPanelState extends State<HomeFeedInfoPanel> {
} }
} }
/// 热搜榜降级胶囊:🔍 共 xxx 人在看 ›(暂无榜单接口,点击跳搜索页)。
class _HotSearchPill extends StatelessWidget {
const _HotSearchPill({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
key: const Key('home-feed-hot-search-pill'),
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: AppColors.textPrimary.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.search_rounded,
size: 15,
color: AppColors.textPrimary,
),
const SizedBox(width: 5),
Text(
label,
maxLines: 1,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 2),
const Icon(
Icons.chevron_right_rounded,
size: 16,
color: AppColors.textPrimary,
),
],
),
),
);
}
}
/// 热剧红胶囊 / 类型深色半透明填充胶囊。
class _TagChip extends StatelessWidget {
const _TagChip({super.key, required this.label, this.filled = false});
final String label;
final bool filled;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
alignment: Alignment.center,
decoration: BoxDecoration(
color: filled
? AppColors.brandRed
: AppColors.textPrimary.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: Text(
label,
maxLines: 1,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _tagFont,
fontWeight: FontWeight.w500,
height: 1.2,
shadows: _textShadows,
),
),
);
}
}
/// 底部通栏条:🎬 观看完整剧集 全 N 集。
class HomeFeedWatchAllBar extends StatelessWidget {
const HomeFeedWatchAllBar({
super.key,
required this.drama,
required this.onTap,
});
final Drama drama;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Material(
key: const Key('home-feed-full-series-bg'),
color: AppColors.textPrimary.withValues(alpha: 0.15),
child: InkWell(
key: const Key('home-feed-full-series'),
onTap: onTap,
child: SizedBox(
height: _watchAllHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
const Icon(
Icons.movie_rounded,
size: 22,
color: AppColors.onPrimary,
),
const SizedBox(width: 10),
Expanded(
child: Text(
l10n.watchFullSeries('${drama.episodeCount}'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
),
const Icon(
Icons.chevron_right_rounded,
size: 22,
color: AppColors.textPrimary,
),
],
),
),
),
),
);
}
}
class HomeFeedActionRail extends StatelessWidget { class HomeFeedActionRail extends StatelessWidget {
const HomeFeedActionRail({ const HomeFeedActionRail({
super.key, super.key,
@@ -305,7 +434,7 @@ class HomeFeedActionRail extends StatelessWidget {
required this.onSave, required this.onSave,
required this.onLike, required this.onLike,
required this.onComments, required this.onComments,
required this.onEpisodes, required this.onShare,
this.favoriteCount, this.favoriteCount,
this.likeCount, this.likeCount,
this.commentCount, this.commentCount,
@@ -319,13 +448,17 @@ class HomeFeedActionRail extends StatelessWidget {
final VoidCallback onSave; final VoidCallback onSave;
final VoidCallback onLike; final VoidCallback onLike;
final VoidCallback onComments; final VoidCallback onComments;
final VoidCallback onEpisodes; final VoidCallback onShare;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
return InteractionRail( return InteractionRail(
itemGap: 14, itemGap: 12,
buttonSize: 48,
iconSize: 36,
countFontSize: 15,
iconCountGap: 2,
items: [ items: [
InteractionRailItem( InteractionRailItem(
key: const Key('rail-save'), key: const Key('rail-save'),
@@ -334,18 +467,9 @@ class HomeFeedActionRail extends StatelessWidget {
count: favoriteCount, count: favoriteCount,
countKey: const Key('rail-save-count'), countKey: const Key('rail-save-count'),
selected: saved, selected: saved,
selectedColor: AppColors.brandRed,
onTap: onSave, onTap: onSave,
), ),
InteractionRailItem(
key: const Key('rail-like'),
asset: 'assets/icon/rail/rail_like.png',
label: l10n.like,
count: likeCount,
countKey: const Key('rail-like-count'),
selected: liked,
selectedColor: AppColors.doubleTapHeartRed,
onTap: onLike,
),
InteractionRailItem( InteractionRailItem(
key: const Key('rail-comments'), key: const Key('rail-comments'),
asset: 'assets/icon/rail/rail_comment.png', asset: 'assets/icon/rail/rail_comment.png',
@@ -355,11 +479,21 @@ class HomeFeedActionRail extends StatelessWidget {
onTap: onComments, onTap: onComments,
), ),
InteractionRailItem( InteractionRailItem(
key: const Key('rail-episodes'), key: const Key('rail-like'),
icon: Icons.layers_rounded, asset: 'assets/icon/rail/rail_like.png',
label: l10n.episodes, label: l10n.like,
countKey: const Key('rail-episodes-label'), count: likeCount,
onTap: onEpisodes, countKey: const Key('rail-like-count'),
selected: liked,
selectedColor: AppColors.brandRed,
onTap: onLike,
),
InteractionRailItem(
key: const Key('rail-share'),
asset: 'assets/icon/rail/rail_share.png',
label: l10n.share,
countKey: const Key('rail-share-label'),
onTap: onShare,
), ),
], ],
); );
+48 -20
View File
@@ -5,8 +5,10 @@ import 'package:flutter/material.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';
const double _buttonSize = 44; const double _defaultButtonSize = 44;
const double _iconSize = 32; const double _defaultIconSize = 32;
const double _defaultCountFont = 12;
const double _defaultIconCountGap = 2;
const double _defaultItemGap = 24; const double _defaultItemGap = 24;
/// 互动栏单项:图标、计数与点击。 /// 互动栏单项:图标、计数与点击。
@@ -21,6 +23,7 @@ class InteractionRailItem {
this.countKey, this.countKey,
this.selected = false, this.selected = false,
this.selectedColor = AppColors.blue, this.selectedColor = AppColors.blue,
this.mirrorIcon = false,
}) : assert((asset == null) != (icon == null)); }) : assert((asset == null) != (icon == null));
final Key key; final Key key;
@@ -32,6 +35,9 @@ class InteractionRailItem {
final Key? countKey; final Key? countKey;
final bool selected; final bool selected;
final Color selectedColor; final Color selectedColor;
/// 水平镜像图标(分享弯箭头用 reply 镜像得到)。
final bool mirrorIcon;
} }
/// 播放页与首页 feed 共用的右侧互动栏:收藏/评论/喜欢/分享。 /// 播放页与首页 feed 共用的右侧互动栏:收藏/评论/喜欢/分享。
@@ -40,10 +46,18 @@ class InteractionRail extends StatelessWidget {
super.key, super.key,
required this.items, required this.items,
this.itemGap = _defaultItemGap, this.itemGap = _defaultItemGap,
this.buttonSize = _defaultButtonSize,
this.iconSize = _defaultIconSize,
this.countFontSize = _defaultCountFont,
this.iconCountGap = _defaultIconCountGap,
}); });
final List<InteractionRailItem> items; final List<InteractionRailItem> items;
final double itemGap; final double itemGap;
final double buttonSize;
final double iconSize;
final double countFontSize;
final double iconCountGap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -52,7 +66,13 @@ class InteractionRail extends StatelessWidget {
children: [ children: [
for (var index = 0; index < items.length; index++) ...[ for (var index = 0; index < items.length; index++) ...[
if (index > 0) SizedBox(height: itemGap), if (index > 0) SizedBox(height: itemGap),
_RailButton(item: items[index]), _RailButton(
item: items[index],
buttonSize: buttonSize,
iconSize: iconSize,
countFontSize: countFontSize,
iconCountGap: iconCountGap,
),
], ],
], ],
); );
@@ -60,53 +80,61 @@ class InteractionRail extends StatelessWidget {
} }
class _RailButton extends StatelessWidget { class _RailButton extends StatelessWidget {
const _RailButton({required this.item}); const _RailButton({
required this.item,
required this.buttonSize,
required this.iconSize,
required this.countFontSize,
required this.iconCountGap,
});
final InteractionRailItem item; final InteractionRailItem item;
final double buttonSize;
final double iconSize;
final double countFontSize;
final double iconCountGap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tint = item.selected ? item.selectedColor : AppColors.onPrimary;
return Column( return Column(
key: item.key, key: item.key,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
SizedBox.square( SizedBox.square(
dimension: _buttonSize, dimension: buttonSize,
child: IconButton( child: IconButton(
onPressed: item.onTap, onPressed: item.onTap,
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
icon: item.asset != null icon: item.asset != null
? Image.asset( ? Image.asset(
item.asset!, item.asset!,
width: _iconSize, width: iconSize,
height: _iconSize, height: iconSize,
fit: BoxFit.contain, fit: BoxFit.contain,
color: item.selected color: tint,
? item.selectedColor
: AppColors.onPrimary,
colorBlendMode: BlendMode.srcIn, colorBlendMode: BlendMode.srcIn,
) )
: Icon( : Transform.flip(
item.icon, flipX: item.mirrorIcon,
size: _iconSize, child: Icon(item.icon, size: iconSize, color: tint),
color: item.selected
? item.selectedColor
: AppColors.onPrimary,
), ),
), ),
), ),
const SizedBox(height: 2), SizedBox(height: iconCountGap),
Text( Text(
item.count == null ? item.label : formatInteractionCount(item.count!), item.count == null ? item.label : formatInteractionCount(item.count!),
key: item.countKey, key: item.countKey,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: TextStyle(
color: AppColors.onPrimary, color: AppColors.onPrimary,
height: 1, height: 1,
fontSize: 12, fontSize: countFontSize,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
shadows: [Shadow(blurRadius: 6, color: AppColors.mediaBlack)], shadows: const [
Shadow(blurRadius: 6, color: AppColors.mediaBlack),
],
), ),
), ),
], ],
@@ -3,6 +3,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/app_glow_panel.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
const double _sheetRadius = 20; const double _sheetRadius = 20;
@@ -52,6 +53,8 @@ class PlayerMoreActions {
required this.onDownload, required this.onDownload,
required this.onQualityChanged, required this.onQualityChanged,
this.onPip, this.onPip,
this.autoPipEnabled = false,
this.onAutoPipChanged,
}); });
/// 倍速变化。 /// 倍速变化。
@@ -68,6 +71,12 @@ class PlayerMoreActions {
/// 进入小窗播放;平台不支持时为 null。 /// 进入小窗播放;平台不支持时为 null。
final VoidCallback? onPip; final VoidCallback? onPip;
/// 退出 App 自动小窗开关的当前状态(仅支持自动小窗的平台展示开关行)。
final bool autoPipEnabled;
/// 切换退出 App 自动小窗;为 null 时隐藏开关行。
final ValueChanged<bool>? onAutoPipChanged;
} }
/// K29.6 精简版播放页更多面板。 /// K29.6 精简版播放页更多面板。
@@ -96,16 +105,19 @@ class PlayerMoreSheet extends StatefulWidget {
class _PlayerMoreSheetState extends State<PlayerMoreSheet> { class _PlayerMoreSheetState extends State<PlayerMoreSheet> {
late double _speed = widget.state.speed; late double _speed = widget.state.speed;
late String _quality = widget.state.selectedQuality.toUpperCase(); late String _quality = widget.state.selectedQuality.toUpperCase();
late bool _autoPip = widget.actions.autoPipEnabled;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = widget.l10n; final l10n = widget.l10n;
return Material( return AppGlowPanel(
key: const Key('player-more-sheet'),
color: AppColors.ink,
borderRadius: const BorderRadius.vertical( borderRadius: const BorderRadius.vertical(
top: Radius.circular(_sheetRadius), top: Radius.circular(_sheetRadius),
), ),
border: false,
child: Material(
key: const Key('player-more-sheet'),
type: MaterialType.transparency,
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: SafeArea( child: SafeArea(
top: false, top: false,
@@ -142,6 +154,17 @@ class _PlayerMoreSheetState extends State<PlayerMoreSheet> {
title: l10n.pictureInPicture, title: l10n.pictureInPicture,
onTap: () => _closeThen(widget.actions.onPip!), onTap: () => _closeThen(widget.actions.onPip!),
), ),
if (widget.actions.onAutoPipChanged != null)
_switchRow(
key: const Key('player-more-auto-pip'),
icon: Icons.auto_awesome_motion_outlined,
title: l10n.autoEnterPip,
value: _autoPip,
onChanged: (value) {
setState(() => _autoPip = value);
widget.actions.onAutoPipChanged!(value);
},
),
_actionRow( _actionRow(
key: const Key('player-more-offline'), key: const Key('player-more-offline'),
icon: Icons.download_outlined, icon: Icons.download_outlined,
@@ -153,6 +176,7 @@ class _PlayerMoreSheetState extends State<PlayerMoreSheet> {
), ),
), ),
), ),
),
); );
} }
@@ -160,7 +184,7 @@ class _PlayerMoreSheetState extends State<PlayerMoreSheet> {
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: _groupVerticalPadding), padding: const EdgeInsets.symmetric(vertical: _groupVerticalPadding),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.card, color: Colors.white.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(_groupRadius), borderRadius: BorderRadius.circular(_groupRadius),
), ),
child: Column(mainAxisSize: MainAxisSize.min, children: rows), child: Column(mainAxisSize: MainAxisSize.min, children: rows),
@@ -241,7 +265,7 @@ class _PlayerMoreSheetState extends State<PlayerMoreSheet> {
horizontal: _segmentTrackPadding, horizontal: _segmentTrackPadding,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.raised, color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(_segmentTrackRadius), borderRadius: BorderRadius.circular(_segmentTrackRadius),
), ),
child: Row( child: Row(
@@ -286,6 +310,54 @@ class _PlayerMoreSheetState extends State<PlayerMoreSheet> {
); );
} }
/// 开关行:左侧图标+标题,右侧主题色 Switch。
Widget _switchRow({
required Key key,
required IconData icon,
required String title,
required bool value,
required ValueChanged<bool> onChanged,
}) {
return SizedBox(
key: key,
height: _rowHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: _rowHorizontalPadding),
child: Row(
children: [
Icon(icon, color: AppColors.onSurface, size: _rowIconSize),
const SizedBox(width: _rowIconGap),
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: _rowLabelSize,
fontWeight: FontWeight.w500,
),
),
),
Transform.scale(
scale: 0.7,
child: Switch(
key: const Key('player-more-auto-pip-switch'),
value: value,
activeThumbColor: AppColors.onSurface,
activeTrackColor: AppColors.brandRed,
inactiveThumbColor: AppColors.textSecondary,
inactiveTrackColor: AppColors.contentBorder,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onChanged: onChanged,
),
),
],
),
),
);
}
Widget _actionRow({ Widget _actionRow({
required Key key, required Key key,
required IconData icon, required IconData icon,
@@ -380,7 +452,9 @@ class _SegmentButton extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: _segmentPadding), padding: const EdgeInsets.symmetric(horizontal: _segmentPadding),
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
color: selected ? AppColors.card : AppColors.transparent, color: selected
? Colors.white.withValues(alpha: 0.16)
: AppColors.transparent,
borderRadius: BorderRadius.circular(_segmentRadius), borderRadius: BorderRadius.circular(_segmentRadius),
boxShadow: selected boxShadow: selected
? const [ ? const [
+8 -4
View File
@@ -158,16 +158,20 @@ class PlayerPlaybackOverlay extends StatelessWidget {
} }
Widget _rail(BuildContext context) { Widget _rail(BuildContext context) {
final compact = MediaQuery.sizeOf(context).height < 700;
return Positioned( return Positioned(
right: AppDimens.spaceMd, right: AppDimens.spaceMd,
top: MediaQuery.sizeOf(context).height * (compact ? 0.30 : 0.43), // 底部锚定:无论屏幕高矮,始终悬在进度条上方,不会压到进度条。
bottom: 120,
child: IgnorePointer( child: IgnorePointer(
ignoring: scrubbing, ignoring: scrubbing,
child: Opacity( child: Opacity(
opacity: scrubbing ? 0 : 1, opacity: scrubbing ? 0 : 1,
child: InteractionRail( child: InteractionRail(
key: const Key('player-interaction-rail'), key: const Key('player-interaction-rail'),
itemGap: 12,
buttonSize: 48,
iconSize: 36,
countFontSize: 15,
items: [ items: [
InteractionRailItem( InteractionRailItem(
key: const Key('player-rail-save'), key: const Key('player-rail-save'),
@@ -185,7 +189,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
count: data.likeCount, count: data.likeCount,
countKey: const Key('player-rail-like-count'), countKey: const Key('player-rail-like-count'),
selected: data.liked, selected: data.liked,
selectedColor: AppColors.doubleTapHeartRed, selectedColor: AppColors.brandRed,
onTap: actions.onLike, onTap: actions.onLike,
), ),
InteractionRailItem( InteractionRailItem(
@@ -336,7 +340,7 @@ class PlayerPlaybackOverlay extends StatelessWidget {
color: AppColors.onPrimary, color: AppColors.onPrimary,
fontFamily: 'Outfit', fontFamily: 'Outfit',
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w400,
shadows: _playerTextShadows, shadows: _playerTextShadows,
), ),
), ),
@@ -131,9 +131,9 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
int _detailRequest = 0; int _detailRequest = 0;
bool _detailRequested = false; bool _detailRequested = false;
/// 「第 n 集」、分隔符与简介正文共用同一套灰字规格。 /// 「第 n 集」、分隔符与简介正文共用同一套白字规格。
static const TextStyle _lineStyle = TextStyle( static const TextStyle _lineStyle = TextStyle(
color: AppColors.playerMuted, color: AppColors.onPrimary,
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
height: 1.35, height: 1.35,
@@ -142,13 +142,10 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
static const TextStyle _linkStyle = TextStyle( static const TextStyle _linkStyle = TextStyle(
color: AppColors.textPrimary, color: AppColors.textPrimary,
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w600,
shadows: _playerTextShadows, shadows: _playerTextShadows,
); );
static const int _collapsedMaxLines = 2; static const int _collapsedMaxLines = 2;
static const double _expandGap = 6;
static const double _expandTrailing = 28;
static const double _separatorGap = 6;
String get _synopsis => _fullSynopsis ?? widget.synopsis; String get _synopsis => _fullSynopsis ?? widget.synopsis;
@@ -200,236 +197,116 @@ class _EpisodeSynopsisState extends State<_EpisodeSynopsis> {
); );
} }
/// 与首页推荐 feed 的简介展开/收起保持一致:
/// 折叠单行省略 + 行尾「展开」(整行可点);展开后内联「收起」。
Widget _block(double maxWidth) { Widget _block(double maxWidth) {
final label = widget.l10n.episodeLabel(widget.episodeNumber); final label = widget.l10n.episodeLabel(widget.episodeNumber);
if (_expanded) return _expandedBlock(label, maxWidth); final span = TextSpan(
return _collapsedBlock(label, maxWidth); style: _lineStyle,
children: [
TextSpan(text: label, style: _lineStyle),
_separatorSpan(),
TextSpan(text: _synopsis, style: _lineStyle),
],
);
if (_expanded) return _expandedBlock(span);
final painter = TextPainter(
text: span,
maxLines: 1,
textDirection: Directionality.of(context),
textScaler: MediaQuery.textScalerOf(context),
)..layout(maxWidth: maxWidth);
if (!painter.didExceedMaxLines) {
return Text.rich(
span,
key: const Key('player-synopsis'),
maxLines: 1,
style: _lineStyle,
);
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _expanded = true),
child: Row(
key: const Key('player-synopsis-collapsed'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text.rich(
span,
key: const Key('player-synopsis'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: _lineStyle,
),
),
const SizedBox(width: 2),
_toggle(widget.l10n.expandSynopsis,
padding: const EdgeInsets.only(left: 6)),
],
),
);
} }
Widget _collapsedBlock(String label, double maxWidth) { Widget _expandedBlock(InlineSpan span) {
final link = widget.l10n.expandSynopsis; final expandedSpan = TextSpan(
final body = _collapsedBody(label, maxWidth, link); style: _lineStyle,
final text = _synopsisText(label, body, link: null, expanded: false);
if (body == _synopsis) return text;
final caret = _caret(label, body, maxWidth);
return Stack(
clipBehavior: Clip.none,
children: [ children: [
text, span,
Positioned( WidgetSpan(
left: caret.dx, alignment: PlaceholderAlignment.baseline,
top: caret.dy, baseline: TextBaseline.alphabetic,
child: Padding( child: _toggle(
key: const Key('player-synopsis-gap'), widget.l10n.showLess,
padding: const EdgeInsets.only(left: _expandGap), padding: const EdgeInsets.only(left: 8),
child: _toggle(link),
), ),
), ),
], ],
); );
} return Column(
key: const Key('player-synopsis-expanded'),
Widget _expandedBlock(String label, double maxWidth) { mainAxisSize: MainAxisSize.min,
return SizedBox( crossAxisAlignment: CrossAxisAlignment.start,
width: maxWidth, children: [
child: Column( Text.rich(expandedSpan, key: const Key('player-synopsis')),
mainAxisSize: MainAxisSize.min, if (widget.authorNote.isNotEmpty) ...[
crossAxisAlignment: CrossAxisAlignment.stretch, const SizedBox(height: 4),
children: [ Text(
_synopsisText(label, _synopsis, link: null, expanded: true), widget.authorNote,
Align( key: const Key('player-author-note'),
alignment: Alignment.centerRight, maxLines: _collapsedMaxLines,
child: _toggle(widget.l10n.showLess), overflow: TextOverflow.ellipsis,
), style: const TextStyle(
if (widget.authorNote.isNotEmpty) ...[ color: AppColors.playerMuted,
const SizedBox(height: 4), fontSize: 10,
Text( shadows: _playerTextShadows,
widget.authorNote,
key: const Key('player-author-note'),
maxLines: _collapsedMaxLines,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.playerMuted,
fontSize: 10,
shadows: _playerTextShadows,
),
), ),
], ),
], ],
), ],
); );
} }
Widget _synopsisText( /// 分隔竖线:同字号 w400 + 45% 透明白,靠两侧空格留白。
String label, /// 必须用 TextSpan:WidgetSpan 占位盒的基线对齐会让第n集与简介错位。
String body, { TextSpan _separatorSpan() => TextSpan(
required String? link, text: ' | ',
required bool expanded, style: _lineStyle.copyWith(
}) { color: AppColors.onPrimary.withValues(alpha: 0.45),
return Text.rich( fontWeight: FontWeight.w400,
TextSpan(
style: _lineStyle,
children: _spans(label, body, link),
),
key: const Key('player-synopsis'),
style: _lineStyle,
maxLines: expanded ? null : _collapsedMaxLines,
overflow: expanded ? TextOverflow.clip : TextOverflow.ellipsis,
);
}
List<InlineSpan> _spans(String label, String body, String? link) {
return [
WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: Text(
label,
key: const Key('player-episode-label'),
style: _lineStyle,
), ),
), );
WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: _separatorGap),
child: Text(
'|',
style: _lineStyle.copyWith(
color: AppColors.playerMuted.withValues(alpha: 0.6),
),
),
),
),
TextSpan(text: body, style: _lineStyle),
if (link != null)
WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: Padding(
key: const Key('player-synopsis-gap'),
padding: const EdgeInsets.only(left: _expandGap),
child: _toggle(link),
),
),
];
}
String _collapsedBody(String label, double maxWidth, String link) { Widget _toggle(String label, {required EdgeInsets padding}) {
if (_fits(label, _synopsis, maxWidth, reserve: 0)) { return GestureDetector(
return _synopsis;
}
final reserve =
_textWidth(link, _linkStyle) + _expandGap + _expandTrailing;
var low = 0;
var high = _synopsis.length;
var best = '…';
while (low <= high) {
final mid = (low + high) >> 1;
final slice = '${_synopsis.substring(0, mid)}…';
if (_fits(label, slice, maxWidth, reserve: reserve)) {
best = slice;
low = mid + 1;
} else {
high = mid - 1;
}
}
return best;
}
bool _fits(
String label,
String body,
double maxWidth, {
required double reserve,
}) {
final painter = TextPainter(
text: TextSpan(
children: [
TextSpan(text: label, style: _lineStyle),
const WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: SizedBox(width: _separatorGap),
),
TextSpan(
text: '|',
style: _lineStyle.copyWith(
color: AppColors.playerMuted.withValues(alpha: 0.6),
),
),
const WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: SizedBox(width: _separatorGap),
),
TextSpan(text: body, style: _lineStyle),
if (reserve > 0)
WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: SizedBox(width: reserve),
),
],
),
maxLines: _collapsedMaxLines,
textDirection: TextDirection.ltr,
);
painter.setPlaceholderDimensions(_slots(reserve));
painter.layout(maxWidth: maxWidth);
return !painter.didExceedMaxLines;
}
List<PlaceholderDimensions> _slots(double reserve) {
return [
const PlaceholderDimensions(
size: Size(_separatorGap, 1),
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
),
const PlaceholderDimensions(
size: Size(_separatorGap, 1),
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
),
if (reserve > 0)
PlaceholderDimensions(
size: Size(reserve, _lineStyle.fontSize ?? 12),
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
),
];
}
Offset _caret(String label, String body, double maxWidth) {
final painter = TextPainter(
text: TextSpan(children: _spans(label, body, null)),
maxLines: _collapsedMaxLines,
textDirection: TextDirection.ltr,
);
painter.setPlaceholderDimensions(_slots(0));
painter.layout(maxWidth: maxWidth);
return painter.getOffsetForCaret(
TextPosition(offset: painter.plainText.length),
Rect.zero,
);
}
double _textWidth(String text, TextStyle style) {
final painter = TextPainter(
text: TextSpan(text: text, style: style),
maxLines: 1,
textDirection: TextDirection.ltr,
)..layout();
return painter.width;
}
Widget _toggle(String label) {
return InkWell(
key: const Key('player-synopsis-toggle'), key: const Key('player-synopsis-toggle'),
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _expanded = !_expanded), onTap: () => setState(() => _expanded = !_expanded),
borderRadius: BorderRadius.circular(4), child: Padding(
child: Text(label, maxLines: 1, style: _linkStyle), padding: padding,
child: Text(label, maxLines: 1, style: _linkStyle),
),
); );
} }
} }
@@ -53,12 +53,16 @@ class PlayerRenderStack extends StatefulWidget {
required this.pageController, required this.pageController,
required this.slots, required this.slots,
this.onCurrentVideoVisible, this.onCurrentVideoVisible,
this.resumeSignal = 0,
}); });
final PageController pageController; final PageController pageController;
final Map<PlayerRenderSlotRole, PlayerRenderSlotData> slots; final Map<PlayerRenderSlotRole, PlayerRenderSlotData> slots;
final ValueChanged<int?>? onCurrentVideoVisible; final ValueChanged<int?>? onCurrentVideoVisible;
/// iOS 画中画还原的恢复信号,透传给每个槽位的视频表面。
final int resumeSignal;
@override @override
State<PlayerRenderStack> createState() => _PlayerRenderStackState(); State<PlayerRenderStack> createState() => _PlayerRenderStackState();
} }
@@ -200,6 +204,7 @@ class _PlayerRenderStackState extends State<PlayerRenderStack> {
onVideoVisible: slot.role == PlayerRenderSlotRole.current onVideoVisible: slot.role == PlayerRenderSlotRole.current
? () => widget.onCurrentVideoVisible?.call(slot.index) ? () => widget.onCurrentVideoVisible?.call(slot.index)
: null, : null,
resumeSignal: widget.resumeSignal,
), ),
), ),
); );
@@ -3,6 +3,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/app_glow_panel.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
const List<double> _speedOptions = [0.75, 1.0, 1.25, 1.5, 2.0, 3.0]; const List<double> _speedOptions = [0.75, 1.0, 1.25, 1.5, 2.0, 3.0];
@@ -19,14 +20,16 @@ class PlayerSpeedSheet 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 AppGlowPanel(
key: const Key('player-speed-sheet'),
color: AppColors.card,
borderRadius: const BorderRadius.vertical( borderRadius: const BorderRadius.vertical(
top: Radius.circular(_sheetRadius), top: Radius.circular(_sheetRadius),
), ),
clipBehavior: Clip.antiAlias, border: false,
child: SafeArea( child: Material(
key: const Key('player-speed-sheet'),
type: MaterialType.transparency,
clipBehavior: Clip.antiAlias,
child: SafeArea(
top: false, top: false,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -50,6 +53,7 @@ class PlayerSpeedSheet extends StatelessWidget {
], ],
), ),
), ),
),
); );
} }
@@ -6,6 +6,7 @@ import '../../../core/data/models/drama.dart';
import '../../../core/data/models/play_ticket.dart'; import '../../../core/data/models/play_ticket.dart';
import '../../../core/formatting/error_text.dart'; import '../../../core/formatting/error_text.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/app_glow_panel.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
const double _cardRadius = 20; const double _cardRadius = 20;
@@ -209,12 +210,10 @@ class _PlayerUnlockSheetState extends State<PlayerUnlockSheet> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = widget.l10n; final l10n = widget.l10n;
return Container( return AppGlowPanel(
key: const Key('player-unlock-panel'), key: const Key('player-unlock-panel'),
decoration: const BoxDecoration( borderRadius: const BorderRadius.vertical(top: Radius.circular(_cardRadius)),
color: AppColors.card, border: false,
borderRadius: BorderRadius.vertical(top: Radius.circular(_cardRadius)),
),
child: SafeArea( child: SafeArea(
top: false, top: false,
child: Padding( child: Padding(
@@ -29,6 +29,7 @@ class PlayerVideoSurface extends StatefulWidget {
this.debugIndex, this.debugIndex,
this.showFallbackBackground = true, this.showFallbackBackground = true,
this.onVideoVisible, this.onVideoVisible,
this.resumeSignal = 0,
}); });
final VideoPlayerController? controller; final VideoPlayerController? controller;
@@ -60,6 +61,10 @@ class PlayerVideoSurface extends StatefulWidget {
final bool theater; final bool theater;
final VoidCallback? onVideoVisible; final VoidCallback? onVideoVisible;
/// iOS 画中画还原等场景的恢复信号:数值变化时回退到占位并重新等首帧,
/// 避免纹理被系统回收后显示黑屏(封面占位 → 新帧淡入)。
final int resumeSignal;
@override @override
State<PlayerVideoSurface> createState() => _PlayerVideoSurfaceState(); State<PlayerVideoSurface> createState() => _PlayerVideoSurfaceState();
} }
@@ -76,6 +81,11 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
bool _transitioning = false; bool _transitioning = false;
bool _videoVisible = false; bool _videoVisible = false;
bool _videoVisibleNotified = false; bool _videoVisibleNotified = false;
/// 清晰度切换时新源 seek 后的进度;position 越过它才认定纹理已有画面。
Duration? _confirmBasePosition;
/// 画中画还原等场景的首帧确认下限时刻:position 可能一拍就过基准,
/// 但纹理出帧滞后于进度观测,淡入前强制等待一小段真实解码时间。
DateTime? _confirmNotBefore;
String? _lastLayerState; String? _lastLayerState;
@override @override
@@ -99,6 +109,13 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
@override @override
void didUpdateWidget(covariant PlayerVideoSurface oldWidget) { void didUpdateWidget(covariant PlayerVideoSurface oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.resumeSignal != widget.resumeSignal) {
_logSlot('pip-resume-reset', widget.controller?.value);
_resetForControllerChange();
_confirmNotBefore = DateTime.now().add(const Duration(milliseconds: 450));
setState(() {});
if (oldWidget.controller == widget.controller) return;
}
if (oldWidget.controller != widget.controller) { if (oldWidget.controller != widget.controller) {
_logSlot('controller-change-before', oldWidget.controller?.value); _logSlot('controller-change-before', oldWidget.controller?.value);
_detachController(oldWidget.controller); _detachController(oldWidget.controller);
@@ -261,6 +278,16 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
return false; return false;
} }
if (widget.warmFrameAvailable || !widget.waitForPlaybackFrame) return true; if (widget.warmFrameAvailable || !widget.waitForPlaybackFrame) return true;
// 清晰度切换:新源 seek 完成后 position 立即大于 0,但不代表纹理已有
// 画面;等 position 越过切换时刻的值(解码循环已在出帧)再确认,避免闪黑。
final base = _confirmBasePosition;
if (base != null) {
final notBefore = _confirmNotBefore;
if (notBefore != null && DateTime.now().isBefore(notBefore)) {
return false;
}
return value.position > base || value.isCompleted;
}
if (value.position > Duration.zero || value.isCompleted) return true; if (value.position > Duration.zero || value.isCompleted) return true;
return _playbackStarted && !value.isPlaying && !value.isBuffering; return _playbackStarted && !value.isPlaying && !value.isBuffering;
} }
@@ -321,6 +348,8 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface>
_videoVisible = false; _videoVisible = false;
_videoVisibleNotified = false; _videoVisibleNotified = false;
_lastLayerState = null; _lastLayerState = null;
// 记录新源切换时刻的进度,供首帧确认判断 position 是否已前进。
_confirmBasePosition = widget.controller?.value.position;
} }
void _notifyVideoVisible() { void _notifyVideoVisible() {
@@ -0,0 +1,477 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/home_feed.dart';
import '../../core/data/remote/catalog_api.dart';
import '../../core/data/services/category_controller.dart';
import '../../core/theme/app_colors.dart';
import '../../core/widgets/app_pull_to_refresh.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/paged_list_footer.dart';
import '../../l10n/app_localizations.dart';
import '../player/open_player.dart';
import 'widgets/home_hero_carousel.dart';
import 'widgets/home_recommend_content.dart';
import 'widgets/recommend_channel_nav.dart';
/// 底部第二个 tab「推荐」(礼花)。
///
/// 频道全部来自分类接口;内容区是一个横向 [PageView],左右滑动即可切换频道,
/// 顶部频道导航随之高亮,并把选中频道滚动到可见区域。
/// 第一个频道(接口的「精选」)在网格顶部额外挂一张 banners 轮播图,
/// 轮播数据来自 `GET /app/home/feed`。
class RecommendDiscoverPage extends StatefulWidget {
const RecommendDiscoverPage({
super.key,
required this.active,
required this.onSearch,
this.onOpenWallet,
});
final bool active;
final VoidCallback onSearch;
/// 顶部金色礼物盒(钱包入口)。
final VoidCallback? onOpenWallet;
@override
State<RecommendDiscoverPage> createState() => _RecommendDiscoverPageState();
}
class _RecommendDiscoverPageState extends State<RecommendDiscoverPage>
with AutomaticKeepAliveClientMixin<RecommendDiscoverPage> {
/// 当前频道 id;频道全部来自分类接口,首项即「精选」。
String? _channelId;
final Set<String> _requestedCategories = {};
final PageController _pageController = PageController();
/// `GET /app/home/feed` 返回的轮播图,只挂在第一个「精选」频道。
List<FeedBanner> _banners = const [];
bool _bannersRequested = false;
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _loadBanners());
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
/// 点击顶部频道:更新高亮并让 PageView 滑到对应页。
void _selectChannel(String id, int index) {
if (id == _channelId && _pageController.hasClients) {
// 已在该页时仍允许回到顶部对齐。
return;
}
setState(() => _channelId = id);
_ensureCategory(id);
if (_pageController.hasClients) {
unawaited(
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
),
);
}
}
/// 左右滑动翻页:更新高亮(不反向驱动 PageView)。
void _onPageChanged(int index, List<String> orderedIds) {
if (index < 0 || index >= orderedIds.length) return;
final id = orderedIds[index];
if (id != _channelId) setState(() => _channelId = id);
_ensureCategory(id);
}
void _ensureCategory(String id) {
if (_requestedCategories.contains(id)) return;
_requestedCategories.add(id);
final categories = context.read<CategoryController>();
if (categories.dramaPageLoader == null) return;
final slot = categories.slotOf(id);
if (slot.dramas.isNotEmpty || slot.loading) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(categories.openCategory(id));
});
}
/// 下拉刷新:重拉该分类第一页;精选频道同时刷新轮播图。
Future<void> _refreshCategory(String id, bool isFeatured) async {
final categories = context.read<CategoryController>();
final tasks = <Future<void>>[categories.openCategory(id)];
if (isFeatured) tasks.add(_loadBanners(force: true));
await Future.wait(tasks);
}
/// 轮播图只依赖 home/feed 的 banners;接口缺失(如测试注入)时静默跳过,
/// 不影响下方分类网格。
Future<void> _loadBanners({bool force = false}) async {
if (_bannersRequested && !force) return;
_bannersRequested = true;
CatalogApi? api;
try {
api = context.read<CatalogApi>();
} on ProviderNotFoundException {
api = null;
}
if (api == null) return;
try {
final feed = await api.homeFeed();
if (mounted && feed.banners.isNotEmpty) {
setState(() => _banners = feed.banners);
}
} catch (_) {
// 轮播拉取失败时保留纯网格。
}
}
Future<void> _openDrama(Drama drama) =>
playDramaFromCatalog(context, drama);
@override
Widget build(BuildContext context) {
super.build(context);
final categories = context.watch<CategoryController>();
final mediaTop = MediaQuery.of(context).padding.top;
final categoryList = categories.categories;
final orderedIds = categoryList.map((c) => c.id).toList();
// 频道来自接口分类;默认选中第一个分类(即「精选」)。
_channelId ??= categoryList.isEmpty ? null : categoryList.first.id;
final selectedIndex = _channelId == null
? 0
: orderedIds.indexOf(_channelId!).clamp(0, orderedIds.length - 1);
return ColoredBox(
key: const Key('recommend-page'),
color: AppColors.appBackground,
child: Stack(
children: [
Positioned.fill(
child: categoryList.isEmpty
? const Center(
child: CircularProgressIndicator(
color: AppColors.brandRed,
),
)
: PageView.builder(
key: const Key('recommend-channel-pager'),
controller: _pageController,
itemCount: categoryList.length,
onPageChanged: (i) => _onPageChanged(i, orderedIds),
itemBuilder: (context, index) {
final category = categoryList[index];
return _ChannelFeed(
key: PageStorageKey<String>(
'recommend-channel-${category.id}',
),
categoryId: category.id,
isFeatured: index == 0,
banners: _banners,
carouselActive: widget.active && index == selectedIndex,
mediaTop: mediaTop,
onRequest: _ensureCategory,
onRefresh: () => _refreshCategory(category.id, index == 0),
onOpenDrama: _openDrama,
);
},
),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: IgnorePointer(
child: SizedBox(
height: mediaTop + RecommendChannelNav.height,
child: const DecoratedBox(
key: Key('recommend-top-gradient'),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.mediaBlack, AppColors.transparent],
),
),
),
),
),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
bottom: false,
child: RecommendChannelNav(
categories: categoryList,
selectedId: _channelId,
onSelected: (id) {
final index = orderedIds.indexOf(id);
if (index >= 0) _selectChannel(id, index);
},
onSearch: widget.onSearch,
onGift: widget.onOpenWallet,
),
),
),
],
),
);
}
}
/// 单个分类频道的内容:可选顶部轮播 + 该分类短剧三列网格。
class _ChannelFeed extends StatefulWidget {
const _ChannelFeed({
super.key,
required this.categoryId,
required this.isFeatured,
required this.banners,
required this.carouselActive,
required this.mediaTop,
required this.onRequest,
required this.onRefresh,
required this.onOpenDrama,
});
final String categoryId;
final bool isFeatured;
final List<FeedBanner> banners;
final bool carouselActive;
final double mediaTop;
final ValueChanged<String> onRequest;
final Future<void> Function() onRefresh;
final ValueChanged<Drama> onOpenDrama;
@override
State<_ChannelFeed> createState() => _ChannelFeedState();
}
class _ChannelFeedState extends State<_ChannelFeed>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
widget.onRequest(widget.categoryId);
}
Drama? _dramaForBanner(FeedBanner banner, List<Drama> pool) {
final id = banner.dramaId;
if (id == null) return null;
for (final drama in pool) {
if (drama.id == id) return drama;
}
return null;
}
@override
Widget build(BuildContext context) {
super.build(context);
final categories = context.watch<CategoryController>();
final slot = categories.slotOf(widget.categoryId);
final dramas = slot.dramas;
final showCarousel = widget.isFeatured && widget.banners.isNotEmpty;
final bottomInset = MediaQuery.of(context).padding.bottom;
// 精选频道:按接口下发的标签(tag)把「上新」筛出来横滑,其余短剧一行两个。
List<Drama> newArrivals = const [];
List<Drama> rest = dramas;
if (widget.isFeatured && dramas.isNotEmpty) {
final lang = Localizations.localeOf(context).languageCode;
final badge = AppLocalizations.of(context)!.newArrivalBadge;
newArrivals = dramas
.where((drama) => _isNewArrivalTag(drama, lang, badge))
.toList(growable: false);
if (newArrivals.isNotEmpty) {
final newIds = newArrivals.map((drama) => drama.id).toSet();
rest = dramas
.where((drama) => !newIds.contains(drama.id))
.toList(growable: false);
}
}
final sliverList = <Widget>[];
if (showCarousel) {
sliverList.add(
SliverToBoxAdapter(
child: HomeHeroCarousel(
banners: widget.banners,
active: widget.carouselActive,
dramaFor: (banner) => _dramaForBanner(banner, dramas),
onWatch: widget.onOpenDrama,
),
),
);
}
if (dramas.isEmpty) {
sliverList.add(
SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: EdgeInsets.only(
top: showCarousel ? 0 : widget.mediaTop + RecommendChannelNav.height,
),
child: Center(child: _placeholder(slot.loading)),
),
),
);
} else if (widget.isFeatured) {
// 无轮播图时内容需避开顶部悬浮导航。
final contentTop =
showCarousel ? 24.0 : widget.mediaTop + RecommendChannelNav.height + 16;
if (newArrivals.isNotEmpty) {
sliverList.add(
SliverToBoxAdapter(child: SizedBox(height: contentTop)),
);
sliverList.add(
SliverToBoxAdapter(
child: HomeNewArrivalRow(
dramas: newArrivals,
onOpen: widget.onOpenDrama,
),
),
);
sliverList.add(
const SliverToBoxAdapter(child: SizedBox(height: 28)),
);
}
sliverList.add(
_gridSliver(
rest,
topPadding: newArrivals.isNotEmpty ? 0 : contentTop,
crossAxisCount: 2,
),
);
} else {
sliverList.add(
_gridSliver(
dramas,
topPadding: showCarousel ? 20 : widget.mediaTop + RecommendChannelNav.height + 16,
crossAxisCount: 3,
),
);
}
sliverList.add(
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.only(bottom: bottomInset),
child: PagedListFooter(
key: const Key('recommend-category-footer'),
loading: slot.loadingMore,
hasMore: slot.hasMore,
hasItems: dramas.isNotEmpty,
errorMessage: null,
onRetry: () => categories.loadMore(widget.categoryId),
),
),
),
);
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification.depth != 0) return false;
if (notification.metrics.extentAfter > 300) return false;
if (notification is! ScrollUpdateNotification &&
notification is! ScrollEndNotification &&
notification is! OverscrollNotification) {
return false;
}
unawaited(categories.loadMore(widget.categoryId));
return false;
},
child: AppPullToRefresh(
onRefresh: widget.onRefresh,
child: CustomScrollView(
key: PageStorageKey<String>('recommend-feed-${widget.categoryId}'),
physics: AppPullToRefresh.physics,
slivers: sliverList,
),
),
);
}
Widget _placeholder(bool loading) {
if (loading) {
return const CircularProgressIndicator(color: AppColors.brandRed);
}
final l10n = AppLocalizations.of(context)!;
return EmptyState(
key: const Key('recommend-category-empty'),
icon: Icons.movie_outlined,
title: l10n.noStories,
);
}
Widget _gridSliver(
List<Drama> dramas, {
required double topPadding,
required int crossAxisCount,
}) {
const pageSide = 14.0;
const cardGap = 9.0;
const rowGap = 16.0;
const cardBodyExtent = 44.0; // 封面下间距 + 剧名行 + 集数行
// 两列时卡更大,卡间距略放宽。
final spacing = crossAxisCount == 2 ? 12.0 : cardGap;
return SliverPadding(
padding: EdgeInsets.fromLTRB(pageSide, topPadding, pageSide, 0),
sliver: SliverLayoutBuilder(
builder: (context, constraints) {
final itemWidth =
(constraints.crossAxisExtent - spacing * (crossAxisCount - 1)) /
crossAxisCount;
final itemExtent = itemWidth * 157 / 110 + cardBodyExtent;
return SliverGrid(
key: Key('recommend-category-grid-${widget.categoryId}'),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: spacing,
mainAxisSpacing: rowGap,
mainAxisExtent: itemExtent,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
final drama = dramas[index];
return HomePosterCard(
key: Key('recommend-category-card-${drama.id}'),
drama: drama,
onTap: () => widget.onOpenDrama(drama),
);
},
childCount: dramas.length,
),
);
},
),
);
}
}
/// 判定接口下发的短剧标签是否为「上新」。
///
/// 完全以后端返回的 `tag` / `tagLocales`(经 [Drama.localizedTag] 本地化)
/// 为准;兼容「上新 / NEW / BARU」三种文案,并兼容「本周上新」这类带前后缀的写法。
bool _isNewArrivalTag(Drama drama, String lang, String localizedBadge) {
final tag = drama.localizedTag(lang).trim();
if (tag.isEmpty) return false;
if (tag == localizedBadge) return true;
final lower = tag.toLowerCase();
return tag.contains('上新') || lower == 'new' || lower == 'baru';
}
+24 -31
View File
@@ -9,8 +9,10 @@ import '../../core/data/models/drama.dart';
import '../../core/data/remote/catalog_api.dart'; import '../../core/data/remote/catalog_api.dart';
import '../../core/data/models/playlist_detail.dart'; import '../../core/data/models/playlist_detail.dart';
import '../../core/data/services/category_controller.dart'; import '../../core/data/services/category_controller.dart';
import '../../core/data/services/download_controller.dart';
import '../../core/data/services/home_feed_controller.dart'; import '../../core/data/services/home_feed_controller.dart';
import '../../core/data/services/playlist_controller.dart'; import '../../core/data/services/playlist_controller.dart';
import '../../core/data/services/wallet_controller.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/widgets/empty_state.dart'; import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@@ -30,6 +32,7 @@ class RecommendPage extends StatefulWidget {
this.catalogApi, this.catalogApi,
required this.active, required this.active,
required this.onSearch, required this.onSearch,
this.onOpenWallet,
this.videoOnly = false, this.videoOnly = false,
this.onImmersiveChanged, this.onImmersiveChanged,
}); });
@@ -39,6 +42,9 @@ class RecommendPage extends StatefulWidget {
final bool active; final bool active;
final VoidCallback onSearch; final VoidCallback onSearch;
/// 顶部金色礼物盒(钱包入口)。
final VoidCallback? onOpenWallet;
/// 画中画时只留视频,不画顶栏。 /// 画中画时只留视频,不画顶栏。
final bool videoOnly; final bool videoOnly;
final ValueChanged<bool>? onImmersiveChanged; final ValueChanged<bool>? onImmersiveChanged;
@@ -272,6 +278,7 @@ class _RecommendPageState extends State<RecommendPage>
onVideo: _tab == RecommendInnerTab.feed, onVideo: _tab == RecommendInnerTab.feed,
onSelected: _selectTab, onSelected: _selectTab,
onSearch: _handleSearch, onSearch: _handleSearch,
onGift: widget.onOpenWallet,
), ),
), ),
), ),
@@ -294,6 +301,10 @@ class _RecommendPageState extends State<RecommendPage>
initialIndex: 0, initialIndex: 0,
feedDramas: dramas, feedDramas: dramas,
feedChannel: widget.feedController.channel, feedChannel: widget.feedController.channel,
// 内嵌流也是真实播放页:不传钱包/下载,锁定集点「解锁」会静默无响应,
// 进全屏后也拿不到这两个依赖。
wallet: context.read<WalletController>(),
downloads: context.read<DownloadController>(),
onOpenFullPlayer: (drama, episode) => onOpenFullPlayer: (drama, episode) =>
_openDrama(drama, episodeId: episode.id), _openDrama(drama, episodeId: episode.id),
onRefreshFeed: widget.feedController.refreshCurrent, onRefreshFeed: widget.feedController.refreshCurrent,
@@ -353,40 +364,22 @@ class _RecommendPageState extends State<RecommendPage>
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: [
AppColors.mineGradientTop, AppColors.followingGlow,
AppColors.mineGradientMiddle, AppColors.followingGlowMid,
AppColors.mineGradientBottom, AppColors.appBackground,
], ],
stops: [0, 0.4, 0.75], stops: [0, 0.14, 0.3],
), ),
), ),
child: Stack( child: _withTopNavSpace(
fit: StackFit.expand, RecommendSectionsView(
children: [ feedController: widget.feedController,
const DecoratedBox( categories: categories,
key: Key('recommend-playlist-glow'), onOpenDrama: _openDrama,
decoration: BoxDecoration( onMore: _handleMore,
gradient: RadialGradient( playlists: _watchPlaylists(context),
center: Alignment(0, -0.95), onOpenPlaylist: _openPlaylist,
radius: 0.9, ),
colors: [
AppColors.recommendPlaylistGlow,
AppColors.transparent,
],
),
),
),
_withTopNavSpace(
RecommendSectionsView(
feedController: widget.feedController,
categories: categories,
onOpenDrama: _openDrama,
onMore: _handleMore,
playlists: _watchPlaylists(context),
onOpenPlaylist: _openPlaylist,
),
),
],
), ),
); );
} }
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff. Show more