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

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
# Author: fruit.com.cn
# ReelShort 客户端 CI:静态分析 + 测试门禁
name: flutter-ci
on:
push:
branches: [develop, main]
pull_request:
branches: [develop, main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- name: 依赖
run: flutter pub get
- name: 静态分析(零告警门禁)
run: dart analyze --fatal-infos
- name: 单元测试(全绿门禁)
run: flutter test -r expanded
+3
View File
@@ -65,3 +65,6 @@ app.*.map.json
.env .env
.env.* .env.*
!.env.example !.env.example
# IDE 的 Flutter Widget Preview 生成目录。
.widget_preview/
+93 -57
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,60 @@ 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 需在同一网络,首次启动允许“本地网络”权限: iOS 模拟器直接访问 Mac 本机服务,默认地址为 `http://127.0.0.1:8081`:
```bash ```bash
flutter run -d <udid> \ flutter run -d <simulator-udid>
--dart-define=API_BASE_URL=http://<Mac局域网IP>:8081
``` ```
### Debug 运行时地址 iPhone 真机不能用 `127.0.0.1` 访问 Mac;真机联调时需让手机与 Mac 位于同一网络,
并通过调试入口临时填写 Mac 的局域网地址。
debug 版本可在「我的 → 设置 → 服务器地址」修改后端地址,保存后重启 App 生效。 ### 服务地址配置
地址优先级:
```text 地址是代码里的一个开关,**不做任何网络探测**,也不用传构建参数。两套环境各占
本地运行时设置 > --dart-define(API_BASE_URL) > 平台默认地址 一个文件:
| 文件 | 用途 | 默认值 |
| --- | --- | --- |
| `lib/core/config/dev_config.dart` | 开发 / 联调 | `http://127.0.0.1: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。 真机或 Android 模拟器联调时,通过调试入口覆盖地址,不修改默认开发配置。打生产包前把
release 版本不显示入口,也不会读取本地覆盖。 `useDevConfig` 改成 `false`;release 构建若仍为 `true` 会在启动时直接报错,
避免线上包连到开发机。
debug 包在找剧页加载失败时会显示调试条,点「修改服务器地址」可临时改成当前
电脑 IP,保存后立即重载、不需要重新打包,该覆盖保存在本地偏好里(仅 debug
生效,release 不读取)。
## 画中画
| 平台 | 行为 |
| --- | --- |
| Android | 首次点播放页小窗按钮时申请悬浮窗权限;授权后退出 App 自动进入系统小窗。沉浸流与独立播放页共用一份引用计数,任一处活跃都算「在播」,避免互相顶掉 |
| iOS | 原生影子播放器承载 `AVPictureInPictureController`,只有播放页处于活跃状态时退到后台才自动进小窗;停在剧单等页面退后台不会触发 |
- 小窗内切集直接换源,不走页面翻页,可连续自动播放
- iOS 从系统小窗还原时用原生过渡层盖住纹理黑帧,等首帧就绪后再淡出
- 播放页「更多」面板里有「退出自动小窗」开关(仅支持自动小窗的平台显示),关掉后
退到后台不再进入小窗
## 环境注意 ## 环境注意
@@ -131,46 +157,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.**
+4 -1
View File
@@ -2,8 +2,11 @@
<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="ymb"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true"> android:usesCleartextTraffic="true">
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

@@ -2,6 +2,18 @@
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splash_background" /> <item android:drawable="@color/splash_background" />
<item>
<shape android:shape="rectangle">
<gradient
android:type="radial"
android:centerX="0"
android:centerY="0"
android:gradientRadius="350dp"
android:startColor="#FF6B011A"
android:centerColor="#616B011A"
android:endColor="#006B011A" />
</shape>
</item>
<item> <item>
<bitmap <bitmap
android:gravity="center" android:gravity="center"
@@ -2,6 +2,18 @@
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splash_background" /> <item android:drawable="@color/splash_background" />
<item>
<shape android:shape="rectangle">
<gradient
android:type="radial"
android:centerX="0"
android:centerY="0"
android:gradientRadius="350dp"
android:startColor="#FF6B011A"
android:centerColor="#616B011A"
android:endColor="#006B011A" />
</shape>
</item>
<item> <item>
<bitmap <bitmap
android:gravity="center" android:gravity="center"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

@@ -2,6 +2,18 @@
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splash_background" /> <item android:drawable="@color/splash_background" />
<item>
<shape android:shape="rectangle">
<gradient
android:type="radial"
android:centerX="0"
android:centerY="0"
android:gradientRadius="350dp"
android:startColor="#FF6B011A"
android:centerColor="#616B011A"
android:endColor="#006B011A" />
</shape>
</item>
<item> <item>
<bitmap <bitmap
android:gravity="center" android:gravity="center"
@@ -2,6 +2,18 @@
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splash_background" /> <item android:drawable="@color/splash_background" />
<item>
<shape android:shape="rectangle">
<gradient
android:type="radial"
android:centerX="0"
android:centerY="0"
android:gradientRadius="350dp"
android:startColor="#FF6B011A"
android:centerColor="#616B011A"
android:endColor="#006B011A" />
</shape>
</item>
<item> <item>
<bitmap <bitmap
android:gravity="center" android:gravity="center"
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

@@ -2,6 +2,18 @@
<!-- Author: fruit.com.cn --> <!-- Author: fruit.com.cn -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splash_background" /> <item android:drawable="@color/splash_background" />
<item>
<shape android:shape="rectangle">
<gradient
android:type="radial"
android:centerX="0"
android:centerY="0"
android:gradientRadius="350dp"
android:startColor="#FF6B011A"
android:centerColor="#616B011A"
android:endColor="#006B011A" />
</shape>
</item>
<item> <item>
<bitmap <bitmap
android:gravity="center" android:gravity="center"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 325 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 469 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -8,7 +8,7 @@
<item name="android:windowDrawsSystemBarBackgrounds">false</item> <item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item> <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#000000</item> <item name="android:windowSplashScreenBackground">#000000</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_brand</item> <item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
<item name="android:windowSplashScreenAnimationDuration">0</item> <item name="android:windowSplashScreenAnimationDuration">0</item>
</style> </style>
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar"> <style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
@@ -8,7 +8,7 @@
<item name="android:windowDrawsSystemBarBackgrounds">false</item> <item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item> <item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#000000</item> <item name="android:windowSplashScreenBackground">#000000</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/splash_brand</item> <item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
<item name="android:windowSplashScreenAnimationDuration">0</item> <item name="android:windowSplashScreenAnimationDuration">0</item>
</style> </style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar"> <style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
+2 -2
View File
@@ -3,5 +3,5 @@
<resources> <resources>
<color name="splash_background">#000000</color> <color name="splash_background">#000000</color>
<color name="ic_launcher_background">#FF6B2C</color> <color name="ic_launcher_background">#E9142E</color>
</resources> </resources>
+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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 734 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+447 -6
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()
@@ -72,15 +386,29 @@ final class RunnerFlutterViewController: FlutterViewController {
guard call.method == "shareText", guard call.method == "shareText",
let arguments = call.arguments as? [String: Any], let arguments = call.arguments as? [String: Any],
let text = arguments["text"] as? String, let text = arguments["text"] as? String,
!text.isEmpty, !text.isEmpty else {
let controller = self?.window?.rootViewController else {
result(FlutterError(code: "share_unavailable", message: "Share sheet is unavailable", details: nil)) result(FlutterError(code: "share_unavailable", message: "Share sheet is unavailable", details: nil))
return return
} }
let activity = UIActivityViewController(activityItems: [text], applicationActivities: nil) DispatchQueue.main.async {
activity.popoverPresentationController?.sourceView = controller.view guard let controller = self?.sharePresenter() else {
activity.completionWithItemsHandler = { _, _, _, _ in result(nil) } result(FlutterError(code: "share_unavailable", message: "Share sheet is unavailable", details: nil))
controller.present(activity, animated: true) return
}
let activity = UIActivityViewController(activityItems: [text], applicationActivities: nil)
if let popover = activity.popoverPresentationController {
popover.sourceView = controller.view
popover.sourceRect = CGRect(
x: controller.view.bounds.midX,
y: controller.view.bounds.midY,
width: 0,
height: 0
)
popover.permittedArrowDirections = []
}
activity.completionWithItemsHandler = { _, _, _, _ in result(nil) }
controller.present(activity, animated: true)
}
} }
let thumbnailChannel = FlutterMethodChannel( let thumbnailChannel = FlutterMethodChannel(
@@ -145,6 +473,119 @@ final class RunnerFlutterViewController: FlutterViewController {
} }
} }
/// 当前前台 Scene 的根控制器之上,最适合展示系统分享面板的控制器。
private func sharePresenter() -> UIViewController? {
let keyWindow = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first { $0.activationState == .foregroundActive }?
.windows.first { $0.isKeyWindow }
guard let rootController = keyWindow?.rootViewController else { return nil }
let presenter = topPresentedController(from: rootController)
guard presenter.viewIfLoaded?.window != nil else { return nil }
return presenter
}
/// 解析已展示页面、导航栈和标签页中的当前可见控制器。
private func topPresentedController(from controller: UIViewController) -> UIViewController {
if let presented = controller.presentedViewController {
return topPresentedController(from: presented)
}
if let navigation = controller as? UINavigationController,
let visible = navigation.visibleViewController {
return topPresentedController(from: visible)
}
if let tabs = controller as? UITabBarController,
let selected = tabs.selectedViewController {
return topPresentedController(from: selected)
}
return controller
}
/// 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) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 734 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 B

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 907 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 593 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 907 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 655 KiB

