Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
9b78e43702 | ||
|
|
f3ec12e3c3 | ||
|
|
45a53180d9 | ||
|
|
d227ac69fb | ||
|
|
862b7e92dd | ||
|
|
8b53adc2be | ||
|
|
ecdff54f2e | ||
|
|
6c2f77926b | ||
|
|
86588d7faa | ||
|
|
91a622733c | ||
|
|
08980ff09b | ||
|
|
30a6bcccfc | ||
|
|
a42d3a26ac | ||
|
|
c278503d0e | ||
|
|
76f347cc35 | ||
|
|
349ea12404 | ||
|
|
dfc33027d8 | ||
|
|
13a14f3e85 | ||
|
|
f8769f2e1d | ||
|
|
a2a491d056 | ||
|
|
c9adf8b399 | ||
|
|
838ca2ce2a | ||
|
|
677e49fa6e | ||
|
|
dfa79bed88 | ||
|
|
3c4d65a9b2 | ||
|
|
c8392b2f2a | ||
|
|
ae61d9ec4c | ||
|
|
4d0be47ee3 | ||
|
|
83822a0cbb | ||
|
|
e6f34977f5 | ||
|
|
35860e056b | ||
|
|
01bef7f758 | ||
|
|
45e8707f1e | ||
|
|
d39d8d43c1 | ||
|
|
3fbfa9ab81 | ||
|
|
036bd29855 | ||
|
|
d2f2459a96 | ||
|
|
e90217bdfd | ||
|
|
ea1a9f424b | ||
|
|
18a1d06821 | ||
|
|
a66cdb2e1b | ||
|
|
6c72f8bf22 | ||
|
|
c892d9b995 | ||
|
|
2ae4aedac0 | ||
|
|
db6fbd58ea | ||
|
|
8dc9d55106 | ||
|
|
4908dc9d76 | ||
|
|
5b4526cb97 | ||
|
|
bac42bc653 | ||
|
|
bc971c17d5 | ||
|
|
cb58f649c1 | ||
|
|
f9f77090d9 | ||
|
|
5d459dd08a | ||
|
|
c3e6e02e47 | ||
|
|
e86daf559a | ||
|
|
9c0c1b8982 | ||
|
|
e0024ed123 | ||
|
|
c8ac2c9823 | ||
|
|
282f51c24b | ||
|
|
aa14d0a835 | ||
|
|
50edbf40c4 | ||
|
|
fc877f25bf | ||
|
|
f87b0d2689 | ||
|
|
146cd2a29e | ||
|
|
425ea4c0b7 | ||
|
|
a142a6c318 | ||
|
|
d081cff3e4 | ||
|
|
9876772d06 | ||
|
|
bc4c84a4ae | ||
|
|
a7c0576a03 | ||
|
|
ad69ef7f54 | ||
|
|
de99c09454 | ||
|
|
1776c622ae | ||
|
|
33b49c161a | ||
|
|
c7b230263a | ||
|
|
226ac5920c | ||
|
|
9bd46521aa | ||
|
|
838beaaa3e | ||
|
|
519cab20fe | ||
|
|
8b51a7c18e | ||
|
|
dbeb9883a5 | ||
|
|
5160ab437d | ||
|
|
ab476cab7d | ||
|
|
97735763a1 | ||
|
|
2659c29501 | ||
|
|
22cb74ded4 | ||
|
|
7862e0be09 | ||
|
|
bdd3a53632 | ||
|
|
5e2b8d9e4d | ||
|
|
97bbced02c | ||
|
|
cbb9f60f7f | ||
|
|
35de4095b0 | ||
|
|
4b1aea29c7 | ||
|
|
e6a31a60df | ||
|
|
622dc2f9e8 | ||
|
|
62f87040d6 | ||
|
|
7fccd16750 | ||
|
|
c06a67c4b0 | ||
|
|
1993ff1102 | ||
|
|
72708630ba | ||
|
|
d6b2d308eb | ||
|
|
0ba6ea9c52 | ||
|
|
cf4b07aa69 | ||
|
|
76cc52ad66 | ||
|
|
99b45e4f12 | ||
|
|
c0697c01e3 | ||
|
|
8c04099d97 | ||
|
|
3a636ca894 | ||
|
|
d13b601ae8 | ||
|
|
827c972da0 | ||
|
|
92bfc76e50 | ||
|
|
1dbc3acd80 | ||
|
|
66e9062ba5 | ||
|
|
ac1202c329 | ||
|
|
0c6a103c1c | ||
|
|
51c363540a | ||
|
|
9ebb54ecfd | ||
|
|
2c5ff3579c | ||
|
|
3fefd544fb | ||
|
|
d2174c5b74 | ||
|
|
7871b0cfbb | ||
|
|
c76b388814 | ||
|
|
9b31d83e39 | ||
|
|
1199db760f | ||
|
|
8f60046ec3 | ||
|
|
210ccc97cb | ||
|
|
699fd427b8 | ||
|
|
911f13f464 | ||
|
|
4c8ae7bcec | ||
|
|
51e94029e4 | ||
|
|
0aa5a99a22 | ||
|
|
9eec9ed77a | ||
|
|
983e99a4b6 | ||
|
|
7c2fd090e0 | ||
|
|
a756faca6c | ||
|
|
da549492b0 | ||
|
|
398dc3f8b4 | ||
|
|
576f2b95f4 | ||
|
|
6b397015b1 | ||
|
|
ca6776580f | ||
|
|
c259b0b543 | ||
|
|
eab5d3e230 | ||
|
|
730a96559b | ||
|
|
d68734cdab | ||
|
|
aac9195e05 | ||
|
|
ae11846b63 | ||
|
|
a63334f5a5 | ||
|
|
23059578d2 | ||
|
|
bb92996db5 | ||
|
|
a11ba50b13 | ||
|
|
fe5b6e6efa | ||
|
|
53591abcd0 | ||
|
|
c147645824 | ||
|
|
fcf30ff39c | ||
|
|
dfaff9714a | ||
|
|
04609ace7c | ||
|
|
7e60e5ebc4 | ||
|
|
375b013b1c | ||
|
|
353196aaa3 | ||
|
|
da78d8723d | ||
|
|
15570f117a | ||
|
|
499e42b511 | ||
|
|
5034ac7670 | ||
|
|
030a0e7986 | ||
|
|
420da29658 | ||
|
|
3ce2599754 | ||
|
|
5af70c345a | ||
|
|
3041849344 | ||
|
|
107ae40d2a | ||
|
|
8bcd882aa5 | ||
|
|
9870502901 | ||
|
|
fb0585c9cf | ||
|
|
69eaacd74f | ||
|
|
8f3049f374 | ||
|
|
c4dcb714ab | ||
|
|
805762f761 | ||
|
|
5bdcb989bb | ||
|
|
0ad2cd5e95 | ||
|
|
b2a8bfa59f | ||
|
|
dc73f4e3bb | ||
|
|
0d2680e6c1 | ||
|
|
37802b24bf | ||
|
|
1d8582dfaa | ||
|
|
4c9bb18403 | ||
|
|
f62324084e | ||
|
|
9f4dc93d11 | ||
|
|
794dc87083 | ||
|
|
7285a1cd4e | ||
|
|
81bb1543bb | ||
|
|
f72cb41670 | ||
|
|
c1dd033581 | ||
|
|
230f16a44a | ||
|
|
870a170fad | ||
|
|
a5c84b0297 | ||
|
|
8493c4ef3b | ||
|
|
457d2fa6f2 | ||
|
|
d0c1a5f499 | ||
|
|
1eea9fafe7 | ||
|
|
fa5fd124a4 | ||
|
|
9864c8e102 | ||
|
|
1c6c92170d | ||
|
|
7b2970d5c5 | ||
|
|
69faa7818c | ||
|
|
7250b5f0f9 | ||
|
|
850f8e7c8c | ||
|
|
85ba2a4fec | ||
|
|
4f81bcf2b1 | ||
|
|
fdee89b345 | ||
|
|
6463436a0d | ||
|
|
fcc1995d8b | ||
|
|
6e9a93c920 | ||
|
|
72410b2a87 | ||
|
|
d1abfd47c7 | ||
|
|
963f376159 | ||
|
|
720fd26935 | ||
|
|
9949b61560 | ||
|
|
04a1a4ee82 | ||
|
|
d80fff74ba | ||
|
|
881088a428 | ||
|
|
ffedde0db5 | ||
|
|
bfba2b7c72 | ||
|
|
7a75ee6de8 | ||
|
|
eccd3c6dc1 | ||
|
|
63ace3b574 | ||
|
|
5283ab2549 | ||
|
|
f9d4f982f0 | ||
|
|
9175a7f253 | ||
|
|
1cb4c56261 | ||
|
|
551da77e67 | ||
|
|
33de925f65 | ||
|
|
b2c219d6bd | ||
|
|
8327c1dd13 | ||
|
|
c87798fb16 | ||
|
|
9b99d96a07 | ||
|
|
bc8f3bcab7 | ||
|
|
28ff9a7642 | ||
|
|
7e415f6f4f | ||
|
|
e800f5971f | ||
|
|
ea9ea6e606 | ||
|
|
564c0dbbef | ||
|
|
82fe237c24 | ||
|
|
a3126723d2 | ||
|
|
636b369bd4 | ||
|
|
f94926cff5 | ||
|
|
521ebfc1ab | ||
|
|
c3f5f43744 | ||
|
|
321c62664c | ||
|
|
435fbbbbb1 | ||
|
|
d36d62f6a6 | ||
|
|
ea28420489 | ||
|
|
ee6f0d7e72 | ||
|
|
d6b055dc62 | ||
|
|
fe84244e7d | ||
|
|
53cd2dbba1 | ||
|
|
b7926d64a5 | ||
|
|
698fb12e07 | ||
|
|
5e2fba1746 | ||
|
|
edfef61ee5 | ||
|
|
5a51af4264 | ||
|
|
2abb8c7032 | ||
|
|
524b7f5b35 | ||
|
|
e32fa58979 | ||
|
|
1fc25ec5d1 | ||
|
|
40e23793b4 | ||
|
|
124ba75ab8 | ||
|
|
8e2cd5c838 | ||
|
|
7cc65b7b09 | ||
|
|
cfc41efddc | ||
|
|
0e48c0773e | ||
|
|
290b92c6a7 | ||
|
|
a24f1fa7c0 | ||
|
|
36c71e63b4 | ||
|
|
033ae5987c | ||
|
|
7a7bf0c207 | ||
|
|
96074bc5e8 | ||
|
|
7bb2714941 | ||
|
|
a72adcfc3f | ||
|
|
b8d2617873 | ||
|
|
8013cc18ad | ||
|
|
ac73bf2208 | ||
|
|
02763c3dfb | ||
|
|
427e7a7897 | ||
|
|
90769de3b7 | ||
|
|
ced0fae64a | ||
|
|
8709b64dec | ||
|
|
edcd25fe68 | ||
|
|
9493ad23bb | ||
|
|
ced1ec3d9e | ||
|
|
bed6bbb75b | ||
|
|
e39aa766ac | ||
|
|
d25519bed7 | ||
|
|
55d22a9940 | ||
|
|
0af5de9641 | ||
|
|
3b2bdb8e5a | ||
|
|
f083a161e6 | ||
|
|
1e3e5b64dc | ||
|
|
c4797cd239 | ||
|
|
08440b0160 | ||
|
|
d849834ce6 | ||
|
|
b26ca6cb9f | ||
|
|
3068eb99da | ||
|
|
a26b0071e4 | ||
|
|
0af68f2455 | ||
|
|
d762511412 | ||
|
|
1dbe2cb8e9 | ||
|
|
b2284b3a74 | ||
|
|
1666bc220e | ||
|
|
9429257d62 | ||
|
|
e2162773e2 | ||
|
|
cc26c5472b | ||
|
|
cf93d8d794 | ||
|
|
7b4a792c12 | ||
|
|
6dbeb3e505 | ||
|
|
c4ad5d72d7 | ||
|
|
8c43e9e385 | ||
|
|
f1efbc5be8 | ||
|
|
9a4269b847 | ||
|
|
148fbc3c93 | ||
|
|
03c3964353 | ||
|
|
d50c6e6d9f | ||
|
|
73aef4e44e | ||
|
|
678b976ce1 | ||
|
|
2f1a4c5c69 | ||
|
|
e448003f1b | ||
|
|
634b63e16c | ||
|
|
0d2367431d | ||
|
|
49f3e63ed0 | ||
|
|
f981f88e0d | ||
|
|
959edc0a32 | ||
|
|
99c72e2c32 | ||
|
|
6022d789ed | ||
|
|
842e716ea6 | ||
|
|
12e57de3b3 | ||
|
|
35338cbfc8 | ||
|
|
dc8ca7b743 | ||
|
|
19caaa0a22 | ||
|
|
7ca7d20630 | ||
|
|
7850dfc746 | ||
|
|
2b2b7e9913 | ||
|
|
30a5fa0776 | ||
|
|
ade0cc3566 | ||
|
|
730e38622f | ||
|
|
ec2db6842e | ||
|
|
dfc650456a | ||
|
|
7b927c794e | ||
|
|
061aac6577 | ||
|
|
2b95c79931 | ||
|
|
136ee74618 | ||
|
|
de941c841c | ||
|
|
190e4f9c67 | ||
|
|
d75b69621f | ||
|
|
75438c204d | ||
|
|
cb2fa920ea | ||
|
|
53b77263ca | ||
|
|
501446b3a6 | ||
|
|
75dacf22ab | ||
|
|
d271c43feb | ||
|
|
cabc0e72f5 | ||
|
|
884687d10a | ||
|
|
bbae8cf4ed | ||
|
|
4c2384fea9 | ||
|
|
149b51b2c1 | ||
|
|
16175803ed | ||
|
|
1eaab2a150 | ||
|
|
4d7173d035 | ||
|
|
87628e7d10 | ||
|
|
3a7e0bc4b7 | ||
|
|
5ca5846a26 | ||
|
|
5b7a8bd775 | ||
|
|
99a57ec502 | ||
|
|
71e317f4ab | ||
|
|
b1747afbe8 | ||
|
|
0d977da75b | ||
|
|
bc5feebf39 | ||
|
|
aea130be11 | ||
|
|
a2de5e8eb7 | ||
|
|
e2e809e2d5 | ||
|
|
3fcfd68660 | ||
|
|
0eabb0a763 | ||
|
|
60ec19f08d | ||
|
|
f613033088 | ||
|
|
f4e1a5424e | ||
|
|
fbfb9769a0 | ||
|
|
cbbd4c80e4 | ||
|
|
2fd92cdbea | ||
|
|
e03ea76505 | ||
|
|
03e34b3b57 | ||
|
|
568f051b7a | ||
|
|
f0c53542ef | ||
|
|
c82502559b | ||
|
|
5422581f8b | ||
|
|
a5398daab3 | ||
|
|
8767b5644b | ||
|
|
fecc5b0d27 | ||
|
|
21d457fd98 | ||
|
|
87db548591 | ||
|
|
eefabb6898 | ||
|
|
1c94b727fb | ||
|
|
04fddce1d1 | ||
|
|
689063a694 | ||
|
|
ec163f905d | ||
|
|
4553606ec2 | ||
|
|
e8c1859e70 | ||
|
|
eae1c533ce | ||
|
|
b1c69e22af | ||
|
|
8a96f341c8 | ||
|
|
e44fbdf6f2 | ||
|
|
5f1df401dc | ||
|
|
c5bfd006fa | ||
|
|
df5fbe5b08 | ||
|
|
42ce1d1d84 | ||
|
|
27ab7cebfb | ||
|
|
9b3ab9d5de | ||
|
|
7151bc9d8b | ||
|
|
6fce24d9ec | ||
|
|
da095115d4 | ||
|
|
c68f02449f | ||
|
|
ef7f4ce3b6 | ||
|
|
44f6feb249 | ||
|
|
7d68699523 | ||
|
|
60043d1e41 | ||
|
|
9df36bc358 | ||
|
|
c1cb7d1c11 | ||
|
|
4778ce2c75 | ||
|
|
c4694c6f18 | ||
|
|
9fe62d13cb | ||
|
|
213c86e93c | ||
|
|
27e5286b10 | ||
|
|
5a28845b5e | ||
|
|
f95325d581 | ||
|
|
907562a874 | ||
|
|
b76228905c | ||
|
|
2dd4627bdc | ||
|
|
b5a8656ff0 | ||
|
|
f0db631fa2 | ||
|
|
0466b4b425 | ||
|
|
6d08330388 | ||
|
|
19babb8f4a | ||
|
|
2c30f83ab3 | ||
|
|
a4415c872d | ||
|
|
2c381be5e8 | ||
|
|
cf03178cc5 | ||
|
|
99f64ab870 | ||
|
|
45c3c645ff | ||
|
|
6cc5bfc3be | ||
|
|
5058853af3 | ||
|
|
3e8703b840 | ||
|
|
b870956665 | ||
|
|
b7b1c50427 | ||
|
|
732483465c | ||
|
|
ac706a42a5 | ||
|
|
cc037a2956 | ||
|
|
0cedaab795 | ||
|
|
8a481d5c62 | ||
|
|
e609e3ad8d | ||
|
|
e52d81881c | ||
|
|
10d448c8ba | ||
|
|
e1147cfcba | ||
|
|
1488ee8f85 | ||
|
|
9db6935308 | ||
|
|
d361034226 | ||
|
|
45f5f25640 | ||
|
|
cc4d7c5a9d | ||
|
|
00280ca0da | ||
|
|
84a1f58a0a | ||
|
|
d7eff4ca59 | ||
|
|
94ff9f1177 | ||
|
|
61454e36b1 | ||
|
|
0e5d550b50 | ||
|
|
1853e1845c | ||
|
|
ae49b10407 | ||
|
|
feecc349a0 | ||
|
|
e1b98df509 | ||
|
|
77d5748dbb |
No files matched your search
@@ -45,7 +45,13 @@ app.*.map.json
|
||||
/android/app/release
|
||||
|
||||
# Local demo media and internal planning are absent from the public repo.
|
||||
/assets/
|
||||
# Launcher/splash source icons are required at build time, so keep them tracked.
|
||||
/assets/*
|
||||
!/assets/icon/
|
||||
!/assets/icon/*.png
|
||||
!/assets/fonts/
|
||||
!/assets/fonts/*.ttf
|
||||
!/assets/fonts/OFL-*.txt
|
||||
/server/
|
||||
/docs/
|
||||
/AGENTS.md
|
||||
|
||||
@@ -1,17 +1,176 @@
|
||||
一个使用 Flutter 开发的短剧播放 App 原型,面向海外用户,重点探索沉浸式刷剧、找剧和连续观看的移动端体验
|
||||
# FruitShorts(ReelShort)客户端
|
||||
|
||||
## 主要功能
|
||||
FruitShorts 是面向印尼市场的短剧 Flutter 客户端。当前版本已连接
|
||||
`FruitServer` 的 `/app/**` 接口,支持沉浸播放、找剧、账号、观看记录、收藏、
|
||||
下载授权、评论互动、站内通知和中英印尼三语界面。
|
||||
|
||||
- **沉浸式首页**:竖屏播放,上下滑切换短剧,左右滑切换内容类别;支持拖动进度、横屏全屏观看。
|
||||
- **剧集播放**:从详情页进入后按集连续播放,支持选集和本地续看。
|
||||
- **找剧与个人页**:浏览、搜索和查看剧集详情;在个人页查看观看记录、收藏与喜欢的内容。
|
||||
- **互动与多语言**:本地演示评论、系统分享,以及英文和简体中文界面。
|
||||
- **画中画**:Android 接入系统画中画;iOS 画中画尚未实现。
|
||||
## 技术栈
|
||||
|
||||
## 技术实现
|
||||
- Flutter 3.47.7 / Dart 3.13.5
|
||||
- Provider 状态管理与依赖注入
|
||||
- dio 网络请求
|
||||
- video_player 播放
|
||||
- shared_preferences 与 `SharedPreferencesAsync` 本地配置
|
||||
- flutter_localizations,支持 `en`、`zh`、`id`
|
||||
- Android 系统画中画
|
||||
|
||||
项目使用 Flutter、Provider 和 `video_player`。播放会话统一管理播放器生命周期;观看进度和演示互动数据保存在本机。首页、找剧、我的共用同一个根页面,切换时保留页面状态。
|
||||
## 目录结构
|
||||
|
||||
## 当前说明
|
||||
```text
|
||||
lib/
|
||||
core/
|
||||
config/ 地址、语言和运行时配置
|
||||
data/ models、remote API、repository、controller
|
||||
formatting/ 文案与日期格式化
|
||||
platform/ 触觉等平台能力
|
||||
theme/ 颜色与主题
|
||||
widgets/ 跨页面复用组件
|
||||
l10n/ 三语 ARB 与生成代码
|
||||
pages/ 业务页面
|
||||
test/ 单元、组件、页面和联调测试
|
||||
integration_test/ 设备侧集成测试
|
||||
android/ Android 工程
|
||||
ios/ iOS 工程与 SPM 配置
|
||||
assets/ 图标、图片和本地媒体文件
|
||||
```
|
||||
|
||||
现阶段的剧目与剧集是演示数据,多部剧暂时复用同一段测试视频。
|
||||
## 已接接口
|
||||
|
||||
以下接口均在 `lib/core/data/remote/` 中有实际调用,共 26 个不同路径、
|
||||
31 个 HTTP 方法级调用。
|
||||
|
||||
| 分组 | 契约 | 接口 |
|
||||
| --- | --- | --- |
|
||||
| 账号 | §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` |
|
||||
|
||||
客户端没有调用 `/app/config` 或创作者入驻接口。
|
||||
|
||||
## 运行
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
### Android USB
|
||||
|
||||
手机开启 USB 调试并连接电脑后:
|
||||
|
||||
```bash
|
||||
adb devices
|
||||
adb reverse tcp:8081 tcp:8081
|
||||
flutter run -d <device> \
|
||||
--dart-define=API_BASE_URL=http://127.0.0.1:8081
|
||||
```
|
||||
|
||||
`adb reverse` 将手机的 `127.0.0.1:8081` 转发到电脑;USB 断开或重新连接后需要
|
||||
重新执行。
|
||||
|
||||
Android 模拟器访问宿主机时使用:
|
||||
|
||||
```bash
|
||||
flutter run --dart-define=API_BASE_URL=http://10.0.2.2:8081
|
||||
```
|
||||
|
||||
### iOS
|
||||
|
||||
手机与 Mac 需在同一网络,首次启动允许“本地网络”权限:
|
||||
|
||||
```bash
|
||||
flutter run -d <udid> \
|
||||
--dart-define=API_BASE_URL=http://<Mac局域网IP>:8081
|
||||
```
|
||||
|
||||
### Debug 运行时地址
|
||||
|
||||
debug 版本可在「我的 → 设置 → 服务器地址」修改后端地址,保存后重启 App 生效。
|
||||
地址优先级:
|
||||
|
||||
```text
|
||||
本地运行时设置 > --dart-define(API_BASE_URL) > 平台默认地址
|
||||
```
|
||||
|
||||
因此不带 `--dart-define` 构建的 debug APK 也可在安装后改成当前电脑 IP。
|
||||
release 版本不显示入口,也不会读取本地覆盖。
|
||||
|
||||
## 环境注意
|
||||
|
||||
### iOS Swift 版本
|
||||
|
||||
`pubspec.yaml` 将 `shared_preferences_foundation` 固定为 `2.5.6`。该版本兼容
|
||||
Xcode 16.2 / Swift 6.0.3;`2.6.0` 的生成代码使用 Swift 6.1 才支持的 trailing
|
||||
comma。不要手工升级该依赖,`flutter pub get` 后应显示
|
||||
`shared_preferences_foundation 2.5.6 (overridden)`。
|
||||
|
||||
### iOS SPM 包缺失
|
||||
|
||||
如果 Xcode 报 `Missing package product FlutterGeneratedPluginSwiftPackage`:
|
||||
|
||||
```bash
|
||||
rm -rf ~/Library/Developer/Xcode/DerivedData/Runner-*
|
||||
flutter clean
|
||||
flutter pub get
|
||||
xcodebuild -resolvePackageDependencies \
|
||||
-project ios/Runner.xcodeproj \
|
||||
-scheme Runner
|
||||
```
|
||||
|
||||
随后重新打开工程构建。`Runner.xcscheme` 的 PreAction 会先执行
|
||||
`flutter pub get`,再执行 Flutter prepare。
|
||||
|
||||
### Android libdartjni
|
||||
|
||||
若启动时报 `libdartjni.so` 加载失败,通常是旧安装中的原生库残留:
|
||||
|
||||
```bash
|
||||
adb uninstall com.stormg.lumadrama
|
||||
flutter run -d <device> \
|
||||
--dart-define=API_BASE_URL=http://127.0.0.1:8081
|
||||
```
|
||||
|
||||
## 生产打包(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)
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `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),默认不用。
|
||||
|
||||
## 门禁
|
||||
|
||||
```bash
|
||||
dart analyze lib test
|
||||
flutter test
|
||||
flutter build apk --debug
|
||||
```
|
||||
|
||||
测试文件按模块放在 `test/` 下,用例名使用
|
||||
`should_<期望>_when_<条件>`。真实后端联调测试在服务未启动时会跳过。
|
||||
|
||||
## 当前边界
|
||||
|
||||
- 下载仅管理服务端授权,不落盘、不续传、不支持离线播放。
|
||||
- 没有独立搜索接口,搜索能力尚未形成完整闭环。
|
||||
- 站内通知没有已读/未读和红点状态。
|
||||
- 支付、金币和会员权益尚未形成客户端闭环。
|
||||
- Android 支持系统画中画;iOS 画中画尚未接入。
|
||||
@@ -29,6 +29,10 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// 生产包只保留 arm64-v8a 与 armeabi-v7a,避免插件带入 x86_64 原生库
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||
}
|
||||
// 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 +46,13 @@ kotlin {
|
||||
}
|
||||
}
|
||||
|
||||
// 生产包不允许出现 x86_64:部分插件 AAR 会带入少量 x86_64 原生库,这里显式剔除。
|
||||
androidComponents {
|
||||
onVariants(selector().withBuildType("release")) { variant ->
|
||||
variant.packaging.jniLibs.excludes.add("lib/x86_64/**")
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
@@ -6,4 +6,6 @@
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- 开发期后端是明文 HTTP,仅调试构建放开。 -->
|
||||
<application android:usesCleartextTraffic="true"/>
|
||||
</manifest>
|
||||
@@ -1,10 +1,12 @@
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<application
|
||||
android:label="Nontonki"
|
||||
android:label="FruitShorts"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
@@ -15,7 +17,7 @@
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
android:windowSoftInputMode="adjustNothing">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
|
||||
@@ -5,17 +5,40 @@ package com.stormg.lumadrama
|
||||
import android.content.Intent
|
||||
import android.app.PictureInPictureParams
|
||||
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.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.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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
@@ -62,8 +85,133 @@ class MainActivity : FlutterActivity() {
|
||||
result.error("share_failed", error.message, null)
|
||||
}
|
||||
}
|
||||
keyboardChannel = MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
"com.stormg.lumadrama/keyboard"
|
||||
)
|
||||
keyboardChannel?.setMethodCallHandler { call, result ->
|
||||
if (call.method != "get") {
|
||||
result.notImplemented()
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
val inset = currentImeInset()
|
||||
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)
|
||||
ViewCompat.onApplyWindowInsets(view, insets)
|
||||
}
|
||||
ViewCompat.requestApplyInsets(decorView)
|
||||
}
|
||||
|
||||
private fun currentImeInset(): Int =
|
||||
ViewCompat.getRootWindowInsets(window.decorView)
|
||||
?.getInsets(WindowInsetsCompat.Type.ime())
|
||||
?.bottom
|
||||
?: 0
|
||||
|
||||
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)
|
||||
@@ -77,4 +225,21 @@ class MainActivity : FlutterActivity() {
|
||||
pendingShare = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
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 thumbnailMaxWidth = 240
|
||||
private const val thumbnailJpegQuality = 70
|
||||
private const val thumbnailTimeoutMs = 1500L
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/splash_background" />
|
||||
</layer-list>
|
||||
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/splash_background" />
|
||||
</layer-list>
|
||||
|
After Width: | Height: | Size: 69 B |
@@ -1,11 +1,9 @@
|
||||
<?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_icon" />
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?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" />
|
||||
</layer-list>
|
||||
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 69 B |
@@ -1,11 +1,9 @@
|
||||
<?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_icon" />
|
||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
||||
</item>
|
||||
<item>
|
||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -1,11 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_foreground"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 813 B After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -6,6 +6,8 @@
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||
<item name="android:windowSplashScreenIconBackgroundColor">@color/splash_background</item>
|
||||
</style>
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">@color/splash_background</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,12 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_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>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
@@ -15,6 +18,6 @@
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
<item name="android:windowBackground">@color/splash_background</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -6,6 +6,8 @@
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:windowSplashScreenBackground">@color/splash_background</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||
<item name="android:windowSplashScreenIconBackgroundColor">@color/splash_background</item>
|
||||
</style>
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">@color/splash_background</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -2,6 +2,5 @@
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<resources>
|
||||
<color name="splash_background">#FF000000</color>
|
||||
<color name="ic_launcher_background">#D74A7D</color>
|
||||
</resources>
|
||||
<color name="splash_background">#1F1B16</color>
|
||||
</resources>
|
||||
@@ -1,12 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Author: fruit.com.cn -->
|
||||
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_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>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
@@ -15,6 +18,6 @@
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
<item name="android:windowBackground">@color/splash_background</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -6,4 +6,6 @@
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- 开发期后端是明文 HTTP,仅调试构建放开。 -->
|
||||
<application android:usesCleartextTraffic="true"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2021 The Outfit Project Authors (https://github.com/Outfitio/Outfit-Fonts)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Plus Jakarta Sans Project Authors (https://github.com/tokotype/PlusJakartaSans)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 928 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1004 B |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 39 KiB |
@@ -0,0 +1,99 @@
|
||||
# 客户端文件说明
|
||||
|
||||
## 目录树
|
||||
|
||||
```text
|
||||
.
|
||||
├── android/ Android 工程与 Gradle 配置
|
||||
├── assets/
|
||||
│ ├── icon/ 启动图标
|
||||
│ ├── images/ 本地图片
|
||||
│ └── media/ 本地视频文件(当前未在 pubspec 声明)
|
||||
├── integration_test/ 设备侧集成测试
|
||||
├── ios/
|
||||
│ ├── Flutter/ Flutter 生成配置与 SPM 临时包
|
||||
│ ├── Runner/ iOS Runner
|
||||
│ ├── Runner.xcodeproj/ Xcode 工程与共享 Scheme
|
||||
│ └── RunnerTests/ iOS 原生测试
|
||||
├── lib/
|
||||
│ ├── core/
|
||||
│ │ ├── config/ 地址、语言、运行时服务器设置
|
||||
│ │ ├── data/ models、remote、repositories、services
|
||||
│ │ ├── formatting/ 多语言文案、日期和错误文本
|
||||
│ │ ├── platform/ 平台能力封装
|
||||
│ │ ├── theme/ 颜色与 ThemeData
|
||||
│ │ └── widgets/ 通用组件
|
||||
│ ├── l10n/ ARB 与本地化生成代码
|
||||
│ └── pages/ 业务页面
|
||||
├── test/ 单元和 Widget 测试
|
||||
├── docs/ 客户端任务与文件说明
|
||||
├── pubspec.yaml 依赖、资源和 Flutter 配置
|
||||
└── README.md 运行、接口与环境说明
|
||||
```
|
||||
|
||||
## 关键文件
|
||||
|
||||
| 路径 | 职责 |
|
||||
| --- | --- |
|
||||
| `lib/main.dart` | 初始化 Flutter、屏幕方向、debug 服务器覆盖并启动 App |
|
||||
| `lib/app.dart` | Provider、repository、controller、ApiClient 和主题装配 |
|
||||
| `lib/core/config/app_config.dart` | 地址优先级、超时、媒体 URL 和平台默认地址 |
|
||||
| `lib/core/config/server_endpoint.dart` | debug 运行时地址规范化、读取、保存和清除 |
|
||||
| `lib/core/config/locale_controller.dart` | 语言选择与系统语言回退 |
|
||||
| `lib/core/data/models/` | 账号、剧目、评论、观看、下载、公告等数据结构 |
|
||||
| `lib/core/data/remote/` | `/app/**` 接口调用;`api_client.dart` 统一鉴权和错误映射 |
|
||||
| `lib/core/data/repositories/` | HTTP 与内存实现的 repository 边界 |
|
||||
| `lib/core/data/services/` | 页面 controller、播放会话、分享、画中画等业务服务 |
|
||||
| `lib/core/formatting/` | 本地化内容、日期与错误文本 |
|
||||
| `lib/core/platform/haptics.dart` | 可注入的触觉反馈封装 |
|
||||
| `lib/core/theme/app_colors.dart` | 克莱因蓝和界面颜色 token |
|
||||
| `lib/core/theme/app_theme.dart` | light/dark ColorScheme 与组件主题 |
|
||||
| `lib/core/widgets/empty_state.dart` | 统一空列表占位 |
|
||||
| `lib/core/widgets/remote_image.dart` | 后端相对图片地址与失败占位 |
|
||||
| `lib/core/widgets/auth_gate.dart` | 未登录操作拦截 |
|
||||
| `lib/core/widgets/download_action.dart` | 下载授权操作 |
|
||||
| `lib/pages/home/` | 根页面、四个底部 tab 和抽屉 |
|
||||
| `lib/pages/player/` | 竖屏播放、选集、进度和播放器交互 |
|
||||
| `lib/pages/discover/` | 首页/发现信息流、轮播、分类与三列剧目网格 |
|
||||
| `lib/pages/drama_detail/` | 剧目详情与剧集入口 |
|
||||
| `lib/pages/auth/` | 登录/注册页面与表单逻辑 |
|
||||
| `lib/pages/mine/` | 我的页、片单、设置、语言与 debug 地址入口 |
|
||||
| `lib/pages/favorites/` | 收藏列表 |
|
||||
| `lib/pages/watch_records/` | 独立观看记录页 |
|
||||
| `lib/pages/downloads/` | 下载授权与策略页面 |
|
||||
| `lib/pages/notices/` | 站内通知分页和详情 |
|
||||
| `lib/pages/interaction/` | 举报弹窗 |
|
||||
| `lib/l10n/*.arb` | en/zh/id 源文案,修改后执行 `flutter gen-l10n` |
|
||||
| `ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme` | iOS SPM 预构建脚本 |
|
||||
|
||||
## 测试结构
|
||||
|
||||
| 目录 | 覆盖范围 |
|
||||
| --- | --- |
|
||||
| `test/auth/` | 登录 controller、表单、视觉与交互 |
|
||||
| `test/catalog/`、`test/category/` | 首页数据、分类和模型解析 |
|
||||
| `test/comment/`、`test/interaction/` | 评论、点赞、举报和弹窗 |
|
||||
| `test/config/` | 三语、系统语言和运行时服务器地址 |
|
||||
| `test/core/` | ApiClient、ID 解析、触觉、分享和画中画 |
|
||||
| `test/discover/`、`test/home/` | 首页、轮播、网格和导航 |
|
||||
| `test/download/`、`test/library/`、`test/watch/` | 下载、收藏、观看记录 |
|
||||
| `test/drama_detail/`、`test/player/`、`test/playback/` | 详情、播放器与播放队列 |
|
||||
| `test/notice/` | 通知分页、空态、详情与入口 |
|
||||
| `test/empty_state/`、`test/theme/` | 空态组件与主题 token |
|
||||
| `test/integration/` | 依赖本机后端的联调测试,服务未启动时跳过 |
|
||||
| `integration_test/app_backend_test.dart` | 设备侧后端连通性测试 |
|
||||
| `test/support/` | fake repository、HTTP adapter、播放器和测试数据 |
|
||||
|
||||
## 生成物
|
||||
|
||||
以下内容由工具生成,不要手工修改:
|
||||
|
||||
- `lib/l10n/app_localizations*.dart`
|
||||
- `*.g.dart`
|
||||
- `.dart_tool/`
|
||||
- `build/`
|
||||
- `ios/Flutter/ephemeral/`
|
||||
- `ios/Runner/GeneratedPluginRegistrant.h`
|
||||
- `ios/Runner/GeneratedPluginRegistrant.m`
|
||||
|
||||
`pubspec.lock` 由 Dart 包管理工具维护,应提交但不手工编辑。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 客户端任务清单
|
||||
|
||||
状态按当前 `develop` 中已合入的功能整理。契约章节以
|
||||
`FruitServer/docs/api-phase1.md` 为准;任务过程记录见
|
||||
`FruitServer/docs/plans/PROGRESS.md`。
|
||||
|
||||
## K1-K13
|
||||
|
||||
| 批次 | 范围 | 状态 | 关键提交或契约章节 | 备注 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| K1 | 详情、播放票据、观看进度、续看、收藏 | 已完成并合入 | §1.3-§1.5 | 已有三态和基础页面 |
|
||||
| K2 | 分类、观看记录、收藏列表、下载授权管理 | 已完成并合入 | §1.2、§1.5、§1.6 | 下载只管理授权,不落盘 |
|
||||
| K3 | 评论、回复、点赞、举报、分享 | 已完成并合入 | §1.7 | 分享使用系统分享面板 |
|
||||
| K4 | 账号剩余能力 | 未开始 | §1.1 | Google 登录所需凭据和契约范围需核对 |
|
||||
| K5 | 克莱因蓝主题、印尼语适配 | 已完成并合入 | 无接口变更 | 三语为 en/zh/id |
|
||||
| K6 | 登录页、底部导航、触觉反馈 | 已完成并合入 | 无接口变更 | 触觉通过 `Haptics` 封装 |
|
||||
| K7 | 首页轮播、站内通知 | 已完成并合入 | §1.3、§1.9.2 | 通知无已读态 |
|
||||
| K8 | 登录页视觉重设计 | 已完成并合入 | 无接口变更 | 保留原有登录逻辑和校验 |
|
||||
| K9 | 首页全部剧目双列网格 | 已完成并合入 | 无接口变更 | 数据源为 feed 分类结果 |
|
||||
| K10 | 统一空列表占位 | 已完成并合入 | 无接口变更 | 评论弹窗使用 compact 变体 |
|
||||
| K11 | 首页三列、分类首位“全部” | 已完成并合入 | 无接口变更 | 网格比例保持 2:3 |
|
||||
| K12 | 首页去标题、空态视觉重做 | 已完成并合入 | 无接口变更 | 双层同心圆空态 |
|
||||
| K13 | debug 运行时服务器地址 | 已完成并合入 | 无接口变更 | 保存后重启生效,release 不开放 |
|
||||
|
||||
## 待办
|
||||
|
||||
| 事项 | 状态 | 依赖或说明 |
|
||||
| --- | --- | --- |
|
||||
| K4 账号剩余能力 | 待办 | Google 登录需要客户端凭据、平台配置和后端契约,范围需核对 |
|
||||
| B1 支付与金币 | 待办 | 客户端尚未接入 §1.8 的支付/权益闭环,商品、渠道和验证方式需核对 |
|
||||
| 搜索 | 待办 | 当前没有独立搜索接口,接口契约和数据范围需核对 |
|
||||
| 下载落盘 | 待办 | 需要新增文件下载、进度持久化、续传、离线播放和本地清理设计 |
|
||||
|
||||
## 维护规则
|
||||
|
||||
- 新批次完成后更新本表,不写未经确认的提交号。
|
||||
- 功能状态以当前 `develop` 代码和自动化测试为准。
|
||||
- “需核对”表示产品范围、接口或外部凭据尚未确认,不能按已完成处理。
|
||||
@@ -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。
|
||||
@@ -0,0 +1,74 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/app.dart';
|
||||
import 'package:fruitshorts/core/config/app_config.dart';
|
||||
import 'package:fruitshorts/core/data/remote/api_client.dart';
|
||||
import 'package:fruitshorts/core/data/remote/catalog_api.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
/// 设备侧集成测试:**在手机进程里**真实请求开发机上的 app-api。
|
||||
///
|
||||
/// 跑法(红线第 10 条要求的"设备侧取证"):
|
||||
/// ```
|
||||
/// IP=$(ipconfig getifaddr en0)
|
||||
/// flutter test integration_test -d <device-id> --dart-define=API_BASE_URL=http://$IP:8081
|
||||
/// ```
|
||||
/// 断言失败时会把实际使用的地址一起打出来,便于区分"地址不对"还是"设备连不上"。
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final baseUrl = AppConfig.apiBaseUrl;
|
||||
|
||||
testWidgets('should_fetch_home_feed_and_drama_detail_when_device_reaches_backend', (
|
||||
tester,
|
||||
) async {
|
||||
debugPrint('[device] API_BASE_URL=$baseUrl');
|
||||
final client = ApiClient(baseUrl: baseUrl);
|
||||
addTearDown(client.close);
|
||||
final api = CatalogApi(client);
|
||||
|
||||
final feed = await api.homeFeed();
|
||||
expect(
|
||||
feed.dramas,
|
||||
isNotEmpty,
|
||||
reason: '设备侧拿不到首页列表(API_BASE_URL=$baseUrl)',
|
||||
);
|
||||
debugPrint('[device] 首页分区=${feed.sections.length} 剧目=${feed.dramas.length}');
|
||||
|
||||
final card = feed.dramas.first;
|
||||
expect(card.id, isNotEmpty, reason: '设备侧首页卡片 id 为空');
|
||||
|
||||
final detail = await api.dramaDetail(card.id);
|
||||
expect(detail.id, card.id, reason: '详情返回的 id 与卡片不一致');
|
||||
expect(
|
||||
detail.episodes,
|
||||
isNotEmpty,
|
||||
reason: '设备侧拿到的详情没有剧集(dramaId=${card.id})',
|
||||
);
|
||||
debugPrint('[device] 详情 id=${detail.id} 剧集=${detail.episodes.length}');
|
||||
});
|
||||
|
||||
testWidgets('should_render_home_drama_list_when_app_boots_on_device', (
|
||||
tester,
|
||||
) async {
|
||||
// 用真 App(真网络、真目录控制器):首页底部标签栏出现即代表"剧目列表加载成功",
|
||||
// 也就是用户在真机上遇到的那个「剧目列表加载失败」已经消失。
|
||||
await tester.pumpWidget(const LumaDramaApp());
|
||||
|
||||
var loaded = false;
|
||||
for (var attempt = 0; attempt < 60 && !loaded; attempt++) {
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
loaded = find.byKey(const Key('home-tabs-bar')).evaluate().isNotEmpty;
|
||||
}
|
||||
|
||||
expect(
|
||||
loaded,
|
||||
isTrue,
|
||||
reason: '首页 30 秒内没渲染出剧目列表(API_BASE_URL=$baseUrl)',
|
||||
);
|
||||
debugPrint('[device] 首页剧目列表已渲染:API_BASE_URL=$baseUrl');
|
||||
});
|
||||
}
|
||||
@@ -360,7 +360,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@@ -487,7 +487,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -538,7 +538,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
|
||||
@@ -6,11 +6,27 @@
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Bootstrap Flutter SPM Package"
|
||||
scriptText = "#!/bin/sh set -e FLUTTER="" if [ -n "${FLUTTER_ROOT:-}" ] && [ -x "$FLUTTER_ROOT/bin/flutter" ]; then FLUTTER="$FLUTTER_ROOT/bin/flutter" elif FLUTTER="$(command -v flutter)"; then : elif [ -x "$HOME/flutter/bin/flutter" ]; then FLUTTER="$HOME/flutter/bin/flutter" elif [ -x "/opt/homebrew/bin/flutter" ]; then FLUTTER="/opt/homebrew/bin/flutter" fi if [ -z "$FLUTTER" ]; then echo "error: Flutter executable not found. Set FLUTTER_ROOT or add Flutter to PATH." >&2 exit 1 fi if [ -z "${SRCROOT:-}" ]; then echo "error: SRCROOT is not set; cannot locate the Flutter project." >&2 exit 1 fi cd "$SRCROOT/.." "$FLUTTER" pub get ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Run Prepare Flutter Framework Script"
|
||||
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||
scriptText = "#!/bin/sh set -e FLUTTER="" if [ -n "${FLUTTER_ROOT:-}" ] && [ -x "$FLUTTER_ROOT/bin/flutter" ]; then FLUTTER="$FLUTTER_ROOT/bin/flutter" elif FLUTTER="$(command -v flutter)"; then : elif [ -x "$HOME/flutter/bin/flutter" ]; then FLUTTER="$HOME/flutter/bin/flutter" elif [ -x "/opt/homebrew/bin/flutter" ]; then FLUTTER="/opt/homebrew/bin/flutter" fi if [ -z "$FLUTTER" ]; then echo "error: Flutter executable not found. Set FLUTTER_ROOT or add Flutter to PATH." >&2 exit 1 fi FLUTTER_ROOT="$(cd "$(dirname "$FLUTTER")/.." && pwd -P)" if [ -z "${SRCROOT:-}" ]; then echo "error: SRCROOT is not set; cannot locate the Flutter project." >&2 exit 1 fi cd "$SRCROOT" "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import AVFoundation
|
||||
|
||||
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,16 +29,22 @@ final class RunnerFlutterViewController: FlutterViewController {
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .black
|
||||
view.backgroundColor = UIColor(red: 16.0 / 255.0, green: 12.0 / 255.0, blue: 10.0 / 255.0, alpha: 1)
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -43,5 +68,66 @@ final class RunnerFlutterViewController: FlutterViewController {
|
||||
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
|
||||
: [AVURLAssetHTTPHeaderFieldsKey: 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 340 B After Width: | Height: | Size: 866 B |
|
Before Width: | Height: | Size: 644 B After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1001 B After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 499 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 958 B After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 6.1 KiB |