Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe0e77775e | ||
|
|
6ef5429a52 | ||
|
|
9ef78ad670 | ||
|
|
bea08110c9 | ||
|
|
67670d46b1 | ||
|
|
7b90bd59b9 | ||
|
|
938435dfb9 | ||
|
|
b61bfe625c | ||
|
|
a7b4277536 | ||
|
|
dabff48f7c | ||
|
|
6acc00394b | ||
|
|
f3cfd0d0ec | ||
|
|
35cbdd2bd3 | ||
|
|
3ad371aa9d | ||
|
|
7463d3ee26 | ||
|
|
59fbd33059 | ||
|
|
8c1662475a | ||
|
|
7f65fe4d48 | ||
|
|
613147c20c | ||
|
|
731638b31b | ||
|
|
a833d62c50 | ||
|
|
bc01716449 | ||
|
|
12b1a11bd3 | ||
|
|
8ec6df2be5 | ||
|
|
5ec4db151a | ||
|
|
a47af8c3ad | ||
|
|
2ddf9ee1c6 | ||
|
|
3bc79ad8e2 | ||
|
|
c307903c42 | ||
|
|
c5b94633f1 | ||
|
|
fd2ba0bf43 | ||
|
|
aed9ee2b01 | ||
|
|
335e5dc286 | ||
|
|
2f4593a46e | ||
|
|
7509cf2ecd | ||
|
|
501bc66282 | ||
|
|
19196ceb8e | ||
|
|
9ef8eb15b0 | ||
|
|
53e8c924a6 | ||
|
|
e7399fa3fd | ||
|
|
22cc669bbe | ||
|
|
26321e3348 | ||
|
|
3590cd0c55 | ||
|
|
c311985601 | ||
|
|
4dd37b2588 | ||
|
|
543d0aadd5 | ||
|
|
d5bcda54d3 | ||
|
|
79463e65c2 | ||
|
|
ad7aba3daf | ||
|
|
47fdd4a29d | ||
|
|
fe49527dd8 | ||
|
|
4aaa5bbd60 | ||
|
|
5649e4c3d4 | ||
|
|
9f7301305e | ||
|
|
63191b4692 | ||
|
|
0f777b19f2 | ||
|
|
fb9001a02d | ||
|
|
1b80f841be | ||
|
|
3e89c71f50 | ||
|
|
2ce92aa7dd | ||
|
|
a6d6b66d31 | ||
|
|
57f84c45bd | ||
|
|
a4d08e7c1e | ||
|
|
9c9a277217 | ||
|
|
1be8ebf1f9 | ||
|
|
70be941755 | ||
|
|
351d0afd09 | ||
|
|
7d32324a2d | ||
|
|
1d4ab5e0e6 | ||
|
|
799f3d1b7d | ||
|
|
08aa6903d2 | ||
|
|
4de9417e27 | ||
|
|
e269d5dce5 | ||
|
|
78f464f2a4 | ||
|
|
9983d42015 | ||
|
|
bfe0df62dc | ||
|
|
df2654c9d6 | ||
|
|
496b1cb797 | ||
|
|
85dedf20fa | ||
|
|
43e60e29ec | ||
|
|
5346be40a9 | ||
|
|
de7da64886 | ||
|
|
709972245f | ||
|
|
d5d13549f5 | ||
|
|
c027acc91f | ||
|
|
a3f3481f4f | ||
|
|
1b19513efb | ||
|
|
935de13343 | ||
|
|
01a3b0101e | ||
|
|
d6df5fbe5f | ||
|
|
929b7f72f8 | ||
|
|
e028ca40b1 | ||
|
|
d206465ee1 | ||
|
|
9117bf58b8 | ||
|
|
bd826a9236 | ||
|
|
a5dbae7fff | ||
|
|
00405d2e3b | ||
|
|
72dcb92aca | ||
|
|
8d511933df | ||
|
|
06a2f6d7c0 | ||
|
|
0d785bf1f8 | ||
|
|
4c3acb047c | ||
|
|
d3dbc9f8dc | ||
|
|
4b97f89c0b | ||
|
|
6be024f86f | ||
|
|
6512808506 | ||
|
|
2d65cd3006 | ||
|
|
391b01e9d0 | ||
|
|
d4ce3a5d8b | ||
|
|
bf47c065c9 | ||
|
|
c6b3ca9311 | ||
|
|
a3d50eb935 | ||
|
|
188811fde6 | ||
|
|
37353dac15 | ||
|
|
350d16db75 | ||
|
|
76429628ec | ||
|
|
0f27daf664 | ||
|
|
afdc4af2f2 | ||
|
|
1b50a73bfc | ||
|
|
978c309cd7 | ||
|
|
c0f65042e6 | ||
|
|
a7d01e0306 | ||
|
|
ed2cf4c18c | ||
|
|
db3e153409 | ||
|
|
810ca5bf9d | ||
|
|
84b24775d6 | ||
|
|
9039b27c7f | ||
|
|
30b43878ab | ||
|
|
e7afd316f8 | ||
|
|
f3dd4a9b5c | ||
|
|
94b37ef00f | ||
|
|
bea50ab32f | ||
|
|
63296d2a92 | ||
|
|
b291044acc | ||
|
|
ee4e573f4f | ||
|
|
e8f192ccf8 | ||
|
|
c8ec481fc9 | ||
|
|
1fc8c4924e | ||
|
|
55fc2796a9 | ||
|
|
4d3d067f77 | ||
|
|
bb2c729a7e | ||
|
|
54eb5a60a8 | ||
|
|
6667330ce0 | ||
|
|
440188f261 | ||
|
|
9d2d3b97a1 | ||
|
|
23e80dd637 | ||
|
|
ab4c246675 | ||
|
|
2a32dec183 | ||
|
|
5dafc68775 | ||
|
|
80c962a95c | ||
|
|
2fef7470e0 | ||
|
|
0cb0ab0613 | ||
|
|
70d8e0f982 | ||
|
|
48b4e67d34 | ||
|
|
a3a79f7068 | ||
|
|
04f034817c | ||
|
|
516593499c | ||
|
|
df36890b48 | ||
|
|
222bacdeeb | ||
|
|
43f3593218 | ||
|
|
b83b5799e3 | ||
|
|
d4ae8f427f | ||
|
|
0eca7ecc31 | ||
|
|
98d7debfb5 | ||
|
|
b5e9deb4bd | ||
|
|
af5d8f9d80 | ||
|
|
47df002d8e | ||
|
|
4f1feeadbd | ||
|
|
8b50b5bd06 | ||
|
|
b84a5368d6 | ||
|
|
67e178c89d | ||
|
|
8481611372 | ||
|
|
b1e95b370c | ||
|
|
0ef0d46919 | ||
|
|
b4d38f8e10 | ||
|
|
c034627fdb | ||
|
|
ef29fc5f15 | ||
|
|
671e0ce143 | ||
|
|
0f1f64a09f | ||
|
|
d29ae20491 | ||
|
|
12e08a89a4 | ||
|
|
e0ba54f23b | ||
|
|
5043d6c25b | ||
|
|
ab6800aba2 | ||
|
|
f8500bbaca | ||
|
|
9b1afbdb36 | ||
|
|
f4cf7bc2dd | ||
|
|
60489d6dca | ||
|
|
44d3bcba18 | ||
|
|
f206977ad8 | ||
|
|
f3d64f6fb5 | ||
|
|
6318ca24ec | ||
|
|
b513ac0a9c | ||
|
|
25d90e1383 | ||
|
|
7804809d83 | ||
|
|
5f278a3510 | ||
|
|
56d7abc6b1 | ||
|
|
345cc2ad28 | ||
|
|
41bd4683c3 | ||
|
|
d868ce9336 | ||
|
|
928ac1de19 | ||
|
|
6b3d870c93 | ||
|
|
281f13a810 | ||
|
|
8637f5da21 | ||
|
|
0319f8a6a8 | ||
|
|
9aa797504b | ||
|
|
933333b0fe | ||
|
|
bbc759473c | ||
|
|
9ddd7fc833 | ||
|
|
5794dd4e4d | ||
|
|
76c4b528d3 | ||
|
|
68ccfa21c0 | ||
|
|
78d0166eb7 | ||
|
|
4140c4eb50 | ||
|
|
e2cc409e5a | ||
|
|
9b83283689 | ||
|
|
db8433ace3 | ||
|
|
6f1da5aa39 | ||
|
|
397483d339 | ||
|
|
37e35f4b47 | ||
|
|
86aa7e0f4f | ||
|
|
604ab60b63 | ||
|
|
0b3f362093 | ||
|
|
19acbb2278 | ||
|
|
ce3ecc4d21 | ||
|
|
c604cc1333 | ||
|
|
e7367e99be | ||
|
|
9953c955e3 | ||
|
|
7971ac18fc | ||
|
|
a81bec4d47 | ||
|
|
647f6afd77 | ||
|
|
9785a1baf3 | ||
|
|
e70ca03c03 | ||
|
|
95fb2ff882 | ||
|
|
b6e5f808d6 | ||
|
|
3f3dc9263c | ||
|
|
f67b8bd514 | ||
|
|
4d304cb549 | ||
|
|
201d58e8f4 | ||
|
|
091ea358bd | ||
|
|
af828d2c46 | ||
|
|
c14d89ffce | ||
|
|
b185200e8c | ||
|
|
2fac46f248 | ||
|
|
cffb68809c | ||
|
|
13d70fcef2 | ||
|
|
bf20e44b85 | ||
|
|
9cf1efc316 | ||
|
|
3655d94869 | ||
|
|
196ce69a12 | ||
|
|
c7a0e277f2 | ||
|
|
57c9e6b477 | ||
|
|
64bc1997ba | ||
|
|
21bb716af4 | ||
|
|
a40adbb99a | ||
|
|
d79e200650 | ||
|
|
3b9ff8a306 | ||
|
|
af4d918938 | ||
|
|
3289012f64 | ||
|
|
e147002e00 | ||
|
|
099d05064e | ||
|
|
4eb411ef28 | ||
|
|
14885d9efa | ||
|
|
17faa749b2 | ||
|
|
5fce65ac57 | ||
|
|
357492acb2 | ||
|
|
23799353b5 | ||
|
|
cf6d12b7c8 | ||
|
|
5af6caea6e | ||
|
|
d66ff13d0f | ||
|
|
7599da1d40 | ||
|
|
04f1f5a73f | ||
|
|
f5721592f7 | ||
|
|
5abee5f69a | ||
|
|
3690fb1bc4 | ||
|
|
2765343340 | ||
|
|
afe7a49ef0 | ||
|
|
b6666290ba | ||
|
|
c478eebb0e | ||
|
|
d731b36c1f | ||
|
|
ac4a800b78 | ||
|
|
30b55afdd4 | ||
|
|
76c4f1d411 | ||
|
|
d66ac8327e | ||
|
|
ccda41b15b | ||
|
|
0c06a6dccd | ||
|
|
8737937875 | ||
|
|
5dd3492aea | ||
|
|
77689a3eaa | ||
|
|
b22ac6ab04 | ||
|
|
2fdff34ed0 | ||
|
|
f9e9334934 | ||
|
|
856a825a74 | ||
|
|
df960f84ea | ||
|
|
bfe1a2f9e8 | ||
|
|
fd2b0dbf23 | ||
|
|
5860302ce7 | ||
|
|
4592b5dfba | ||
|
|
6a10992812 | ||
|
|
8ae2ed4d93 | ||
|
|
b1963a718e | ||
|
|
f47e4dd298 | ||
|
|
c24518c1a4 | ||
|
|
97e2db1b7b | ||
|
|
bd4bdf8aea | ||
|
|
38643e7fde | ||
|
|
2cfa655750 | ||
|
|
8936c3d6eb | ||
|
|
4291031ace | ||
|
|
7945f3ffed | ||
|
|
f69a27bb80 | ||
|
|
d6f1cf2a2c | ||
|
|
6c1f3b9eae | ||
|
|
5282194ca5 | ||
|
|
b921f79a71 | ||
|
|
4fa9d36b45 | ||
|
|
024f5f3736 | ||
|
|
403cce0e3b | ||
|
|
ae8546fcf9 | ||
|
|
5a4e910554 | ||
|
|
147662d085 | ||
|
|
714e5caec3 | ||
|
|
c1d4b61936 | ||
|
|
953ddec587 | ||
|
|
afd8927a9f | ||
|
|
8feede1763 | ||
|
|
f4e5af19fe | ||
|
|
909762ac3b | ||
|
|
acae05d354 | ||
|
|
ba0ce2f8c0 | ||
|
|
62cb4d9809 | ||
|
|
b3ea85df41 | ||
|
|
76296f2be0 | ||
|
|
4304727ffd | ||
|
|
8ac781bb5e | ||
|
|
448010115f | ||
|
|
8197abd55d | ||
|
|
c6f3e3fa68 | ||
|
|
368fbe7ef1 | ||
|
|
91f4de01a1 | ||
|
|
3886c577eb | ||
|
|
0e7dcf4739 | ||
|
|
f20e7ba7c5 | ||
|
|
8375d8b255 | ||
|
|
972e542e5c | ||
|
|
e11f55777e | ||
|
|
325f7c1f5d | ||
|
|
da69a058b4 | ||
|
|
1adcc16727 | ||
|
|
5da836dded | ||
|
|
f7782aef14 | ||
|
|
c9fce7745e | ||
|
|
e16faa258a | ||
|
|
3abf69e886 | ||
|
|
ce310afa89 | ||
|
|
d3fdb3c631 | ||
|
|
9543d850c8 | ||
|
|
81dd33e8c7 | ||
|
|
4f6116896f | ||
|
|
7446431511 | ||
|
|
aa4870cce6 | ||
|
|
10477562d4 | ||
|
|
d7a59e5a27 | ||
|
|
d03dcf162e | ||
|
|
9cf832b318 | ||
|
|
5cead7ce00 | ||
|
|
79bdef4b43 | ||
|
|
cbc6d80cd4 | ||
|
|
5aa8170951 | ||
|
|
d06967c6ab | ||
|
|
af6b9bb3db | ||
|
|
97e3f4fc47 | ||
|
|
73380ee090 | ||
|
|
f9aec827cf | ||
|
|
f1b5418954 | ||
|
|
0ef2438100 | ||
|
|
e40f4779c0 | ||
|
|
83129acae4 | ||
|
|
47bf6458e8 | ||
|
|
1bb227e083 | ||
|
|
dd96792e33 | ||
|
|
6dea9d479e | ||
|
|
5362f97744 | ||
|
|
81787dc3aa | ||
|
|
667fdc5f0a | ||
|
|
729c226c95 | ||
|
|
91e869d589 | ||
|
|
ec3cb8f163 | ||
|
|
4bc04fd8ae | ||
|
|
295c21513c | ||
|
|
5b2d8445dc | ||
|
|
1924b8626f | ||
|
|
a48433f01d | ||
|
|
f2ae0c70b6 | ||
|
|
57f3029018 | ||
|
|
9093700a04 | ||
|
|
78114fa46d | ||
|
|
abfca7a3be | ||
|
|
235b50598a | ||
|
|
63d3b0f7ca | ||
|
|
7a588898fa | ||
|
|
dc4b60270d | ||
|
|
12b733d111 | ||
|
|
89a94f85c2 | ||
|
|
51c56d884b | ||
|
|
d06dce9753 | ||
|
|
7ad4681049 | ||
|
|
f302b396b7 | ||
|
|
5268adc999 | ||
|
|
47ca21406a | ||
|
|
759a02375a | ||
|
|
fe49c6d8b2 | ||
|
|
a25c500371 | ||
|
|
5bf10221a8 | ||
|
|
623c184243 | ||
|
|
7b068e8eb4 | ||
|
|
d076c57283 | ||
|
|
11e37a92b5 | ||
|
|
b0002abf82 | ||
|
|
a1c0af3f00 | ||
|
|
b5bfc1d7b2 | ||
|
|
9b78e43702 | ||
|
|
f3ec12e3c3 | ||
|
|
45a53180d9 | ||
|
|
d227ac69fb | ||
|
|
862b7e92dd | ||
|
|
8b53adc2be | ||
|
|
1d2ba93e48 | ||
|
|
c08df9af40 | ||
|
|
be166f5856 | ||
|
|
e531c78997 | ||
|
|
8ed6cd9fd6 | ||
|
|
c1d55b91a2 | ||
|
|
3dcfa02372 | ||
|
|
b6826f7483 | ||
|
|
cd3d84cf9a | ||
|
|
010ce326ea | ||
|
|
2577797772 | ||
|
|
05e2952815 | ||
|
|
6ae9e8e043 | ||
|
|
182b7e3ac2 | ||
|
|
8bb365db2a | ||
|
|
0cb57d084d | ||
|
|
5c174b007c | ||
|
|
35364c949a | ||
|
|
814b2c0b7b | ||
|
|
3d0e0804ca |
No files matched your search
@@ -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
|
||||
@@ -65,3 +65,6 @@ app.*.map.json
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# IDE 的 Flutter Widget Preview 生成目录。
|
||||
.widget_preview/
|
||||
@@ -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,46 +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)
|
||||
|
||||
生产包只包含以下两个 ABI,**打成一个 APK**,不做 split:
|
||||
两个渠道出两种产物,源码同一套:
|
||||
|
||||
| ABI | 适用设备 |
|
||||
| --- | --- |
|
||||
| `arm64-v8a` | 现代手机(64 位) |
|
||||
| `armeabi-v7a` | 老 32 位机 |
|
||||
|
||||
```bash
|
||||
flutter build apk --release --target-platform android-arm64,android-arm
|
||||
# 产出单个:build/app/outputs/flutter-apk/app-release.apk(约 37MB)
|
||||
```
|
||||
| 渠道 | 产物 | 命令 | 体积 |
|
||||
| --- | --- | --- | --- |
|
||||
| Google Play | AAB(按设备下发) | `flutter build appbundle --release` | 上传 28MB,用户下载约 8MB |
|
||||
| 国内各大市场 | 通用 APK(arm64 + v7) | `flutter build apk --release` | 约 13MB |
|
||||
|
||||
说明:
|
||||
|
||||
- `android/app/build.gradle.kts` 的 `release` 已配置 `abiFilters`,只保留 `arm64-v8a` 与 `armeabi-v7a`,插件也不会再带入 `x86_64` 原生库。
|
||||
- `x86_64` 只用于模拟器,不进入生产包。
|
||||
- 不要直接运行 `flutter build apk --release`:默认会编译三套原生库,包体约 57MB。
|
||||
- 只有需要进一步压缩单机下载体积时,才考虑 `--split-per-abi` 出分架构包(arm64 约 21MB、armv7 约 19MB),默认不用。
|
||||
- `android/gradle.properties` 里固定了 `split-debug-info=build/symbols` 与 `dart-obfuscation=true`,**IDE 里点 Build APK / Build App Bundle 也会生效**:`libapp.so` 从 ~8MB/ABI 降到 ~1.1MB/ABI。
|
||||
- `useLegacyPackaging = true`:原生库以压缩形式存储,这是国内 APK 从 40MB 降到 13MB 的主要原因;代价是安装时解压。
|
||||
- **每次发版都要归档 `build/symbols`**,线上崩溃栈需要它还原。
|
||||
- release 仍在用 debug 签名(`build.gradle.kts` 的 TODO),上架前必须换成正式 keystore。
|
||||
- 不要用 `--split-per-abi`:与 `abiFilters` 冲突;Play 的 AAB 已经按设备拆好。
|
||||
|
||||
## 生产打包(iOS)
|
||||
|
||||
```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,4 +1,5 @@
|
||||
/.gradle
|
||||
/build/
|
||||
/captures/
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
|
||||
@@ -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 {
|
||||
@@ -33,6 +35,13 @@ android {
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
}
|
||||
// R8 裁剪未使用的 Java/Kotlin 代码与资源,减小 dex 与 res。
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
@@ -50,6 +59,9 @@ kotlin {
|
||||
androidComponents {
|
||||
onVariants(selector().withBuildType("release")) { variant ->
|
||||
variant.packaging.jniLibs.excludes.add("lib/x86_64/**")
|
||||
// 原生库随 APK 压缩存储:下载体积明显小于解压后的体积,代价是安装时解压。
|
||||
// 本包 90% 体积是 so,这一项对国内商店的 APK 分发影响最大。
|
||||
variant.packaging.jniLibs.useLegacyPackaging = true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.**
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 9.1 KiB |
@@ -1,4 +1,10 @@
|
||||
<?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="center"
|
||||
android:src="@drawable/splash_screen" />
|
||||
</item>
|
||||
</layer-list>
|
||||
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 43 KiB |
@@ -1,4 +1,10 @@
|
||||
<?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="center"
|
||||
android:src="@drawable/splash_screen" />
|
||||
</item>
|
||||
</layer-list>
|
||||
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 69 B |
@@ -1,9 +1,10 @@
|
||||
<?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"/>
|
||||
</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,10 @@
|
||||
<?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="center"
|
||||
android:src="@drawable/splash_screen" />
|
||||
</item>
|
||||
</layer-list>
|
||||
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 69 B After Width: | Height: | Size: 69 B |
@@ -1,9 +1,10 @@
|
||||
<?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"/>
|
||||
</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>
|
||||
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 156 B |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 113 B |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 216 B |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 384 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 623 B |
@@ -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,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">#000000</color>
|
||||
</resources>
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 16 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。
|
||||
@@ -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 */,
|
||||
);
|
||||
@@ -383,7 +387,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.stormg.lumadramv;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.stormg.lumadrama;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -563,7 +567,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.stormg.lumadramv;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.stormg.lumadrama;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -586,7 +590,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.stormg.lumadramv;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.stormg.lumadrama;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "app-check",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/app-check.git",
|
||||
"state" : {
|
||||
"revision" : "b7c71458b70284cc0f5a87103f66f0b1d98b1873",
|
||||
"version" : "12.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "appauth-ios",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/openid/AppAuth-iOS.git",
|
||||
"state" : {
|
||||
"revision" : "a972daac82d449d58ab119e91c68153e29ddac33",
|
||||
"version" : "3.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "googlesignin-ios",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleSignIn-iOS.git",
|
||||
"state" : {
|
||||
"revision" : "bfaebbc04a4d13ed7a89b0fc1fb8ad66b362cc1c",
|
||||
"version" : "10.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "googleutilities",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GoogleUtilities.git",
|
||||
"state" : {
|
||||
"revision" : "5459ee5603def3db0fe14ca522185f42c48d978e",
|
||||
"version" : "8.1.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "gtm-session-fetcher",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/gtm-session-fetcher.git",
|
||||
"state" : {
|
||||
"revision" : "724a52eea6329b7e12d3ad8300d76ca9f3895fcc",
|
||||
"version" : "5.3.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "gtmappauth",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/GTMAppAuth.git",
|
||||
"state" : {
|
||||
"revision" : "57094cd46bc8700d3a887e2a429f42b6e8ea3f0b",
|
||||
"version" : "6.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "interop-ios-for-google-sdks",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
|
||||
"state" : {
|
||||
"revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe",
|
||||
"version" : "101.0.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 866 B After Width: | Height: | Size: 361 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 799 B |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 587 B |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 799 B |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 4.2 KiB |