465 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
Vicky a3f3481f4f feat: phase1 UI prep — 设备ID系统传递 + 国家本地化展示 + 继续观看直进播放页
设备标识: Android SSAID/ iOS IDFV 原生通道 → Dart DeviceIdProvider
国家解析: 注册/登录从后端 IP → 国家/语言/时区/币种 自动落库
账号资料: 全部本地化展示 (account_display.dart)
我的页: 继续观看卡片点击直接进播放页
2026-10-27 15:45:20 +08:00
Vicky 1b19513efb feat(core): 设备号改用系统标识,重装不再变成新游客
- Android 读 SSAID、iOS 读 IDFV,均免权限,由原生通道提供,不引插件
- 取不到时回退本地 UUIDv4,替代原来 32 位随机 + 时间戳的拼接
2026-10-27 14:15:50 +08:00
Vicky 935de13343 refactor(core): 平台上报收敛为单个请求头
去掉 device_info_plus 依赖与机型/系统版本,只保留 X-Client-Platform。
2026-10-27 14:00:19 +08:00
Vicky 01a3b0101e chore(merge): 合并客户端设备画像上报 2026-10-27 13:54:29 +08:00
Vicky d6df5fbe5f feat(core): 请求头统一上报设备系统与机型
- 新增 DeviceProfileReader,首次读取后缓存,插件不可用时安全降级
- 拦截器为所有请求补 X-Client-Platform / Os / Model,匿名请求同样携带
2026-10-27 13:54:28 +08:00
Vicky 929b7f72f8 chore(merge): 合并右侧操作栏顺序统一 2026-10-27 13:35:41 +08:00
Vicky e028ca40b1 fix(ui): 统一播放页右侧操作栏顺序为收藏-点赞-评论-分享
与推荐页操作栏保持一致,同步更新布局断言。
2026-10-27 13:35:41 +08:00
Zhou Weidong d206465ee1 fix(recommend): 首屏不再兜出重试空态 2026-10-27 11:24:21 +08:00
Zhou Weidong 9117bf58b8 fix(ui): 拆分媒体骨架底色,恢复可见度 2026-10-27 11:16:24 +08:00
Zhou Weidong bd826a9236 fix(ui): 骨架屏扫光收窄压淡 2026-10-27 11:12:31 +08:00
Zhou Weidong a5dbae7fff fix(ui): 骨架屏改为逐卡片扫光 2026-10-27 11:08:43 +08:00
Zhou Weidong 00405d2e3b fix(ui): 收敛骨架屏亮度 2026-10-27 10:49:30 +08:00
Zhou Weidong 72dcb92aca fix(following): 收紧取消追剧确认弹窗 2026-10-27 10:34:07 +08:00
Zhou Weidong 8d511933df fix(mine): 支持未登录展示互动计数 2026-10-27 10:26:11 +08:00
Zhou Weidong 06a2f6d7c0 fix(following): 支持未登录追剧与历史 2026-10-27 10:13:27 +08:00
Zhou Weidong 0d785bf1f8 feat(library): 支持设备追剧与观看历史 2026-10-27 09:54:51 +08:00
Zhou Weidong 4c3acb047c fix(player): 收紧推荐页互动栏间距 2026-10-27 09:37:00 +08:00
Zhou Weidong d3dbc9f8dc fix(favorite): 统一收藏状态与互动图标 2026-10-27 09:31:48 +08:00
Zhou Weidong 4b97f89c0b fix(recommend): 延后推荐数据刷新 2026-10-27 09:19:18 +08:00
Zhou Weidong 6be024f86f test(flutter): 收敛异步配置与分页测试 2026-10-26 22:49:27 +08:00
Zhou Weidong 6512808506 test(ui): 更新主题导航与布局断言 2026-10-26 22:49:23 +08:00
Zhou Weidong 2d65cd3006 test(auth): 更新登录失败提示断言 2026-10-26 22:49:21 +08:00
Zhou Weidong 391b01e9d0 fix(theater): 修复新剧卡片与榜单溢出 2026-10-26 22:49:13 +08:00
Zhou Weidong d4ce3a5d8b fix(home): 修复目录错误态被占位层遮挡 2026-10-26 22:49:10 +08:00
Zhou Weidong bf47c065c9 fix(ui): 修复继续观看卡片纵向溢出 2026-10-26 22:49:07 +08:00
Zhou Weidong c6b3ca9311 fix(auth): 禁止刷新接口递归重试 2026-10-26 22:49:04 +08:00
Zhou Weidong a3d50eb935 feat(interaction): 支持游客点赞收藏评论 2026-10-26 22:33:14 +08:00
Zhou Weidong 188811fde6 feat(player): 切集刷新互动计数统计 2026-10-26 21:40:39 +08:00
Zhou Weidong 37353dac15 fix(player): 观看全集进入后自动播放 2026-10-26 21:25:38 +08:00
Zhou Weidong 350d16db75 fix(player): 修正缓冲态与首帧时间闪现 2026-10-26 21:13:55 +08:00
Zhou Weidong 76429628ec chore(merge): 合并播放器切集与双击点赞优化到 main 2026-10-26 19:31:47 +08:00
Zhou Weidong 0f27daf664 chore(merge): 合并播放器切集与双击点赞优化 2026-10-26 19:24:49 +08:00
Zhou Weidong afdc4af2f2 chore(player): 移除飘心毫秒调试文字 2026-10-26 19:21:24 +08:00
Zhou Weidong 1b50a73bfc fix(player): 修复首次双击点赞状态刷新 2026-10-26 19:17:14 +08:00
Zhou Weidong 978c309cd7 fix(player): 改用墙钟驱动飘心动画 2026-10-26 19:07:56 +08:00
Zhou Weidong c0f65042e6 fix(player): 修正飘心Overlay生命周期 2026-10-26 19:00:37 +08:00
Zhou Weidong a7d01e0306 test(player): 增加飘心像素定位回归 2026-10-26 18:57:04 +08:00
Zhou Weidong ed2cf4c18c fix(player): 使用根Overlay承载飘心 2026-10-26 18:54:05 +08:00
Zhou Weidong db3e153409 fix(player): 使用根Overlay承载飘心 2026-10-26 18:49:36 +08:00
Zhou Weidong 810ca5bf9d fix(player): 延长飘心并显示毫秒计时 2026-10-26 18:48:12 +08:00
Zhou Weidong 84b24775d6 fix(player): 恢复develop飘心实现 2026-10-26 18:38:59 +08:00
Zhou Weidong 9039b27c7f fix(player): 使用根Overlay渲染飘心 2026-10-26 18:35:30 +08:00
Zhou Weidong 30b43878ab fix(player): 使用根Overlay渲染飘心 2026-10-26 18:29:48 +08:00
Zhou Weidong e7afd316f8 fix(player): 放慢随机飘心并验证完整时长 2026-10-26 18:24:19 +08:00
Zhou Weidong f3dd4a9b5c fix(player): 恢复随机飘心粒子模型 2026-10-26 18:19:39 +08:00
Zhou Weidong 94b37ef00f fix(player): 优化双击飘心轨迹 2026-10-26 18:16:06 +08:00
Zhou Weidong bea50ab32f fix(player): 调整双击飘心动画 2026-10-26 18:11:01 +08:00
Zhou Weidong 63296d2a92 fix(player): 恢复独立播放页双击点赞 2026-10-26 18:06:55 +08:00
Zhou Weidong b291044acc fix(player): 收敛播放页入口加载态 2026-10-26 17:56:40 +08:00
Zhou Weidong ee4e573f4f fix(player): 播放页改为非阻塞加载 2026-10-26 17:43:57 +08:00
Zhou Weidong e8f192ccf8 fix(player): 顶层抓帧淡出揭幕 2026-10-26 17:31:59 +08:00
Zhou Weidong c8ec481fc9 fix(player): 预解码观看全集首帧 2026-10-26 17:27:43 +08:00
Zhou Weidong 1fc8c4924e fix(player): 调整观看全集淡入时长 2026-10-26 17:21:52 +08:00
Zhou Weidong 55fc2796a9 fix(player): 观看全集复用抓帧首屏 2026-10-26 17:20:24 +08:00
Zhou Weidong 4d3d067f77 fix(player): 规范预热回收日志并抓帧过渡 2026-10-26 17:13:56 +08:00
Zhou Weidong bb2c729a7e fix(player): 隔离播放资源所有权并修复黑屏 2026-10-26 17:03:19 +08:00
Zhou Weidong 54eb5a60a8 fix(player): 修复返回首页黑屏并恢复预热 2026-10-26 16:45:00 +08:00
Zhou Weidong 6667330ce0 fix(player): 分离首页与播放页播放上下文 2026-10-26 16:37:37 +08:00
Zhou Weidong 440188f261 fix(player): 修复切集返回推荐页黑屏 2026-10-26 16:20:31 +08:00
Zhou Weidong 9d2d3b97a1 fix(player): 独立全屏路由复用播放会话 2026-10-26 16:08:15 +08:00
Zhou Weidong 23e80dd637 fix(player): 复用播放会话进入全屏 2026-10-26 15:55:46 +08:00
Zhou Weidong ab4c246675 fix(player): 保持推荐页返回时播放状态 2026-10-26 15:44:28 +08:00
Zhou Weidong 2a32dec183 fix(player): 持久化竖滑播放画面层 2026-10-26 15:34:24 +08:00
Zhou Weidong 5dafc68775 test(player): 覆盖 feed 高清首帧解析 2026-10-26 15:20:45 +08:00
Zhou Weidong 80c962a95c fix(player): 收紧图片与快速切集资源占用 2026-10-26 15:10:02 +08:00
Zhou Weidong 2fef7470e0 fix(player): 使用高清首帧并稳定切集占位 2026-10-26 14:58:00 +08:00
Zhou Weidong 0cb0ab0613 fix(player): 重构单层过渡并阻止锁定集串播 2026-10-26 14:41:18 +08:00
Zhou Weidong 70d8e0f982 fix(player): 使用雪碧图预热首帧兜底 2026-10-26 14:21:52 +08:00
Zhou Weidong 48b4e67d34 fix(player): 预热直出并与视频统一封面布局 2026-10-26 14:12:42 +08:00
Zhou Weidong a3a79f7068 fix(player): 修正封面比例与首帧切换闪烁 2026-10-26 14:07:36 +08:00
Zhou Weidong 04f034817c fix(player): 优化封面过渡与换集起播 2026-10-26 14:01:42 +08:00
Zhou Weidong 516593499c fix(player): 修复竖滑上一集不自动播放 2026-10-26 13:43:43 +08:00
Zhou Weidong df36890b48 chore(analyze): 清理静态检查告警 2026-10-26 13:43:38 +08:00
Zhou Weidong 222bacdeeb fix(player): 优化上下滑切集黑屏 2026-10-26 13:15:56 +08:00
Zhou Weidong 43f3593218 chore(merge): 合并 iOS 无线数据选择框触发到 main 2026-10-26 09:35:29 +08:00
Zhou Weidong b83b5799e3 chore(merge): 合并 iOS 无线数据选择框触发 2026-10-26 09:35:29 +08:00
Zhou Weidong d4ae8f427f fix(ios): 用系统网络请求唤起无线数据选择
Flutter 的接口请求不会触发系统选择框,进入前台后补一次 URLSession 请求。
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 98d7debfb5 chore(merge): 合并 iOS 无线数据询问 2026-10-25 21:23:26 +08:00
Zhou Weidong b5e9deb4bd feat(ios): 首次启动询问无线数据权限
iOS 在首个接口请求前等待系统无线数据选择,避免未授权时请求被静默拒绝。
2026-10-25 21:23:25 +08:00
Zhou Weidong af5d8f9d80 chore(merge): 合并客户端多语言筛选到 main 2026-10-25 19:52:30 +08:00
Zhou Weidong 47df002d8e chore(merge): 合并客户端多语言筛选与注销原因修正 2026-10-24 17:22:57 +08:00
Zhou Weidong 4f1feeadbd fix(app): 注销原因对齐到输入框顶部 2026-10-24 17:20:24 +08:00
Zhou Weidong 8b50b5bd06 feat(app): 客户端展示多语言并支持筛选分页 2026-10-24 17:20:24 +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
Zhou Weidong 8481611372 chore(merge): 合并错误码三语文案 2026-10-23 13:05:29 +08:00
Zhou Weidong b1e95b370c feat(app): 按错误码展示中英印尼文案 2026-10-23 13:05:29 +08:00
Zhou Weidong 0ef0d46919 chore(merge): 合并未使用注销原因清理 2026-10-23 12:45:30 +08:00
Zhou Weidong b4d38f8e10 refactor(app): 删除未使用的注销原因组件 2026-10-23 12:45:30 +08:00
Zhou Weidong c034627fdb chore(merge): 合并播放协议搜索与空状态 2026-10-23 12:38:47 +08:00
Zhou Weidong ef29fc5f15 feat(app): 完善播放协议搜索与空状态 2026-10-23 12:38:47 +08:00
Zhou Weidong 671e0ce143 merge: 将远程 main 同步到 develop 2026-10-23 08:09:24 +08:00
Zhou Weidong 0f1f64a09f merge: 将 release/v1.0.0 同步到 main 2026-10-23 03:22:26 +08:00
Zhou Weidong d29ae20491 merge: 将客户端调整同步到 release/v1.0.0 2026-10-23 03:21:53 +08:00
Zhou Weidong 12e08a89a4 fix(player): 提高解锁金币标签对比度 2026-10-23 03:18:24 +08:00
Zhou Weidong e0ba54f23b fix(cover): 封面占位改为品牌标识 2026-10-23 03:18:24 +08:00
Zhou Weidong 5043d6c25b fix(l10n): 缩短印尼语全部短剧文案 2026-10-23 03:18:24 +08:00
Zhou Weidong ab6800aba2 fix(search): 避免键盘顶起搜索页 2026-10-23 03:18:24 +08:00
Zhou Weidong f8500bbaca fix(player): 完善进度预览清屏与下拉刷新 2026-10-23 03:18:24 +08:00
Zhou Weidong 9b1afbdb36 feat(recommend): 剧单更多进入两列详情页 2026-10-23 03:18:24 +08:00
Zhou Weidong f4cf7bc2dd fix(home): 调整榜单版式并去掉二级分类 2026-10-23 03:18:24 +08:00
Zhou Weidong 60489d6dca fix(ios): 修复逐帧请求头编译失败 2026-10-23 03:18:23 +08:00
Zhou Weidong 44d3bcba18 style(empty): 放大空状态图标并降低亮度 2026-10-23 01:10:59 +08:00
Zhou Weidong f206977ad8 fix(config): 开发服务地址改为当前局域网 2026-10-23 01:10:59 +08:00
Zhou Weidong f3d64f6fb5 fix(auth): 登录页靠上排列并支持外部失焦 2026-10-23 01:10:59 +08:00
Zhou Weidong 6318ca24ec feat(splash): 缩短开屏停留并统一开屏图 2026-10-23 01:06:07 +08:00
Zhou Weidong b513ac0a9c merge: 调整品牌开屏与纯黑原生启动 2026-10-22 23:15:36 +08:00
Zhou Weidong 25d90e1383 feat: 调整品牌开屏与纯黑原生启动 2026-10-22 23:15:27 +08:00
Zhou Weidong 7804809d83 merge: 开屏后直接展示首页骨架 2026-10-22 23:11:13 +08:00
Zhou Weidong 5f278a3510 feat: 开屏后直接展示首页骨架 2026-10-22 23:11:03 +08:00
Zhou Weidong 56d7abc6b1 merge: 保留系统开屏并更新品牌素材 2026-10-22 23:04:31 +08:00
Zhou Weidong 345cc2ad28 feat: 保留系统开屏并更新品牌素材 2026-10-22 23:04:23 +08:00
Zhou Weidong 41bd4683c3 merge: 更新 App 图标与开屏素材 2026-10-22 22:59:14 +08:00
Zhou Weidong d868ce9336 feat: 更新 App 图标与开屏素材 2026-10-22 22:59:07 +08:00
Zhou Weidong 928ac1de19 merge: 增加搜索结果下拉刷新 2026-10-22 22:53:01 +08:00
Zhou Weidong 6b3d870c93 feat: 增加搜索结果下拉刷新 2026-10-22 22:52:50 +08:00
Zhou Weidong 281f13a810 merge: 调整搜索结果两列布局 2026-10-22 22:50:55 +08:00
Zhou Weidong 8637f5da21 style: 调整搜索结果两列布局 2026-10-22 22:50:47 +08:00
Zhou Weidong 0319f8a6a8 merge: 统一首页与推荐搜索入口 2026-10-22 22:48:29 +08:00
Zhou Weidong 9aa797504b feat: 统一首页与推荐搜索入口 2026-10-22 22:48:21 +08:00
Zhou Weidong 933333b0fe merge: 使用点击序号防止双击误暂停 2026-10-22 22:39:30 +08:00
Zhou Weidong bbc759473c fix: 使用点击序号防止双击误暂停 2026-10-22 22:39:21 +08:00
Zhou Weidong 9ddd7fc833 merge: 修复播放器重复暂停手势 2026-10-22 22:34:26 +08:00
Zhou Weidong 5794dd4e4d fix: 修复播放器重复暂停手势 2026-10-22 22:34:18 +08:00
Zhou Weidong 76c4b528d3 merge: 修正双击点赞与心形联动 2026-10-22 22:30:11 +08:00
Zhou Weidong 68ccfa21c0 fix: 修正双击点赞与心形联动 2026-10-22 22:30:01 +08:00
Zhou Weidong 78d0166eb7 merge: 增加播放器双击点赞与飘心 2026-10-22 22:25:22 +08:00
Zhou Weidong 4140c4eb50 feat: 增加播放器双击点赞与飘心 2026-10-22 22:25:14 +08:00
Zhou Weidong e2cc409e5a merge: 重做首页热播榜布局 2026-10-22 22:20:01 +08:00
Zhou Weidong 9b83283689 feat: 重做首页热播榜布局 2026-10-22 22:19:55 +08:00
Zhou Weidong db8433ace3 merge: 统一下拉与加载指示器 2026-10-22 22:07:21 +08:00
Zhou Weidong 6f1da5aa39 style: 统一下拉与加载指示器 2026-10-22 22:07:13 +08:00
Zhou Weidong 397483d339 merge: 统一全站深色视觉 2026-10-22 22:01:45 +08:00
Zhou Weidong 37e35f4b47 style: 统一全站深色视觉 2026-10-22 22:01:36 +08:00
Zhou Weidong 86aa7e0f4f merge: 增加推荐剧单渐变背景 2026-10-22 21:52:20 +08:00
Zhou Weidong 604ab60b63 style: 增加推荐剧单渐变背景 2026-10-22 21:52:13 +08:00
Zhou Weidong 0b3f362093 merge: 修正我的 UID 行高度 2026-10-22 21:49:54 +08:00
Zhou Weidong 19acbb2278 fix: 修正我的 UID 行高度 2026-10-22 21:49:47 +08:00
Zhou Weidong ce3ecc4d21 merge: 收紧我的用户区信息间距 2026-10-22 21:46:57 +08:00
Zhou Weidong c604cc1333 style: 收紧我的用户区信息间距 2026-10-22 21:46:49 +08:00
Zhou Weidong e7367e99be merge: 补充我的页内容区块 2026-10-22 21:44:36 +08:00
Zhou Weidong 9953c955e3 feat: 补充我的页内容区块 2026-10-22 21:44:03 +08:00
Zhou Weidong 7971ac18fc merge: 增强我的页渐变与图标配色 2026-10-22 21:38:55 +08:00
Zhou Weidong a81bec4d47 style: 增强我的页渐变与图标配色 2026-10-22 21:38:49 +08:00
Zhou Weidong 647f6afd77 merge: 改造我的页渐变与图标网格 2026-10-22 21:35:20 +08:00
Zhou Weidong 9785a1baf3 feat: 改造我的页渐变与图标网格 2026-10-22 21:35:14 +08:00
Zhou Weidong e70ca03c03 merge: 调整继续观看按钮样式 2026-10-22 21:28:41 +08:00
Zhou Weidong 95fb2ff882 style: 调整继续观看按钮样式 2026-10-22 21:28:36 +08:00
Zhou Weidong b6e5f808d6 merge: 优化追剧页切换与编辑按钮 2026-10-22 21:26:01 +08:00
Zhou Weidong 3f3dc9263c fix: 优化追剧页切换与编辑按钮 2026-10-22 21:25:55 +08:00
Zhou Weidong f67b8bd514 merge: 调整追剧加号颜色 2026-10-22 21:21:58 +08:00
Zhou Weidong 4d304cb549 style: 调整追剧加号颜色 2026-10-22 21:21:52 +08:00
Zhou Weidong 201d58e8f4 merge: 调整追剧加号卡片顺序 2026-10-22 21:20:12 +08:00
Zhou Weidong 091ea358bd fix: 调整追剧加号卡片顺序 2026-10-22 21:20:06 +08:00
Zhou Weidong af828d2c46 merge: 播放历史单列列表 2026-10-22 21:18:08 +08:00
Zhou Weidong c14d89ffce feat: 调整播放历史为单列列表 2026-10-22 21:18:02 +08:00
Zhou Weidong b185200e8c merge: 追剧页双列表与三列卡片 2026-10-22 21:14:31 +08:00
Zhou Weidong 2fac46f248 feat: 改造追剧页双列表与三列卡片 2026-10-22 21:14:23 +08:00
Zhou Weidong cffb68809c merge: 更正筛选按钮所属页面 2026-10-22 21:06:00 +08:00
Zhou Weidong 13d70fcef2 fix(recommend): 更正筛选按钮所属页面 2026-10-22 21:05:51 +08:00
Zhou Weidong bf20e44b85 merge: 移除二级标签筛选按钮 2026-10-22 21:02:40 +08:00
Zhou Weidong 9cf1efc316 fix(home): 移除二级标签筛选按钮 2026-10-22 21:02:29 +08:00
Zhou Weidong 3655d94869 merge: 修正窄筛选行横向居中 2026-10-22 20:59:14 +08:00
Zhou Weidong 196ce69a12 fix(recommend): 修正窄筛选行横向居中 2026-10-22 20:59:02 +08:00
Zhou Weidong c7a0e277f2 merge: 统一全部短剧筛选左对齐 2026-10-22 20:56:53 +08:00
Zhou Weidong 57c9e6b477 fix(recommend): 统一全部短剧筛选左对齐 2026-10-22 20:56:45 +08:00
Zhou Weidong 64bc1997ba merge: 修正筛选左起始与横向滑动 2026-10-22 20:53:16 +08:00
Zhou Weidong 21bb716af4 fix(recommend): 修正筛选左起始与横向滑动 2026-10-22 20:53:07 +08:00
Zhou Weidong a40adbb99a merge: 按截图重做全部短剧筛选区 2026-10-22 20:50:03 +08:00
Zhou Weidong d79e200650 style(recommend): 按截图重做全部短剧筛选区 2026-10-22 20:49:54 +08:00
Zhou Weidong 3b9ff8a306 merge: 对齐全部短剧筛选行 2026-10-22 20:46:21 +08:00
Zhou Weidong af4d918938 fix(recommend): 对齐全部短剧筛选行 2026-10-22 20:46:13 +08:00
Zhou Weidong 3289012f64 merge: 固定全部短剧筛选区并统一间距 2026-10-22 20:43:22 +08:00
Zhou Weidong e147002e00 fix(recommend): 固定全部短剧筛选区并统一间距 2026-10-22 20:43:15 +08:00
Zhou Weidong 099d05064e merge: 重排全部短剧筛选与网格 2026-10-22 20:39:09 +08:00
Zhou Weidong 4eb411ef28 style(recommend): 重排全部短剧筛选与网格 2026-10-22 20:39:00 +08:00
Zhou Weidong 14885d9efa merge: 支持横向切换三个内容页 2026-10-22 20:35:06 +08:00
Zhou Weidong 17faa749b2 feat(recommend): 支持横向切换三个内容页 2026-10-22 20:34:58 +08:00
Zhou Weidong 5fce65ac57 merge: 修正标题与观看全集跳转 2026-10-22 20:31:48 +08:00
Zhou Weidong 357492acb2 fix(recommend): 修正标题与观看全集跳转 2026-10-22 20:31:41 +08:00
Zhou Weidong 23799353b5 merge: 修复进度条拖动树稳定性 2026-10-22 20:27:07 +08:00
Zhou Weidong cf6d12b7c8 fix(player): 修复进度条拖动树稳定性 2026-10-22 20:26:58 +08:00
Zhou Weidong 5af6caea6e merge: 修正进度条按下与拖动状态 2026-10-22 20:23:32 +08:00
Zhou Weidong d66ff13d0f fix(player): 修正进度条按下与拖动状态 2026-10-22 20:23:24 +08:00
Zhou Weidong 7599da1d40 merge: 收窄拖动清屏范围并提升灵敏度 2026-10-22 20:19:29 +08:00
Zhou Weidong 04f1f5a73f fix(recommend): 收窄拖动清屏范围并提升灵敏度 2026-10-22 20:19:21 +08:00
Zhou Weidong f5721592f7 merge: 收窄观看全集按钮 2026-10-22 20:16:22 +08:00
Zhou Weidong 5abee5f69a style(recommend): 收窄观看全集按钮 2026-10-22 20:16:13 +08:00
Zhou Weidong 3690fb1bc4 merge: 修正进度拖动与底栏联动 2026-10-22 20:15:14 +08:00
Zhou Weidong 2765343340 fix(recommend): 修正进度拖动与底栏联动 2026-10-22 20:15:07 +08:00
Zhou Weidong afe7a49ef0 merge: 恢复进度拖动前浮层状态 2026-10-22 20:08:53 +08:00
Zhou Weidong b6666290ba fix(recommend): 恢复进度拖动前浮层状态 2026-10-22 20:08:45 +08:00
Zhou Weidong c478eebb0e merge: 修正进度取消状态更新编译错误 2026-10-22 20:04:51 +08:00
Zhou Weidong d731b36c1f fix(recommend): 修正进度取消状态更新编译错误 2026-10-22 20:04:43 +08:00
Zhou Weidong ac4a800b78 merge: 稳定进度拖动与清屏状态 2026-10-22 20:02:02 +08:00
Zhou Weidong 30b55afdd4 fix(recommend): 稳定进度拖动与清屏状态 2026-10-22 20:01:54 +08:00
Zhou Weidong 76c4f1d411 merge: 精简底部信息并优化进度拖动 2026-10-22 19:55:16 +08:00
Zhou Weidong d66ac8327e fix(recommend): 精简底部信息并优化进度拖动 2026-10-22 19:55:07 +08:00
Zhou Weidong ccda41b15b merge: 修正热播信息行与操作栏位置 2026-10-22 19:46:52 +08:00
Zhou Weidong 0c06a6dccd fix(recommend): 修正热播信息行与操作栏位置 2026-10-22 19:46:44 +08:00
Zhou Weidong 8737937875 merge: 对齐沉浸式推荐页视觉布局 2026-10-22 19:42:33 +08:00
Zhou Weidong 5dd3492aea feat(recommend): 对齐沉浸式推荐页视觉布局 2026-10-22 19:42:22 +08:00
Zhou Weidong 77689a3eaa merge: 再次加宽首页胶片带素材 2026-10-22 19:34:07 +08:00
Zhou Weidong b22ac6ab04 chore(home): 再次加宽首页胶片带素材 2026-10-22 19:34:00 +08:00
Zhou Weidong 2fdff34ed0 merge: 加宽首页胶片带素材 2026-10-22 19:32:25 +08:00
Zhou Weidong f9e9334934 chore(home): 加宽首页胶片带素材 2026-10-22 19:32:20 +08:00
Zhou Weidong 856a825a74 merge: 更新首页胶片带导航素材 2026-10-22 19:31:01 +08:00
Zhou Weidong df960f84ea chore(home): 更新首页胶片带导航素材 2026-10-22 19:30:52 +08:00
Zhou Weidong bfe1a2f9e8 merge: 放大底部导航图标与文字 2026-10-22 19:30:11 +08:00
Zhou Weidong fd2b0dbf23 style(home): 放大底部导航图标与文字 2026-10-22 19:30:03 +08:00
Zhou Weidong 5860302ce7 merge: 首页图标区分实心选中态 2026-10-22 19:29:18 +08:00
Zhou Weidong 4592b5dfba feat(home): 首页图标区分实心选中态 2026-10-22 19:29:12 +08:00
Zhou Weidong 6a10992812 merge: 更新首页导航摄影机素材 2026-10-22 19:26:36 +08:00
Zhou Weidong 8ae2ed4d93 chore(home): 更新首页导航摄影机素材 2026-10-22 19:26:31 +08:00
Zhou Weidong b1963a718e merge: 使用胶片盘图片导航图标 2026-10-22 19:24:40 +08:00
Zhou Weidong f47e4dd298 feat(home): 使用胶片盘图片导航图标 2026-10-22 19:24:34 +08:00
Zhou Weidong c24518c1a4 merge: 更换底部首页导航图标 2026-10-22 19:22:09 +08:00
Zhou Weidong 97e2db1b7b feat(home): 更换底部首页导航图标 2026-10-22 19:22:01 +08:00
Zhou Weidong bd4bdf8aea merge: 修正二级筛选按钮右对齐 2026-10-22 19:19:56 +08:00
Zhou Weidong 38643e7fde fix(home): 修正二级筛选按钮右对齐 2026-10-22 19:19:50 +08:00
Zhou Weidong 2cfa655750 merge: 修正 ChangeNotifier 监听生命周期 2026-10-22 19:14:41 +08:00
Zhou Weidong 8936c3d6eb fix(client): 修正 ChangeNotifier 监听生命周期 2026-10-22 19:14:35 +08:00
Zhou Weidong 4291031ace merge: 优化短剧列表下拉刷新体验 2026-10-22 19:11:36 +08:00
Zhou Weidong 7945f3ffed fix(home): 优化短剧列表下拉刷新体验 2026-10-22 19:11:30 +08:00
Zhou Weidong f69a27bb80 merge: 修复分类分页控制器初始化 2026-10-22 19:07:41 +08:00
Zhou Weidong d6f1cf2a2c fix(home): 修复分类分页控制器初始化 2026-10-22 19:07:32 +08:00
Zhou Weidong 6c1f3b9eae merge: 支持滑动切换首页分类 2026-10-22 19:06:05 +08:00
Zhou Weidong 5282194ca5 feat(home): 支持滑动切换首页分类 2026-10-22 19:05:55 +08:00
Zhou Weidong b921f79a71 merge: 更新搜索栏图标比例与间距 2026-10-22 19:03:52 +08:00
Zhou Weidong 4fa9d36b45 assets(home): 更新搜索栏图标比例与间距 2026-10-22 19:03:42 +08:00
Zhou Weidong 024f5f3736 merge: 统一搜索栏图标视觉比例 2026-10-22 19:00:08 +08:00
Zhou Weidong 403cce0e3b assets(home): 统一搜索栏图标视觉比例 2026-10-22 18:59:58 +08:00
Zhou Weidong ae8546fcf9 merge: 收紧二级标签间距 2026-10-22 18:59:19 +08:00
Zhou Weidong 5a4e910554 fix(home): 收紧二级标签间距 2026-10-22 18:59:11 +08:00
Zhou Weidong 147662d085 merge: 添加搜索栏皇冠与礼物图标 2026-10-22 18:58:11 +08:00
Zhou Weidong 714e5caec3 assets(home): 添加搜索栏皇冠与礼物图标 2026-10-22 18:58:03 +08:00
Zhou Weidong c1d4b61936 merge: 使用图片渲染搜索栏入口 2026-10-22 18:52:51 +08:00
Zhou Weidong 953ddec587 fix(home): 使用图片渲染搜索栏入口 2026-10-22 18:52:43 +08:00
Zhou Weidong afd8927a9f merge: 放大搜索栏右侧图标 2026-10-22 18:48:29 +08:00
Zhou Weidong 8feede1763 fix(home): 放大搜索栏右侧图标 2026-10-22 18:48:20 +08:00
Zhou Weidong f4e5af19fe merge: 调整搜索栏右侧入口样式 2026-10-22 18:46:27 +08:00
Zhou Weidong 909762ac3b fix(home): 调整搜索栏右侧入口样式 2026-10-22 18:46:19 +08:00
Zhou Weidong acae05d354 merge: 修复样式常量与测试类型错误 2026-10-22 18:43:11 +08:00
Zhou Weidong ba0ce2f8c0 fix(home): 修复样式常量与测试类型错误 2026-10-22 18:43:02 +08:00
Zhou Weidong 62cb4d9809 merge: 稳定二级标签切换布局 2026-10-22 18:41:05 +08:00
Zhou Weidong b3ea85df41 fix(home): 稳定二级标签切换布局 2026-10-22 18:40:56 +08:00
Zhou Weidong 76296f2be0 merge: 对齐二级筛选图标中心线 2026-10-22 18:38:48 +08:00
Zhou Weidong 4304727ffd fix(home): 对齐二级筛选图标中心线 2026-10-22 18:38:41 +08:00
Zhou Weidong 8ac781bb5e merge: 重绘二级筛选三线图标 2026-10-22 18:37:22 +08:00
Zhou Weidong 448010115f fix(home): 重绘二级筛选三线图标 2026-10-22 18:37:16 +08:00
Zhou Weidong 8197abd55d merge: 调整二级筛选按钮尺寸 2026-10-22 18:36:35 +08:00
Zhou Weidong c6f3e3fa68 fix(home): 调整二级筛选按钮尺寸 2026-10-22 18:36:30 +08:00
Zhou Weidong 368fbe7ef1 merge: 调整短剧封面比例 2026-10-22 18:34:17 +08:00
Zhou Weidong 91f4de01a1 fix(home): 调整短剧封面比例 2026-10-22 18:34:11 +08:00
Zhou Weidong 3886c577eb merge: 调整封面播放量图标与位置 2026-10-22 18:33:38 +08:00
Zhou Weidong 0e7dcf4739 fix(home): 调整封面播放量图标与位置 2026-10-22 18:33:31 +08:00
Zhou Weidong f20e7ba7c5 merge: 调整封面播放量与状态角标 2026-10-22 18:31:09 +08:00
Zhou Weidong 8375d8b255 fix(home): 收紧封面状态角标 2026-10-22 18:31:03 +08:00
Zhou Weidong 972e542e5c fix(home): 调整封面播放量位置 2026-10-22 18:30:33 +08:00
Zhou Weidong e11f55777e merge: 调整一级分类未选字号 2026-10-22 18:29:28 +08:00
Zhou Weidong 325f7c1f5d fix(home): 调整一级分类未选字号 2026-10-22 18:29:22 +08:00
Zhou Weidong da69a058b4 merge: 优化二级标签选中样式 2026-10-22 18:28:51 +08:00
Zhou Weidong 1adcc16727 fix(home): 优化二级标签选中样式 2026-10-22 18:28:44 +08:00
Zhou Weidong 5da836dded merge: 调整播放量行内展示 2026-10-22 18:28:00 +08:00
Zhou Weidong f7782aef14 fix(home): 调整播放量行内展示 2026-10-22 18:27:55 +08:00
Zhou Weidong c9fce7745e merge: 修正封面角标与题材标签 2026-10-22 18:26:59 +08:00
Zhou Weidong e16faa258a fix(home): 修正封面角标与题材标签 2026-10-22 18:26:52 +08:00
Zhou Weidong 3abf69e886 merge: 收紧首页顶部区间距 2026-10-22 18:23:44 +08:00
Zhou Weidong ce310afa89 fix(home): 收紧首页顶部区间距 2026-10-22 18:23:37 +08:00
Zhou Weidong d3fdb3c631 merge: 收紧短剧卡片间距 2026-10-22 18:22:52 +08:00
Zhou Weidong 9543d850c8 fix(home): 收紧短剧卡片间距 2026-10-22 18:22:46 +08:00
Zhou Weidong 81dd33e8c7 merge: 调整首页分类与卡片标签样式 2026-10-22 18:22:11 +08:00
Zhou Weidong 4f6116896f fix(home): 调整首页分类与卡片标签样式 2026-10-22 18:22:05 +08:00
Zhou Weidong 7446431511 merge: 修正首页分类与卡片布局 2026-10-22 18:14:40 +08:00
Zhou Weidong aa4870cce6 fix(home): 修正首页分类与卡片布局 2026-10-22 18:14:33 +08:00
Zhou Weidong 10477562d4 merge: 对齐短剧列表字号与间距 2026-10-22 18:06:09 +08:00
Zhou Weidong d7a59e5a27 style(client): 对齐短剧列表字号与间距 2026-10-22 18:06:03 +08:00
Zhou Weidong d03dcf162e merge: 移除全局两侧悬浮元素 2026-10-22 17:58:51 +08:00
Zhou Weidong 9cf832b318 refactor(navigation): 移除全局两侧悬浮元素 2026-10-22 17:58:43 +08:00
Zhou Weidong 5cead7ce00 merge: 重排我的页并新增迷你播放器 2026-10-22 17:45:05 +08:00
Zhou Weidong 79bdef4b43 docs(mine): 回填我的页改版记录 2026-10-22 17:44:59 +08:00
Zhou Weidong cbc6d80cd4 feat(mine): 重排我的页并新增迷你播放器 2026-10-22 17:44:48 +08:00
Zhou Weidong 5aa8170951 merge: 修正推荐子页内容映射 2026-10-22 17:37:03 +08:00
Zhou Weidong d06967c6ab fix(recommend): 修正推荐子页内容映射 2026-10-22 17:36:58 +08:00
Zhou Weidong af6b9bb3db merge: 追剧页新增三栏与双列网格 2026-10-22 17:32:17 +08:00
Zhou Weidong 97e3f4fc47 feat(following): 新增追剧三栏与双列网格 2026-10-22 17:32:09 +08:00
Zhou Weidong 73380ee090 merge: 推荐页新增分区流与全部短剧 2026-10-22 17:23:41 +08:00
Zhou Weidong f9aec827cf docs(recommend): 回填推荐页改版记录 2026-10-22 17:23:36 +08:00
Zhou Weidong f1b5418954 feat(recommend): 新增分区流全部短剧与剧单 2026-10-22 17:23:22 +08:00
Zhou Weidong 0ef2438100 merge: 统一我的页深色视觉风格 2026-10-22 17:07:32 +08:00
Zhou Weidong e40f4779c0 docs(mine): 回填我的页视觉统一记录 2026-10-22 17:07:27 +08:00
Zhou Weidong 83129acae4 feat(mine): 统一我的页深色视觉风格 2026-10-22 17:07:09 +08:00
Zhou Weidong 47bf6458e8 merge: 首页切换三列探索网格 2026-10-22 17:04:08 +08:00
Zhou Weidong 1bb227e083 docs(home): 回填首页网格改版记录 2026-10-22 17:04:03 +08:00
Zhou Weidong dd96792e33 feat(home): 首页改为三列探索网格 2026-10-22 17:03:45 +08:00
Zhou Weidong 6dea9d479e merge: 对齐四 Tab 底栏布局规格 2026-10-22 16:38:29 +08:00
Zhou Weidong 5362f97744 docs(nav): 回填底栏布局规格记录 2026-10-22 16:38:25 +08:00
Zhou Weidong 81787dc3aa style(nav): 对齐四 Tab 底栏布局规格 2026-10-22 16:38:14 +08:00
Zhou Weidong 667fdc5f0a merge: 合并四 Tab 底部导航 2026-10-22 16:21:39 +08:00
Zhou Weidong 729c226c95 docs(nav): 回填四 Tab 底部导航记录 2026-10-22 16:21:30 +08:00
Zhou Weidong 91e869d589 feat(nav): 底部导航改为首页推荐追剧我的 2026-10-22 16:21:05 +08:00
Zhou Weidong ec3cb8f163 merge: 客户端切换深色视觉风格 2026-10-22 13:49:38 +08:00
Zhou Weidong 4bc04fd8ae style(theme): 客户端切换深色视觉风格 2026-10-22 13:49:20 +08:00
Zhou Weidong 295c21513c merge: 观看历史改用分页契约 2026-10-22 13:21:36 +08:00
Zhou Weidong 5b2d8445dc refactor(watch): 观看历史改用分页契约 2026-10-22 13:21:27 +08:00
Zhou Weidong 1924b8626f chore(merge): 修复我的收藏与观看历史列表不显示 2026-10-22 13:10:03 +08:00
Zhou Weidong a48433f01d fix(profile): 修复我的收藏与观看历史列表不显示 2026-10-22 13:09:55 +08:00
Zhou Weidong f2ae0c70b6 merge: 收藏卡片展示简介 2026-10-22 12:45:31 +08:00
Zhou Weidong 57f3029018 feat(library): 收藏卡片展示简介 2026-10-22 12:45:27 +08:00
Zhou Weidong 9093700a04 merge: 优化观看历史标题加载 2026-10-22 12:43:06 +08:00
Zhou Weidong 78114fa46d fix(library): 优化观看历史标题加载 2026-10-22 12:42:58 +08:00
Zhou Weidong abfca7a3be merge: 收藏与观看历史支持分页 2026-10-22 12:34:39 +08:00
Zhou Weidong 235b50598a feat(library): 收藏与观看历史支持分页 2026-10-22 12:34:27 +08:00
Zhou Weidong 63d3b0f7ca merge: 增加 Android Studio 调试 HTTP 日志 2026-10-22 12:16:02 +08:00
Zhou Weidong 7a588898fa feat(debug): 增加 HTTP 请求响应日志 2026-10-22 12:15:51 +08:00
Zhou Weidong dc4b60270d merge: 合并进度条雪碧图预览 2026-10-22 11:40:30 +08:00
Zhou Weidong 12b733d111 feat(player): 进度条拖动预览改用雪碧图 2026-10-22 11:40:23 +08:00
Zhou Weidong 89a94f85c2 merge: 合并进度条画面预览 2026-10-22 11:03:25 +08:00
Zhou Weidong 51c56d884b feat(player): 增加进度条拖动画面预览 2026-10-22 11:03:17 +08:00
Zhou Weidong d06dce9753 merge: 合并播放入口无缓冲提示 2026-10-22 10:47:28 +08:00
Zhou Weidong 7ad4681049 fix(player): 收敛播放入口缓冲提示 2026-10-22 10:47:16 +08:00
Zhou Weidong f302b396b7 merge: 合并 Seek 播放位置修复 2026-10-22 10:41:12 +08:00
Zhou Weidong 5268adc999 fix(player): 记住拖动后的实际播放位置 2026-10-22 10:41:01 +08:00
Zhou Weidong 47ca21406a merge: 合并播放位置连续性修复 2026-10-22 10:28:07 +08:00
Zhou Weidong 759a02375a fix(player): 修复详情往返播放位置丢失 2026-10-22 10:27:58 +08:00
Zhou Weidong fe49c6d8b2 merge: 合并屏幕常亮与自动续播 2026-10-22 10:17:53 +08:00
Zhou Weidong a25c500371 feat(player): 保持屏幕常亮并自动恢复播放 2026-10-22 10:17:43 +08:00
Zhou Weidong 5bf10221a8 merge: 合并播放器加载与锁定进度条优化 2026-10-22 10:09:44 +08:00
Zhou Weidong 623c184243 fix(player): 优化加载指示器与锁定进度条 2026-10-22 10:09:33 +08:00
Zhou Weidong 7b068e8eb4 merge: 合并登录异常居中吐司 2026-10-22 09:58:08 +08:00
Zhou Weidong d076c57283 fix(auth): 登录异常改为居中吐司 2026-10-22 09:57:57 +08:00
Zhou Weidong 11e37a92b5 merge: 合并登录异常居中弹窗 2026-10-22 09:50:15 +08:00
Zhou Weidong b0002abf82 feat(auth): 登录异常改为居中弹窗 2026-10-22 09:50:00 +08:00
Vicky a1c0af3f00 merge: 将 release/v1.0.0 同步到 main 2026-10-21 20:07:35 +08:00
Vicky b5bfc1d7b2 merge: 将 develop 同步到 release/v1.0.0 2026-10-21 20:07:30 +08:00
Zhou Weidong 9b78e43702 merge: 合并 K98.3 视频控制器释放修复 2026-10-21 20:00:34 +08:00
Zhou Weidong f3ec12e3c3 fix(player): 修复切换频道时复用已释放的视频控制器 2026-10-21 20:00:15 +08:00
Zhou Weidong 45a53180d9 merge: 合并 K98.2 feed 更新构建期修复 2026-10-21 19:45:54 +08:00
Zhou Weidong d227ac69fb fix(home): 修复 feed 更新触发的 build 期 setState 2026-10-21 19:45:38 +08:00
Zhou Weidong 862b7e92dd merge: 合并 K98.1 列表直达播放页 2026-10-21 19:33:36 +08:00
Zhou Weidong 8b53adc2be feat(library): 收藏与观看记录直接进入播放页 2026-10-21 19:33:22 +08:00
Zhou Weidong ecdff54f2e merge: 合并 K98 登录键盘避让 2026-10-21 19:27:21 +08:00
Zhou Weidong 6c2f77926b fix(auth): 登录注册与手机号登录键盘避让 2026-10-21 19:26:46 +08:00
Vicky 1d2ba93e48 merge: 同步 release 剔除 x86_64 到 main 2026-10-21 19:13:33 +08:00
Vicky c08df9af40 merge: 同步 release 剔除 x86_64 到 release/v1.0.0 2026-10-21 19:13:28 +08:00
Zhou Weidong 86588d7faa build(android): release 包显式剔除 x86_64 原生库 2026-10-21 19:13:27 +08:00
Vicky be166f5856 merge: 同步生产包 ABI 约束到 main 2026-10-21 19:10:11 +08:00
Vicky e531c78997 merge: 同步生产包 ABI 约束到 release/v1.0.0 2026-10-21 19:10:10 +08:00
Zhou Weidong 91a622733c build(android): 生产包 abiFilters 仅保留 arm64-v8a 与 armeabi-v7a 2026-10-21 19:10:05 +08:00
Vicky 8ed6cd9fd6 merge: 同步生产打包单包说明到 main 2026-10-21 19:07:03 +08:00
Vicky c1d55b91a2 merge: 同步生产打包单包说明到 release/v1.0.0 2026-10-21 19:06:57 +08:00
Zhou Weidong 08980ff09b docs(readme): 生产包合并 arm64-v8a 与 armeabi-v7a 单包输出 2026-10-21 19:06:56 +08:00
Vicky 3dcfa02372 merge: 同步 README 生产打包说明到 main 2026-10-21 19:03:06 +08:00
Vicky b6826f7483 merge: 同步 README 生产打包说明到 release/v1.0.0 2026-10-21 19:03:01 +08:00
Zhou Weidong 30a6bcccfc docs(readme): 记录 Android 生产打包只保留 arm64-v8a 与 armeabi-v7a 2026-10-21 19:02:57 +08:00
Vicky cd3d84cf9a merge: 将 release/v1.0.0 同步到 main 2026-10-21 18:58:26 +08:00
Vicky 010ce326ea merge: 将 develop 同步到 release/v1.0.0 2026-10-21 18:58:12 +08:00
Zhou Weidong a42d3a26ac merge: 合并 K97.1 剧场搜索框圆角调整 2026-10-21 18:55:00 +08:00
Zhou Weidong c278503d0e fix(theater): 减小剧场搜索框圆角 2026-10-21 18:54:49 +08:00
Zhou Weidong 76f347cc35 merge: 合并 K97 iOS 评论键盘避让 2026-10-21 18:47:04 +08:00
Zhou Weidong 349ea12404 fix(comment): 修复 iOS 评论输入弹窗键盘避让 2026-10-21 18:46:43 +08:00
Zhou Weidong dfc33027d8 merge: 合并 K96 剧场分类分页 2026-10-21 18:24:28 +08:00
Zhou Weidong 13a14f3e85 feat(theater): 剧场分类列表服务端分页 2026-10-21 18:24:14 +08:00
Zhou Weidong f8769f2e1d Revert "merge: 合并 K95.2 首切频道封面过渡"
This reverts commit a2a491d056, reversing
changes made to 838ca2ce2a.
2026-10-21 18:07:25 +08:00
Zhou Weidong a2a491d056 merge: 合并 K95.2 首切频道封面过渡 2026-10-21 18:06:17 +08:00
Zhou Weidong c9adf8b399 fix(home): 频道切换用封面过渡 2026-10-21 18:06:09 +08:00
Zhou Weidong 838ca2ce2a merge: 合并 K95.1 首次切换频道无感 2026-10-21 17:54:10 +08:00
Zhou Weidong 677e49fa6e fix(home): 首切频道预取并保留播放画面 2026-10-21 17:53:58 +08:00
Zhou Weidong dfa79bed88 merge: 合并 K95 频道切换加载修复 2026-10-21 17:46:49 +08:00
Zhou Weidong 3c4d65a9b2 fix(home): 修复频道快速切换卡 loading 2026-10-21 17:46:42 +08:00
Zhou Weidong c8392b2f2a merge: 合并 K94 首页类型 Feed 2026-10-21 17:34:02 +08:00
Zhou Weidong ae61d9ec4c feat(home): 首页按内容类型分频道 Feed 并支持分页 2026-10-21 17:33:52 +08:00
Zhou Weidong 4d0be47ee3 merge: 合并 K90 点赞与评论按集 2026-10-21 16:15:44 +08:00
Zhou Weidong 83822a0cbb feat(player): 点赞与评论改为按集 2026-10-21 16:15:29 +08:00
Zhou Weidong e6f34977f5 merge: 合并 K87.1 下拉刷新冻结位移修复 2026-10-21 15:23:51 +08:00
Zhou Weidong 35860e056b fix(ui): 下拉刷新刷新期间冻结下拉位移 2026-10-21 15:23:46 +08:00
Zhou Weidong 01bef7f758 merge: 合并 K87 下拉刷新完成后回弹 2026-10-21 15:12:46 +08:00
Zhou Weidong 45e8707f1e feat(ui): 下拉刷新完成后才回弹 2026-10-21 15:12:41 +08:00
Zhou Weidong d39d8d43c1 merge: 合并 K86.22 评论输入面板安全区修复 2026-10-21 15:00:26 +08:00
Zhou Weidong 3fbfa9ab81 fix(player): 去除评论输入面板安全区导致的收键盘位移 2026-10-21 15:00:20 +08:00
Zhou Weidong 036bd29855 merge: 合并 K86.21 评论输入浮层稳定定位修复 2026-10-21 14:54:12 +08:00
Zhou Weidong d2f2459a96 fix(player): 固定评论输入浮层位置避免收键盘跳动 2026-10-21 14:54:05 +08:00
Zhou Weidong 2577797772 merge: 将 C3 埋点同步到 main 2026-10-21 09:09:34 +08:00
Zhou Weidong 05e2952815 merge: 将 C3 埋点同步到 release/v1.0.0 2026-10-21 09:07:42 +08:00
Zhou Weidong 6ae9e8e043 merge: 将 release/v1.0.0 同步到 main 2026-10-20 22:26:48 +08:00
Zhou Weidong 182b7e3ac2 merge: 将 develop 同步到 release/v1.0.0 2026-10-20 22:23:32 +08:00
Zhou Weidong 8bb365db2a merge: 将 release/v1.0.0 合入 main 2026-10-20 20:38:58 +08:00
Zhou Weidong 0cb57d084d merge: 将 develop 合入 release/v1.0.0
# Conflicts:
#	lib/core/config/app_config.dart
#	lib/main.dart
#	test/config/server_endpoint_test.dart
2026-10-20 20:36:43 +08:00
jianghaifeng 5c174b007c Merge pull request 'Release/v1.0.0' (#1) from release/v1.0.0 into main
Reviewed-on: #1
2026-10-19 13:42:16 +00:00
Zhou Weidong 35364c949a chore(release): 合并 develop 到 release/v1.0.0 2026-10-19 21:39:21 +08:00
Zhou Weidong 814b2c0b7b chore(config): 默认服务地址改为公网 39.162.50.99:8081 2026-10-19 21:12:16 +08:00
Zhou Weidong 3d0e0804ca fix(app): 修复视频播放明文HTTP与双网络访问切换 2026-10-19 18:37:38 +08:00
488 changed files with 50008 additions and 7163 deletions

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
# Author: fruit.com.cn
# ReelShort 客户端 CI:静态分析 + 测试门禁
name: flutter-ci
on:
push:
branches: [develop, main]
pull_request:
branches: [develop, main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
channel: stable
cache: true
- name: 依赖
run: flutter pub get
- name: 静态分析(零告警门禁)
run: dart analyze --fatal-infos
- name: 单元测试(全绿门禁)
run: flutter test -r expanded
+3
View File
@@ -65,3 +65,6 @@ app.*.map.json
.env
.env.*
!.env.example
# IDE 的 Flutter Widget Preview 生成目录。
.widget_preview/
+101 -44
View File
@@ -1,18 +1,22 @@
# FruitShorts(ReelShort)客户端
FruitShorts 是面向印尼市场的短剧 Flutter 客户端。当前版本已连接
`FruitServer` 的 `/app/**` 接口,支持沉浸播放、找剧、账号、观看记录、收藏、
下载授权、评论互动、站内通知和中英印尼三语界面。
FruitShorts 是面向印尼市场的短剧 Flutter 客户端,已连接 `FruitServer` 的
`/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 状态管理与依赖注入
- dio 网络请求
- video_player 播放
- shared_preferences 与 `SharedPreferencesAsync` 本地配置
- path_provider + dart:io 下载落盘(先写 `.part`,完成后原子改名)
- flutter_localizations,支持 `en`、`zh`、`id`
- Android 系统画中画
- 画中画:Android 用 `floating` + `permission_handler`(需悬浮窗权限);
iOS 用原生 `AVPictureInPictureController` + 影子播放器(`ios/Runner/AppDelegate.swift`)
## 目录结构
@@ -22,7 +26,7 @@ lib/
config/ 地址、语言和运行时配置
data/ models、remote API、repository、controller
formatting/ 文案与日期格式化
platform/ 触觉等平台能力
platform/ 平台能力:画中画通道、设备号、触觉
theme/ 颜色与主题
widgets/ 跨页面复用组件
l10n/ 三语 ARB 与生成代码
@@ -36,20 +40,21 @@ assets/ 图标、图片和本地媒体文件
## 已接接口
以下接口均在 `lib/core/data/remote/` 中有实际调用,共 26 个不同路径、
31 个 HTTP 方法级调用。
以下接口均在 `lib/core/data/remote/` 中有实际调用,共 47 个不同路径。
| 分组 | 契约 | 接口 |
| --- | --- | --- |
| 账号 | §1.1 | `POST /app/auth/register`、`/login`、`/refresh`、`/logout` |
| 内容 | §1.3、§1.4 | `GET /app/home/feed`、`/app/discover/feed`、`/app/categories`、`/app/dramas/{id}`、`/app/episodes/{id}/play` |
| 观看记录 | §1.5 | `POST/GET /app/watch/progress`、`GET /app/watch/recent`、`DELETE /app/watch/progress/{dramaId}`、`DELETE /app/watch/progress` |
| 收藏 | §1.5 | `POST /app/favorites/toggle`、`GET /app/favorites`、`DELETE /app/favorites/{dramaId}` |
| 下载授权 | §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` |
| 互动 | §1.7 | `GET/POST /app/comments`、`DELETE /app/comments/{id}`、`POST /app/likes/toggle`、`POST /app/reports` |
| 公告 | §1.9.2 | `GET /app/notices` |
| 分组 | 接口 |
| --- | --- |
| 账号 | `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` |
| 内容 | `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}` |
| 播放 | `GET /app/episodes/{id}/play`、`/app/episodes/{id}/stats`;`POST /app/episodes/{id}/unlock` |
| 观看记录 | `POST/GET /app/watch/progress`、`GET /app/watch/recent`、`DELETE /app/watch/progress/{dramaId}`、`DELETE /app/watch/progress` |
| 收藏 | `POST /app/favorites/toggle`、`GET /app/favorites`、`DELETE /app/favorites/{dramaId}`、`POST /app/favorites/remove` |
| 下载 | `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` |
| 互动 | `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
adb devices
adb reverse tcp:8081 tcp:8081
flutter run -d <device> \
--dart-define=API_BASE_URL=http://127.0.0.1:8081
flutter run -d <device>
```
`adb reverse` 将手机的 `127.0.0.1:8081` 转发到电脑;USB 断开或重新连接后需要
重新执行。
重新执行。此时用调试条把地址改成 `http://127.0.0.1:8081`(见下节)。
Android 模拟器访问宿主机时使用:
```bash
flutter run --dart-define=API_BASE_URL=http://10.0.2.2:8081
```
Android 模拟器访问宿主机时,同样在调试条里填 `http://10.0.2.2:8081`。
### iOS
手机与 Mac 需在同一网络,首次启动允许“本地网络”权限:
iOS 模拟器直接访问 Mac 本机服务,默认地址为 `http://127.0.0.1:8081`:
```bash
flutter run -d <udid> \
--dart-define=API_BASE_URL=http://<Mac局域网IP>:8081
flutter run -d <simulator-udid>
```
### 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。
release 版本不显示入口,也不会读取本地覆盖。
真机或 Android 模拟器联调时,通过调试入口覆盖地址,不修改默认开发配置。打生产包前把
`useDevConfig` 改成 `false`;release 构建若仍为 `true` 会在启动时直接报错,
避免线上包连到开发机。
debug 包在找剧页加载失败时会显示调试条,点「修改服务器地址」可临时改成当前
电脑 IP,保存后立即重载、不需要重新打包,该覆盖保存在本地偏好里(仅 debug
生效,release 不读取)。
## 画中画
| 平台 | 行为 |
| --- | --- |
| Android | 首次点播放页小窗按钮时申请悬浮窗权限;授权后退出 App 自动进入系统小窗。沉浸流与独立播放页共用一份引用计数,任一处活跃都算「在播」,避免互相顶掉 |
| iOS | 原生影子播放器承载 `AVPictureInPictureController`,只有播放页处于活跃状态时退到后台才自动进小窗;停在剧单等页面退后台不会触发 |
- 小窗内切集直接换源,不走页面翻页,可连续自动播放
- iOS 从系统小窗还原时用原生过渡层盖住纹理黑帧,等首帧就绪后再淡出
- 播放页「更多」面板里有「退出自动小窗」开关(仅支持自动小窗的平台显示),关掉后
退到后台不再进入小窗
## 环境注意
@@ -131,25 +157,56 @@ xcodebuild -resolvePackageDependencies \
```bash
adb uninstall com.stormg.lumadrama
flutter run -d <device> \
--dart-define=API_BASE_URL=http://127.0.0.1:8081
flutter run -d <device>
```
## 生产打包(Android)
两个渠道出两种产物,源码同一套:
| 渠道 | 产物 | 命令 | 体积 |
| --- | --- | --- | --- |
| Google Play | AAB(按设备下发) | `flutter build appbundle --release` | 上传 28MB,用户下载约 8MB |
| 国内各大市场 | 通用 APK(arm64 + v7) | `flutter build apk --release` | 约 13MB |
说明:
- `android/app/build.gradle.kts` 的 `release` 已配置 `abiFilters`,只保留 `arm64-v8a` 与 `armeabi-v7a`,插件也不会再带入 `x86_64` 原生库。
- `android/gradle.properties` 里固定了 `split-debug-info=build/symbols` 与 `dart-obfuscation=true`,**IDE 里点 Build APK / Build App Bundle 也会生效**:`libapp.so` 从 ~8MB/ABI 降到 ~1.1MB/ABI。
- `useLegacyPackaging = true`:原生库以压缩形式存储,这是国内 APK 从 40MB 降到 13MB 的主要原因;代价是安装时解压。
- **每次发版都要归档 `build/symbols`**,线上崩溃栈需要它还原。
- release 仍在用 debug 签名(`build.gradle.kts` 的 TODO),上架前必须换成正式 keystore。
- 不要用 `--split-per-abi`:与 `abiFilters` 冲突;Play 的 AAB 已经按设备拆好。
## 生产打包(iOS)
```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
dart analyze lib test
dart analyze
flutter test
flutter build apk --debug
```
测试文件按模块放在 `test/` 下,用例名使用
当前基线:`dart analyze` 仅 1 条既存 info(`intl` 未在 `pubspec.yaml` 声明);
`flutter test` 全量 775 例通过。测试文件按模块放在 `test/` 下,用例名使用
`should_<期望>_when_<条件>`。真实后端联调测试在服务未启动时会跳过。
## 当前边界
- 下载仅管理服务端授权,不落盘、不续传、不支持离线播放。
- 没有独立搜索接口,搜索能力尚未形成完整闭环。
- 下载会落盘并支持离线播放,但不支持断点续传:中途失败会丢弃 `.part` 重下。
- 支付与金币:客户端已有钱包、商品、下单接口与页面,商店票据校验依赖服务端配置凭据。
- 站内通知没有已读/未读和红点状态。
- 支付、金币和会员权益尚未形成客户端闭环。
- Android 支持系统画中画;iOS 画中画尚未接入。
- 播放清晰度切换会保留旧画面 500ms 掩盖闪屏,切换瞬间的进度以相对位置续播。
+1
View File
@@ -1,4 +1,5 @@
/.gradle
/build/
/captures/
/local.properties
GeneratedPluginRegistrant.java
+24 -1
View File
@@ -8,7 +8,9 @@ plugins {
android {
namespace = "com.stormg.lumadrama"
compileSdk = flutter.compileSdkVersion
// permission_handler_android 14 的 AAR 元数据要求依赖方 compileSdk >= 37,
// 固定为 37(比 Flutter 默认的 36 高一档,只影响编译期 API 可见性)。
compileSdk = 37
ndkVersion = flutter.ndkVersion
compileOptions {
@@ -29,6 +31,17 @@ android {
buildTypes {
release {
// 生产包只保留 arm64-v8a 与 armeabi-v7a,避免插件带入 x86_64 原生库
ndk {
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
}
// R8 裁剪未使用的 Java/Kotlin 代码与资源,减小 dex 与 res。
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
@@ -42,6 +55,16 @@ kotlin {
}
}
// 生产包不允许出现 x86_64:部分插件 AAR 会带入少量 x86_64 原生库,这里显式剔除。
androidComponents {
onVariants(selector().withBuildType("release")) { variant ->
variant.packaging.jniLibs.excludes.add("lib/x86_64/**")
// 原生库随 APK 压缩存储:下载体积明显小于解压后的体积,代价是安装时解压。
// 本包 90% 体积是 so,这一项对国内商店的 APK 分发影响最大。
variant.packaging.jniLibs.useLegacyPackaging = true
}
}
flutter {
source = "../.."
}
+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.**
+13 -9
View File
@@ -1,9 +1,12 @@
<!-- Author: fruit.com.cn -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission
android:name="android.permission.INTERNET"/>
<!-- 画中画自动触发依赖的「显示在其他应用上层」权限(部分 ROM 用它管控 PiP) -->
<uses-permission
android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application
android:label="FruitShorts"
android:label="ymb"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
@@ -23,9 +26,8 @@
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
@@ -35,7 +37,7 @@
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
android:value="2"/>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
@@ -44,8 +46,10 @@
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
<action
android:name="android.intent.action.PROCESS_TEXT"/>
<data
android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -4,23 +4,49 @@ package com.stormg.lumadrama
import android.content.Intent
import android.app.PictureInPictureParams
import android.content.Context
import android.content.res.Configuration
import android.graphics.Bitmap
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.telephony.TelephonyManager
import android.util.Rational
import android.content.pm.PackageManager
import android.view.WindowManager
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import java.io.ByteArrayOutputStream
import java.util.Locale
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
class MainActivity : FlutterActivity() {
private val shareRequestCode = 9041
private val thumbnailExecutor: ExecutorService = Executors.newSingleThreadExecutor()
private val thumbnailHandler = Handler(Looper.getMainLooper())
private var pendingShare: MethodChannel.Result? = null
private var pipChannel: MethodChannel? = null
private var keyboardChannel: MethodChannel? = null
private var thumbnailChannel: MethodChannel? = null
private var imeInset = 0
override fun onCreate(savedInstanceState: Bundle?) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
splashScreen.setOnExitAnimationListener { screen -> screen.remove() }
}
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
pipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.stormg.lumadrama/pip")
@@ -43,6 +69,23 @@ class MainActivity : FlutterActivity() {
result.success(false)
}
}
// 设备标识:SSAID 免权限,卸载重装不变,避免客户端自己生成的 id 每次重装都变
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.stormg.lumadrama/device")
.setMethodCallHandler { call, result ->
when (call.method) {
"ssaid" -> {
val value = try {
Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID)
} catch (_: Exception) {
null
}
result.success(value)
}
// 设备区域:注册时作为服务端 IP 解析失败后的兜底
"region" -> result.success(currentRegion())
else -> result.notImplemented()
}
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.stormg.lumadrama/share")
.setMethodCallHandler { call, result ->
if (call.method != "shareText") {
@@ -79,6 +122,47 @@ class MainActivity : FlutterActivity() {
emitImeInset(inset)
result.success(inset)
}
thumbnailChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"com.stormg.lumadrama/thumbnail"
)
thumbnailChannel?.setMethodCallHandler { call, result ->
if (call.method != "frame") {
result.notImplemented()
return@setMethodCallHandler
}
val timeMs = (call.argument<Number>("timeMs"))?.toLong()
if (timeMs == null) {
result.success(null)
return@setMethodCallHandler
}
val localPath = call.argument<String>("localPath").orEmpty()
val remoteUrl = call.argument<String>("url").orEmpty()
val rawHeaders = call.argument<Map<*, *>>("headers")
val headers = rawHeaders?.entries
?.associate { it.key.toString() to it.value.toString() }
.orEmpty()
val responded = AtomicBoolean(false)
val task = thumbnailExecutor.submit {
val bytes = renderThumbnail(
timeMs = timeMs,
localPath = localPath,
remoteUrl = remoteUrl,
headers = headers
)
thumbnailHandler.post {
if (responded.compareAndSet(false, true)) {
result.success(bytes)
}
}
}
thumbnailHandler.postDelayed({
if (responded.compareAndSet(false, true)) {
task.cancel(true)
result.success(null)
}
}, thumbnailTimeoutMs)
}
val decorView = window.decorView
ViewCompat.setOnApplyWindowInsetsListener(decorView) { view, insets ->
emitImeInset(insets.getInsets(WindowInsetsCompat.Type.ime()).bottom)
@@ -93,12 +177,87 @@ class MainActivity : FlutterActivity() {
?.bottom
?: 0
/** 网络运营商国家优先,其次 SIM 卡,最后系统语言;都拿不到返回空串。 */
private fun currentRegion(): String {
return try {
val telephony = getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager
val localeCountry = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
resources.configuration.locales.get(0).country
} else {
@Suppress("DEPRECATION")
resources.configuration.locale.country
}
val candidates = listOfNotNull(
telephony?.networkCountryIso,
telephony?.simCountryIso,
localeCountry
)
candidates.firstOrNull { it.isNotBlank() }?.uppercase(Locale.ROOT) ?: ""
} catch (_: Exception) {
// 个别 ROM 读运营商信息会抛 SecurityException,别让注册流程卡住
""
}
}
private fun emitImeInset(value: Int) {
if (value == imeInset) return
imeInset = value
keyboardChannel?.invokeMethod("insetsChanged", value)
}
private fun renderThumbnail(
timeMs: Long,
localPath: String,
remoteUrl: String,
headers: Map<String, String>
): ByteArray? {
val retriever = MediaMetadataRetriever()
var frame: Bitmap? = null
var scaled: Bitmap? = null
return try {
when {
localPath.isNotEmpty() -> retriever.setDataSource(normalizeLocalPath(localPath))
remoteUrl.isNotEmpty() -> retriever.setDataSource(remoteUrl, headers)
else -> return null
}
frame = retriever.getFrameAtTime(
timeMs * 1000,
MediaMetadataRetriever.OPTION_CLOSEST_SYNC
)
val source = frame ?: return null
scaled = if (source.width > thumbnailMaxWidth) {
val targetHeight = (source.height * thumbnailMaxWidth / source.width)
.coerceAtLeast(1)
Bitmap.createScaledBitmap(
source,
thumbnailMaxWidth,
targetHeight,
true
)
} else {
source
}
val outputBitmap = scaled ?: source
val output = ByteArrayOutputStream()
val compressed = outputBitmap.compress(
Bitmap.CompressFormat.JPEG,
thumbnailJpegQuality,
output
)
if (!compressed) return null
output.toByteArray()
} catch (_: Exception) {
null
} finally {
if (scaled != null && scaled !== frame) scaled.recycle()
frame?.recycle()
retriever.release()
}
}
private fun normalizeLocalPath(path: String): String =
if (path.startsWith("file:")) Uri.parse(path).path ?: path else path
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
pipChannel?.invokeMethod("onPipChanged", isInPictureInPictureMode)
@@ -117,6 +276,17 @@ class MainActivity : FlutterActivity() {
ViewCompat.setOnApplyWindowInsetsListener(window.decorView, null)
keyboardChannel?.setMethodCallHandler(null)
keyboardChannel = null
thumbnailChannel?.setMethodCallHandler(null)
thumbnailChannel = null
thumbnailHandler.removeCallbacksAndMessages(null)
thumbnailExecutor.shutdownNow()
super.onDestroy()
}
companion object {
private const val TAG = "FruitPip"
private const val thumbnailMaxWidth = 240
private const val thumbnailJpegQuality = 70
private const val thumbnailTimeoutMs = 1500L
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

@@ -1,4 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Author: fruit.com.cn -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<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>
<bitmap
android:gravity="center"
android:src="@drawable/splash_screen" />
</item>
</layer-list>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

@@ -1,4 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Author: fruit.com.cn -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<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>
<bitmap
android:gravity="center"
android:src="@drawable/splash_screen" />
</item>
</layer-list>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

@@ -1,9 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Author: fruit.com.cn -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splash_background" />
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
<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>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
<bitmap
android:gravity="center"
android:src="@drawable/splash_screen" />
</item>
</layer-list>
@@ -1,6 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Author: fruit.com.cn -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<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>
<bitmap
android:gravity="center"
android:src="@drawable/splash_screen" />
</item>
</layer-list>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 69 B

@@ -1,9 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Author: fruit.com.cn -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splash_background" />
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
<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>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
<bitmap
android:gravity="center"
android:src="@drawable/splash_screen" />
</item>
</layer-list>
@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.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: 2.4 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: 6.1 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: 10 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: 15 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,11 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Author: fruit.com.cn -->
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#000000</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
<item name="android:windowSplashScreenAnimationDuration">0</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
@@ -1,11 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Author: fruit.com.cn -->
<resources>
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#000000</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
<item name="android:windowSplashScreenAnimationDuration">0</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@color/splash_background</item>
+2 -1
View File
@@ -2,5 +2,6 @@
<!-- Author: fruit.com.cn -->
<resources>
<color name="splash_background">#1F1B16</color>
<color name="splash_background">#000000</color>
<color name="ic_launcher_background">#E9142E</color>
</resources>
+5
View File
@@ -6,3 +6,8 @@ android.useAndroidX=true
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false
# release 包默认剥离 Dart 调试符号并混淆:libapp.so 由 ~8MB 降到 ~1MB/ABI。
# 符号表输出到 build/symbols,崩溃分析时用它还原堆栈,发布前务必归档。
split-debug-info=build/symbols
dart-obfuscation=true
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 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

@@ -0,0 +1,28 @@
# ReelShort 客户端深色视觉改造
## 目标
- 深色设为默认且唯一主题,页面、卡片、浮层、播放器和底部导航保持一致。
- 色板收敛到 `AppColors`,主题组件配置收敛到 `AppTheme`。
- 保持现有布局、信息结构、路由、业务逻辑和文案不变。
## 结果
- 背景 `#0B1020`、卡片 `#151B2E`、浮层 `#1C2436`、分割线白色 6%。
- 主文字白色 92%、次文字 64%、禁用 38%。
- 主色 `#FF6B2C`,辅助链接色 `#22D3EE`。
- 卡片 12、按钮/输入框 10、标签 6,阴影关闭,组件使用分割线分层。
- 页面级透明、遮罩、阴影、媒体底、玻璃面和渐变全部映射到 AppColors 语义 token。
- 系统栏统一浅色图标,AppBar 与底部导航使用深色表面。
## 验证
- 主题 token、对比度、系统栏、圆角与关键页 smoke 测试。
- `dart analyze lib test`
- `flutter test --no-pub`
- `flutter build apk --debug`
## 约束
- 不提交 `pubspec.lock` 漂移。
- 不启停服务、不操作真机。
@@ -0,0 +1,24 @@
# Android Studio 调试 HTTP 日志
## 目标
- 仅在调试构建中输出 Dio 请求、响应与异常日志。
- Android Studio Run 控制台通过 `debugPrint` 展示,logcat 标签保持 `flutter`。
- Authorization 脱敏,X-Device-Id 保留,二进制响应只显示字节数。
- 单条请求体或响应体最多 3000 字符。
## 实现步骤
1. 新增 `NetworkLogInterceptor`,支持注入日志回调并保留请求耗时。
2. 在 Envelope 解包前暂存原始 bytes;日志拦截器追加到拦截链末尾后读取暂存值。
3. 在 `ApiClient` 注册调试拦截器,`kDebugMode` 与常量开关同时满足时才输出。
4. 以 fake adapter 补单测,覆盖行格式、脱敏、UTF-8、截断、非 JSON 与二进制。
5. 运行 `dart analyze lib test` 和 `flutter test`,更新 PROGRESS 留痕。
## 风险与约束
- `ResponseType.bytes` 不能直接 `toString`,响应体统一 `utf8.decode`。
- Dio 响应拦截器按 FIFO 执行;链尾日志通过 Envelope 暂存的原始 bytes 读取,
避免拿到已解包后的 `data`。
- release/profile 通过 `kDebugMode` 短路,不输出任何 HTTP 日志。
- 不新增依赖,不提交现有 `pubspec.lock` 改动,不启停服务、不操作真机。
@@ -0,0 +1,20 @@
# 收藏卡片简介
## 目标
- 收藏卡片在标题/标签下方显示后端 `synopsis`,最多 2 行并省略溢出。
- 空白简介不创建文本节点、不占高度,不显示 `null/undefined`。
- 保持封面 80x106 和现有卡片高度,不引入布局溢出。
## 实现
1. `_FavoriteCard` 对 synopsis 做 trim 后条件渲染。
2. 简介使用 `Flexible` 占据标题、标签与底部统计之间的剩余空间,
`maxLines: 2`、`TextOverflow.ellipsis`、次要文案色和字号。
3. 无简介时继续使用原 Spacer,保持原布局。
4. widget 测试覆盖有简介、空简介和卡片高度稳定性。
## 约束
- 不改接口、后端、收藏分页。
- 不新增依赖,不提交现有 `pubspec.lock`,不启停服务、不操作真机。
@@ -0,0 +1,31 @@
# 屏幕常亮与前台自动续播
## 目标
- Android 与 iOS 前台保持屏幕常亮,不引入插件或依赖。
- 用户主动暂停后切后台再回前台,在播放器与票据状态安全时自动续播。
- 锁定、blocked、票据失败、错误态及浮层遮挡场景不得强制起播。
## 实施步骤
1. Android `MainActivity.onCreate` 添加 `FLAG_KEEP_SCREEN_ON`,保留现有渠道逻辑。
2. iOS `AppDelegate` 设置 `UIApplication.shared.isIdleTimerDisabled = true`。
3. 为 `PlaybackSession` 增加无浮层、路线可见且 active 播放器可用的恢复判断。
4. 调整 `PlayerLogic.onLifecycle`,在现有 PiP 复位和过期票据刷新流程后安全设置播放意图。
5. 补用户暂停恢复、锁定/票据失败不恢复、后台保存并暂停测试。
6. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
## 风险 / 待确认
- 真机常亮和生命周期行为由 PM 验收,本次不启停服务、不操作设备。
- PiP 继续沿用现有可见性策略,不额外修改后台播放行为。
## 执行结果
- Android 主 Activity 已添加 `FLAG_KEEP_SCREEN_ON`,未改动 Flutter 渠道与 PiP 逻辑。
- iOS AppDelegate 已设置 `UIApplication.shared.isIdleTimerDisabled = true`。
- 用户主动暂停后回前台可安全恢复;过期票据会等待重取完成后再恢复。
- locked、blocked、票据错误、会话错误、解锁/浮层遮挡和 PiP 场景均不会强制续播。
- 后台仍先保存进度并暂停。
- `dart analyze lib test` 零告警,`flutter test --no-pub` 580 项全过。
- 未启停服务,未操作真机。
@@ -0,0 +1,31 @@
# 收藏与观看历史分页 UI
## 目标
- `/app/favorites` 与 `/app/watch/recent` 按 `PageResult{total,list}` 分页接入。
- 收藏页、观看历史页支持首屏、触底加载、无更多、刷新、失败重试和总数展示。
- 观看记录直接使用后端 `dramaTitle/dramaCover/episodeNumber`,缺失时安全兜底。
## 实现步骤
1. 新增通用 `PageResult<T>`,更新 LibraryApi、WatchApi、Repository 与内存替身。
2. FavoriteController、WatchRecordsController 增加分页状态、请求代次、dramaId 去重。
3. 页面通过滚动通知触发 `loadMore`,复用下拉刷新并增加通用分页 footer。
4. 观看卡片优先渲染接口字段,标题为空时用本地化“第 N 集”,封面为空显示占位。
5. 补 API、Controller 和 widget 测试,运行 analyze 与全量 flutter test。
## 风险与约束
- 刷新期间保持旧列表,成功后原子替换并回到第 1 页;失败保留旧数据。
- loadMore 与 refresh 通过请求代次隔离,旧响应不得覆盖新列表。
- 空页立即停止加载;`loaded >= total` 后不再请求。
- 不新增依赖、不提交现有 `pubspec.lock`,不启停服务、不操作真机。
## 2026-10-22 范围修订
- 分页协议和 Controller 分页状态保持不改。
- 观看历史优先使用后端 `dramaTitle/dramaCover/episodeNumber`,只有标题为空时才预取详情。
- 标题为空且详情也未返回标题时,用本地化“第 N 集”兜底。
- 有标题的条目点击播放时再按需加载详情,不在列表加载阶段产生 N+1。
- 使用当前真实后端校验第 1/2 页请求参数、total 与 list;若运行实例仍忽略分页,
只在 PROGRESS 记录证据,不做前端全量兜底。
@@ -0,0 +1,28 @@
# 登录异常居中弹窗
## 目标
- 邮箱登录、邮箱注册及手机号 OTP 登录的提交异常改为屏幕居中弹窗。
- 本地表单校验继续使用现有内联字段错误,不触发弹窗。
## 实施步骤
1. 新增 `lib/core/widgets/centered_error_dialog.dart`,统一弹窗样式、遮罩关闭和确认按钮。
2. 补充登录失败、注册失败、确认按钮的中英文印尼语文案并重新生成 l10n。
3. 调整邮箱认证页:接口异常展示居中弹窗,提交 busy 在展示弹窗前复位。
4. 调整手机号弹窗:OTP 发送和验证码登录/注册异常展示居中弹窗,成功 SnackBar 保持不变。
5. 补接口异常、注册标题、本地校验不弹窗和手机号两条异常路径测试。
6. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
## 风险 / 待确认
- 同级 FruitServer 的 `INBOX.md` 当前顶部为已处理 K100,未找到本需求条目;以用户消息中的完整需求为准。
- 现有主工作区处于 `feature/payment-iap` 且有未提交 `pubspec.lock`,本次使用独立 worktree,不触碰该文件。
## 执行结果
- 已新增居中错误弹窗组件及三语言文案,邮箱登录/注册和手机号 OTP/验证码提交异常均改为弹窗。
- 本地字段校验仍走原内联错误;异常展示前提交 busy 已复位。
- 接口异常、注册标题、未知异常网络文案、遮罩关闭、确认关闭、本地校验不弹窗均有测试覆盖。
- `dart analyze lib test` 零告警,`flutter test --no-pub` 570 项全过。
- 未启停服务,未操作真机。
@@ -0,0 +1,29 @@
# 登录异常居中吐司
## 目标
- 将上一版认证异常 AlertDialog 替换为无交互的屏幕居中吐司。
- 吐司淡入展示约 2 秒后自动淡出,不遮罩、不拦截手势。
- 同一时刻只存在一个吐司,新消息替换旧消息。
## 实施步骤
1. 删除 `centered_error_dialog.dart`,新增 `centered_toast.dart`。
2. 认证页和手机号弹窗异常路径改用 `showCenteredToast`,保留 busy 复位和成功 SnackBar。
3. 清理不再使用的认证错误标题与确认按钮 l10n 键,重新生成 AppLocalizations。
4. 更新认证测试为吐司断言,并补单例替换、自动消失测试。
5. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
## 风险 / 待确认
- 吐司使用 root Overlay,认证路由退出时仍会按自身计时器自动清理。
- 测试必须 pump 超过展示和淡出时长,确保不遗留 Timer 或 OverlayEntry。
## 执行结果
- 已删除 AlertDialog 组件,新增 OverlayEntry 居中吐司。
- 邮箱和手机号异常改用吐司;busy 复位、成功 SnackBar 和本地内联校验保持不变。
- 已清理三个不再使用的 l10n 键并重新生成 AppLocalizations。
- 覆盖单例替换、点击穿透、自动消失、接口异常和本地校验不弹吐司。
- `dart analyze lib test` 零告警,`flutter test --no-pub` 573 项全过。
- 未启停服务,未操作真机。
@@ -0,0 +1,21 @@
# 播放入口无缓冲提示
## 目标
- 未初始化进度条显示静态 inactive 轨道,不出现流动加载高光。
- 仅真实 buffering 持续超过 350ms 时显示白色高光。
- 起播定位阶段不叠加 session 居中 loading;只有 ticket loading 居中显示。
- 同集返回复用 controller;重建时 initialize→seek→play。
## 实施步骤
1. 调整 SeekProgressBar 与 _FeedProgressBar 的初始化/缓冲状态判断。
2. 删除 PlayerView 的 session loading 居中圈,仅保留 ticket loading。
3. preparePlayer 统一通过 VideoPlayerPort.seekTo 定位缓存位置。
4. 更新进度条、全屏入口、详情返回和重建顺序测试。
5. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
## 风险 / 待确认
- 真实持续网络卡顿仍保留缓冲高光。
- 锁定集继续沿用静态空轨道和禁用 seek 行为。
@@ -0,0 +1,32 @@
# 播放位置连续性
## 目标
- 首页进入详情、详情返回首页、详情再进播放器时保持同一集同一秒。
- 新增进程内最近播放位置缓存,优先于服务端旧记录。
- 跨剧、跨集不串位;锁定/票据失败不因缓存强行起播;进程重启仍回服务端。
## 实施步骤
1. 新增 `PlaybackPositionCache`,按 episodeId 保存 dramaId/position/duration/updatedAt。
2. App Provider 注入单一缓存实例,PlayerPage 与详情页共享。
3. PlayerLogic 进度监听、saveCurrentProgress 写入缓存;loadResumePoint 优先命中缓存。
4. preparePlayer 对每个待准备集按 dramaId 校验缓存,并继续使用现有位置钳位。
5. 详情页续看状态优先使用该剧最新缓存,未命中再请求服务端。
6. 补缓存、缓存命中不请求服务端、跨剧隔离、服务端回落和详情返回位置测试。
7. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
## 风险 / 待确认
- 缓存仅保存在进程内,App 重启后自动回落到服务端观看记录。
- 片尾附近继续复用现有 `clampResumePosition` 完成保护。
## 执行结果
- 新增进程内按集缓存并通过 Provider 全局共享,详情页与播放器共用。
- 进度监听、`saveCurrentProgress` 和进入详情前会写入缓存。
- `loadResumePoint` 优先缓存,未命中或跨剧时才请求服务端。
- 详情页继续观看与 CTA 优先进缓存集;返回首页保持原播放器与位置。
- 已覆盖缓存读写/过期、跨剧隔离、缓存命中去网络、服务端回落和详情往返。
- `dart analyze lib test` 零告警,`flutter test --no-pub` 589 项全过。
- 未启停服务,未操作真机。
@@ -0,0 +1,21 @@
# Seek 后播放位置保持
## 目标
- 拖动进度条后,缓存与观看记录立即使用 seek 完成后的真实位置。
- 拖动后立即进详情/离开也不会回退到旧进度。
- 越界位置 clamp,锁定/票据失败不写无效缓存。
## 实施步骤
1. 增加播放位置 clamp 纯函数,缓存写入统一限制在 `0..duration`。
2. VideoPlayerPort 暴露 seek 方法,PlayerLogic 等待 seek 完成后再读取 controller。
3. PlayerLogic 统一当前播放快照,监听、缓存和上报共用同一份 position/duration。
4. 增加 pending seek 屏障,saveCurrentProgress 与离开路径等待 seek 和上报完成。
5. 补 seek 缓存、越界 clamp、seek 后进详情并返回测试。
6. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
## 风险 / 待确认
- 片尾与完成逻辑继续沿用现有 controller/播放器行为。
- 缓存仅在进程内,不改变服务端观看记录契约。
@@ -0,0 +1,30 @@
# 播放器 loading 与锁定进度条调整
## 目标
- 播放器票据与 session loading 圈统一为 24×24、线宽 2。
- 缓冲/加载进度高光改为白色。
- 未解锁剧集显示静态 inactive 轨道,不进入 loading,不允许拖动 seek。
## 实施步骤
1. 更新 `LoadingProgressTrack` 默认高光颜色,并补颜色断言。
2. 为 `SeekProgressBar`、`PlayerOverlayData`、`HomeFeedOverlay` 和 `_FeedProgressBar` 增加 locked 状态。
3. `PlayerPage` 使用 `logic.session.isLocked || episode.locked` 传入两个浮层。
4. 缩小 `player-ticket-loading` 与 `player-session-loading` 的尺寸和线宽。
5. 补首页/全屏锁定态、缓冲高光和两个 loading 指示器尺寸测试。
6. 执行 `dart analyze lib test` 与 `flutter test --no-pub`,回填 PROGRESS 后提交合并。
## 风险 / 待确认
- 锁定态只影响进度条 UI 与手势,不改播放、解锁或接口逻辑。
- 首页浮层锁定线沿用当前 `Colors.white24` inactive 色,避免改变非锁定轨道样式。
## 执行结果
- 两个播放器 loading 圈均为 24×24、线宽 2、主色不变。
- loading 高光默认改为白色,普通轨道与首页 white24 轨道颜色未变。
- 首页与全屏播放器均按剧集锁定态渲染静态空轨道并禁用 seek。
- 已覆盖锁定轨道高度、无 loading、无拖动 seek、loading 指示器尺寸和高光颜色。
- `dart analyze lib test` 零告警,`flutter test --no-pub` 576 项全过。
- 未启停服务,未操作真机。
@@ -0,0 +1,62 @@
# 播放器进度拖动画面预览
## 目标
首页 feed 与全屏播放器的进度条在拖动时显示当前时间点的视频画面缩略图、
当前时间与总时长;松手立即隐藏并提交 seek。缩略图未返回或失败时显示短剧
封面,不出现空白闪烁或错误文案。
## 性能红线
1. 拖动期间主线程无卡顿;图片解码和原生取帧不得阻塞 UI 线程。
2. 原生取帧并发数不超过 1。
3. 预览图最大宽度 240px。
4. Dart 内存 LRU 缓存不超过 24 帧。
5. 不新增 pub 依赖。
6. 不改后端契约。
## 实现
### 缩略图服务
- 新增 `PlaybackThumbnailService`,公开入口按 `episodeId + position` 取
`ByteData`,底层通过
`MethodChannel('com.stormg.lumadrama/thumbnail')` 调用原生。
- 时间按 1 秒分桶;同一 bucket 的在途请求合流。
- 最多 24 帧 LRU;成功和失败结果都做过期控制,避免旧拖动位置覆盖当前预览。
- 全局单飞;拖动期间只保留最新 bucket,尚未开始的旧请求直接丢弃。
- 拖动停顿 120ms 后最多预取相邻一个 bucket,松手取消尚未开始的请求。
- 原生异常、缺失实现和超过 1.5 秒无结果统一返回 `null`。
### 原生取帧
- Android 使用 `MediaMetadataRetriever`,优先本地下载路径,其次签名 URL;
`OPTION_CLOSEST_SYNC` 取帧,最大宽 240px,JPEG 质量 70,单线程 Executor,
完成后立即 release。
- iOS 使用 `AVAssetImageGenerator`,应用画面方向,最大尺寸约 240x426,
合理时间容差,后台串行队列生成 JPEG。
- 两端均保证只回调一次;失败或 1.5 秒超时返回 `null`,不向 UI 抛异常。
### UI
- 新增 `ScrubPreviewCard`:宽 120dp、圆角 8、白色 1px 描边,显示
`当前时间 / 总时长`;沿拖动点水平跟随并做安全边距 clamp。
- 首页 `_FeedProgressBar` 与全屏 `SeekProgressBar` 共用该卡片。
- 封面始终铺在卡片底层;`Image.memory` 仅在帧到达后覆盖,并限制
`cacheWidth: 240`。
- 开始拖动请求当前 bucket,拖动更新只保留最新位置;松手立即隐藏卡片并取消
未开始请求。
- `PlayerPage` 持有页面级服务,dispose 时清空缓存,避免跨页面串集。
## 测试
- 单测:bucket、LRU 淘汰、同 bucket 合流、过期请求丢弃、松手取消、
相邻 bucket 防抖、异常/超时回落。
- Widget:拖动显示预览卡片和时间、松手消失、无缩略图显示封面。
- 门禁:`dart analyze lib test` 与 `flutter test --no-pub`。
## 非目标
- 不改服务端接口或持久化策略。
- 不新增第三方依赖。
- 不启停服务、不操作真机;原生取帧实际效果由 PM 真机验收。
@@ -0,0 +1,46 @@
# 雪碧图 VTT 拖动预览
## 目标
将进度条拖动预览主链路改为服务端雪碧图 + WebVTT 局部裁剪,原生取帧只作为降级:
1. `Episode` 与 `PlayTicket` 解析 `thumbnailsUrl`。
2. VTT 只拉取/解析一次,内存缓存 10 分钟。
3. cue 使用起止时间 + 相对 sprite 文件名 + `xywh`,按 position 二分选择。
4. sprite 图最多缓存 2 张 `ui.Image`,拖动帧只绘制 sourceRect。
5. 无 VTT、空轴或图片失败时回退现有 `PlaybackThumbnailService`,再回退封面。
## 性能红线
- 拖动帧禁止网络请求与图片解码,只允许 cue 二分查找和 `RawImage.sourceRect` 绘制。
- VTT 每个 URL 只解析一次;sprite 解码不在 build/build paint 中执行。
- sprite ui.Image LRU 上限 2 张,页面 dispose 清空。
- 解码 sprite 最长边超过 2048px 时按比例缩小后常驻。
- 签名 `thumbnailsUrl` 不写入任何持久化缓存。
## 实施
- 新增 `SpriteThumbnailAxisService`:
- Dio 拉取 VTT,403 清缓存后重试一次;
- 解析和选 cue 为纯函数,解析失败返回空轴;
- 播放器就绪时预取当前 cue 及相邻 sprite;
- LRU2 sprite 缓存和 10 分钟 VTT 缓存。
- `PlayerPage` 持有页面级服务,按当前选中集预取,dispose 时清空。
- `ScrubPreviewCard` 优先读取已缓存 sprite frame,显示 `RawImage` 裁剪;
VTT 未就绪时暂显封面,轴为空/不可用后启动本地取帧,全部失败保留封面。
- 首页 feed 和全屏播放器都把 `thumbnailsUrl` 传到预览卡片。
- 不新增 pub 依赖、不改后端契约。
## 测试
- VTT 解析:时间、xywh、多 sprite 文件。
- cue 选择:命中、越界最近邻。
- sprite LRU 最多 2 张。
- 回退状态判定。
- Widget:sprite 裁剪且不触发本地取帧;无 URL 回退本地;全部失败显示封面。
- 门禁:`dart analyze lib test`、`flutter test --no-pub`。
## 风险
- 真实网络 VTT/sprite 可访问性和签名过期由 PM 真机验收。
- 本次不启动服务、不操作真机、不修改 Web/FruitServer。
File diff suppressed because it is too large. Load diff
+4
View File
@@ -11,6 +11,7 @@
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
8C4E1A022F9A4B1000C636F3 /* WirelessDataPrompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C4E1A012F9A4B1000C636F3 /* WirelessDataPrompt.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
@@ -49,6 +50,7 @@
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
8C4E1A012F9A4B1000C636F3 /* WirelessDataPrompt.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WirelessDataPrompt.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
@@ -122,6 +124,7 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
8C4E1A012F9A4B1000C636F3 /* WirelessDataPrompt.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
@@ -282,6 +285,7 @@
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
8C4E1A022F9A4B1000C636F3 /* WirelessDataPrompt.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
+589 -7
View File
@@ -1,5 +1,27 @@
import Flutter
import UIKit
import AVFoundation
import AVKit
private let thumbnailHTTPHeaderFieldsKey = "AVURLAssetHTTPHeaderFieldsKey"
private final class ThumbnailResponseGate {
private let lock = NSLock()
private var completed = false
func send(_ result: @escaping FlutterResult, data: Data?) {
lock.lock()
if completed {
lock.unlock()
return
}
completed = true
lock.unlock()
DispatchQueue.main.async {
result(data)
}
}
}
/// Keeps the system LaunchScreen as the only splash. Flutter would otherwise
/// instantiate that storyboard again and the icon appears a second time.
@@ -10,21 +32,352 @@ final class RunnerFlutterViewController: FlutterViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(red: 16.0 / 255.0, green: 12.0 / 255.0, blue: 10.0 / 255.0, alpha: 1)
view.backgroundColor = .black
}
}
/// 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
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private let wirelessDataPrompt = WirelessDataPrompt()
private let thumbnailQueue = DispatchQueue(
label: "com.stormg.lumadrama.thumbnail",
qos: .userInitiated
)
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
UIApplication.shared.isIdleTimerDisabled = true
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
override func applicationDidBecomeActive(_ application: UIApplication) {
super.applicationDidBecomeActive(application)
requestWirelessDataPrompt()
}
func requestWirelessDataPrompt() {
wirelessDataPrompt.requestSystemPrompt()
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
registerWirelessDataChannel(engineBridge)
registerDeviceIdChannel(engineBridge)
registerPipChannel(engineBridge)
let channel = FlutterMethodChannel(
name: "com.stormg.lumadrama/share",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
@@ -33,15 +386,244 @@ final class RunnerFlutterViewController: FlutterViewController {
guard call.method == "shareText",
let arguments = call.arguments as? [String: Any],
let text = arguments["text"] as? String,
!text.isEmpty,
let controller = self?.window?.rootViewController else {
!text.isEmpty else {
result(FlutterError(code: "share_unavailable", message: "Share sheet is unavailable", details: nil))
return
}
let activity = UIActivityViewController(activityItems: [text], applicationActivities: nil)
activity.popoverPresentationController?.sourceView = controller.view
activity.completionWithItemsHandler = { _, _, _, _ in result(nil) }
controller.present(activity, animated: true)
DispatchQueue.main.async {
guard let controller = self?.sharePresenter() else {
result(FlutterError(code: "share_unavailable", message: "Share sheet is unavailable", details: nil))
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(
name: "com.stormg.lumadrama/thumbnail",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
)
thumbnailChannel.setMethodCallHandler { [weak self] call, result in
guard call.method == "frame" else {
result(FlutterMethodNotImplemented)
return
}
guard let self,
let arguments = call.arguments as? [String: Any],
let timeNumber = arguments["timeMs"] as? NSNumber else {
result(nil)
return
}
let localPath = arguments["localPath"] as? String ?? ""
let remoteUrl = arguments["url"] as? String ?? ""
let headers = arguments["headers"] as? [String: String] ?? [:]
let assetUrl: URL?
if !localPath.isEmpty {
assetUrl = localPath.hasPrefix("file:")
? URL(string: localPath)
: URL(fileURLWithPath: localPath)
} else if !remoteUrl.isEmpty {
assetUrl = URL(string: remoteUrl)
} else {
assetUrl = nil
}
guard let assetUrl else {
result(nil)
return
}
let options: [String: Any]? = headers.isEmpty
? nil
: [thumbnailHTTPHeaderFieldsKey: headers]
let asset = AVURLAsset(url: assetUrl, options: options)
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
generator.maximumSize = CGSize(width: 240, height: 426)
generator.requestedTimeToleranceBefore = CMTime(seconds: 0.5, preferredTimescale: 600)
generator.requestedTimeToleranceAfter = CMTime(seconds: 0.5, preferredTimescale: 600)
let time = CMTime(value: timeNumber.int64Value, timescale: 1000)
let gate = ThumbnailResponseGate()
self.thumbnailQueue.async {
do {
let image = try generator.copyCGImage(at: time, actualTime: nil)
let data = UIImage(cgImage: image).jpegData(compressionQuality: 0.7)
gate.send(result, data: data)
} catch {
gate.send(result, data: nil)
}
}
DispatchQueue.global(qos: .userInitiated).asyncAfter(
deadline: .now() + 1.5
) {
gate.send(result, data: nil)
}
}
}
/// 当前前台 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 每次重装都变。
/// 同通道顺带提供设备区域(ISO-2),注册时作为服务端 IP 解析失败后的兜底。
private func registerDeviceIdChannel(_ engineBridge: FlutterImplicitEngineBridge) {
let channel = FlutterMethodChannel(
name: "com.stormg.lumadrama/device",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
)
channel.setMethodCallHandler { call, result in
switch call.method {
case "idfv":
result(UIDevice.current.identifierForVendor?.uuidString)
case "region":
if #available(iOS 16.0, *) {
result(Locale.current.region?.identifier)
} else {
result(Locale.current.regionCode)
}
default:
result(FlutterMethodNotImplemented)
}
}
}
private func registerWirelessDataChannel(_ engineBridge: FlutterImplicitEngineBridge) {
let channel = FlutterMethodChannel(
name: WirelessDataPrompt.channelName,
binaryMessenger: engineBridge.applicationRegistrar.messenger()
)
channel.setMethodCallHandler { [weak self] call, result in
guard call.method == WirelessDataPrompt.awaitDecisionMethod else {
result(FlutterMethodNotImplemented)
return
}
guard let self else {
result(nil)
return
}
self.wirelessDataPrompt.awaitDecision(result)
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 734 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 866 B

After

Width:  |  Height:  |  Size: 822 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Loaded 100 of 488 files, more files were not shown because too many files have changed in this diff. Show more