+103 -99
View File
@@ -1,111 +1,115 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>ymb</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>ymb</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict> <dict>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>NSAllowsArbitraryLoads</key>
<true/> <true/>
<key>CFBundleDevelopmentRegion</key> <key>NSAllowsLocalNetworking</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>FruitShorts</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>FruitShorts</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSAppTransportSecurity</key> <key>NSExceptionDomains</key>
<dict> <dict>
<key>NSAllowsArbitraryLoads</key> <key>127.0.0.1</key>
<true/>
<key>NSAllowsLocalNetworking</key>
<true/>
<key>NSExceptionDomains</key>
<dict> <dict>
<key>127.0.0.1</key> <key>NSExceptionAllowsInsecureHTTPLoads</key>
<dict> <true/>
<key>NSExceptionAllowsInsecureHTTPLoads</key> <key>NSIncludesSubdomains</key>
<true/> <true/>
<key>NSIncludesSubdomains</key> </dict>
<true/> <key>192.168.31.243</key>
</dict> <dict>
<key>localhost</key> <key>NSExceptionAllowsInsecureHTTPLoads</key>
<dict> <true/>
<key>NSExceptionAllowsInsecureHTTPLoads</key> <key>NSIncludesSubdomains</key>
<true/> <true/>
<key>NSIncludesSubdomains</key> </dict>
<true/> <key>39.162.50.99</key>
</dict> <dict>
<key>192.168.31.243</key> <key>NSExceptionAllowsInsecureHTTPLoads</key>
<dict> <true/>
<key>NSExceptionAllowsInsecureHTTPLoads</key> <key>NSIncludesSubdomains</key>
<true/> <true/>
<key>NSIncludesSubdomains</key> </dict>
<true/> <key>localhost</key>
</dict> <dict>
<key>39.162.50.99</key> <key>NSExceptionAllowsInsecureHTTPLoads</key>
<dict> <true/>
<key>NSExceptionAllowsInsecureHTTPLoads</key> <key>NSIncludesSubdomains</key>
<true/> <true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict> </dict>
</dict> </dict>
<key>NSLocalNetworkUsageDescription</key>
<string>开发阶段需要访问同一局域网内的短剧内容服务。</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIStatusBarHidden</key>
<false/>
</dict> </dict>
<key>NSLocalNetworkUsageDescription</key>
<string>开发阶段需要访问同一局域网内的短剧内容服务。</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist> </plist>
+18
View File
@@ -14,6 +14,7 @@ import 'core/data/remote/catalog_api.dart';
import 'core/data/remote/category_api.dart'; import 'core/data/remote/category_api.dart';
import 'core/data/remote/comment_api.dart'; import 'core/data/remote/comment_api.dart';
import 'core/data/remote/commerce_api.dart'; import 'core/data/remote/commerce_api.dart';
import 'core/data/remote/config_api.dart';
import 'core/data/remote/device_profile.dart'; import 'core/data/remote/device_profile.dart';
import 'core/data/remote/download_api.dart'; import 'core/data/remote/download_api.dart';
import 'core/data/remote/interaction_api.dart'; import 'core/data/remote/interaction_api.dart';
@@ -35,6 +36,7 @@ import 'core/data/repositories/playlist_repository.dart';
import 'core/data/repositories/search_repository.dart'; import 'core/data/repositories/search_repository.dart';
import 'core/data/repositories/store_repository.dart'; import 'core/data/repositories/store_repository.dart';
import 'core/data/repositories/watch_history_repository.dart'; import 'core/data/repositories/watch_history_repository.dart';
import 'core/data/services/app_config_controller.dart';
import 'core/data/services/auth_controller.dart'; import 'core/data/services/auth_controller.dart';
import 'core/data/services/auth_session_store.dart'; import 'core/data/services/auth_session_store.dart';
import 'core/data/services/catalog_controller.dart'; import 'core/data/services/catalog_controller.dart';
@@ -47,6 +49,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,12 +210,22 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
late final LocaleController _localeController = LocaleController(); late final LocaleController _localeController = LocaleController();
/// 后台下发的启动配置(登录页背景等);拉不到就用本地默认。
late final AppConfigController _appConfigController = AppConfigController(
ConfigApi(_apiClient),
);
/// 登录期内共享的「已解锁收费集」:播放页进出、feed 与全屏切换都要沿用。
late final UnlockedEpisodeRegistry _unlockedEpisodes =
UnlockedEpisodeRegistry();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// 恢复本地令牌与设备号;未登录也能正常浏览,不阻塞首屏 // 恢复本地令牌与设备号;未登录也能正常浏览,不阻塞首屏
_auth.load(); _auth.load();
unawaited(_localeController.load()); unawaited(_localeController.load());
unawaited(_appConfigController.load());
// 登录/登出时同步收藏状态:收藏属于账号,不能跨账号串联 // 登录/登出时同步收藏状态:收藏属于账号,不能跨账号串联
_auth.addListener(_onAuthChanged); _auth.addListener(_onAuthChanged);
unawaited(_favoriteController.load()); unawaited(_favoriteController.load());
@@ -226,6 +239,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());
@@ -245,6 +260,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
_walletController.dispose(); _walletController.dispose();
_interactionController.dispose(); _interactionController.dispose();
_localeController.dispose(); _localeController.dispose();
_appConfigController.dispose();
_apiClient.close(); _apiClient.close();
super.dispose(); super.dispose();
} }
@@ -271,6 +287,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),
@@ -296,6 +313,7 @@ class _LumaDramaAppState extends State<LumaDramaApp> {
ChangeNotifierProvider.value(value: _walletController), ChangeNotifierProvider.value(value: _walletController),
ChangeNotifierProvider.value(value: _interactionController), ChangeNotifierProvider.value(value: _interactionController),
ChangeNotifierProvider.value(value: _localeController), ChangeNotifierProvider.value(value: _localeController),
ChangeNotifierProvider.value(value: _appConfigController),
ChangeNotifierProvider.value(value: _auth), ChangeNotifierProvider.value(value: _auth),
], ],
child: Consumer<LocaleController>( child: Consumer<LocaleController>(
+24 -145
View File
@@ -1,165 +1,51 @@
// Author: fruit.com.cn // Author: fruit.com.cn
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
/// 客户端与后端约定的运行时配置。 import 'dev_config.dart';
import 'prod_config.dart';
/// 客户端运行时配置。
/// ///
/// 接口地址优先级:运行时设置 > `--dart-define=API_BASE_URL=...` > 平台默认值。 /// 两套环境各一个文件:
/// 开发机的局域网地址会随网络变化,换网络后改 [devHostBaseUrl] 即可。 /// - `dev_config.dart`:[DevConfig](本机联调)
class AppConfig { /// - `prod_config.dart`:[ProdConfig](生产)
const AppConfig._(); ///
/// Debug 使用开发环境,Release 自动使用生产环境,不做网络探测。
abstract final class AppConfig {
/// **环境开关**:Debug 使用开发环境,Release 使用生产环境。
static const bool useDevConfig = !kReleaseMode;
/// 开发调试默认服务地址,当前指向本机局域网 IP。 /// 当前选中的环境地址。
/// static String get baseUrl =>
/// 换网络时只改这一行,地址不要带结尾斜杠。 useDevConfig ? DevConfig.baseUrl : ProdConfig.baseUrl;
static const String devHostBaseUrl = 'http://192.168.31.243:8081';
/// 本机后端地址:通过 `adb reverse tcp:8081 tcp:8081` 映射到开发机,
/// 探测可达时 App 直连本机服务;不可达时回落到公网地址。
static const String lanBaseUrl = 'http://127.0.0.1:8081';
/// 公网地址(远程/移动网络访问时使用)。
static const String publicBaseUrl = 'http://39.162.50.99:8081';
static const String _overrideBaseUrl = String.fromEnvironment('API_BASE_URL');
/// 调试面板手改的地址(见 `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;
}
+10
View File
@@ -0,0 +1,10 @@
// Author: fruit.com.cn
/// 开发环境配置。
///
/// 本机联调用:模拟器与桌面端可直接用回环地址,但 iOS/Android 真机必须用
/// 开发机的局域网 IP(真机上的 127.0.0.1 指向手机自己)。地址不要带结尾斜杠。
abstract final class DevConfig {
/// 开发机后端地址(Mac 当前 Wi-Fi 地址,换网络后需要同步修改)。
static const String baseUrl = 'http://192.168.31.243:8081';
}
+9
View File
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
/// 生产环境配置。
///
/// 发版前确认这里的 [baseUrl],地址不要带结尾斜杠。
abstract final class ProdConfig {
/// 生产后端地址。
static const String baseUrl = 'http://39.162.50.99:8081';
}
+4 -15
View File
@@ -22,16 +22,9 @@ abstract final class ServerEndpoint {
return value.replaceFirst(RegExp(r'/+$'), ''); return value.replaceFirst(RegExp(r'/+$'), '');
} }
/// Resolves runtime > dart-define > default using normalized valid values. /// 运行时手改优先,其次构建期默认地址;两者都必须先通过规范校验。
static String resolve({ static String resolve({required String? stored, required String fallback}) {
required String? stored, return normalize(stored) ?? normalize(fallback) ?? fallback;
required String compiled,
required String fallback,
}) {
return normalize(stored) ??
normalize(compiled) ??
normalize(fallback) ??
fallback;
} }
/// Loads the persisted override before the app creates its ApiClient. /// Loads the persisted override before the app creates its ApiClient.
@@ -46,11 +39,7 @@ abstract final class ServerEndpoint {
final prefs = preferences ?? SharedPreferencesAsync(); final prefs = preferences ?? SharedPreferencesAsync();
final stored = await prefs.getString(preferenceKey); final stored = await prefs.getString(preferenceKey);
AppConfig.applyRuntimeBaseUrl( AppConfig.applyRuntimeBaseUrl(
resolve( resolve(stored: stored, fallback: AppConfig.baseUrl),
stored: stored,
compiled: AppConfig.compiledBaseUrl,
fallback: AppConfig.defaultBaseUrl,
),
); );
} catch (_) { } catch (_) {
AppConfig.clearRuntimeBaseUrl(); AppConfig.clearRuntimeBaseUrl();
+24 -3
View File
@@ -97,11 +97,13 @@ class Drama {
required this.title, required this.title,
required this.synopsis, required this.synopsis,
required this.posterUrl, required this.posterUrl,
this.coverLandscape = '',
this.episodes = const [], this.episodes = const [],
this.titleLocales = const {}, this.titleLocales = const {},
this.synopsisLocales = const {}, this.synopsisLocales = const {},
this.tag = '', this.tag = '',
this.tagLocales = const {}, this.tagLocales = const {},
this.tagCode = '',
this.tagColor = '', this.tagColor = '',
this.category = '', this.category = '',
this.categoryLocales = const {}, this.categoryLocales = const {},
@@ -127,8 +129,14 @@ class Drama {
final Map<String, String> synopsisLocales; final Map<String, String> synopsisLocales;
final String tag; final String tag;
final Map<String, String> tagLocales; final Map<String, String> tagLocales;
/// 标签语义码(如 `NEW_ARRIVAL`)。文案会随语言变,语义判断只认这个码。
final String tagCode;
final String tagColor; final String tagColor;
final String posterUrl; final String posterUrl;
/// 横版封面,用于继续观看横幅等横向展示位;为空时回退竖版 `posterUrl`。
final String coverLandscape;
final List<Episode> episodes; final List<Episode> episodes;
final String category; final String category;
final Map<String, String> categoryLocales; final Map<String, String> categoryLocales;
@@ -172,8 +180,10 @@ class Drama {
synopsisLocales: text.synopsisLocales, synopsisLocales: text.synopsisLocales,
tag: text.tag, tag: text.tag,
tagLocales: text.tagLocales, tagLocales: text.tagLocales,
tagCode: (json['tagCode'] as String? ?? '').trim(),
tagColor: json['tagColor'] as String? ?? '', tagColor: json['tagColor'] as String? ?? '',
posterUrl: json['cover'] as String? ?? '', posterUrl: json['cover'] as String? ?? '',
coverLandscape: json['coverLandscape'] as String? ?? '',
episodes: preview is Map<String, dynamic> episodes: preview is Map<String, dynamic>
? [Episode.fromJson(preview)] ? [Episode.fromJson(preview)]
: const [], : const [],
@@ -204,8 +214,10 @@ class Drama {
synopsisLocales: text.synopsisLocales, synopsisLocales: text.synopsisLocales,
tag: text.tag, tag: text.tag,
tagLocales: text.tagLocales, tagLocales: text.tagLocales,
tagCode: (json['tagCode'] as String? ?? '').trim(),
tagColor: json['tagColor'] as String? ?? '', tagColor: json['tagColor'] as String? ?? '',
posterUrl: json['cover'] as String? ?? '', posterUrl: json['cover'] as String? ?? '',
coverLandscape: json['coverLandscape'] as String? ?? '',
category: text.category, category: text.category,
categoryLocales: text.categoryLocales, categoryLocales: text.categoryLocales,
types: text.types, types: text.types,
@@ -315,7 +327,10 @@ class _DramaText {
final category = _firstText(categoryLocales, json['category'], types); final category = _firstText(categoryLocales, json['category'], types);
return _DramaText( return _DramaText(
title: _firstText(titleLocales, json['title']), title: _firstText(titleLocales, json['title']),
synopsis: _firstText(synopsisLocales, json['synopsis'] ?? json['description']), synopsis: _firstText(
synopsisLocales,
json['synopsis'] ?? json['description'],
),
titleLocales: titleLocales, titleLocales: titleLocales,
synopsisLocales: synopsisLocales, synopsisLocales: synopsisLocales,
tag: _firstText(tagLocales, json['tag']), tag: _firstText(tagLocales, json['tag']),
@@ -328,7 +343,11 @@ class _DramaText {
} }
} }
String _firstText(Map<String, String> locales, Object? fallback, [List<Map<String, String>>? types]) { String _firstText(
Map<String, String> locales,
Object? fallback, [
List<Map<String, String>>? types,
]) {
final chinese = locales['zh'] ?? ''; final chinese = locales['zh'] ?? '';
if (chinese.isNotEmpty) return chinese; if (chinese.isNotEmpty) return chinese;
if (types != null && types.isNotEmpty) { if (types != null && types.isNotEmpty) {
@@ -339,7 +358,9 @@ String _firstText(Map<String, String> locales, Object? fallback, [List<Map<Strin
} }
String _genreText(List<Map<String, String>> types, Object? fallback) { String _genreText(List<Map<String, String>> types, Object? fallback) {
final names = types.map((item) => item['zh'] ?? '').where((value) => value.isNotEmpty); final names = types
.map((item) => item['zh'] ?? '')
.where((value) => value.isNotEmpty);
final joined = names.join(' / '); final joined = names.join(' / ');
if (joined.isNotEmpty) return joined; if (joined.isNotEmpty) return joined;
return fallback is String ? fallback : ''; return fallback is String ? fallback : '';
+5 -1
View File
@@ -7,7 +7,11 @@ Map<String, String> localeMap(Object? raw) {
} }
/// 按当前语言取文案。没有对应语言时用中文,中文也没有时用 fallback。 /// 按当前语言取文案。没有对应语言时用中文,中文也没有时用 fallback。
String localizedText(String languageCode, String fallback, Map<String, String> locales) { String localizedText(
String languageCode,
String fallback,
Map<String, String> locales,
) {
final selected = locales[languageCode] ?? ''; final selected = locales[languageCode] ?? '';
if (selected.isNotEmpty) return selected; if (selected.isNotEmpty) return selected;
final chinese = locales['zh'] ?? ''; final chinese = locales['zh'] ?? '';
+6
View File
@@ -16,6 +16,7 @@ class WatchProgress {
this.dramaTitle, this.dramaTitle,
this.titleLocales = const {}, this.titleLocales = const {},
this.dramaCover, this.dramaCover,
this.dramaCoverLandscape,
this.episodeNumber, this.episodeNumber,
this.totalEpisodes, this.totalEpisodes,
this.finished = false, this.finished = false,
@@ -29,6 +30,9 @@ class WatchProgress {
final String? dramaTitle; final String? dramaTitle;
final Map<String, String> titleLocales; final Map<String, String> titleLocales;
final String? dramaCover; final String? dramaCover;
/// 横版封面,用于继续观看横幅等横向位;为空时回退 [dramaCover]。
final String? dramaCoverLandscape;
final int? episodeNumber; final int? episodeNumber;
final int? totalEpisodes; final int? totalEpisodes;
final Duration position; final Duration position;
@@ -60,6 +64,7 @@ class WatchProgress {
dramaTitle: json['dramaTitle']?.toString(), dramaTitle: json['dramaTitle']?.toString(),
titleLocales: localeMap(json['titleLocales']), titleLocales: localeMap(json['titleLocales']),
dramaCover: json['dramaCover']?.toString(), dramaCover: json['dramaCover']?.toString(),
dramaCoverLandscape: json['dramaCoverLandscape']?.toString(),
episodeNumber: json['episodeNumber'] == null episodeNumber: json['episodeNumber'] == null
? null ? null
: jsonToInt(json['episodeNumber']), : jsonToInt(json['episodeNumber']),
@@ -83,6 +88,7 @@ class WatchProgress {
if (dramaTitle != null) 'dramaTitle': dramaTitle, if (dramaTitle != null) 'dramaTitle': dramaTitle,
if (titleLocales.isNotEmpty) 'titleLocales': titleLocales, if (titleLocales.isNotEmpty) 'titleLocales': titleLocales,
if (dramaCover != null) 'dramaCover': dramaCover, if (dramaCover != null) 'dramaCover': dramaCover,
if (dramaCoverLandscape != null) 'dramaCoverLandscape': dramaCoverLandscape,
if (episodeNumber != null) 'episodeNumber': episodeNumber, if (episodeNumber != null) 'episodeNumber': episodeNumber,
if (totalEpisodes != null) 'totalEpisodes': totalEpisodes, if (totalEpisodes != null) 'totalEpisodes': totalEpisodes,
'positionMs': position.inMilliseconds, 'positionMs': position.inMilliseconds,
+8
View File
@@ -46,6 +46,14 @@ class WatchRecord {
return pageCover.isNotEmpty ? pageCover : drama?.posterUrl ?? ''; return pageCover.isNotEmpty ? pageCover : drama?.posterUrl ?? '';
} }
/// 横向展示位(继续观看横幅)优先用横版封面,缺省回退竖版。
String get displayLandscapeCover {
final pageLandscape = progress.dramaCoverLandscape?.trim() ?? '';
if (pageLandscape.isNotEmpty) return pageLandscape;
final landscape = drama?.coverLandscape.trim() ?? '';
return landscape.isNotEmpty ? landscape : displayCover;
}
/// 分页接口已带展示字段时无需等待详情加载。 /// 分页接口已带展示字段时无需等待详情加载。
bool get hasDisplayMetadata => bool get hasDisplayMetadata =>
displayTitle.isNotEmpty || displayTitle.isNotEmpty ||
+6 -6
View File
@@ -4,6 +4,7 @@ import '../models/account_device.dart';
import '../models/account_export.dart'; import '../models/account_export.dart';
import '../models/account_profile.dart'; import '../models/account_profile.dart';
import 'api_client.dart'; import 'api_client.dart';
import 'response_decoders.dart';
/// 个人中心接口:`/app/me*`。 /// 个人中心接口:`/app/me*`。
class AccountApi { class AccountApi {
@@ -23,11 +24,9 @@ class AccountApi {
Future<List<AccountDevice>> devices() async { Future<List<AccountDevice>> devices() async {
final data = await _client.get('/app/me/devices'); final data = await _client.get('/app/me/devices');
if (data is! List) return const []; return ResponseDecoders.asMapList(
return [ data,
for (final item in data) ).map(AccountDevice.fromJson).toList();
if (item is Map) AccountDevice.fromJson(item.cast<String, dynamic>()),
];
} }
Future<AccountExport> export() async { Future<AccountExport> export() async {
@@ -47,6 +46,7 @@ class AccountApi {
await _client.post('/app/me/deactivate/cancel'); await _client.post('/app/me/deactivate/cancel');
} }
/// 个人资料允许缺失:后端未返回对象时按空对象解析,页面用默认值兜底。
Map<String, dynamic> _map(Object? data) => Map<String, dynamic> _map(Object? data) =>
data is Map ? data.cast<String, dynamic>() : const {}; ResponseDecoders.tryMap(data) ?? const {};
} }
+14 -16
View File
@@ -6,6 +6,7 @@ import '../models/page_result.dart';
import '../models/playlist_detail.dart'; import '../models/playlist_detail.dart';
import '../models/search_result.dart'; import '../models/search_result.dart';
import 'api_client.dart'; import 'api_client.dart';
import 'response_decoders.dart';
/// 客户端目录接口,对应后端 `/app/**`。 /// 客户端目录接口,对应后端 `/app/**`。
class CatalogApi { class CatalogApi {
@@ -14,11 +15,15 @@ class CatalogApi {
final ApiClient _client; final ApiClient _client;
Future<HomeFeed> homeFeed() async { Future<HomeFeed> homeFeed() async {
return HomeFeed.fromJson(_asMap(await _client.get('/app/home/feed'))); return HomeFeed.fromJson(
ResponseDecoders.asMap(await _client.get('/app/home/feed')),
);
} }
Future<HomeFeed> discoverFeed() async { Future<HomeFeed> discoverFeed() async {
return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed'))); return HomeFeed.fromJson(
ResponseDecoders.asMap(await _client.get('/app/discover/feed')),
);
} }
/// 推荐流;types/categoryId 为空表示不过滤,cursor 为上一页末条。 /// 推荐流;types/categoryId 为空表示不过滤,cursor 为上一页末条。
@@ -29,7 +34,7 @@ class CatalogApi {
int size = 10, int size = 10,
}) async { }) async {
return FeedPage.fromJson( return FeedPage.fromJson(
_asMap( ResponseDecoders.asMap(
await _client.get( await _client.get(
'/app/feed', '/app/feed',
query: { query: {
@@ -58,7 +63,7 @@ class CatalogApi {
'pageSize': '$pageSize', 'pageSize': '$pageSize',
}, },
); );
return SearchResult.fromJson(_asMap(data)); return SearchResult.fromJson(ResponseDecoders.asMap(data));
} }
static const int playlistPageSize = 10; static const int playlistPageSize = 10;
@@ -86,7 +91,7 @@ class CatalogApi {
int pageSize = playlistDramaPageSize, int pageSize = playlistDramaPageSize,
}) async { }) async {
return PlaylistDetail.fromJson( return PlaylistDetail.fromJson(
_asMap( ResponseDecoders.asMap(
await _client.get( await _client.get(
'/app/playlists/$id', '/app/playlists/$id',
query: { query: {
@@ -138,20 +143,13 @@ class CatalogApi {
Future<Drama> dramaDetail(String dramaId) async { Future<Drama> dramaDetail(String dramaId) async {
return Drama.fromDetailJson( return Drama.fromDetailJson(
_asMap(await _client.get('/app/dramas/$dramaId')), ResponseDecoders.asMap(await _client.get('/app/dramas/$dramaId')),
); );
} }
List<ContentCategory> _categories(Object? data) { List<ContentCategory> _categories(Object? data) {
if (data is! List) return const []; return ResponseDecoders.asMapList(
return [ data,
for (final item in data) ).map(ContentCategory.fromJson).toList();
if (item is Map<String, dynamic>) ContentCategory.fromJson(item),
];
}
Map<String, dynamic> _asMap(Object? data) {
if (data is Map<String, dynamic>) return data;
throw const ApiException(-1, '返回数据格式异常');
} }
} }
+4 -5
View File
@@ -4,6 +4,7 @@ import '../models/content_category.dart';
import '../models/drama.dart'; import '../models/drama.dart';
import '../models/page_result.dart'; import '../models/page_result.dart';
import 'api_client.dart'; import 'api_client.dart';
import 'response_decoders.dart';
/// 内容分类接口,对应契约 `GET /app/categories`。 /// 内容分类接口,对应契约 `GET /app/categories`。
class CategoryApi { class CategoryApi {
@@ -16,11 +17,9 @@ class CategoryApi {
/// 拉取已启用分类,id 全程按字符串解析。 /// 拉取已启用分类,id 全程按字符串解析。
Future<List<ContentCategory>> list() async { Future<List<ContentCategory>> list() async {
final data = await _client.get('/app/categories'); final data = await _client.get('/app/categories');
if (data is! List) return const []; return ResponseDecoders.asMapList(
return [ data,
for (final item in data) ).map(ContentCategory.fromJson).toList();
if (item is Map<String, dynamic>) ContentCategory.fromJson(item),
];
} }
/// 分类下已上线短剧,pageIndex 从 1 开始。 /// 分类下已上线短剧,pageIndex 从 1 开始。
+7 -13
View File
@@ -4,6 +4,7 @@ import '../models/order.dart';
import '../models/product.dart'; import '../models/product.dart';
import '../models/wallet.dart'; import '../models/wallet.dart';
import 'api_client.dart'; import 'api_client.dart';
import 'response_decoders.dart';
/// 金币与解锁接口,对应契约 §1.8。 /// 金币与解锁接口,对应契约 §1.8。
class CommerceApi { class CommerceApi {
@@ -13,8 +14,7 @@ class CommerceApi {
Future<WalletSnapshot> wallet() async { Future<WalletSnapshot> wallet() async {
final data = await _client.get('/app/wallet'); final data = await _client.get('/app/wallet');
if (data is Map<String, dynamic>) return WalletSnapshot.fromJson(data); return WalletSnapshot.fromJson(ResponseDecoders.asMap(data));
throw const ApiException(-1, '返回数据格式异常');
} }
/// 上架商品列表;`type` 传 `COIN_PACK` 或 `VIP`。 /// 上架商品列表;`type` 传 `COIN_PACK` 或 `VIP`。
@@ -23,13 +23,9 @@ class CommerceApi {
'/app/products', '/app/products',
query: type == null ? null : {'type': type}, query: type == null ? null : {'type': type},
); );
if (data is List) { return ResponseDecoders.requiredMapList(
return data data,
.whereType<Map<String, dynamic>>() ).map(AppProduct.fromJson).toList();
.map(AppProduct.fromJson)
.toList();
}
throw const ApiException(-1, '返回数据格式异常');
} }
/// 创建充值订单,返回 `CREATED` 状态订单。 /// 创建充值订单,返回 `CREATED` 状态订单。
@@ -38,13 +34,11 @@ class CommerceApi {
'/app/orders', '/app/orders',
body: {'productId': productId}, body: {'productId': productId},
); );
if (data is Map<String, dynamic>) return AppOrder.fromJson(data); return AppOrder.fromJson(ResponseDecoders.asMap(data));
throw const ApiException(-1, '返回数据格式异常');
} }
Future<UnlockResult> unlock(String episodeId) async { Future<UnlockResult> unlock(String episodeId) async {
final data = await _client.post('/app/episodes/$episodeId/unlock'); final data = await _client.post('/app/episodes/$episodeId/unlock');
if (data is Map<String, dynamic>) return UnlockResult.fromJson(data); return UnlockResult.fromJson(ResponseDecoders.asMap(data));
throw const ApiException(-1, '返回数据格式异常');
} }
} }
+16
View File
@@ -0,0 +1,16 @@
// Author: fruit.com.cn
import 'api_client.dart';
/// 启动配置:`GET /app/config`(匿名,后台只下发白名单键)。
class ConfigApi {
const ConfigApi(this._client);
final ApiClient _client;
/// 白名单配置表;结构异常时返回空表,调用方按本地默认处理。
Future<Map<String, dynamic>> appConfig() async {
final data = await _client.get('/app/config');
return data is Map<String, dynamic> ? data : const <String, dynamic>{};
}
}
+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 (_) {}
} }
} }
@@ -0,0 +1,31 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../remote/config_api.dart';
/// 后台下发的启动配置。
///
/// 失败一律静默:拿不到就用 App 内置默认,不阻塞启动、不弹错。
class AppConfigController extends ChangeNotifier {
AppConfigController(this._api);
final ConfigApi _api;
String _loginBackgroundUrl = '';
/// 登录页整屏背景图;空串表示用本地默认图。
String get loginBackgroundUrl => _loginBackgroundUrl;
Future<void> load() async {
try {
final config = await _api.appConfig();
final url = (config['loginBackgroundUrl'] as String? ?? '').trim();
if (url == _loginBackgroundUrl) return;
_loginBackgroundUrl = url;
notifyListeners();
} catch (error) {
debugPrint('[config] load failed: $error');
}
}
}
-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();
}
} }
Loaded 100 of 299 files, more files were not shown because too many files have changed in this diff. Show more