Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e0988e381 | ||
|
|
8efd8f1dc8 | ||
|
|
38868e0078 | ||
|
|
273d0c7985 | ||
|
|
4728564e0c | ||
|
|
489bf914af | ||
|
|
248b5fd91a | ||
|
|
bbf7b1f9da | ||
|
|
83899a38f3 | ||
|
|
cda4c1e78a | ||
|
|
01e5814943 | ||
|
|
3cca29512d | ||
|
|
8139fc2b17 | ||
|
|
1ef4032666 | ||
|
|
4c4b18e2cd | ||
|
|
f74314bbf0 | ||
|
|
32421e4c84 | ||
|
|
d90cc61541 | ||
|
|
6b3e49d410 | ||
|
|
a9b812f66c | ||
|
|
0fd0506551 | ||
|
|
e6ca5f254d | ||
|
|
de3219abb9 | ||
|
|
8201d8dcda | ||
|
|
aed420525c | ||
|
|
75b1c86306 | ||
|
|
43012da2b8 | ||
|
|
145881318e | ||
|
|
79c06b82d3 | ||
|
|
e5bbdc06f9 | ||
|
|
0e7ff609b6 | ||
|
|
f3e108e867 | ||
|
|
92bfae322e | ||
|
|
08491511e9 | ||
|
|
24b5080c38 | ||
|
|
985c279b1f | ||
|
|
cb2e56442d | ||
|
|
a425798bae | ||
|
|
1020188883 | ||
|
|
caf192fd78 | ||
|
|
74b8dc4561 | ||
|
|
7b59dac26b | ||
|
|
09f5121732 | ||
|
|
8f9d20a2e4 | ||
|
|
273f129b68 | ||
|
|
483c0b504e | ||
|
|
7c38668a51 | ||
|
|
eba553413c | ||
|
|
f8658027b8 | ||
|
|
58bceb29cb | ||
|
|
c5d8f9c62b | ||
|
|
4ffa3d30f6 | ||
|
|
d5c8778074 | ||
|
|
c6c76d6828 | ||
|
|
9cfc6c0f4a | ||
|
|
2eae450aa0 | ||
|
|
1b5334e72f | ||
|
|
e3db54940d | ||
|
|
656d512858 | ||
|
|
d9135e0d25 | ||
|
|
4f636dfffd | ||
|
|
6438b444bf | ||
|
|
8413e1e5a7 | ||
|
|
5671f6e5d1 | ||
|
|
a4a0475fdb | ||
|
|
9a740b91c1 | ||
|
|
63ab80fa82 | ||
|
|
0bb085c70c | ||
|
|
9825ce3c7c | ||
|
|
f5012b36ea | ||
|
|
6111c381af | ||
|
|
00fce3a997 | ||
|
|
b8417f0277 | ||
|
|
4148c1c8ff | ||
|
|
9e5098f356 | ||
|
|
139eed9ca9 | ||
|
|
23efb02636 | ||
|
|
294996eac1 | ||
|
|
a4f0b92b4e | ||
|
|
7cac050500 | ||
|
|
1685ecc991 | ||
|
|
a71711de5f | ||
|
|
7534a6c60a | ||
|
|
dba01f14bb | ||
|
|
d93ea9813f | ||
|
|
7501f461b9 | ||
|
|
5e5702c961 | ||
|
|
4b477a4fe3 | ||
|
|
50ff514be8 | ||
|
|
1c7a083d6c | ||
|
|
097e48d705 | ||
|
|
a931d5910b | ||
|
|
1cd585eecb | ||
|
|
9981b277c6 | ||
|
|
2b7ea5fe02 | ||
|
|
47276e1bac | ||
|
|
c8670a8d6a | ||
|
|
9cc32e125b | ||
|
|
4217da1a8f | ||
|
|
704c37f97f | ||
|
|
0c8e0fc2ec | ||
|
|
d6315a0f75 | ||
|
|
92cdbdd723 | ||
|
|
3939ba809f | ||
|
|
6642348a96 | ||
|
|
0b29673a5e | ||
|
|
3747a74cb3 | ||
|
|
bcb7f1d3c1 | ||
|
|
7b56b90f24 | ||
|
|
ba62129414 | ||
|
|
e559a6125d | ||
|
|
869b4fe230 | ||
|
|
3b4110d7e5 | ||
|
|
7b4e399013 | ||
|
|
5788d93e89 | ||
|
|
08a806e457 | ||
|
|
e45bfadc14 | ||
|
|
56cfb95af5 | ||
|
|
8e8942fb36 | ||
|
|
02f2657d18 | ||
|
|
39709e5c42 | ||
|
|
a101ee76d6 | ||
|
|
4eadec5b17 | ||
|
|
be86a33057 | ||
|
|
ce19b9dfa7 | ||
|
|
15cc85147a | ||
|
|
3fc3baef83 | ||
|
|
91b926acac | ||
|
|
8b2ff18842 | ||
|
|
1958f8ca7e | ||
|
|
8e53232961 | ||
|
|
cec8993b80 | ||
|
|
366ae67341 | ||
|
|
50be8565d9 | ||
|
|
9365fcf246 | ||
|
|
8d4ef2757a | ||
|
|
6b147e2053 | ||
|
|
5fa8326341 | ||
|
|
c8cded70ef | ||
|
|
8fe51075be | ||
|
|
d24c95f1e1 | ||
|
|
1c8be031ae | ||
|
|
83c47b529c | ||
|
|
9dce396fb4 | ||
|
|
eeaa831cc2 | ||
|
|
f1a02ade17 | ||
|
|
dcd0b03096 | ||
|
|
a189ee5774 | ||
|
|
e74df0d245 | ||
|
|
d980540c09 | ||
|
|
a51215952f | ||
|
|
993a0e4666 | ||
|
|
4c43d6fdec | ||
|
|
1c58a12c90 | ||
|
|
b6d23ed089 | ||
|
|
3a042d79f6 | ||
|
|
1a73fc585b | ||
|
|
8ec2795da1 | ||
|
|
cbea1c8d8f | ||
|
|
048d22cb99 | ||
|
|
31c01ab3e0 | ||
|
|
16e31303ae | ||
|
|
e301fec38c | ||
|
|
e1e9adbb01 | ||
|
|
636ecc9d3a | ||
|
|
fc231a9e72 | ||
|
|
c354193187 | ||
|
|
bb7ed72073 | ||
|
|
fea973a72a | ||
|
|
487c7b87f6 | ||
|
|
afc94a50fc | ||
|
|
2ebd730201 | ||
|
|
0dd7d7c522 | ||
|
|
eaf0f4fa1a | ||
|
|
743a8668ec | ||
|
|
6a74ff083f | ||
|
|
9dc3a1a1b1 | ||
|
|
2b45557743 | ||
|
|
11b97e626e | ||
|
|
d8f69f7acd | ||
|
|
b547f8a47f | ||
|
|
f4c83bedc0 | ||
|
|
0582ab2947 | ||
|
|
209101796b | ||
|
|
448a5d084c | ||
|
|
1ebfee9354 | ||
|
|
29e0654d47 | ||
|
|
f565684842 | ||
|
|
5d6f34f756 | ||
|
|
73735c5a00 | ||
|
|
c93a266722 | ||
|
|
2b167a60f5 | ||
|
|
63e807dd58 | ||
|
|
16e3500c49 | ||
|
|
6e47a5fbcf | ||
|
|
b18774d60b | ||
|
|
0383a77b56 | ||
|
|
d58007a9e2 | ||
|
|
7d60875e19 | ||
|
|
6be1b7c486 | ||
|
|
88c65e1857 | ||
|
|
95ff95d397 | ||
|
|
1978d710da | ||
|
|
423be62218 | ||
|
|
d2650667b8 | ||
|
|
48cf8accfa | ||
|
|
565885cc1e | ||
|
|
d0e74151b8 | ||
|
|
89c9c6c49b | ||
|
|
e98d7febdd | ||
|
|
55ee2c270d | ||
|
|
81b05f00df | ||
|
|
f4f2571168 | ||
|
|
d85a622fbe | ||
|
|
d3e8dcef86 | ||
|
|
e9c93415a8 | ||
|
|
68e27fdbb3 | ||
|
|
a679bae354 | ||
|
|
5a68711f7b | ||
|
|
324d145292 | ||
|
|
d004342c98 | ||
|
|
d3d7957b62 | ||
|
|
0e94558548 | ||
|
|
8d14a14781 | ||
|
|
20f704a56d | ||
|
|
93f0da7a27 | ||
|
|
8b3f8856f7 | ||
|
|
39fb828580 | ||
|
|
3e0f7b4f86 | ||
|
|
7e9f62dc7f | ||
|
|
c855dba3be | ||
|
|
e726f9f528 | ||
|
|
2c54baca34 | ||
|
|
232d98c4ce | ||
|
|
d93c81a7cb | ||
|
|
326d53415b | ||
|
|
87db591792 | ||
|
|
6bad454830 | ||
|
|
cfd4aa307d | ||
|
|
3798a0c332 | ||
|
|
be0e9686b5 | ||
|
|
7ec45274bd | ||
|
|
3e74937386 | ||
|
|
310c4280fc | ||
|
|
ee6e327b12 | ||
|
|
e71dcdaa6c | ||
|
|
1b969953b5 | ||
|
|
7690a16efe | ||
|
|
91298294b0 | ||
|
|
5df3ec0c12 | ||
|
|
ef4980db09 | ||
|
|
e37602421b | ||
|
|
562d19c207 | ||
|
|
e54bbb2214 | ||
|
|
db2a2c34d2 | ||
|
|
12b006ee0c | ||
|
|
be3efa8335 | ||
|
|
d1d549d602 | ||
|
|
7de2a62c55 | ||
|
|
7b4a37e576 | ||
|
|
5a46857035 | ||
|
|
7ffb20b501 | ||
|
|
856ac83e6a | ||
|
|
fb8fc1db1c | ||
|
|
76e8ea6901 | ||
|
|
e8c27f00a7 | ||
|
|
9f19f550c3 | ||
|
|
fce794cd6c | ||
|
|
8f9947a874 | ||
|
|
126e6b9349 | ||
|
|
fcbe459177 | ||
|
|
0e296f8659 | ||
|
|
12073ea134 | ||
|
|
8aea796dbd | ||
|
|
9af6f32157 | ||
|
|
b0f1a5d4f1 | ||
|
|
de62c8530f | ||
|
|
0f4629f52b | ||
|
|
de129fbb0a | ||
|
|
3a01fa25a7 | ||
|
|
86c19fc1fc | ||
|
|
ae1f6d7822 | ||
|
|
4577f26aa8 | ||
|
|
fd26f470a5 | ||
|
|
bae127e459 | ||
|
|
606627cd0c | ||
|
|
3ddd89113c | ||
|
|
5139e39ed4 | ||
|
|
3a6012cc21 | ||
|
|
37b1a26cde | ||
|
|
e5922f4870 | ||
|
|
ae7f4a7871 | ||
|
|
9e85aa7be6 | ||
|
|
fd69fe668a | ||
|
|
9085d4d94b | ||
|
|
0775ea0d26 | ||
|
|
d901f5efc2 | ||
|
|
fcd8198fcb | ||
|
|
fd1eaf9d96 | ||
|
|
d0bd919ff4 | ||
|
|
2bbd31d927 | ||
|
|
617f200cb5 | ||
|
|
b96d734e8c | ||
|
|
89f256138d | ||
|
|
9b248a7a5b | ||
|
|
8d76dff353 | ||
|
|
eff75d2029 | ||
|
|
f9615491b3 | ||
|
|
4252bd88e9 | ||
|
|
00917a0393 | ||
|
|
4cdcd2f2e4 | ||
|
|
27006cfa99 | ||
|
|
bdbd667584 | ||
|
|
4b243d38b9 | ||
|
|
cd31b2e2e6 | ||
|
|
f185ebd554 | ||
|
|
7d679c9d78 | ||
|
|
17184881f3 | ||
|
|
a39a2a25eb | ||
|
|
e0f1695260 | ||
|
|
d0ffd49f69 | ||
|
|
bfb6aa01b9 | ||
|
|
6f273b0aa8 | ||
|
|
3a93ec80ec | ||
|
|
dd0fd1c828 | ||
|
|
ec93062600 | ||
|
|
822333bf34 | ||
|
|
ffc20b26d4 | ||
|
|
3a612df3d3 | ||
|
|
afda61119e | ||
|
|
bb66ab6526 | ||
|
|
ec2b94e367 | ||
|
|
5401b5005f | ||
|
|
d38f7cf78f | ||
|
|
633ce11c88 | ||
|
|
7a3f63683e | ||
|
|
3e6ebcdb6a | ||
|
|
c38c0cf55d | ||
|
|
5d2302b322 | ||
|
|
51a8b971f6 | ||
|
|
3319df4538 | ||
|
|
1e6bd01987 | ||
|
|
e10cc7d273 | ||
|
|
19dea4b31e | ||
|
|
653fada5dc | ||
|
|
65b42bca3f | ||
|
|
5fe5534bae | ||
|
|
f07feaaa52 | ||
|
|
92014404f5 | ||
|
|
e2a7a7ab4b | ||
|
|
cedd448ef2 | ||
|
|
2208d6c637 | ||
|
|
187eeb19d3 | ||
|
|
955c75db60 | ||
|
|
9446f976a4 | ||
|
|
7988c83bec | ||
|
|
ebec3ad20c | ||
|
|
3b18b44cd2 | ||
|
|
cf4fc74d68 | ||
|
|
c75c87f527 | ||
|
|
e71c39271c | ||
|
|
de547884f8 | ||
|
|
4700cc5bd4 | ||
|
|
07d25f5d5f | ||
|
|
1f09c07e96 | ||
|
|
e8769e7059 | ||
|
|
88430ba7b6 | ||
|
|
25a2187bf4 | ||
|
|
fb084d3ea1 | ||
|
|
da7aef4548 | ||
|
|
91c23786f2 | ||
|
|
18a5041334 | ||
|
|
94dbae0af9 | ||
|
|
05c4d6dff3 | ||
|
|
ef2db78310 | ||
|
|
955bc9548c | ||
|
|
ef8026797c | ||
|
|
ed4be7b96d | ||
|
|
fdaff3687d | ||
|
|
f573eda657 | ||
|
|
708a1391e3 | ||
|
|
9e4230597f | ||
|
|
720d0d2604 | ||
|
|
dee12ac336 | ||
|
|
5f9f0964b2 | ||
|
|
bf89925d6a | ||
|
|
aa6e9648bb | ||
|
|
c4b9da0950 | ||
|
|
c7b023222c | ||
|
|
060926b749 | ||
|
|
aa3c83bad1 | ||
|
|
70606c8ac5 | ||
|
|
5010833da9 | ||
|
|
f5901389e0 | ||
|
|
11e1a1464e | ||
|
|
2664caf004 | ||
|
|
8594a7bc52 | ||
|
|
8a7d767e54 | ||
|
|
d09893470f | ||
|
|
a40879849b | ||
|
|
20953152b6 | ||
|
|
876a44b244 | ||
|
|
e9a706fa72 | ||
|
|
8820c0e059 | ||
|
|
45770e1ad6 | ||
|
|
afdec05dbf | ||
|
|
81e036dc4f | ||
|
|
8555254e81 | ||
|
|
1781fc06ae | ||
|
|
810f1e5da5 | ||
|
|
244b291255 | ||
|
|
7fc136f592 | ||
|
|
130e0d6f40 | ||
|
|
8fabf56337 | ||
|
|
b1e218f0ad | ||
|
|
624e8a2991 | ||
|
|
0ac5f49e5e | ||
|
|
de6f8d6cb6 | ||
|
|
b42ee26840 | ||
|
|
50bc245677 | ||
|
|
646832c6b1 | ||
|
|
43242673fb | ||
|
|
87aa7d9d81 | ||
|
|
91d9aad721 | ||
|
|
221902c946 | ||
|
|
8a8c2ecb87 | ||
|
|
f6c6808352 | ||
|
|
036d99f11f | ||
|
|
e78f7cee5e | ||
|
|
f03c84e8dc | ||
|
|
d5a12c404a | ||
|
|
dc74300ef5 | ||
|
|
b2c3fe61b3 | ||
|
|
97e65a811c | ||
|
|
d9d66fe54e | ||
|
|
ed7dae601a | ||
|
|
024af35bf5 | ||
|
|
9bd5732f2b | ||
|
|
117d1b8882 | ||
|
|
f6aad1b8fc | ||
|
|
c602936d2a | ||
|
|
a351ca70ba | ||
|
|
396a5a8453 | ||
|
|
db6d5cadd9 | ||
|
|
5568266c99 | ||
|
|
a605db31f9 | ||
|
|
35ca723c20 | ||
|
|
7057ee93ac | ||
|
|
6cde5b6bd1 | ||
|
|
08564bba20 | ||
|
|
495f2e3640 | ||
|
|
31d2d9dc98 | ||
|
|
68d34f81a0 | ||
|
|
e733a0abc7 | ||
|
|
c800baeb25 | ||
|
|
4080c812d2 | ||
|
|
617bf64105 | ||
|
|
9f19082d47 | ||
|
|
5b9719b96f | ||
|
|
3fd4320e52 | ||
|
|
d66e5af289 | ||
|
|
c5867861eb | ||
|
|
fdede5f643 | ||
|
|
bb0f70c75c | ||
|
|
dcffb74529 | ||
|
|
d8f665ef97 | ||
|
|
6bd203f606 | ||
|
|
240389fb6d | ||
|
|
ca6cb46ba8 | ||
|
|
616ed72503 | ||
|
|
310d4b46b2 | ||
|
|
d6b47f9d51 | ||
|
|
19a0128363 | ||
|
|
3433dcb21c | ||
|
|
62e3cbcfd0 | ||
|
|
4688e1eb5e | ||
|
|
f5e0e8c8b5 | ||
|
|
8c8fc13baf | ||
|
|
a9a8fa388e | ||
|
|
6371d7a36e | ||
|
|
425da3ab7d | ||
|
|
7aaa373bdf | ||
|
|
23d958fac8 | ||
|
|
e61f0002e5 | ||
|
|
303bd406c2 | ||
|
|
ce7e936931 | ||
|
|
f049c65623 | ||
|
|
7f8c066062 | ||
|
|
edff1f29db | ||
|
|
87696d77d1 | ||
|
|
0dbcda25e7 | ||
|
|
ebbcc60e13 | ||
|
|
1ce561a1ad | ||
|
|
c36fee17dd | ||
|
|
eeed34e2df | ||
|
|
52b70a0080 | ||
|
|
feba5b6f74 | ||
|
|
c2cd2dea37 | ||
|
|
5d87c6808d | ||
|
|
0531c33b0c | ||
|
|
e4e8299981 | ||
|
|
d0a93d57dd | ||
|
|
1949c22b85 | ||
|
|
3a69e49ab8 | ||
|
|
df94e83592 | ||
|
|
1de91a598c | ||
|
|
49a86e402c | ||
|
|
3d023962e1 | ||
|
|
a13cfedbce | ||
|
|
2b59de91c7 | ||
|
|
2479e1faaa | ||
|
|
77401bef32 | ||
|
|
e961bc6293 | ||
|
|
bf2584d90f | ||
|
|
e8729a4e27 | ||
|
|
1582fdbd5b | ||
|
|
431c20f817 | ||
|
|
4a5cb224b1 | ||
|
|
d5d9da046e | ||
|
|
0c6796ebe7 | ||
|
|
d73b9182cc | ||
|
|
39bcd9b943 | ||
|
|
ca5d87143b | ||
|
|
1e54344ec3 | ||
|
|
c10cf55f2d | ||
|
|
05aeee1124 | ||
|
|
74c6af2980 | ||
|
|
0883fe2e36 | ||
|
|
e95475f845 | ||
|
|
b2cd0c2c37 | ||
|
|
06b3c9645c | ||
|
|
557ef69697 | ||
|
|
eb49ead83b | ||
|
|
e2be6a0093 | ||
|
|
d4235a286a | ||
|
|
fd701e5d1f | ||
|
|
e6c1923b0e | ||
|
|
839465b7f2 | ||
|
|
7c331e51e6 | ||
|
|
775998466d | ||
|
|
ee020d81bd | ||
|
|
93d196572f | ||
|
|
f69068eb63 | ||
|
|
1edf3da984 | ||
|
|
4665f2a5d8 | ||
|
|
fd898e0567 | ||
|
|
48ba923f47 | ||
|
|
e1c9ce6f71 | ||
|
|
5937e5c721 | ||
|
|
3b7b532a8d | ||
|
|
bc57ffb930 | ||
|
|
1fd415ae31 | ||
|
|
6d265e7bae | ||
|
|
4667b104d2 | ||
|
|
2f524cffae | ||
|
|
87215d9e7b | ||
|
|
65e19706f8 | ||
|
|
f96342bd81 | ||
|
|
f7a6ab25c3 | ||
|
|
fa88d35b2b | ||
|
|
64d5c1f6aa | ||
|
|
56baa48223 | ||
|
|
ad3edd4652 | ||
|
|
81bac6ee9b | ||
|
|
a715f4e26b | ||
|
|
6115c21b44 | ||
|
|
f367260acd | ||
|
|
7f8b827c0e | ||
|
|
0a1abfcbbb | ||
|
|
f53592f32b | ||
|
|
fd1d187ede | ||
|
|
b4e78febc0 | ||
|
|
ff2928b18f | ||
|
|
8b34307975 | ||
|
|
9cca40420c | ||
|
|
3da51f9d64 | ||
|
|
2bd9e6655f | ||
|
|
d2b01100a5 | ||
|
|
47837edb01 | ||
|
|
91057c12a4 | ||
|
|
66b32468eb | ||
|
|
1e62c6c756 | ||
|
|
f8132e7bdb | ||
|
|
dec2e1c9fc | ||
|
|
ee20965218 | ||
|
|
2e429840d2 | ||
|
|
e63920e23b | ||
|
|
11d2e40e34 | ||
|
|
ddb4a91b08 | ||
|
|
157669bd76 | ||
|
|
f055759c7a | ||
|
|
34815f22ee | ||
|
|
1d3ae3e1c5 | ||
|
|
3cf1ebc09b | ||
|
|
360741674d | ||
|
|
c369f2c3d7 | ||
|
|
65e22fbbbd | ||
|
|
74b2d76fea | ||
|
|
6c61e0e4c9 | ||
|
|
a60e1e7f42 | ||
|
|
1b1c4b741b | ||
|
|
f9d8549e56 | ||
|
|
bdd9d24d32 | ||
|
|
a7ba837cb1 | ||
|
|
d04781e4b3 | ||
|
|
f87f0b0807 | ||
|
|
59346f4b67 | ||
|
|
06174af16c | ||
|
|
f1867ddeb7 | ||
|
|
79f7de1b4c | ||
|
|
15de88463c | ||
|
|
c032d97c5f | ||
|
|
2eca47ab69 | ||
|
|
caa70dd460 | ||
|
|
6b0e65b563 | ||
|
|
e0308be8bb | ||
|
|
4663c435c9 | ||
|
|
8380980abd | ||
|
|
771c431188 | ||
|
|
1e1bb9176a | ||
|
|
6eb5c847af | ||
|
|
e50fdc4eb9 | ||
|
|
4edf1a8c42 | ||
|
|
cb455d379c | ||
|
|
d093023ce7 | ||
|
|
a55650bc40 | ||
|
|
6cbf0980c5 | ||
|
|
4cb65c1bfa | ||
|
|
f30e47dbd7 | ||
|
|
7ab6b9720f | ||
|
|
2f7c87d437 | ||
|
|
bc4b4955da | ||
|
|
f7e3408c86 | ||
|
|
2126a38f39 | ||
|
|
b81f6eaa96 | ||
|
|
17eb37cb37 | ||
|
|
9208aa8d16 | ||
|
|
bd7f7894ed | ||
|
|
6dfdb62df8 | ||
|
|
3be302d69f | ||
|
|
66ebc547f0 | ||
|
|
3366b83a89 | ||
|
|
cd3db94b5d | ||
|
|
d56067e7cc | ||
|
|
7c3cee692c | ||
|
|
a948d82c24 | ||
|
|
77893bb9ba | ||
|
|
1b2ecd9de4 | ||
|
|
b92842f9ac | ||
|
|
91d191b3d8 | ||
|
|
21accaa883 | ||
|
|
c6f2e949a7 | ||
|
|
9d9d9bb311 | ||
|
|
0b20300572 | ||
|
|
f47bcb69fd | ||
|
|
c20bb93f1f | ||
|
|
497259ca16 | ||
|
|
4322ffc6e5 | ||
|
|
8b40e0993f | ||
|
|
966b6371f5 | ||
|
|
5b493a70b0 | ||
|
|
1cb793d781 | ||
|
|
7f237c9c7b | ||
|
|
d0934295b0 | ||
|
|
8d40ba3011 | ||
|
|
f926deb63b | ||
|
|
b82c612f94 | ||
|
|
e7ae108c49 | ||
|
|
08689df2f5 | ||
|
|
87c308f6ca | ||
|
|
15b22f75a8 | ||
|
|
8e7431e7e5 | ||
|
|
c034f9b66e | ||
|
|
5cf53f8cfa | ||
|
|
e4de7f1ecb | ||
|
|
bf9913ba0f | ||
|
|
6ac8c905c9 | ||
|
|
603b5b5105 | ||
|
|
7528dcec71 | ||
|
|
d96848181d | ||
|
|
16b6ea8f29 | ||
|
|
7150ea2987 | ||
|
|
bc2febf480 | ||
|
|
11ec48a506 | ||
|
|
f08989cc99 | ||
|
|
424351092d | ||
|
|
df72c3e6f4 | ||
|
|
82e605a1c6 | ||
|
|
7a91675054 | ||
|
|
edf4eb2090 | ||
|
|
de1b37b7e9 | ||
|
|
928206a179 | ||
|
|
52b8944d71 | ||
|
|
0db5cb7025 | ||
|
|
cef8f5efe2 | ||
|
|
83ff87c79f | ||
|
|
00f5357403 | ||
|
|
010ace58fa | ||
|
|
b3cf4ebf12 | ||
|
|
f707b7b5f2 | ||
|
|
29ab2ddad3 | ||
|
|
627024bd13 | ||
|
|
c4a71048f5 | ||
|
|
bd07b7f011 | ||
|
|
93496849ec | ||
|
|
9984f42176 | ||
|
|
a262c94f88 | ||
|
|
8f326e692d | ||
|
|
7b8a47aac1 | ||
|
|
b46f1cccb1 | ||
|
|
5880451401 | ||
|
|
3c59452782 | ||
|
|
7d35132509 | ||
|
|
dbb3e14c50 | ||
|
|
d1d45c4998 | ||
|
|
c9db6e528e | ||
|
|
062fd82de1 | ||
|
|
122cff359d | ||
|
|
ef96ccb623 | ||
|
|
8a66cf6d92 | ||
|
|
16dd352231 | ||
|
|
fb275ea242 | ||
|
|
533472299a | ||
|
|
da95901d39 | ||
|
|
29101c310b | ||
|
|
91db6d0e57 | ||
|
|
504da89223 | ||
|
|
4a54b897ac | ||
|
|
9565682eda | ||
|
|
17741a2f61 | ||
|
|
f5407ecac8 | ||
|
|
c57cbfb2f6 | ||
|
|
339a767304 | ||
|
|
4c534b44d4 | ||
|
|
984125a79e | ||
|
|
17f1075e64 | ||
|
|
64a5874566 | ||
|
|
b16a6a6bd5 | ||
|
|
87da5b400e | ||
|
|
3f1da5f289 | ||
|
|
45525a4f14 | ||
|
|
fc7892daed | ||
|
|
51a49cfca3 | ||
|
|
da8b583286 | ||
|
|
2c12a07257 | ||
|
|
3a82119c2a | ||
|
|
e6ea1b056a | ||
|
|
1a96d2b418 | ||
|
|
8eb716505d | ||
|
|
ddb157809a | ||
|
|
ad8faab9ea | ||
|
|
6a7644935f | ||
|
|
684196aa36 | ||
|
|
56697303f9 | ||
|
|
3306a28892 | ||
|
|
059509354d | ||
|
|
53d50dbc3b | ||
|
|
46ebda944a | ||
|
|
0bb200a785 | ||
|
|
d3602201c6 | ||
|
|
beb78fada0 | ||
|
|
a05efef3c8 | ||
|
|
36d63860e2 | ||
|
|
ad9be7f132 | ||
|
|
8e9968628a | ||
|
|
e33db4f6d7 | ||
|
|
ab5ad3c839 | ||
|
|
fa4af97fb0 | ||
|
|
25e3c4a543 |
@@ -0,0 +1,9 @@
|
||||
BasedOnStyle: Google
|
||||
AlignAfterOpenBracket: AlwaysBreak
|
||||
ColumnLimit: 120
|
||||
IndentWidth: 2
|
||||
ContinuationIndentWidth: 4
|
||||
DerivePointerAlignment: false
|
||||
LineEnding: LF
|
||||
PointerAlignment: Left
|
||||
SortIncludes: true
|
||||
@@ -1,19 +0,0 @@
|
||||
.git
|
||||
build/
|
||||
.dart_tool/
|
||||
.idea/
|
||||
*.tar.gz
|
||||
nul
|
||||
libmpv-prefix/
|
||||
steamfiles/
|
||||
pubspec_overrides.yaml
|
||||
|
||||
# Flutter ephemeral / generated plugin symlinks
|
||||
linux/flutter/ephemeral/
|
||||
windows/flutter/
|
||||
macos/Flutter/
|
||||
ios/Flutter/
|
||||
android/.gradle/
|
||||
android/app/build/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
@@ -0,0 +1,28 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{c,cc,cpp,h,hpp,m,mm}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.dart]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.{kt,kts,java}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL
|
||||
ktlint_code_style = android_studio
|
||||
ktlint_standard_max-line-length = disabled
|
||||
ktlint_standard_no-wildcard-imports = disabled
|
||||
ktlint_standard_property-naming = disabled
|
||||
|
||||
[*.swift]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
if [ "${SKIP_HOOKS:-}" = "1" ]; then
|
||||
echo "[pre-commit] skipped (SKIP_HOOKS=1)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
GIT_DIR="$(git rev-parse --git-dir)"
|
||||
if [ -f "$GIT_DIR/MERGE_MSG" ] || [ -f "$GIT_DIR/MERGE_HEAD" ] || [ -f "$GIT_DIR/REVERT_HEAD" ]; then
|
||||
echo "[pre-commit] skipped (merge/revert in progress)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if git diff --cached --quiet; then
|
||||
echo "[pre-commit] nothing staged — skipping checks"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ROOT="$(git rev-parse --show-toplevel)"
|
||||
exec "$ROOT/scripts/ci_checks.sh"
|
||||
@@ -1 +1,2 @@
|
||||
github: edde746
|
||||
liberapay: edde746
|
||||
|
||||
@@ -68,16 +68,31 @@ body:
|
||||
- Windows
|
||||
- macOS
|
||||
- Android TV
|
||||
- tvOS
|
||||
- Linux
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: media-server
|
||||
attributes:
|
||||
label: Media Server
|
||||
description: Are you using Jellyfin or Plex?
|
||||
multiple: true
|
||||
options:
|
||||
- Jellyfin
|
||||
- Plex
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: device
|
||||
attributes:
|
||||
label: Device Information
|
||||
description: What device are you using?
|
||||
placeholder: e.g., iPhone 14 Pro, Pixel 7, Chrome on macOS
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
|
||||
@@ -41,6 +41,8 @@ body:
|
||||
1. As a user, I would...
|
||||
2. This would help me...
|
||||
3. The benefit would be...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
@@ -54,8 +56,23 @@ body:
|
||||
- Windows
|
||||
- macOS
|
||||
- Android TV
|
||||
- tvOS
|
||||
- Linux
|
||||
- All platforms
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: media-server
|
||||
attributes:
|
||||
label: Media Server
|
||||
description: Which media server(s) is this for?
|
||||
multiple: true
|
||||
options:
|
||||
- Jellyfin
|
||||
- Plex
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
|
||||
@@ -2,13 +2,45 @@ name: Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_android:
|
||||
description: Build Android
|
||||
default: true
|
||||
type: boolean
|
||||
build_ios:
|
||||
description: Build iOS
|
||||
default: true
|
||||
type: boolean
|
||||
build_macos:
|
||||
description: Build macOS
|
||||
default: true
|
||||
type: boolean
|
||||
build_windows:
|
||||
description: Build Windows
|
||||
default: true
|
||||
type: boolean
|
||||
build_linux:
|
||||
description: Build Linux
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
SENTRY_DART_DEFINE: ${{ github.repository == 'edde746/plezy' && '--dart-define=ENABLE_SENTRY=true' || '' }}
|
||||
GIT_COMMIT_DART_DEFINE: --dart-define=GIT_COMMIT=${{ github.sha }}
|
||||
SENTRY_ENV_DART_DEFINE: --dart-define=SENTRY_ENVIRONMENT=github
|
||||
DONATIONS_DART_DEFINE: --dart-define=ENABLE_DONATIONS=true
|
||||
LINUX_APT_PACKAGES: >
|
||||
clang cmake meson ninja-build pkg-config nasm libgtk-3-dev libevdev-dev liblzma-dev
|
||||
libstdc++-12-dev libasound2-dev libass-dev libfreetype-dev libfontconfig-dev libfribidi-dev
|
||||
libharfbuzz-dev libepoxy-dev libegl-dev libgl-dev libgnutls28-dev libpipewire-0.3-dev
|
||||
libva-dev libvdpau-dev libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxi-dev
|
||||
libxss-dev libxpresent-dev libxkbcommon-dev libpulse-dev libdbus-1-dev libdrm-dev
|
||||
libgbm-dev libwayland-dev wayland-protocols liblcms2-dev libmujs-dev liblua5.2-dev
|
||||
ruby ruby-dev rubygems build-essential rpm libarchive-tools imagemagick libcurl4-openssl-dev
|
||||
|
||||
jobs:
|
||||
build-android:
|
||||
if: ${{ inputs.build_android }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -27,16 +59,16 @@ jobs:
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pub-
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
@@ -49,7 +81,7 @@ jobs:
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
run: flutter pub get --enforce-lockfile --no-example
|
||||
|
||||
- name: Configure Android signing
|
||||
run: |
|
||||
@@ -62,7 +94,14 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Build APKs
|
||||
run: flutter build apk --release --split-per-abi --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }}
|
||||
run: flutter build apk --release --split-per-abi --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-android-apk ${{ env.DONATIONS_DART_DEFINE }} --obfuscate --split-debug-info=debug-info/android-apk --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-apk/obfuscation.map.json
|
||||
|
||||
- name: Upload symbols to bugs.plezy.app
|
||||
if: github.repository == 'edde746/plezy'
|
||||
env:
|
||||
BUGS_ADMIN_TOKEN: ${{ secrets.BUGS_ADMIN_TOKEN }}
|
||||
SENTRY_DIST: github-android-apk
|
||||
run: ./scripts/upload-symbols.sh android-apk
|
||||
|
||||
- name: Clean up keystore
|
||||
if: always()
|
||||
@@ -92,6 +131,7 @@ jobs:
|
||||
plezy-android-x86_64.tar.gz
|
||||
|
||||
build-ios:
|
||||
if: ${{ inputs.build_ios }}
|
||||
runs-on: macos-26
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -104,16 +144,16 @@ jobs:
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pub-
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
|
||||
|
||||
- name: Cache CocoaPods
|
||||
uses: actions/cache@v4
|
||||
@@ -127,10 +167,17 @@ jobs:
|
||||
${{ runner.os }}-pods-
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
run: flutter pub get --enforce-lockfile --no-example
|
||||
|
||||
- name: Build iOS (no codesign)
|
||||
run: flutter build ios --release --no-codesign --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }}
|
||||
run: flutter build ios --release --no-codesign --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-ios ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/ios
|
||||
|
||||
- name: Upload symbols to bugs.plezy.app
|
||||
if: github.repository == 'edde746/plezy'
|
||||
env:
|
||||
BUGS_ADMIN_TOKEN: ${{ secrets.BUGS_ADMIN_TOKEN }}
|
||||
SENTRY_DIST: github-ios
|
||||
run: ./scripts/upload-symbols.sh ios
|
||||
|
||||
- name: Create IPA
|
||||
run: |
|
||||
@@ -150,6 +197,7 @@ jobs:
|
||||
path: plezy-ios.ipa
|
||||
|
||||
build-macos:
|
||||
if: ${{ inputs.build_macos }}
|
||||
runs-on: macos-26
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -162,16 +210,16 @@ jobs:
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pub-
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
|
||||
|
||||
- name: Cache CocoaPods
|
||||
uses: actions/cache@v4
|
||||
@@ -185,10 +233,17 @@ jobs:
|
||||
${{ runner.os }}-macos-pods-
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
run: flutter pub get --enforce-lockfile --no-example
|
||||
|
||||
- name: Build macOS
|
||||
run: flutter build macos --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }}
|
||||
run: flutter build macos --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-macos ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/macos
|
||||
|
||||
- name: Upload symbols to bugs.plezy.app
|
||||
if: github.repository == 'edde746/plezy'
|
||||
env:
|
||||
BUGS_ADMIN_TOKEN: ${{ secrets.BUGS_ADMIN_TOKEN }}
|
||||
SENTRY_DIST: github-macos
|
||||
run: ./scripts/upload-symbols.sh macos
|
||||
|
||||
- name: Import Code Signing Certificate
|
||||
env:
|
||||
@@ -244,6 +299,11 @@ jobs:
|
||||
APP_PATH="build/macos/Build/Products/Release/Plezy.app"
|
||||
echo "Verifying signature..."
|
||||
codesign -dvvv "$APP_PATH"
|
||||
echo "Verifying signed entitlements..."
|
||||
codesign -d --entitlements :- "$APP_PATH" > "$RUNNER_TEMP/macos-entitlements.plist"
|
||||
/usr/libexec/PlistBuddy -c "Print" "$RUNNER_TEMP/macos-entitlements.plist"
|
||||
/usr/libexec/PlistBuddy -c "Print :com.apple.security.cs.allow-jit" "$RUNNER_TEMP/macos-entitlements.plist"
|
||||
/usr/libexec/PlistBuddy -c "Print :com.apple.security.cs.allow-unsigned-executable-memory" "$RUNNER_TEMP/macos-entitlements.plist"
|
||||
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
|
||||
|
||||
- name: Notarize Application
|
||||
@@ -314,6 +374,7 @@ jobs:
|
||||
macos-dmg-size.txt
|
||||
|
||||
build-windows-x64:
|
||||
if: ${{ inputs.build_windows }}
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -324,22 +385,36 @@ jobs:
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~\AppData\Local\Pub\Cache
|
||||
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pub-
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
|
||||
|
||||
- name: Cache Windows native dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: build/windows/x64/_deps
|
||||
key: windows-native-x64-${{ hashFiles('windows/CMakeLists.txt') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
run: flutter pub get --enforce-lockfile --no-example
|
||||
|
||||
- name: Build Windows x64
|
||||
run: flutter build windows --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }}
|
||||
run: flutter build windows --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-windows-x64 ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/windows-x64
|
||||
|
||||
- name: Upload symbols to bugs.plezy.app
|
||||
if: github.repository == 'edde746/plezy'
|
||||
shell: pwsh
|
||||
env:
|
||||
BUGS_ADMIN_TOKEN: ${{ secrets.BUGS_ADMIN_TOKEN }}
|
||||
SENTRY_DIST: github-windows-x64
|
||||
run: .\scripts\upload-symbols.ps1 windows-x64
|
||||
|
||||
- name: Upload x64 build
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -348,35 +423,54 @@ jobs:
|
||||
path: build/windows/x64/runner/Release/
|
||||
|
||||
build-windows-arm64:
|
||||
if: ${{ inputs.build_windows }}
|
||||
runs-on: windows-11-arm
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Cache Windows native dependencies
|
||||
id: windows-native-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
build/windows/arm64/_deps
|
||||
build/windows/arm64/mpv-dev-arm64
|
||||
key: windows-native-arm64-${{ hashFiles('windows/CMakeLists.txt') }}
|
||||
|
||||
- name: Install 7-Zip
|
||||
if: steps.windows-native-cache.outputs.cache-hit != 'true'
|
||||
run: choco install 7zip -y
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "master"
|
||||
flutter-version: "3.44.0"
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~\AppData\Local\Pub\Cache
|
||||
key: ${{ runner.os }}-arm64-pub-${{ hashFiles('**/pubspec.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-arm64-pub-
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
run: flutter pub get --no-example
|
||||
|
||||
- name: Build Windows arm64
|
||||
run: flutter build windows --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }}
|
||||
run: flutter build windows --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-windows-arm64 ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/windows-arm64
|
||||
|
||||
- name: Upload symbols to bugs.plezy.app
|
||||
if: github.repository == 'edde746/plezy'
|
||||
shell: pwsh
|
||||
env:
|
||||
BUGS_ADMIN_TOKEN: ${{ secrets.BUGS_ADMIN_TOKEN }}
|
||||
SENTRY_DIST: github-windows-arm64
|
||||
run: .\scripts\upload-symbols.ps1 windows-arm64
|
||||
|
||||
- name: Upload arm64 build
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -386,6 +480,7 @@ jobs:
|
||||
|
||||
package-windows:
|
||||
needs: [build-windows-x64, build-windows-arm64]
|
||||
if: ${{ inputs.build_windows }}
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -468,6 +563,7 @@ jobs:
|
||||
win-installer-size.txt
|
||||
|
||||
build-linux-x64:
|
||||
if: ${{ inputs.build_linux }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -480,27 +576,38 @@ jobs:
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pub-
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
|
||||
|
||||
- name: Cache APT packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@latest
|
||||
with:
|
||||
packages: clang cmake meson ninja-build pkg-config nasm libgtk-3-dev libevdev-dev liblzma-dev libstdc++-12-dev libasound2-dev libass-dev libfreetype-dev libfontconfig-dev libfribidi-dev libharfbuzz-dev libepoxy-dev libegl-dev libgl-dev libgnutls28-dev libpipewire-0.3-dev libva-dev libvdpau-dev libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxi-dev libxss-dev libxpresent-dev libxkbcommon-dev libpulse-dev libdbus-1-dev libdrm-dev libgbm-dev libwayland-dev wayland-protocols liblcms2-dev ruby ruby-dev rubygems build-essential rpm libarchive-tools imagemagick libcurl4-openssl-dev
|
||||
packages: ${{ env.LINUX_APT_PACKAGES }}
|
||||
version: 1.1
|
||||
|
||||
- name: Install Linux dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y clang cmake meson ninja-build pkg-config nasm libgtk-3-dev libevdev-dev liblzma-dev libstdc++-12-dev libasound2-dev libass-dev libfreetype-dev libfontconfig-dev libfribidi-dev libharfbuzz-dev libepoxy-dev libegl-dev libgl-dev libgnutls28-dev libpipewire-0.3-dev libva-dev libvdpau-dev libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxi-dev libxss-dev libxpresent-dev libxkbcommon-dev libpulse-dev libdbus-1-dev libdrm-dev libgbm-dev libwayland-dev wayland-protocols liblcms2-dev ruby ruby-dev rubygems build-essential rpm libarchive-tools imagemagick libcurl4-openssl-dev
|
||||
read -r -a packages <<< "$LINUX_APT_PACKAGES"
|
||||
|
||||
for attempt in 1 2 3; do
|
||||
if sudo apt-get update -o Acquire::Retries=3 && sudo apt-get install -y -o Acquire::Retries=3 "${packages[@]}"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sudo apt-get clean
|
||||
sudo rm -rf /var/lib/apt/lists/*
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
|
||||
exit 1
|
||||
|
||||
- name: Cache libmpv build
|
||||
id: libmpv-cache
|
||||
@@ -517,13 +624,20 @@ jobs:
|
||||
run: sudo gem install fpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
run: flutter pub get --enforce-lockfile --no-example
|
||||
|
||||
- name: Build Linux x64
|
||||
run: flutter build linux --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }}
|
||||
run: flutter build linux --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-linux-x64 ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/linux-x64
|
||||
env:
|
||||
PKG_CONFIG_PATH: ${{ github.workspace }}/libmpv-prefix/lib/pkgconfig:${{ github.workspace }}/libmpv-prefix/lib/x86_64-linux-gnu/pkgconfig
|
||||
|
||||
- name: Upload symbols to bugs.plezy.app
|
||||
if: github.repository == 'edde746/plezy'
|
||||
env:
|
||||
BUGS_ADMIN_TOKEN: ${{ secrets.BUGS_ADMIN_TOKEN }}
|
||||
SENTRY_DIST: github-linux-x64
|
||||
run: ./scripts/upload-symbols.sh linux-x64
|
||||
|
||||
- name: Build Linux Packages
|
||||
run: ARCH_SUFFIX=x64 OUTPUT_DIR=$GITHUB_WORKSPACE python3 linux/packaging/build-packages.py
|
||||
|
||||
@@ -576,6 +690,7 @@ jobs:
|
||||
plezy-linux-x64.pkg.tar.zst
|
||||
|
||||
build-linux-arm64:
|
||||
if: ${{ inputs.build_linux }}
|
||||
runs-on: ubuntu-24.04-arm
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -588,27 +703,38 @@ jobs:
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "master"
|
||||
flutter-version: "3.44.0"
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
key: ${{ runner.os }}-arm64-pub-${{ hashFiles('**/pubspec.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-arm64-pub-
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-pub-v2-${{ hashFiles('pubspec.yaml', 'pubspec.lock') }}
|
||||
|
||||
- name: Cache APT packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@latest
|
||||
with:
|
||||
packages: clang cmake meson ninja-build pkg-config nasm libgtk-3-dev libevdev-dev liblzma-dev libstdc++-12-dev libasound2-dev libass-dev libfreetype-dev libfontconfig-dev libfribidi-dev libharfbuzz-dev libepoxy-dev libegl-dev libgl-dev libgnutls28-dev libpipewire-0.3-dev libva-dev libvdpau-dev libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxi-dev libxss-dev libxpresent-dev libxkbcommon-dev libpulse-dev libdbus-1-dev libdrm-dev libgbm-dev libwayland-dev wayland-protocols liblcms2-dev ruby ruby-dev rubygems build-essential rpm libarchive-tools imagemagick libcurl4-openssl-dev
|
||||
packages: ${{ env.LINUX_APT_PACKAGES }}
|
||||
version: 1.1
|
||||
|
||||
- name: Install Linux dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y clang cmake meson ninja-build pkg-config nasm libgtk-3-dev libevdev-dev liblzma-dev libstdc++-12-dev libasound2-dev libass-dev libfreetype-dev libfontconfig-dev libfribidi-dev libharfbuzz-dev libepoxy-dev libegl-dev libgl-dev libgnutls28-dev libpipewire-0.3-dev libva-dev libvdpau-dev libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxi-dev libxss-dev libxpresent-dev libxkbcommon-dev libpulse-dev libdbus-1-dev libdrm-dev libgbm-dev libwayland-dev wayland-protocols liblcms2-dev ruby ruby-dev rubygems build-essential rpm libarchive-tools imagemagick libcurl4-openssl-dev
|
||||
read -r -a packages <<< "$LINUX_APT_PACKAGES"
|
||||
|
||||
for attempt in 1 2 3; do
|
||||
if sudo apt-get update -o Acquire::Retries=3 && sudo apt-get install -y -o Acquire::Retries=3 "${packages[@]}"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sudo apt-get clean
|
||||
sudo rm -rf /var/lib/apt/lists/*
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
|
||||
exit 1
|
||||
|
||||
- name: Cache libmpv build
|
||||
id: libmpv-cache
|
||||
@@ -625,13 +751,20 @@ jobs:
|
||||
run: sudo gem install fpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
run: flutter pub get --no-example
|
||||
|
||||
- name: Build Linux arm64
|
||||
run: flutter build linux --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }}
|
||||
run: flutter build linux --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-linux-arm64 ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/linux-arm64
|
||||
env:
|
||||
PKG_CONFIG_PATH: ${{ github.workspace }}/libmpv-prefix/lib/pkgconfig:${{ github.workspace }}/libmpv-prefix/lib/aarch64-linux-gnu/pkgconfig
|
||||
|
||||
- name: Upload symbols to bugs.plezy.app
|
||||
if: github.repository == 'edde746/plezy'
|
||||
env:
|
||||
BUGS_ADMIN_TOKEN: ${{ secrets.BUGS_ADMIN_TOKEN }}
|
||||
SENTRY_DIST: github-linux-arm64
|
||||
run: ./scripts/upload-symbols.sh linux-arm64
|
||||
|
||||
- name: Find bundle directory
|
||||
id: find-bundle
|
||||
run: |
|
||||
@@ -694,7 +827,8 @@ jobs:
|
||||
plezy-linux-arm64.pkg.tar.zst
|
||||
|
||||
create-release:
|
||||
needs: [build-android, build-ios, build-macos, package-windows, build-linux-x64, build-linux-arm64]
|
||||
needs: [build-android, build-ios, build-macos, build-windows-x64, build-windows-arm64, package-windows, build-linux-x64, build-linux-arm64]
|
||||
if: ${{ always() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && (inputs.build_android || inputs.build_ios || inputs.build_macos || inputs.build_windows || inputs.build_linux) }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -704,10 +838,61 @@ jobs:
|
||||
sparse-checkout: pubspec.yaml
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Download all artifacts
|
||||
- name: Download Android artifacts
|
||||
if: ${{ inputs.build_android }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
name: android-apk
|
||||
path: artifacts/android-apk
|
||||
|
||||
- name: Download iOS artifact
|
||||
if: ${{ inputs.build_ios }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ios-ipa
|
||||
path: artifacts/ios-ipa
|
||||
|
||||
- name: Download macOS artifact
|
||||
if: ${{ inputs.build_macos }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: macos-dmg
|
||||
path: artifacts/macos-dmg
|
||||
|
||||
- name: Download Windows x64 artifact
|
||||
if: ${{ inputs.build_windows }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows-x64-portable
|
||||
path: artifacts/windows-x64-portable
|
||||
|
||||
- name: Download Windows arm64 artifact
|
||||
if: ${{ inputs.build_windows }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows-arm64-portable
|
||||
path: artifacts/windows-arm64-portable
|
||||
|
||||
- name: Download Windows installer artifact
|
||||
if: ${{ inputs.build_windows }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows-installer
|
||||
path: artifacts/windows-installer
|
||||
|
||||
- name: Download Linux x64 artifacts
|
||||
if: ${{ inputs.build_linux }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: linux-x64
|
||||
path: artifacts/linux-x64
|
||||
|
||||
- name: Download Linux arm64 artifacts
|
||||
if: ${{ inputs.build_linux }}
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: linux-arm64
|
||||
path: artifacts/linux-arm64
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R artifacts
|
||||
@@ -790,21 +975,39 @@ jobs:
|
||||
echo "Generated appcast.xml:"
|
||||
cat appcast.xml
|
||||
|
||||
- name: Collect release files
|
||||
id: release-files
|
||||
run: |
|
||||
{
|
||||
echo 'files<<EOF'
|
||||
if [ "${{ inputs.build_android }}" = "true" ]; then
|
||||
echo artifacts/android-apk/plezy-android-arm64-v8a.tar.gz
|
||||
echo artifacts/android-apk/plezy-android-armeabi-v7a.tar.gz
|
||||
echo artifacts/android-apk/plezy-android-x86_64.tar.gz
|
||||
fi
|
||||
if [ "${{ inputs.build_ios }}" = "true" ]; then
|
||||
echo artifacts/ios-ipa/plezy-ios.ipa
|
||||
fi
|
||||
if [ "${{ inputs.build_macos }}" = "true" ]; then
|
||||
echo artifacts/macos-dmg/plezy-macos.dmg
|
||||
fi
|
||||
if [ "${{ inputs.build_windows }}" = "true" ]; then
|
||||
echo artifacts/windows-x64-portable/plezy-windows-x64-portable.7z
|
||||
echo artifacts/windows-arm64-portable/plezy-windows-arm64-portable.7z
|
||||
echo artifacts/windows-installer/plezy-windows-installer.exe
|
||||
fi
|
||||
if [ "${{ inputs.build_linux }}" = "true" ]; then
|
||||
echo 'artifacts/linux-x64/*'
|
||||
echo 'artifacts/linux-arm64/*'
|
||||
fi
|
||||
echo appcast.xml
|
||||
echo EOF
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: |
|
||||
artifacts/android-apk/plezy-android-arm64-v8a.tar.gz
|
||||
artifacts/android-apk/plezy-android-armeabi-v7a.tar.gz
|
||||
artifacts/android-apk/plezy-android-x86_64.tar.gz
|
||||
artifacts/ios-ipa/plezy-ios.ipa
|
||||
artifacts/macos-dmg/plezy-macos.dmg
|
||||
artifacts/windows-x64-portable/plezy-windows-x64-portable.7z
|
||||
artifacts/windows-arm64-portable/plezy-windows-arm64-portable.7z
|
||||
artifacts/windows-installer/plezy-windows-installer.exe
|
||||
artifacts/linux-x64/*
|
||||
artifacts/linux-arm64/*
|
||||
appcast.xml
|
||||
files: ${{ steps.release-files.outputs.files }}
|
||||
draft: true
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
|
||||
@@ -23,22 +23,31 @@ jobs:
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-v2-${{ hashFiles('**/pubspec.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pub-v2-
|
||||
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
|
||||
flutter pub get
|
||||
|
||||
- name: Verify generated files committed
|
||||
run: |
|
||||
scripts/codegen.sh
|
||||
if ! git diff --exit-code lib/; then
|
||||
echo "::error::Generated files (.g.dart / .freezed.dart) are out of date."
|
||||
echo "Run 'scripts/codegen.sh' and commit the result."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify formatting
|
||||
run: |
|
||||
# Find all Dart files excluding generated files
|
||||
@@ -106,16 +115,16 @@ jobs:
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-v2-${{ hashFiles('**/pubspec.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pub-v2-
|
||||
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
@@ -130,6 +139,24 @@ jobs:
|
||||
echo "No tests found, skipping test execution"
|
||||
fi
|
||||
|
||||
native-format:
|
||||
name: Native Formatting
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
|
||||
- name: Verify native formatting
|
||||
run: scripts/format_native.sh --check
|
||||
|
||||
dependency-check:
|
||||
name: Dependency Validation
|
||||
runs-on: ubuntu-latest
|
||||
@@ -143,16 +170,16 @@ jobs:
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-v2-${{ hashFiles('**/pubspec.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pub-v2-
|
||||
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
|
||||
|
||||
- name: Verify dependencies
|
||||
run: |
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
name: SonarQube
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sonarqube:
|
||||
name: SonarQube
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: SonarQube Scan
|
||||
uses: SonarSource/sonarqube-scan-action@v6
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
@@ -34,6 +34,20 @@ migrate_working_dir/
|
||||
/build/
|
||||
/debug-info/
|
||||
|
||||
# tvOS: build artifacts and generated assets (regenerated by xcode_appletv.sh)
|
||||
tvos/build/
|
||||
tvos/Pods/
|
||||
tvos/Podfile.lock
|
||||
tvos/Runner.xcworkspace/xcuserdata/
|
||||
tvos/Runner.xcodeproj/xcuserdata/
|
||||
tvos/Runner.xcodeproj/project.xcworkspace/xcuserdata/
|
||||
tvos/Flutter/Generated.xcconfig
|
||||
tvos/Flutter/flutter_export_environment.sh
|
||||
tvos/Flutter/libPods-Runner.a
|
||||
tvos/tvos_flutter_assets/
|
||||
tvos/Runner/GeneratedPluginRegistrant.h
|
||||
tvos/Runner/GeneratedPluginRegistrant.m
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
@@ -55,3 +69,5 @@ duplication-report/
|
||||
**/fastlane/**/
|
||||
**/fastlane/report.xml
|
||||
maestro/**/*.png
|
||||
|
||||
server/deploy.sh
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"threshold": 5,
|
||||
"reporters": ["html", "console"],
|
||||
"ignore": [
|
||||
"**/*.g.dart",
|
||||
"**/*.freezed.dart",
|
||||
"**/*.mocks.dart",
|
||||
"**/generated/**",
|
||||
"**/*.config.dart"
|
||||
],
|
||||
"absolute": true,
|
||||
"gitignore": true,
|
||||
"format": ["dart"],
|
||||
"minLines": 5,
|
||||
"minTokens": 50,
|
||||
"output": "./duplication-report",
|
||||
"mode": "strict",
|
||||
"formatsExts": {
|
||||
"dart": ["dart"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"indentation" : {
|
||||
"spaces" : 2
|
||||
},
|
||||
"lineLength" : 120,
|
||||
"maximumBlankLines" : 1,
|
||||
"respectsExistingLineBreaks" : true,
|
||||
"rules" : {},
|
||||
"version" : 1
|
||||
}
|
||||
@@ -10,7 +10,8 @@
|
||||
## Development
|
||||
|
||||
- Follow Dart/Flutter conventions
|
||||
- Run `dart format .` to format your code (note: generated files like `*.g.dart` are excluded from CI checks)
|
||||
- Run `dart format .` to format Dart code (note: generated files like `*.g.dart` are excluded from CI checks)
|
||||
- Run `scripts/format_native.sh --fix` to format Kotlin, Swift, C++, C, Objective-C, and native headers
|
||||
- Run `flutter analyze` before submitting to check for issues
|
||||
- Run `flutter test` if tests are available
|
||||
- Test your changes thoroughly
|
||||
@@ -19,8 +20,9 @@
|
||||
|
||||
The project includes automated CI checks that run on all pull requests:
|
||||
|
||||
1. **Code Formatting**: Ensures code follows Dart formatting standards
|
||||
- Run locally: `dart format .` to format all files
|
||||
1. **Code Formatting**: Ensures code follows Dart and native formatting standards
|
||||
- Run locally: `dart format .` to format Dart files
|
||||
- Run locally: `scripts/format_native.sh --fix` to format native files
|
||||
- Note: CI only checks non-generated files (excludes `.g.dart`, `.freezed.dart`)
|
||||
- Generated files are reformatted automatically by build tools
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
cask "plezy" do
|
||||
version "1.29.0"
|
||||
sha256 "5f856275e7d5fe66f25238708a41e9f04fa4d327a932a16626b1424807b05866"
|
||||
version "2.4.1"
|
||||
sha256 "c0658bf43b793a04755a9beaedd27c05c897f2c72298cd5da6531463baf15e0c"
|
||||
|
||||
url "https://github.com/edde746/plezy/releases/download/#{version}/plezy-macos.dmg"
|
||||
name "Plezy"
|
||||
desc "Modern Plex client built with Flutter"
|
||||
desc "Modern Plex and Jellyfin client built with Flutter"
|
||||
homepage "https://github.com/edde746/plezy"
|
||||
|
||||
livecheck do
|
||||
|
||||
@@ -3,13 +3,19 @@
|
||||
Plezy
|
||||
</h1>
|
||||
|
||||
A modern Plex client for desktop and mobile. Built with Flutter for native performance and a clean interface.
|
||||
A modern client for Plex and Jellyfin on desktop, mobile, and TV. Built with Flutter for native performance and a clean interface.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/screenshots/macos-home.png" alt="Plezy macOS Home Screen" width="800" />
|
||||
<p>
|
||||
<a href="https://plezy.app">Website</a> ·
|
||||
<a href="https://plezy.app/#screenshots">Screenshots</a> ·
|
||||
<a href="#download">Download</a> ·
|
||||
<a href="CONTRIBUTING.md">Contributing</a> ·
|
||||
<a href="LICENSE">License</a>
|
||||
</p>
|
||||
|
||||
*More screenshots in the [screenshots folder](assets/screenshots/#readme)*
|
||||
<p align="center">
|
||||
<img src="assets/readme-showcase.webp" alt="Plezy mobile screenshots" width="900" />
|
||||
</p>
|
||||
|
||||
## Download
|
||||
|
||||
@@ -17,10 +23,16 @@ A modern Plex client for desktop and mobile. Built with Flutter for native perfo
|
||||
<a href='https://play.google.com/store/apps/details?id=com.edde746.plezy'><img height='60' alt='Get it on Google Play' src='./assets/play-store-badge.png'/></a>
|
||||
<a href='https://www.amazon.com/gp/product/B0GK65CVS1'><img height='60' alt='Available at the Amazon App Store' src='./assets/amazon-badge.png'/></a>
|
||||
|
||||
- [Windows (x64, arm64)](https://github.com/edde746/plezy/releases/latest/download/plezy-windows-installer.exe)
|
||||
- [macOS (x64, arm64)](https://github.com/edde746/plezy/releases/latest/download/plezy-macos.dmg)
|
||||
- [Linux (x64, arm64)](https://github.com/edde746/plezy/releases/latest) - .deb, .rpm, .pkg.tar.zst, and portable tar.gz available
|
||||
- [NixOS/Nix](https://github.com/mio-19/nurpkgs/tree/main/pkgs/plezy) - Community package by [@mio-19](https://github.com/mio-19)
|
||||
| Platform | Download |
|
||||
| --- | --- |
|
||||
| Windows | [Installer (x64, arm64)](https://github.com/edde746/plezy/releases/latest/download/plezy-windows-installer.exe) · [Portable x64](https://github.com/edde746/plezy/releases/latest/download/plezy-windows-x64-portable.7z) · [Portable arm64](https://github.com/edde746/plezy/releases/latest/download/plezy-windows-arm64-portable.7z) |
|
||||
| macOS | [DMG (x64, arm64)](https://github.com/edde746/plezy/releases/latest/download/plezy-macos.dmg) |
|
||||
| Linux x64 | [.deb](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.deb) · [.rpm](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.rpm) · [.pkg.tar.zst](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.pkg.tar.zst) · [portable tar.gz](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.tar.gz) |
|
||||
| Linux arm64 | [.deb](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.deb) · [.rpm](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.rpm) · [.pkg.tar.zst](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.pkg.tar.zst) · [portable tar.gz](https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.tar.gz) |
|
||||
|
||||
Package managers:
|
||||
|
||||
- [Nix](https://search.nixos.org/packages?channel=unstable&query=plezy) - Community package by [@mio-19](https://github.com/mio-19) and [@MiniHarinn](https://github.com/MiniHarinn)
|
||||
- **Homebrew** (macOS):
|
||||
```bash
|
||||
brew tap edde746/plezy https://github.com/edde746/plezy
|
||||
@@ -32,48 +44,74 @@ A modern Plex client for desktop and mobile. Built with Flutter for native perfo
|
||||
```
|
||||
- **WinGet** (Windows):
|
||||
```bash
|
||||
winget install edde746.plezy
|
||||
winget install edde746.Plezy
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### 🔐 Authentication
|
||||
- Sign in with Plex
|
||||
- Automatic server discovery and smart connection selection
|
||||
- Persistent sessions with auto-login
|
||||
### <img src="assets/readme_icons/browse.svg" height="20" alt="" align="center" /> Browse & Discover
|
||||
- Libraries, collections, and playlists
|
||||
- Discover hub — Continue Watching, Next Up, trending, and recommendations
|
||||
- Cross-server search
|
||||
- Filtering, sorting, and alphabetical jump navigation
|
||||
- Extras — trailers, deleted scenes, behind-the-scenes
|
||||
|
||||
### 📚 Media Browsing
|
||||
- Browse libraries with rich metadata
|
||||
- Advanced search across all media
|
||||
- Collections and playlists
|
||||
|
||||
### 🎬 Playback
|
||||
### <img src="assets/readme_icons/playback.svg" height="20" alt="" align="center" /> Playback
|
||||
- Wide codec support (HEVC, AV1, VP9, and more)
|
||||
- HDR and Dolby Vision (not Linux)
|
||||
- Full ASS/SSA subtitle support
|
||||
- Audio and subtitle preferences synced with Plex profile
|
||||
- HDR and Dolby Vision[^1]
|
||||
- Full ASS/SSA subtitles with customizable styling
|
||||
- Online subtitle search & download[^2]
|
||||
- Audio & subtitle choices remembered per title
|
||||
- Progress sync and resume
|
||||
- Auto-play next episode
|
||||
- Auto-play next episode with skip intro / skip credits
|
||||
- Chapter navigation with thumbnail scrub previews
|
||||
- Playback speed, audio sync offset, sleep timer
|
||||
- Ambient lighting and GLSL shader presets[^3]
|
||||
- Picture-in-Picture[^4]
|
||||
- Refresh-rate matching[^5]
|
||||
- External player launch (VLC, MX Player, etc.)
|
||||
|
||||
### 📺 Live TV & DVR
|
||||
- EPG guide grid
|
||||
- Channel tuning
|
||||
- DVR recording rules and scheduled recordings
|
||||
- Multi-server DVR support
|
||||
### <img src="assets/readme_icons/live-tv.svg" height="20" alt="" align="center" /> Live TV & DVR
|
||||
- Live TV channel browsing with favorites
|
||||
- DVR support with EPG guide, recording rules, and scheduled recordings[^2]
|
||||
- Multi-server Live TV support where available
|
||||
|
||||
### 📥 Downloads
|
||||
### <img src="assets/readme_icons/downloads.svg" height="20" alt="" align="center" /> Downloads & Offline
|
||||
- Download media for offline viewing
|
||||
- Background downloads with queue management
|
||||
- Background queue with pause / resume
|
||||
- Sync rules for automatic downloads
|
||||
- Offline browsing with watch state sync-back on reconnect
|
||||
|
||||
### 👥 Watch Together
|
||||
### <img src="assets/readme_icons/watch-together.svg" height="20" alt="" align="center" /> Watch Together
|
||||
- Synchronized playback with friends
|
||||
- Real-time play/pause and seek sync
|
||||
- Real-time play / pause / seek sync
|
||||
|
||||
### <img src="assets/readme_icons/integrations.svg" height="20" alt="" align="center" /> Integrations
|
||||
- Discord Rich Presence[^7]
|
||||
- Trakt, MyAnimeList, AniList, and Simkl tracking & rating
|
||||
- Plezy Remote — control desktop and TV from mobile
|
||||
- Watch Next row[^6]
|
||||
|
||||
### <img src="assets/readme_icons/customization.svg" height="20" alt="" align="center" /> Platform & Customization
|
||||
- Desktop, mobile, and TV — full D-pad, keyboard, and gamepad support
|
||||
- Customizable keyboard shortcuts[^7]
|
||||
- Metadata and artwork editing[^2]
|
||||
- Settings import/export
|
||||
- Localized in English plus 14 translations
|
||||
|
||||
[^1]: Not available on Linux.
|
||||
[^2]: Plex only.
|
||||
[^3]: Not available on iOS or tvOS.
|
||||
[^4]: Android, iOS, and macOS.
|
||||
[^5]: Windows, Android, and tvOS.
|
||||
[^6]: Android TV only.
|
||||
[^7]: Desktop only.
|
||||
|
||||
## Building from Source
|
||||
|
||||
### Prerequisites
|
||||
- Flutter SDK 3.8.1+
|
||||
- A Plex account with server access
|
||||
- Flutter SDK 3.38.4+
|
||||
- A Plex account or Jellyfin server with user credentials
|
||||
|
||||
### Setup
|
||||
|
||||
@@ -81,20 +119,46 @@ A modern Plex client for desktop and mobile. Built with Flutter for native perfo
|
||||
git clone https://github.com/edde746/plezy.git
|
||||
cd plezy
|
||||
flutter pub get
|
||||
dart run build_runner build
|
||||
scripts/codegen.sh
|
||||
flutter run
|
||||
```
|
||||
|
||||
### Code Generation
|
||||
|
||||
After modifying model classes:
|
||||
After modifying model classes or other generated sources:
|
||||
|
||||
```bash
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
scripts/codegen.sh
|
||||
```
|
||||
|
||||
After modifying translations:
|
||||
|
||||
```bash
|
||||
dart run slang
|
||||
```
|
||||
|
||||
### Local Checks
|
||||
|
||||
```bash
|
||||
scripts/ci_checks.sh
|
||||
```
|
||||
|
||||
To install the same pre-commit checks locally:
|
||||
|
||||
```bash
|
||||
scripts/setup_hooks.sh
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for development workflow, formatting, tests, and translation guidelines.
|
||||
|
||||
## License
|
||||
|
||||
Plezy is licensed under [GPL-3.0](LICENSE).
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- Built with [Flutter](https://flutter.dev)
|
||||
- Designed for [Plex Media Server](https://www.plex.tv)
|
||||
- Playback powered by [mpv](https://mpv.io) via [MPVKit](https://github.com/mpvkit/MPVKit) and [libmpv-android](https://github.com/jarnedemeulemeester/libmpv-android)
|
||||
- Supports [Plex Media Server](https://www.plex.tv) and [Jellyfin](https://jellyfin.org)
|
||||
- Playback powered by [mpv](https://mpv.io), [MPVKit](https://github.com/mpvkit/MPVKit), Android [ExoPlayer](https://developer.android.com/media/media3/exoplayer), [libass-android](https://github.com/peerless2012/libass-android), and [libmpv-android](https://github.com/jarnedemeulemeester/libmpv-android)
|
||||
|
||||
@@ -7,6 +7,16 @@ analyzer:
|
||||
plugins:
|
||||
- dart_code_linter
|
||||
|
||||
linter:
|
||||
rules:
|
||||
unawaited_futures: true
|
||||
cancel_subscriptions: true
|
||||
close_sinks: true
|
||||
use_super_parameters: true
|
||||
prefer_final_locals: true
|
||||
prefer_final_in_for_each: true
|
||||
avoid_print: true
|
||||
|
||||
dart_code_linter:
|
||||
extends:
|
||||
- package:dart_code_linter/presets/recommended.yaml
|
||||
|
||||
@@ -1,191 +1,191 @@
|
||||
import java.util.Properties
|
||||
import java.io.FileInputStream
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
val mpvVersion = "v1.0.6"
|
||||
val mpvVersion = "v1.0.7"
|
||||
val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile
|
||||
val mpvAar = "libmpv-release.aar"
|
||||
|
||||
val downloadLibmpv by tasks.registering {
|
||||
val stamp = File(mpvDir, ".version")
|
||||
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == mpvVersion }
|
||||
doLast {
|
||||
mpvDir.mkdirs()
|
||||
val url = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar"
|
||||
exec { commandLine("curl", "-sfL", url, "-o", File(mpvDir, mpvAar).absolutePath) }
|
||||
stamp.writeText(mpvVersion)
|
||||
}
|
||||
val stamp = File(mpvDir, ".version")
|
||||
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == mpvVersion }
|
||||
doLast {
|
||||
mpvDir.mkdirs()
|
||||
val url = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar"
|
||||
exec { commandLine("curl", "-sfL", url, "-o", File(mpvDir, mpvAar).absolutePath) }
|
||||
stamp.writeText(mpvVersion)
|
||||
}
|
||||
}
|
||||
|
||||
val assVersion = "safety-2"
|
||||
val assVersion = "fp-3"
|
||||
val assDir = layout.buildDirectory.dir("libass").get().asFile
|
||||
val assAars = listOf("lib_ass-release.aar", "lib_ass_kt-release.aar", "lib_ass_media-release.aar")
|
||||
|
||||
val downloadLibass by tasks.registering {
|
||||
val stamp = File(assDir, ".version")
|
||||
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == assVersion }
|
||||
doLast {
|
||||
assDir.mkdirs()
|
||||
val baseUrl = "https://github.com/edde746/libass-android/releases/download/$assVersion"
|
||||
assAars.forEach { name ->
|
||||
val dest = File(assDir, name)
|
||||
exec { commandLine("curl", "-sfL", "$baseUrl/$name", "-o", dest.absolutePath) }
|
||||
}
|
||||
stamp.writeText(assVersion)
|
||||
val stamp = File(assDir, ".version")
|
||||
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == assVersion }
|
||||
doLast {
|
||||
assDir.mkdirs()
|
||||
val baseUrl = "https://github.com/edde746/libass-android/releases/download/$assVersion"
|
||||
assAars.forEach { name ->
|
||||
val dest = File(assDir, name)
|
||||
exec { commandLine("curl", "-sfL", "$baseUrl/$name", "-o", dest.absolutePath) }
|
||||
}
|
||||
stamp.writeText(assVersion)
|
||||
}
|
||||
}
|
||||
|
||||
val doviVersion = "2.3.1"
|
||||
val doviDir = layout.buildDirectory.dir("libdovi").get().asFile
|
||||
val doviAbis = mapOf(
|
||||
"arm64-v8a" to "aarch64-linux-android",
|
||||
"armeabi-v7a" to "armv7-linux-androideabi",
|
||||
"x86" to "i686-linux-android",
|
||||
"x86_64" to "x86_64-linux-android",
|
||||
"arm64-v8a" to "aarch64-linux-android",
|
||||
"armeabi-v7a" to "armv7-linux-androideabi",
|
||||
"x86" to "i686-linux-android",
|
||||
"x86_64" to "x86_64-linux-android"
|
||||
)
|
||||
|
||||
val downloadLibdovi by tasks.registering {
|
||||
val stamp = File(doviDir, ".version")
|
||||
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == doviVersion }
|
||||
doLast {
|
||||
doviDir.mkdirs()
|
||||
val baseUrl = "https://github.com/edde746/libdovi-builds/releases/download/v$doviVersion"
|
||||
doviAbis.forEach { (abi, triple) ->
|
||||
val archive = File(doviDir, "$triple.tar.gz")
|
||||
exec { commandLine("curl", "-sfL", "$baseUrl/libdovi-$triple.tar.gz", "-o", archive.absolutePath) }
|
||||
val outDir = File(doviDir, "$abi/lib")
|
||||
outDir.mkdirs()
|
||||
exec { commandLine("tar", "-xzf", archive.absolutePath, "-C", outDir.absolutePath) }
|
||||
archive.delete()
|
||||
}
|
||||
stamp.writeText(doviVersion)
|
||||
val stamp = File(doviDir, ".version")
|
||||
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == doviVersion }
|
||||
doLast {
|
||||
doviDir.mkdirs()
|
||||
val baseUrl = "https://github.com/edde746/libdovi-builds/releases/download/v$doviVersion"
|
||||
doviAbis.forEach { (abi, triple) ->
|
||||
val archive = File(doviDir, "$triple.tar.gz")
|
||||
exec { commandLine("curl", "-sfL", "$baseUrl/libdovi-$triple.tar.gz", "-o", archive.absolutePath) }
|
||||
val outDir = File(doviDir, "$abi/lib")
|
||||
outDir.mkdirs()
|
||||
exec { commandLine("tar", "-xzf", archive.absolutePath, "-C", outDir.absolutePath) }
|
||||
archive.delete()
|
||||
}
|
||||
stamp.writeText(doviVersion)
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.edde746.plezy"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
namespace = "com.edde746.plezy"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.edde746.plezy"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = 25 // Fire OS 6.x (API 25); overrides libmpv-android's minSdk=26
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
arguments += listOf(
|
||||
"-DDOVI_ENABLE_LIBDOVI=ON",
|
||||
"-DDOVI_LIBDOVI_PREBUILT_ROOT=${doviDir.absolutePath}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (System.getenv("AMAZON") != null) {
|
||||
versionCode = (flutter.versionCode ?: 0) + 3000
|
||||
ndk {
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a")
|
||||
}
|
||||
}
|
||||
}
|
||||
defaultConfig {
|
||||
applicationId = "com.edde746.plezy"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = 25 // Fire OS 6.x (API 25); overrides libmpv-android's minSdk=26
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path = file("src/main/cpp/CMakeLists.txt")
|
||||
}
|
||||
cmake {
|
||||
arguments += listOf(
|
||||
"-DDOVI_ENABLE_LIBDOVI=ON",
|
||||
"-DDOVI_LIBDOVI_PREBUILT_ROOT=${doviDir.absolutePath}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
val keystoreProperties = Properties()
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
}
|
||||
}
|
||||
if (System.getenv("AMAZON") != null) {
|
||||
versionCode = (flutter.versionCode ?: 0) + 3000
|
||||
ndk {
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// Only use release signing if key.properties exists (not in CI/CD)
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
// If key.properties doesn't exist, it will use debug signing for CI builds
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
}
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path = file("src/main/cpp/CMakeLists.txt")
|
||||
}
|
||||
}
|
||||
|
||||
packaging {
|
||||
jniLibs {
|
||||
// Resolve conflict between libass-android and libmpv native libraries
|
||||
pickFirsts.add("lib/*/libc++_shared.so")
|
||||
}
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
val keystoreProperties = Properties()
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// Only use release signing if key.properties exists (not in CI/CD)
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
// If key.properties doesn't exist, it will use debug signing for CI builds
|
||||
ndk {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
packaging {
|
||||
jniLibs {
|
||||
// Resolve conflict between libass-android and libmpv native libraries
|
||||
pickFirsts.add("lib/*/libc++_shared.so")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
// Download libdovi before any CMake/native build task
|
||||
tasks.matching { it.name.contains("CMake") || it.name.contains("externalNative") }.configureEach {
|
||||
dependsOn(downloadLibdovi)
|
||||
dependsOn(downloadLibdovi)
|
||||
}
|
||||
|
||||
// Download libmpv and libass AARs before compilation
|
||||
tasks.matching { it.name.startsWith("pre") && it.name.endsWith("Build") }.configureEach {
|
||||
dependsOn(downloadLibmpv)
|
||||
dependsOn(downloadLibass)
|
||||
dependsOn(downloadLibmpv)
|
||||
dependsOn(downloadLibass)
|
||||
}
|
||||
|
||||
|
||||
dependencies {
|
||||
implementation(files(File(mpvDir, mpvAar)))
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
implementation(files(File(mpvDir, mpvAar)))
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
|
||||
// Android TV Watch Next integration
|
||||
implementation("androidx.tvprovider:tvprovider:1.0.0")
|
||||
// Android TV Watch Next integration
|
||||
implementation("androidx.tvprovider:tvprovider:1.0.0")
|
||||
|
||||
// Media3 ExoPlayer for Android
|
||||
implementation("androidx.media3:media3-exoplayer:1.9.2")
|
||||
implementation("androidx.media3:media3-ui:1.9.2")
|
||||
implementation("androidx.media3:media3-common:1.9.2")
|
||||
// Media3 ExoPlayer for Android
|
||||
implementation("androidx.media3:media3-exoplayer:1.9.2")
|
||||
implementation("androidx.media3:media3-exoplayer-hls:1.9.2")
|
||||
implementation("androidx.media3:media3-ui:1.9.2")
|
||||
implementation("androidx.media3:media3-common:1.9.2")
|
||||
|
||||
// Cronet for HTTP/2 multiplexing + better connection management
|
||||
implementation("androidx.media3:media3-datasource-cronet:1.9.2")
|
||||
implementation("org.chromium.net:cronet-embedded:143.7445.0")
|
||||
// Cronet for HTTP/2 multiplexing + better connection management
|
||||
implementation("androidx.media3:media3-datasource-cronet:1.9.2")
|
||||
implementation("org.chromium.net:cronet-embedded:143.7445.0")
|
||||
|
||||
// FFmpeg audio decoder for unsupported codecs (ALAC, DTS, TrueHD, etc.)
|
||||
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1")
|
||||
// FFmpeg audio decoder for unsupported codecs (ALAC, DTS, TrueHD, etc.)
|
||||
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.9.0+1")
|
||||
|
||||
// libass-android for ASS/SSA subtitle rendering
|
||||
assAars.forEach { implementation(files(File(assDir, it))) }
|
||||
// libass-android for ASS/SSA subtitle rendering
|
||||
assAars.forEach { implementation(files(File(assDir, it))) }
|
||||
}
|
||||
|
||||
@@ -6,12 +6,11 @@
|
||||
<!-- Internet access permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
<!-- Camera permission for QR code scanning (companion remote pairing) -->
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
|
||||
<!-- PIP and media session foreground service permissions -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
<uses-permission android:name="android.permission.REORDER_TASKS"/>
|
||||
|
||||
<!-- Background download notifications and foreground service -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
@@ -23,6 +22,7 @@
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="remove" />
|
||||
|
||||
|
||||
<!-- Android TV support (not required, but allow detection) -->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
<!-- Android TV Watch Next integration -->
|
||||
@@ -59,6 +59,9 @@
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<meta-data
|
||||
android:name="flutter_deeplinking_enabled"
|
||||
android:value="false" />
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#include <jni.h>
|
||||
#include <android/log.h>
|
||||
#include <jni.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <new>
|
||||
#include <vector>
|
||||
|
||||
#define TAG "DoviBridge"
|
||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
|
||||
@@ -11,95 +14,133 @@
|
||||
#endif
|
||||
|
||||
static const char* BRIDGE_VERSION = "1.0.0";
|
||||
static constexpr jint CONVERT_FAILED = -1;
|
||||
static constexpr jint DESTINATION_TOO_SMALL = -2;
|
||||
static constexpr jint MAX_RPU_INPUT_SIZE = 8192;
|
||||
static constexpr size_t MAX_RPU_OUTPUT_SIZE = 16384;
|
||||
|
||||
extern "C" JNIEXPORT jbyteArray JNICALL
|
||||
Java_com_edde746_plezy_exoplayer_DoviBridge_nativeConvertDv7RpuToDv81(
|
||||
JNIEnv *env, jclass, jbyteArray payload, jint mode) {
|
||||
extern "C" JNIEXPORT jint JNICALL Java_com_edde746_plezy_exoplayer_DoviBridge_nativeConvertDv7RpuToDv81(
|
||||
JNIEnv* env, jclass, jbyteArray payload, jint payload_offset, jint payload_length, jbyteArray output,
|
||||
jint output_offset, jint output_capacity, jint mode) {
|
||||
#if !DOVI_REAL_LINKED
|
||||
return nullptr;
|
||||
return CONVERT_FAILED;
|
||||
#else
|
||||
if (payload == nullptr) return nullptr;
|
||||
if (payload == nullptr || output == nullptr) return CONVERT_FAILED;
|
||||
if (payload_offset < 0 || payload_length <= 0 || output_offset < 0 || output_capacity < 0) {
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
jsize len = env->GetArrayLength(payload);
|
||||
if (len <= 0) return nullptr;
|
||||
const jsize payload_array_len = env->GetArrayLength(payload);
|
||||
const jsize output_array_len = env->GetArrayLength(output);
|
||||
if (payload_offset > payload_array_len || payload_length > payload_array_len - payload_offset) {
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
if (output_offset > output_array_len) return DESTINATION_TOO_SMALL;
|
||||
|
||||
jbyte *buf = env->GetByteArrayElements(payload, nullptr);
|
||||
if (buf == nullptr) return nullptr;
|
||||
const jsize logical_output_len = output_capacity < output_array_len ? output_capacity : output_array_len;
|
||||
if (output_offset > logical_output_len) return DESTINATION_TOO_SMALL;
|
||||
|
||||
// Try dovi_parse_unspec62_nalu first (handles escaped NALs), fallback to dovi_parse_rpu
|
||||
DoviRpuOpaque *rpu = dovi_parse_unspec62_nalu(
|
||||
reinterpret_cast<const uint8_t *>(buf), static_cast<size_t>(len));
|
||||
// Valid RPU NALs are typically <2 KiB; reject unreasonable sizes
|
||||
if (payload_length > MAX_RPU_INPUT_SIZE) {
|
||||
LOGW("RPU payload too large (%d bytes), skipping", payload_length);
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
// Copy to native heap so libdovi never touches JVM heap memory.
|
||||
// Do not use GetPrimitiveArrayCritical here: it can block concurrent GC
|
||||
// compaction during sustained playback. A thread-local scratch buffer avoids
|
||||
// per-frame heap churn while keeping libdovi away from JVM heap memory.
|
||||
thread_local std::vector<uint8_t> scratch;
|
||||
try {
|
||||
scratch.resize(static_cast<size_t>(payload_length));
|
||||
} catch (...) {
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
env->GetByteArrayRegion(payload, payload_offset, payload_length, reinterpret_cast<jbyte*>(scratch.data()));
|
||||
if (env->ExceptionCheck()) {
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
// Try dovi_parse_unspec62_nalu first (handles escaped NALs), fallback to dovi_parse_rpu
|
||||
const auto rpu_len = static_cast<size_t>(payload_length);
|
||||
DoviRpuOpaque* rpu = dovi_parse_unspec62_nalu(scratch.data(), rpu_len);
|
||||
|
||||
if (rpu == nullptr) {
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
const char* err = dovi_rpu_get_error(rpu);
|
||||
if (err != nullptr) {
|
||||
// Fallback: try dovi_parse_rpu (raw RPU without NAL framing)
|
||||
dovi_rpu_free(rpu);
|
||||
rpu = dovi_parse_rpu(scratch.data(), rpu_len);
|
||||
if (rpu == nullptr) {
|
||||
env->ReleaseByteArrayElements(payload, buf, JNI_ABORT);
|
||||
return nullptr;
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
const char *err = dovi_rpu_get_error(rpu);
|
||||
err = dovi_rpu_get_error(rpu);
|
||||
if (err != nullptr) {
|
||||
// Fallback: try dovi_parse_rpu (raw RPU without NAL framing)
|
||||
dovi_rpu_free(rpu);
|
||||
rpu = dovi_parse_rpu(
|
||||
reinterpret_cast<const uint8_t *>(buf), static_cast<size_t>(len));
|
||||
if (rpu == nullptr) {
|
||||
env->ReleaseByteArrayElements(payload, buf, JNI_ABORT);
|
||||
return nullptr;
|
||||
}
|
||||
err = dovi_rpu_get_error(rpu);
|
||||
if (err != nullptr) {
|
||||
LOGW("RPU parse failed: %s", err);
|
||||
dovi_rpu_free(rpu);
|
||||
env->ReleaseByteArrayElements(payload, buf, JNI_ABORT);
|
||||
return nullptr;
|
||||
}
|
||||
LOGW("RPU parse failed: %s", err);
|
||||
dovi_rpu_free(rpu);
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
env->ReleaseByteArrayElements(payload, buf, JNI_ABORT);
|
||||
// Mode 2 matches Kodi's P8.1 compatibility path and sets luma/chroma curves to no-op.
|
||||
int32_t ret = dovi_convert_rpu_with_mode(rpu, static_cast<uint8_t>(mode));
|
||||
if (ret != 0) {
|
||||
err = dovi_rpu_get_error(rpu);
|
||||
LOGW("RPU conversion failed (mode %d): %s", mode, err ? err : "unknown");
|
||||
dovi_rpu_free(rpu);
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
// Convert to target profile (mode 2 = P8.1 with no-op curves)
|
||||
int32_t ret = dovi_convert_rpu_with_mode(rpu, static_cast<uint8_t>(mode));
|
||||
if (ret != 0) {
|
||||
err = dovi_rpu_get_error(rpu);
|
||||
LOGW("RPU conversion failed (mode %d): %s", mode, err ? err : "unknown");
|
||||
dovi_rpu_free(rpu);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Write back as UNSPEC62 NAL
|
||||
const DoviData *out = dovi_write_unspec62_nalu(rpu);
|
||||
if (out == nullptr || out->data == nullptr || out->len == 0) {
|
||||
err = dovi_rpu_get_error(rpu);
|
||||
LOGW("RPU write failed: %s", err ? err : "unknown");
|
||||
if (out != nullptr) dovi_data_free(out);
|
||||
dovi_rpu_free(rpu);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
jbyteArray result = env->NewByteArray(static_cast<jsize>(out->len));
|
||||
if (result != nullptr) {
|
||||
env->SetByteArrayRegion(result, 0, static_cast<jsize>(out->len),
|
||||
reinterpret_cast<const jbyte *>(out->data));
|
||||
}
|
||||
// Write back as UNSPEC62 NAL
|
||||
const DoviData* out = dovi_write_unspec62_nalu(rpu);
|
||||
if (out == nullptr || out->data == nullptr || out->len == 0) {
|
||||
err = dovi_rpu_get_error(rpu);
|
||||
LOGW("RPU write failed: %s", err ? err : "unknown");
|
||||
if (out != nullptr) dovi_data_free(out);
|
||||
dovi_rpu_free(rpu);
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
if (out->len > MAX_RPU_OUTPUT_SIZE) {
|
||||
LOGW("RPU output unexpectedly large (%zu bytes), discarding", out->len);
|
||||
dovi_data_free(out);
|
||||
dovi_rpu_free(rpu);
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
return result;
|
||||
const auto writable = static_cast<size_t>(logical_output_len - output_offset);
|
||||
if (out->len > writable) {
|
||||
dovi_data_free(out);
|
||||
dovi_rpu_free(rpu);
|
||||
return DESTINATION_TOO_SMALL;
|
||||
}
|
||||
|
||||
env->SetByteArrayRegion(
|
||||
output, output_offset, static_cast<jsize>(out->len), reinterpret_cast<const jbyte*>(out->data));
|
||||
const bool write_failed = env->ExceptionCheck();
|
||||
const auto written = static_cast<jint>(out->len);
|
||||
|
||||
dovi_data_free(out);
|
||||
dovi_rpu_free(rpu);
|
||||
|
||||
return write_failed ? CONVERT_FAILED : written;
|
||||
#endif
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jboolean JNICALL
|
||||
Java_com_edde746_plezy_exoplayer_DoviBridge_nativeIsConversionPathReady(
|
||||
JNIEnv *, jclass) {
|
||||
Java_com_edde746_plezy_exoplayer_DoviBridge_nativeIsConversionPathReady(JNIEnv*, jclass) {
|
||||
#if DOVI_REAL_LINKED
|
||||
return JNI_TRUE;
|
||||
return JNI_TRUE;
|
||||
#else
|
||||
return JNI_FALSE;
|
||||
return JNI_FALSE;
|
||||
#endif
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jstring JNICALL
|
||||
Java_com_edde746_plezy_exoplayer_DoviBridge_nativeGetBridgeVersion(
|
||||
JNIEnv *env, jclass) {
|
||||
return env->NewStringUTF(BRIDGE_VERSION);
|
||||
Java_com_edde746_plezy_exoplayer_DoviBridge_nativeGetBridgeVersion(JNIEnv* env, jclass) {
|
||||
return env->NewStringUTF(BRIDGE_VERSION);
|
||||
}
|
||||
|
||||
@@ -19,149 +19,151 @@ import androidx.media3.extractor.TrackOutput
|
||||
*/
|
||||
@androidx.media3.common.util.UnstableApi
|
||||
class CuelessSeekExtractorWrapper(
|
||||
private val delegate: Extractor,
|
||||
private val delegate: Extractor
|
||||
) : Extractor {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CuelessSeek"
|
||||
// MKV Cluster element ID: 0x1F43B675 (4-byte EBML Class-D ID)
|
||||
private val CLUSTER_ID = byteArrayOf(0x1F, 0x43, 0xB6.toByte(), 0x75)
|
||||
private const val SCAN_BUFFER_SIZE = 8192
|
||||
// Max bytes to scan for a Cluster boundary before giving up
|
||||
private const val MAX_SCAN_BYTES = 1024 * 1024 // 1 MB
|
||||
companion object {
|
||||
private const val TAG = "CuelessSeek"
|
||||
|
||||
// MKV Cluster element ID: 0x1F43B675 (4-byte EBML Class-D ID)
|
||||
private val CLUSTER_ID = byteArrayOf(0x1F, 0x43, 0xB6.toByte(), 0x75)
|
||||
private const val SCAN_BUFFER_SIZE = 8192
|
||||
|
||||
// Max bytes to scan for a Cluster boundary before giving up
|
||||
private const val MAX_SCAN_BYTES = 1024 * 1024 // 1 MB
|
||||
}
|
||||
|
||||
private var inputLength: Long = C.LENGTH_UNSET.toLong()
|
||||
private var needsClusterResync = false
|
||||
private var isApproximateSeeking = false
|
||||
private var pendingSeekTimeUs: Long = C.TIME_UNSET
|
||||
|
||||
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
|
||||
|
||||
override fun init(output: ExtractorOutput) {
|
||||
delegate.init(SeekInterceptingOutput(output))
|
||||
}
|
||||
|
||||
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int {
|
||||
if (inputLength == C.LENGTH_UNSET.toLong()) {
|
||||
inputLength = input.length
|
||||
}
|
||||
|
||||
private var inputLength: Long = C.LENGTH_UNSET.toLong()
|
||||
private var needsClusterResync = false
|
||||
private var isApproximateSeeking = false
|
||||
private var pendingSeekTimeUs: Long = C.TIME_UNSET
|
||||
|
||||
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
|
||||
|
||||
override fun init(output: ExtractorOutput) {
|
||||
delegate.init(SeekInterceptingOutput(output))
|
||||
if (needsClusterResync) {
|
||||
needsClusterResync = false
|
||||
return scanForCluster(input, seekPosition)
|
||||
}
|
||||
return delegate.read(input, seekPosition)
|
||||
}
|
||||
|
||||
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int {
|
||||
if (inputLength == C.LENGTH_UNSET.toLong()) {
|
||||
inputLength = input.length
|
||||
override fun seek(position: Long, timeUs: Long) {
|
||||
if (isApproximateSeeking && position > 0) {
|
||||
needsClusterResync = true
|
||||
pendingSeekTimeUs = timeUs
|
||||
}
|
||||
delegate.seek(position, timeUs)
|
||||
}
|
||||
|
||||
override fun release() = delegate.release()
|
||||
|
||||
/**
|
||||
* Scan forward from the current input position to find the next MKV Cluster
|
||||
* element ID (0x1F43B675). Returns [Extractor.RESULT_SEEK] with the Cluster's
|
||||
* byte position so ExoPlayer repositions the DataSource there.
|
||||
*/
|
||||
private fun scanForCluster(input: ExtractorInput, seekPosition: PositionHolder): Int {
|
||||
val buffer = ByteArray(SCAN_BUFFER_SIZE)
|
||||
var totalScanned = 0L
|
||||
// Carry over last 3 bytes across buffer boundaries to detect split IDs
|
||||
var carry = ByteArray(0)
|
||||
|
||||
while (totalScanned < MAX_SCAN_BYTES) {
|
||||
val toRead = minOf(SCAN_BUFFER_SIZE, (MAX_SCAN_BYTES - totalScanned).toInt())
|
||||
val bytesRead: Int
|
||||
try {
|
||||
bytesRead = input.read(buffer, 0, toRead)
|
||||
} catch (_: Exception) {
|
||||
break
|
||||
}
|
||||
if (bytesRead == C.RESULT_END_OF_INPUT) break
|
||||
|
||||
// Combine carry + new data for scanning
|
||||
val scanData = if (carry.isNotEmpty()) carry + buffer.copyOf(bytesRead) else buffer.copyOf(bytesRead)
|
||||
|
||||
for (i in 0..scanData.size - 4) {
|
||||
if (scanData[i] == CLUSTER_ID[0] &&
|
||||
scanData[i + 1] == CLUSTER_ID[1] &&
|
||||
scanData[i + 2] == CLUSTER_ID[2] &&
|
||||
scanData[i + 3] == CLUSTER_ID[3]
|
||||
) {
|
||||
// Compute the absolute byte position of this Cluster
|
||||
val clusterPosition = input.position - bytesRead - carry.size + i
|
||||
Log.d(TAG, "Found Cluster at byte $clusterPosition (scanned ${totalScanned + i} bytes)")
|
||||
seekPosition.position = clusterPosition
|
||||
delegate.seek(clusterPosition, pendingSeekTimeUs)
|
||||
pendingSeekTimeUs = C.TIME_UNSET
|
||||
return Extractor.RESULT_SEEK
|
||||
}
|
||||
if (needsClusterResync) {
|
||||
needsClusterResync = false
|
||||
return scanForCluster(input, seekPosition)
|
||||
}
|
||||
return delegate.read(input, seekPosition)
|
||||
}
|
||||
|
||||
// Keep last 3 bytes as carry for next iteration
|
||||
carry = if (scanData.size >= 3) scanData.copyOfRange(scanData.size - 3, scanData.size) else scanData.copyOf()
|
||||
totalScanned += bytesRead
|
||||
}
|
||||
|
||||
override fun seek(position: Long, timeUs: Long) {
|
||||
if (isApproximateSeeking && position > 0) {
|
||||
needsClusterResync = true
|
||||
pendingSeekTimeUs = timeUs
|
||||
// Failed to find a Cluster — fall back to position 0
|
||||
Log.w(TAG, "No Cluster found after scanning $totalScanned bytes, resetting to start")
|
||||
seekPosition.position = 0
|
||||
delegate.seek(0, 0)
|
||||
return Extractor.RESULT_SEEK
|
||||
}
|
||||
|
||||
/**
|
||||
* ExtractorOutput wrapper that intercepts [seekMap] calls to replace
|
||||
* [SeekMap.Unseekable] with an approximate proportional SeekMap.
|
||||
*/
|
||||
private inner class SeekInterceptingOutput(
|
||||
private val delegate: ExtractorOutput
|
||||
) : ExtractorOutput {
|
||||
|
||||
override fun track(id: Int, type: Int): TrackOutput = delegate.track(id, type)
|
||||
override fun endTracks() = delegate.endTracks()
|
||||
|
||||
override fun seekMap(seekMap: SeekMap) {
|
||||
if (seekMap is SeekMap.Unseekable) {
|
||||
val durationUs = seekMap.durationUs
|
||||
if (durationUs != C.TIME_UNSET && durationUs > 0) {
|
||||
Log.i(TAG, "Replacing Unseekable with approximate SeekMap (duration=${durationUs / 1_000_000}s)")
|
||||
isApproximateSeeking = true
|
||||
delegate.seekMap(ApproximateSeekMap(durationUs))
|
||||
return
|
||||
}
|
||||
delegate.seek(position, timeUs)
|
||||
}
|
||||
// File has real Cues or unknown duration — pass through
|
||||
isApproximateSeeking = false
|
||||
delegate.seekMap(seekMap)
|
||||
}
|
||||
}
|
||||
|
||||
override fun release() = delegate.release()
|
||||
/**
|
||||
* Approximate SeekMap that estimates byte positions proportionally.
|
||||
* Used when the MKV has no Cues but has a known duration.
|
||||
*/
|
||||
private inner class ApproximateSeekMap(
|
||||
private val durationUs: Long
|
||||
) : SeekMap {
|
||||
|
||||
/**
|
||||
* Scan forward from the current input position to find the next MKV Cluster
|
||||
* element ID (0x1F43B675). Returns [Extractor.RESULT_SEEK] with the Cluster's
|
||||
* byte position so ExoPlayer repositions the DataSource there.
|
||||
*/
|
||||
private fun scanForCluster(input: ExtractorInput, seekPosition: PositionHolder): Int {
|
||||
val buffer = ByteArray(SCAN_BUFFER_SIZE)
|
||||
var totalScanned = 0L
|
||||
// Carry over last 3 bytes across buffer boundaries to detect split IDs
|
||||
var carry = ByteArray(0)
|
||||
override fun isSeekable(): Boolean = true
|
||||
|
||||
while (totalScanned < MAX_SCAN_BYTES) {
|
||||
val toRead = minOf(SCAN_BUFFER_SIZE, (MAX_SCAN_BYTES - totalScanned).toInt())
|
||||
val bytesRead: Int
|
||||
try {
|
||||
bytesRead = input.read(buffer, 0, toRead)
|
||||
} catch (_: Exception) {
|
||||
break
|
||||
}
|
||||
if (bytesRead == C.RESULT_END_OF_INPUT) break
|
||||
override fun getDurationUs(): Long = durationUs
|
||||
|
||||
// Combine carry + new data for scanning
|
||||
val scanData = if (carry.isNotEmpty()) carry + buffer.copyOf(bytesRead) else buffer.copyOf(bytesRead)
|
||||
|
||||
for (i in 0..scanData.size - 4) {
|
||||
if (scanData[i] == CLUSTER_ID[0] &&
|
||||
scanData[i + 1] == CLUSTER_ID[1] &&
|
||||
scanData[i + 2] == CLUSTER_ID[2] &&
|
||||
scanData[i + 3] == CLUSTER_ID[3]
|
||||
) {
|
||||
// Compute the absolute byte position of this Cluster
|
||||
val clusterPosition = input.position - bytesRead - carry.size + i
|
||||
Log.d(TAG, "Found Cluster at byte $clusterPosition (scanned ${totalScanned + i} bytes)")
|
||||
seekPosition.position = clusterPosition
|
||||
delegate.seek(clusterPosition, pendingSeekTimeUs)
|
||||
pendingSeekTimeUs = C.TIME_UNSET
|
||||
return Extractor.RESULT_SEEK
|
||||
}
|
||||
}
|
||||
|
||||
// Keep last 3 bytes as carry for next iteration
|
||||
carry = if (scanData.size >= 3) scanData.copyOfRange(scanData.size - 3, scanData.size) else scanData.copyOf()
|
||||
totalScanned += bytesRead
|
||||
}
|
||||
|
||||
// Failed to find a Cluster — fall back to position 0
|
||||
Log.w(TAG, "No Cluster found after scanning $totalScanned bytes, resetting to start")
|
||||
seekPosition.position = 0
|
||||
delegate.seek(0, 0)
|
||||
return Extractor.RESULT_SEEK
|
||||
}
|
||||
|
||||
/**
|
||||
* ExtractorOutput wrapper that intercepts [seekMap] calls to replace
|
||||
* [SeekMap.Unseekable] with an approximate proportional SeekMap.
|
||||
*/
|
||||
private inner class SeekInterceptingOutput(
|
||||
private val delegate: ExtractorOutput,
|
||||
) : ExtractorOutput {
|
||||
|
||||
override fun track(id: Int, type: Int): TrackOutput = delegate.track(id, type)
|
||||
override fun endTracks() = delegate.endTracks()
|
||||
|
||||
override fun seekMap(seekMap: SeekMap) {
|
||||
if (seekMap is SeekMap.Unseekable) {
|
||||
val durationUs = seekMap.durationUs
|
||||
if (durationUs != C.TIME_UNSET && durationUs > 0) {
|
||||
Log.i(TAG, "Replacing Unseekable with approximate SeekMap (duration=${durationUs / 1_000_000}s)")
|
||||
isApproximateSeeking = true
|
||||
delegate.seekMap(ApproximateSeekMap(durationUs))
|
||||
return
|
||||
}
|
||||
}
|
||||
// File has real Cues or unknown duration — pass through
|
||||
isApproximateSeeking = false
|
||||
delegate.seekMap(seekMap)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Approximate SeekMap that estimates byte positions proportionally.
|
||||
* Used when the MKV has no Cues but has a known duration.
|
||||
*/
|
||||
private inner class ApproximateSeekMap(
|
||||
private val durationUs: Long,
|
||||
) : SeekMap {
|
||||
|
||||
override fun isSeekable(): Boolean = true
|
||||
|
||||
override fun getDurationUs(): Long = durationUs
|
||||
|
||||
override fun getSeekPoints(timeUs: Long): SeekMap.SeekPoints {
|
||||
val length = inputLength
|
||||
if (length == C.LENGTH_UNSET.toLong() || durationUs <= 0) {
|
||||
return SeekMap.SeekPoints(SeekPoint(0, 0))
|
||||
}
|
||||
val clampedTimeUs = timeUs.coerceIn(0, durationUs)
|
||||
val position = (clampedTimeUs.toDouble() / durationUs * length).toLong().coerceIn(0, length)
|
||||
return SeekMap.SeekPoints(SeekPoint(clampedTimeUs, position))
|
||||
}
|
||||
override fun getSeekPoints(timeUs: Long): SeekMap.SeekPoints {
|
||||
val length = inputLength
|
||||
if (length == C.LENGTH_UNSET.toLong() || durationUs <= 0) {
|
||||
return SeekMap.SeekPoints(SeekPoint(0, 0))
|
||||
}
|
||||
val clampedTimeUs = timeUs.coerceIn(0, durationUs)
|
||||
val position = (clampedTimeUs.toDouble() / durationUs * length).toLong().coerceIn(0, length)
|
||||
return SeekMap.SeekPoints(SeekPoint(clampedTimeUs, position))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +1,362 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaCodecList
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
import android.view.WindowManager
|
||||
|
||||
enum class DvConversionMode { DISABLED, DV81, HEVC_STRIP }
|
||||
|
||||
object DoviBridge {
|
||||
private const val TAG = "DoviBridge"
|
||||
private const val TAG = "DoviBridge"
|
||||
|
||||
private val nativeLoaded: Boolean by lazy {
|
||||
try {
|
||||
System.loadLibrary("dovi_bridge")
|
||||
true
|
||||
} catch (_: UnsatisfiedLinkError) {
|
||||
Log.w(TAG, "Native lib not found")
|
||||
false
|
||||
data class DvAutoDecision(
|
||||
val mode: DvConversionMode,
|
||||
val reason: String,
|
||||
val bridgeReady: Boolean,
|
||||
val displayDv: Boolean,
|
||||
val nativeDecoder: Boolean,
|
||||
val advertisedP7: Boolean,
|
||||
val advertisedP8: Boolean,
|
||||
val decoders: String,
|
||||
val displayHdr: String
|
||||
) {
|
||||
fun logMessage(): String = "AUTO P7 DV decision: mode=$mode; reason=$reason; bridgeReady=$bridgeReady, " +
|
||||
"displayDV=$displayDv, nativeDecoder=$nativeDecoder, advertisedP7=$advertisedP7, " +
|
||||
"advertisedP8=$advertisedP8, decoders=$decoders, displayHdr=$displayHdr"
|
||||
}
|
||||
|
||||
data class Dv7FallbackDecision(
|
||||
val mode: DvConversionMode,
|
||||
val reason: String,
|
||||
val bridgeReady: Boolean,
|
||||
val displayDv: Boolean,
|
||||
val advertisedP8: Boolean,
|
||||
val displayHdr: String
|
||||
) {
|
||||
fun logMessage(): String = "DV7 fallback decision: mode=$mode; reason=$reason; bridgeReady=$bridgeReady, " +
|
||||
"displayDV=$displayDv, advertisedP8=$advertisedP8, displayHdr=$displayHdr"
|
||||
}
|
||||
|
||||
private val DOLBY_VISION_MIME_TYPES = setOf(
|
||||
"video/dolby-vision",
|
||||
"video/hevcdv",
|
||||
"video/dv_hevc"
|
||||
)
|
||||
|
||||
const val CONVERT_FAILED = -1
|
||||
const val DESTINATION_TOO_SMALL = -2
|
||||
|
||||
private data class DvProfileLevel(val profile: Int, val level: Int)
|
||||
|
||||
private data class DvDecoderCapability(
|
||||
val name: String,
|
||||
val mimeType: String,
|
||||
val profileLevels: List<DvProfileLevel>
|
||||
)
|
||||
|
||||
private val nativeLoaded: Boolean by lazy {
|
||||
try {
|
||||
System.loadLibrary("dovi_bridge")
|
||||
true
|
||||
} catch (_: UnsatisfiedLinkError) {
|
||||
Log.w(TAG, "Native lib not found")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private val conversionPathReady: Boolean by lazy {
|
||||
nativeLoaded && runCatching { nativeIsConversionPathReady() }.getOrDefault(false)
|
||||
}
|
||||
|
||||
fun isAvailable(): Boolean = conversionPathReady
|
||||
|
||||
private val dolbyVisionDecoders: List<DvDecoderCapability> by lazy {
|
||||
try {
|
||||
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
|
||||
codecList.codecInfos.flatMap { info ->
|
||||
if (info.isEncoder) {
|
||||
emptyList()
|
||||
} else {
|
||||
info.supportedTypes.mapNotNull { type ->
|
||||
if (!DOLBY_VISION_MIME_TYPES.contains(type.lowercase())) return@mapNotNull null
|
||||
val capabilities = runCatching { info.getCapabilitiesForType(type) }
|
||||
.onFailure { Log.w(TAG, "Failed to query ${info.name} capabilities for $type", it) }
|
||||
.getOrNull()
|
||||
?: return@mapNotNull null
|
||||
DvDecoderCapability(
|
||||
name = info.name,
|
||||
mimeType = type,
|
||||
profileLevels = capabilities.profileLevels.map { DvProfileLevel(it.profile, it.level) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to query native Dolby Vision decoder support", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
val hasNativeDolbyVisionDecoder: Boolean by lazy {
|
||||
dolbyVisionDecoders.isNotEmpty().also {
|
||||
Log.i(TAG, "Native Dolby Vision decoder available: $it; decoders=${describeDolbyVisionDecoders()}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun deviceAdvertisesDvProfile(profile: Int, minApi: Int = 0): Boolean {
|
||||
if (Build.VERSION.SDK_INT < minApi) return false
|
||||
return dolbyVisionDecoders.any { decoder ->
|
||||
decoder.profileLevels.any { it.profile == profile }
|
||||
}
|
||||
}
|
||||
|
||||
val deviceSupportsDvProfile7: Boolean by lazy {
|
||||
deviceAdvertisesDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtb)
|
||||
.also { Log.i(TAG, "Device advertises exact DV Profile 7 (DvheDtb): $it") }
|
||||
}
|
||||
|
||||
val deviceSupportsDvProfile8: Boolean by lazy {
|
||||
deviceAdvertisesDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheSt, minApi = 27)
|
||||
.also { Log.i(TAG, "Device advertises DV Profile 8 (DvheSt): $it") }
|
||||
}
|
||||
|
||||
fun displaySupportsDolbyVision(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
|
||||
Log.i(TAG, "Display Dolby Vision support: false (HDR capabilities require API 24, device API=${Build.VERSION.SDK_INT})")
|
||||
return false
|
||||
}
|
||||
|
||||
fun isAvailable(): Boolean = nativeLoaded &&
|
||||
runCatching { nativeIsConversionPathReady() }.getOrDefault(false)
|
||||
val display = getCurrentDisplay(context)
|
||||
if (display == null) {
|
||||
Log.i(TAG, "Display Dolby Vision support: false (no active display)")
|
||||
return false
|
||||
}
|
||||
|
||||
private fun deviceSupportsDvProfile(profile: Int, minApi: Int = 0): Boolean {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT < minApi) return false
|
||||
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
|
||||
return codecList.codecInfos.any { info ->
|
||||
!info.isEncoder && info.supportedTypes.any { type ->
|
||||
type.equals("video/dolby-vision", ignoreCase = true) &&
|
||||
info.getCapabilitiesForType(type).profileLevels.any { it.profile == profile }
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to query DV profile $profile support", e)
|
||||
return false
|
||||
val hdrTypes = runCatching { getDisplayHdrTypes(display) }.getOrElse { error ->
|
||||
Log.w(TAG, "Display Dolby Vision support: false (failed to query HDR types)", error)
|
||||
return false
|
||||
}
|
||||
val supported = hdrTypes.contains(Display.HdrCapabilities.HDR_TYPE_DOLBY_VISION)
|
||||
Log.i(TAG, "Display Dolby Vision support: $supported; ${describeDisplayHdrCapabilities(display)}")
|
||||
return supported
|
||||
}
|
||||
|
||||
fun describeDisplayHdrCapabilities(context: Context): String {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return "HDR capabilities unavailable (API=${Build.VERSION.SDK_INT})"
|
||||
val display = getCurrentDisplay(context) ?: return "HDR capabilities unavailable (no active display)"
|
||||
return describeDisplayHdrCapabilities(display)
|
||||
}
|
||||
|
||||
fun logSupportSummary(context: Context) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"DV support summary: bridgeReady=${isAvailable()}, displayDV=${displaySupportsDolbyVision(context)}, " +
|
||||
"nativeDecoder=$hasNativeDolbyVisionDecoder, advertisedP7=$deviceSupportsDvProfile7, " +
|
||||
"advertisedP8=$deviceSupportsDvProfile8, decoders=${describeDolbyVisionDecoders()}, " +
|
||||
"displayHdr=${describeDisplayHdrCapabilities(context)}"
|
||||
)
|
||||
}
|
||||
|
||||
fun getConversionDecision(context: Context): DvAutoDecision {
|
||||
val bridgeReady = isAvailable()
|
||||
val displayDv = displaySupportsDolbyVision(context)
|
||||
val nativeDecoder = hasNativeDolbyVisionDecoder
|
||||
val advertisedP7 = deviceSupportsDvProfile7
|
||||
val advertisedP8 = deviceSupportsDvProfile8
|
||||
|
||||
val mode = when {
|
||||
!displayDv -> DvConversionMode.HEVC_STRIP
|
||||
advertisedP7 -> DvConversionMode.DISABLED
|
||||
advertisedP8 && bridgeReady -> DvConversionMode.DV81
|
||||
else -> DvConversionMode.HEVC_STRIP
|
||||
}
|
||||
val reason = when {
|
||||
!displayDv -> "active display does not support Dolby Vision; stripping DV metadata for HEVC fallback"
|
||||
advertisedP7 -> "active display supports Dolby Vision and decoder advertises exact Profile 7; trying native DV first"
|
||||
advertisedP8 && bridgeReady -> "active display supports Dolby Vision and decoder advertises Profile 8; converting to Profile 8.1"
|
||||
!bridgeReady -> "conversion bridge unavailable; stripping DV metadata for HEVC fallback"
|
||||
else -> "Dolby Vision output path is unavailable; stripping DV metadata for HEVC fallback"
|
||||
}
|
||||
val decision = DvAutoDecision(
|
||||
mode = mode,
|
||||
reason = reason,
|
||||
bridgeReady = bridgeReady,
|
||||
displayDv = displayDv,
|
||||
nativeDecoder = nativeDecoder,
|
||||
advertisedP7 = advertisedP7,
|
||||
advertisedP8 = advertisedP8,
|
||||
decoders = describeDolbyVisionDecoders(),
|
||||
displayHdr = describeDisplayHdrCapabilities(context)
|
||||
)
|
||||
Log.i(TAG, decision.logMessage())
|
||||
return decision
|
||||
}
|
||||
|
||||
fun getConversionMode(context: Context): DvConversionMode = getConversionDecision(context).mode
|
||||
|
||||
/** Get the fallback mode when native DV7 decoding fails. */
|
||||
fun getDv7FallbackDecision(context: Context): Dv7FallbackDecision {
|
||||
val bridgeReady = isAvailable()
|
||||
val displayDv = displaySupportsDolbyVision(context)
|
||||
val advertisedP8 = deviceSupportsDvProfile8
|
||||
val mode = if (displayDv && advertisedP8 && bridgeReady) DvConversionMode.DV81 else DvConversionMode.HEVC_STRIP
|
||||
val reason = when {
|
||||
mode == DvConversionMode.DV81 -> "display supports Dolby Vision and decoder advertises Profile 8"
|
||||
!bridgeReady -> "conversion bridge unavailable; stripping DV metadata for HEVC fallback"
|
||||
else -> "Dolby Vision output or Profile 8 support is unavailable"
|
||||
}
|
||||
val decision = Dv7FallbackDecision(
|
||||
mode = mode,
|
||||
reason = reason,
|
||||
bridgeReady = bridgeReady,
|
||||
displayDv = displayDv,
|
||||
advertisedP8 = advertisedP8,
|
||||
displayHdr = describeDisplayHdrCapabilities(context)
|
||||
)
|
||||
Log.i(TAG, decision.logMessage())
|
||||
return decision
|
||||
}
|
||||
|
||||
fun getDv7FallbackMode(context: Context): DvConversionMode = getDv7FallbackDecision(context).mode
|
||||
|
||||
private fun getCurrentDisplay(context: Context): Display? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
context.display
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
(context.getSystemService(Context.WINDOW_SERVICE) as? WindowManager)?.defaultDisplay
|
||||
}
|
||||
|
||||
private fun describeDolbyVisionDecoders(): String {
|
||||
if (dolbyVisionDecoders.isEmpty()) return "none"
|
||||
return dolbyVisionDecoders.joinToString { decoder ->
|
||||
val profiles = if (decoder.profileLevels.isEmpty()) {
|
||||
"none"
|
||||
} else {
|
||||
decoder.profileLevels.joinToString(prefix = "[", postfix = "]") {
|
||||
"${describeDvProfile(it.profile)}/${describeDvLevel(it.level)}"
|
||||
}
|
||||
}
|
||||
"${decoder.name}(${decoder.mimeType}, profiles=$profiles)"
|
||||
}
|
||||
}
|
||||
|
||||
val deviceSupportsDvProfile7: Boolean by lazy {
|
||||
deviceSupportsDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtr)
|
||||
.also { Log.i(TAG, "Device DV Profile 7 support: $it") }
|
||||
private fun describeHdrTypes(hdrTypes: IntArray): String {
|
||||
if (hdrTypes.isEmpty()) return "none"
|
||||
return hdrTypes.joinToString(prefix = "[", postfix = "]") { type ->
|
||||
when (type) {
|
||||
Display.HdrCapabilities.HDR_TYPE_DOLBY_VISION -> "DOLBY_VISION"
|
||||
Display.HdrCapabilities.HDR_TYPE_HDR10 -> "HDR10"
|
||||
Display.HdrCapabilities.HDR_TYPE_HLG -> "HLG"
|
||||
Display.HdrCapabilities.HDR_TYPE_HDR10_PLUS -> "HDR10_PLUS"
|
||||
else -> "unknown(${hex(type)})"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val deviceSupportsDvProfile8: Boolean by lazy {
|
||||
deviceSupportsDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheSt, minApi = 27)
|
||||
.also { Log.i(TAG, "Device DV Profile 8 support: $it") }
|
||||
private fun describeDisplayHdrCapabilities(display: Display): String {
|
||||
val hdrCapabilities = display.hdrCapabilities
|
||||
val parts = mutableListOf(
|
||||
"display=${display.displayId}:${display.name}",
|
||||
"activeMode=${describeDisplayMode(display.mode)}",
|
||||
"hdrCapabilities=${describeHdrTypes(hdrCapabilities.supportedHdrTypes)}",
|
||||
"desiredLuminance=max=${formatLuminance(hdrCapabilities.desiredMaxLuminance)}, " +
|
||||
"maxAvg=${formatLuminance(hdrCapabilities.desiredMaxAverageLuminance)}, " +
|
||||
"min=${formatLuminance(hdrCapabilities.desiredMinLuminance)}"
|
||||
)
|
||||
parts += "supportedModes=${display.supportedModes.joinToString(prefix = "[", postfix = "]") { describeDisplayMode(it) }}"
|
||||
return parts.joinToString(prefix = "{", postfix = "}")
|
||||
}
|
||||
|
||||
private fun getDisplayHdrTypes(display: Display): IntArray = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
display.mode.supportedHdrTypes
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
display.hdrCapabilities.supportedHdrTypes
|
||||
}
|
||||
|
||||
private fun describeDisplayMode(mode: Display.Mode): String {
|
||||
val hdr = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
", hdr=${describeHdrTypes(mode.supportedHdrTypes)}"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
return "#${mode.modeId} ${mode.physicalWidth}x${mode.physicalHeight}@${mode.refreshRate}Hz$hdr"
|
||||
}
|
||||
|
||||
fun getConversionMode(): DvConversionMode = when {
|
||||
!isAvailable() -> DvConversionMode.DISABLED
|
||||
deviceSupportsDvProfile7 -> DvConversionMode.DISABLED // try native first; ExoPlayerCore retries with conversion on failure
|
||||
deviceSupportsDvProfile8 -> DvConversionMode.DV81
|
||||
else -> DvConversionMode.HEVC_STRIP
|
||||
private fun formatLuminance(value: Float): String = if (value.isNaN() || value <= 0f) "unknown" else "${value}nits"
|
||||
|
||||
private fun describeDvProfile(profile: Int): String = when (profile) {
|
||||
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvavPer -> "P0/DvavPer"
|
||||
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvavPen -> "P1/DvavPen"
|
||||
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDer -> "P2/DvheDer"
|
||||
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDen -> "P3/DvheDen"
|
||||
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtr -> "P4/DvheDtr"
|
||||
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheStn -> "P5/DvheStn"
|
||||
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDth -> "P6/DvheDth"
|
||||
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtb -> "P7/DvheDtb"
|
||||
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheSt -> "P8/DvheSt"
|
||||
MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvavSe -> "P9/DvavSe"
|
||||
else -> "unknown(${hex(profile)})"
|
||||
}
|
||||
|
||||
private fun describeDvLevel(level: Int): String = when (level) {
|
||||
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelHd24 -> "L1/Hd24"
|
||||
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelHd30 -> "L2/Hd30"
|
||||
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelFhd24 -> "L3/Fhd24"
|
||||
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelFhd30 -> "L4/Fhd30"
|
||||
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelFhd60 -> "L5/Fhd60"
|
||||
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelUhd24 -> "L6/Uhd24"
|
||||
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelUhd30 -> "L7/Uhd30"
|
||||
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelUhd48 -> "L8/Uhd48"
|
||||
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelUhd60 -> "L9/Uhd60"
|
||||
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevelUhd120 -> "L10/Uhd120"
|
||||
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevel8k30 -> "L11/8k30"
|
||||
MediaCodecInfo.CodecProfileLevel.DolbyVisionLevel8k60 -> "L12/8k60"
|
||||
else -> "unknown(${hex(level)})"
|
||||
}
|
||||
|
||||
private fun hex(value: Int): String = "0x${value.toString(16)}"
|
||||
|
||||
fun convertRpuNalu(
|
||||
payload: ByteArray,
|
||||
payloadOffset: Int,
|
||||
payloadLength: Int,
|
||||
output: ByteArray,
|
||||
outputOffset: Int,
|
||||
outputCapacity: Int,
|
||||
mode: Int = 2
|
||||
): Int {
|
||||
if (!conversionPathReady || payloadLength <= 0) return CONVERT_FAILED
|
||||
return runCatching {
|
||||
nativeConvertDv7RpuToDv81(payload, payloadOffset, payloadLength, output, outputOffset, outputCapacity, mode)
|
||||
}
|
||||
.onFailure { Log.w(TAG, "RPU conversion failed: ${it.message}") }
|
||||
.getOrDefault(CONVERT_FAILED)
|
||||
}
|
||||
|
||||
/** Get the fallback mode when native DV7 decoding fails. */
|
||||
fun getDv7FallbackMode(): DvConversionMode = when {
|
||||
deviceSupportsDvProfile8 -> DvConversionMode.DV81
|
||||
else -> DvConversionMode.HEVC_STRIP
|
||||
}
|
||||
fun getVersion(): String? {
|
||||
if (!nativeLoaded) return null
|
||||
return runCatching { nativeGetBridgeVersion() }.getOrNull()
|
||||
}
|
||||
|
||||
fun convertRpuNalu(payload: ByteArray, mode: Int = 2): ByteArray? {
|
||||
if (!isAvailable() || payload.isEmpty()) return null
|
||||
return runCatching { nativeConvertDv7RpuToDv81(payload, mode) }
|
||||
.onFailure { Log.w(TAG, "RPU conversion failed: ${it.message}") }
|
||||
.getOrNull()
|
||||
}
|
||||
@JvmStatic
|
||||
private external fun nativeConvertDv7RpuToDv81(
|
||||
payload: ByteArray,
|
||||
payloadOffset: Int,
|
||||
payloadLength: Int,
|
||||
output: ByteArray,
|
||||
outputOffset: Int,
|
||||
outputCapacity: Int,
|
||||
mode: Int
|
||||
): Int
|
||||
|
||||
fun getVersion(): String? {
|
||||
if (!nativeLoaded) return null
|
||||
return runCatching { nativeGetBridgeVersion() }.getOrNull()
|
||||
}
|
||||
@JvmStatic
|
||||
private external fun nativeIsConversionPathReady(): Boolean
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeConvertDv7RpuToDv81(payload: ByteArray, mode: Int): ByteArray?
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeIsConversionPathReady(): Boolean
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeGetBridgeVersion(): String
|
||||
@JvmStatic
|
||||
private external fun nativeGetBridgeVersion(): String
|
||||
}
|
||||
|
||||
@@ -14,22 +14,23 @@ import androidx.media3.extractor.TrackOutput
|
||||
* Shared by DoviExtractorWrapper for both MP4 and MKV containers.
|
||||
*/
|
||||
class DoviExtractorOutputWrapper(
|
||||
private val delegate: ExtractorOutput,
|
||||
private val dvMode: DvConversionMode,
|
||||
private val onVideoTrackWrapped: (DoviConvertingTrackOutput) -> Unit,
|
||||
private val delegate: ExtractorOutput,
|
||||
private val dvMode: DvConversionMode,
|
||||
private val emitLog: ((String, String, String) -> Unit)?,
|
||||
private val onVideoTrackWrapped: (DoviConvertingTrackOutput) -> Unit
|
||||
) : ExtractorOutput {
|
||||
override fun track(id: Int, type: Int): TrackOutput {
|
||||
val original = delegate.track(id, type)
|
||||
if (type == C.TRACK_TYPE_VIDEO) {
|
||||
val wrapper = DoviConvertingTrackOutput(original, dvMode)
|
||||
onVideoTrackWrapped(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
return original
|
||||
override fun track(id: Int, type: Int): TrackOutput {
|
||||
val original = delegate.track(id, type)
|
||||
if (type == C.TRACK_TYPE_VIDEO) {
|
||||
val wrapper = DoviConvertingTrackOutput(original, dvMode, emitLog)
|
||||
onVideoTrackWrapped(wrapper)
|
||||
return wrapper
|
||||
}
|
||||
return original
|
||||
}
|
||||
|
||||
override fun endTracks() = delegate.endTracks()
|
||||
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
|
||||
override fun endTracks() = delegate.endTracks()
|
||||
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,23 +39,23 @@ class DoviExtractorOutputWrapper(
|
||||
* DV Profile 7 → 8.1 conversion via inline NAL processing.
|
||||
*/
|
||||
class DoviExtractorWrapper(
|
||||
private val delegate: Extractor,
|
||||
private val dvMode: DvConversionMode = DvConversionMode.HEVC_STRIP,
|
||||
private val delegate: Extractor,
|
||||
private val dvMode: DvConversionMode = DvConversionMode.HEVC_STRIP,
|
||||
private val emitLog: ((String, String, String) -> Unit)? = null
|
||||
) : Extractor {
|
||||
|
||||
@Volatile var doviTrackOutput: DoviConvertingTrackOutput? = null
|
||||
private set
|
||||
@Volatile var doviTrackOutput: DoviConvertingTrackOutput? = null
|
||||
private set
|
||||
|
||||
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
|
||||
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
|
||||
|
||||
override fun init(output: ExtractorOutput) {
|
||||
delegate.init(DoviExtractorOutputWrapper(output, dvMode) { doviTrackOutput = it })
|
||||
}
|
||||
override fun init(output: ExtractorOutput) {
|
||||
delegate.init(DoviExtractorOutputWrapper(output, dvMode, emitLog) { doviTrackOutput = it })
|
||||
}
|
||||
|
||||
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int =
|
||||
delegate.read(input, seekPosition)
|
||||
override fun read(input: ExtractorInput, seekPosition: PositionHolder): Int = delegate.read(input, seekPosition)
|
||||
|
||||
override fun seek(position: Long, timeUs: Long) = delegate.seek(position, timeUs)
|
||||
override fun seek(position: Long, timeUs: Long) = delegate.seek(position, timeUs)
|
||||
|
||||
override fun release() = delegate.release()
|
||||
override fun release() = delegate.release()
|
||||
}
|
||||
|
||||
@@ -2,11 +2,14 @@ package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.os.Build
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.PlaybackParameters
|
||||
import androidx.media3.common.util.Clock
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.Renderer
|
||||
import androidx.media3.exoplayer.analytics.PlayerId
|
||||
import androidx.media3.exoplayer.audio.AudioOutput
|
||||
import androidx.media3.exoplayer.audio.AudioOutputProvider
|
||||
@@ -17,39 +20,58 @@ import androidx.media3.exoplayer.audio.DefaultAudioTrackBufferSizeProvider
|
||||
import androidx.media3.exoplayer.audio.ForwardingAudioSink
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlin.math.abs
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context) {
|
||||
|
||||
override fun buildAudioSink(
|
||||
context: Context,
|
||||
enableFloatOutput: Boolean,
|
||||
enableAudioOutputPlaybackParams: Boolean
|
||||
): AudioSink {
|
||||
AudioTrackAudioOutputProvider.failOnSpuriousAudioTimestamp = false
|
||||
/** Audio delay in microseconds. Shared with PositionFixAudioSink for live updates. */
|
||||
val audioDelayUs = AtomicLong(0L)
|
||||
|
||||
val bufferSizeProvider = DefaultAudioTrackBufferSizeProvider.Builder()
|
||||
.setMinPcmBufferDurationUs(500_000)
|
||||
.setMaxPcmBufferDurationUs(1_500_000)
|
||||
.setPcmBufferMultiplicationFactor(8)
|
||||
.build()
|
||||
/** Returns whether direct encoded output should be hidden so decoded PCM output can be selected. */
|
||||
var shouldBlockDirectAudioOutput: ((Format) -> Boolean)? = null
|
||||
|
||||
val realProvider = AudioTrackAudioOutputProvider.Builder(context)
|
||||
.setAudioTrackBufferSizeProvider(bufferSizeProvider)
|
||||
.build()
|
||||
/** Called before Media3 reacts to an audio route/capability change. */
|
||||
var onAudioCapabilitiesChanged: (() -> Unit)? = null
|
||||
|
||||
// Shared position: RawPositionAudioOutput writes the raw AudioTrack position,
|
||||
// PositionFixAudioSink reads it to bypass DefaultAudioSink's writtenDuration clamp.
|
||||
val rawPositionUs = AtomicLong(Long.MIN_VALUE)
|
||||
var audioDiagnosticsLogger: ((String, String, String) -> Unit)? = null
|
||||
|
||||
val defaultSink = DefaultAudioSink.Builder(context)
|
||||
.setEnableFloatOutput(enableFloatOutput)
|
||||
.setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParams)
|
||||
.setAudioOutputProvider(RawPositionOutputProvider(realProvider, rawPositionUs))
|
||||
.build()
|
||||
override fun buildAudioSink(
|
||||
context: Context,
|
||||
enableFloatOutput: Boolean,
|
||||
enableAudioOutputPlaybackParams: Boolean
|
||||
): AudioSink {
|
||||
AudioTrackAudioOutputProvider.failOnSpuriousAudioTimestamp = false
|
||||
|
||||
return PositionFixAudioSink(defaultSink, rawPositionUs)
|
||||
}
|
||||
val bufferSizeProvider = DefaultAudioTrackBufferSizeProvider.Builder()
|
||||
.setMinPcmBufferDurationUs(500_000)
|
||||
.setMaxPcmBufferDurationUs(1_000_000)
|
||||
.setPcmBufferMultiplicationFactor(4)
|
||||
.build()
|
||||
|
||||
val realProvider = AudioTrackAudioOutputProvider.Builder(context)
|
||||
.setAudioTrackBufferSizeProvider(bufferSizeProvider)
|
||||
.build()
|
||||
|
||||
// Shared position: RawPositionAudioOutput writes the raw AudioTrack position,
|
||||
// PositionFixAudioSink reads it to bypass DefaultAudioSink's writtenDuration clamp.
|
||||
val rawPositionUs = AtomicLong(Long.MIN_VALUE)
|
||||
|
||||
val defaultSink = DefaultAudioSink.Builder(context)
|
||||
.setEnableFloatOutput(enableFloatOutput)
|
||||
.setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParams)
|
||||
.setAudioOutputProvider(RawPositionOutputProvider(realProvider, rawPositionUs, audioDiagnosticsLogger))
|
||||
.build()
|
||||
|
||||
return PositionFixAudioSink(
|
||||
defaultSink,
|
||||
rawPositionUs,
|
||||
audioDelayUs,
|
||||
shouldBlockDirectAudioOutput,
|
||||
onAudioCapabilitiesChanged,
|
||||
audioDiagnosticsLogger
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,186 +97,339 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
private class PositionFixAudioSink(
|
||||
sink: AudioSink,
|
||||
private val rawPositionUs: AtomicLong
|
||||
sink: AudioSink,
|
||||
private val rawPositionUs: AtomicLong,
|
||||
private val audioDelayUs: AtomicLong,
|
||||
private val shouldBlockDirectAudioOutput: ((Format) -> Boolean)?,
|
||||
private val onAudioCapabilitiesChanged: (() -> Unit)?,
|
||||
private val log: ((String, String, String) -> Unit)?
|
||||
) : ForwardingAudioSink(sink) {
|
||||
|
||||
private var startMediaTimeUs = Long.MIN_VALUE
|
||||
private var suppressedErrorCount = 0
|
||||
private var startMediaTimeUs = Long.MIN_VALUE
|
||||
private var suppressedErrorCount = 0
|
||||
|
||||
// Speed tracking for position bypass
|
||||
private var currentSpeed = 1.0f
|
||||
private var refMediaTimeUs = Long.MIN_VALUE
|
||||
private var refRawPositionUs = 0L
|
||||
// Speed tracking for position bypass
|
||||
private var currentSpeed = 1.0f
|
||||
private var refMediaTimeUs = Long.MIN_VALUE
|
||||
private var refRawPositionUs = 0L
|
||||
|
||||
override fun handleBuffer(
|
||||
buffer: ByteBuffer,
|
||||
presentationTimeUs: Long,
|
||||
encodedAccessUnitCount: Int
|
||||
): Boolean {
|
||||
if (startMediaTimeUs == Long.MIN_VALUE) {
|
||||
startMediaTimeUs = presentationTimeUs
|
||||
refMediaTimeUs = presentationTimeUs
|
||||
refRawPositionUs = 0
|
||||
// Transient counter-offset: after seek/flush, ramp offset from 0 to full
|
||||
// over recoveryDurationUs to prevent video frame drops.
|
||||
private var recoveryDurationUs = 0L
|
||||
private val loggedDirectBlockMimeTypes = mutableSetOf<String>()
|
||||
private var loggedFirstBuffer = false
|
||||
|
||||
override fun supportsFormat(format: Format): Boolean {
|
||||
if (blocksDirectAudioOutput(format)) return false
|
||||
return super.supportsFormat(format)
|
||||
}
|
||||
|
||||
override fun getFormatSupport(format: Format): Int {
|
||||
if (blocksDirectAudioOutput(format)) return AudioSink.SINK_FORMAT_UNSUPPORTED
|
||||
return super.getFormatSupport(format)
|
||||
}
|
||||
|
||||
private fun blocksDirectAudioOutput(format: Format): Boolean {
|
||||
if (shouldBlockDirectAudioOutput?.invoke(format) != true) return false
|
||||
val mimeType = format.sampleMimeType ?: "unknown"
|
||||
if (loggedDirectBlockMimeTypes.add(mimeType)) {
|
||||
log?.invoke(
|
||||
"info",
|
||||
"audio",
|
||||
"Blocking direct $mimeType output; decoded PCM output will be preferred"
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun handleBuffer(
|
||||
buffer: ByteBuffer,
|
||||
presentationTimeUs: Long,
|
||||
encodedAccessUnitCount: Int
|
||||
): Boolean {
|
||||
if (!loggedFirstBuffer && buffer.hasRemaining()) {
|
||||
loggedFirstBuffer = true
|
||||
log?.invoke(
|
||||
"debug",
|
||||
"audio-sink",
|
||||
"First buffer: size=${buffer.remaining()}B, pts=${presentationTimeUs}us, accessUnits=$encodedAccessUnitCount"
|
||||
)
|
||||
}
|
||||
if (startMediaTimeUs == Long.MIN_VALUE) {
|
||||
startMediaTimeUs = presentationTimeUs
|
||||
refMediaTimeUs = presentationTimeUs
|
||||
refRawPositionUs = 0
|
||||
val delayUs = audioDelayUs.get()
|
||||
recoveryDurationUs = if (delayUs == 0L) 0L else maxOf(200_000L, 2L * abs(delayUs))
|
||||
}
|
||||
return super.handleBuffer(buffer, presentationTimeUs, encodedAccessUnitCount)
|
||||
}
|
||||
|
||||
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {
|
||||
// Capture reference point before speed changes
|
||||
val rawPos = rawPositionUs.get()
|
||||
if (rawPos > 0 && refMediaTimeUs != Long.MIN_VALUE) {
|
||||
refMediaTimeUs = refMediaTimeUs + ((rawPos - refRawPositionUs) * currentSpeed).toLong()
|
||||
refRawPositionUs = rawPos
|
||||
}
|
||||
currentSpeed = playbackParameters.speed
|
||||
super.setPlaybackParameters(playbackParameters)
|
||||
}
|
||||
|
||||
override fun getCurrentPositionUs(sourceEnded: Boolean): Long {
|
||||
val delegatePos = super.getCurrentPositionUs(sourceEnded)
|
||||
if (delegatePos == Long.MIN_VALUE || refMediaTimeUs == Long.MIN_VALUE) {
|
||||
return delegatePos
|
||||
}
|
||||
|
||||
val rawPos = rawPositionUs.get()
|
||||
val basePos = if (rawPos <= 0) {
|
||||
delegatePos
|
||||
} else {
|
||||
val expectedPos = refMediaTimeUs + ((rawPos - refRawPositionUs) * currentSpeed).toLong()
|
||||
if (expectedPos > delegatePos + 30_000) expectedPos else delegatePos
|
||||
}
|
||||
|
||||
// Audio delay with transient counter-offset to prevent frame drops after seeks.
|
||||
// After flush, offset ramps linearly from 0 to full over recoveryDurationUs.
|
||||
val delayUs = audioDelayUs.get()
|
||||
if (delayUs == 0L) return basePos
|
||||
|
||||
val elapsedUs = basePos - startMediaTimeUs
|
||||
val netOffsetUs = if (recoveryDurationUs <= 0L || elapsedUs >= recoveryDurationUs) {
|
||||
delayUs
|
||||
} else {
|
||||
(delayUs * elapsedUs) / recoveryDurationUs
|
||||
}
|
||||
return basePos + netOffsetUs
|
||||
}
|
||||
|
||||
// --- Suppress timestamp discontinuity errors ---
|
||||
|
||||
override fun setListener(listener: AudioSink.Listener) {
|
||||
super.setListener(
|
||||
@OptIn(UnstableApi::class) object : AudioSink.Listener {
|
||||
override fun onPositionDiscontinuity() = listener.onPositionDiscontinuity()
|
||||
override fun onPositionAdvancing(playoutStartSystemTimeUs: Long) = listener.onPositionAdvancing(playoutStartSystemTimeUs)
|
||||
override fun onUnderrun(bufferSize: Int, bufferSizeMs: Long, elapsedSinceLastFeedMs: Long) = listener.onUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs)
|
||||
override fun onSkipSilenceEnabledChanged(skipSilenceEnabled: Boolean) = listener.onSkipSilenceEnabledChanged(skipSilenceEnabled)
|
||||
override fun onOffloadBufferEmptying() = listener.onOffloadBufferEmptying()
|
||||
override fun onOffloadBufferFull() = listener.onOffloadBufferFull()
|
||||
override fun onAudioCapabilitiesChanged() {
|
||||
onAudioCapabilitiesChanged?.invoke()
|
||||
listener.onAudioCapabilitiesChanged()
|
||||
}
|
||||
return super.handleBuffer(buffer, presentationTimeUs, encodedAccessUnitCount)
|
||||
}
|
||||
override fun onAudioTrackInitialized(audioTrackConfig: AudioSink.AudioTrackConfig) = listener.onAudioTrackInitialized(audioTrackConfig)
|
||||
override fun onAudioTrackReleased(audioTrackConfig: AudioSink.AudioTrackConfig) = listener.onAudioTrackReleased(audioTrackConfig)
|
||||
override fun onSilenceSkipped() = listener.onSilenceSkipped()
|
||||
override fun onAudioSessionIdChanged(audioSessionId: Int) = listener.onAudioSessionIdChanged(audioSessionId)
|
||||
|
||||
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {
|
||||
// Capture reference point before speed changes
|
||||
val rawPos = rawPositionUs.get()
|
||||
if (rawPos > 0 && refMediaTimeUs != Long.MIN_VALUE) {
|
||||
refMediaTimeUs = refMediaTimeUs + ((rawPos - refRawPositionUs) * currentSpeed).toLong()
|
||||
refRawPositionUs = rawPos
|
||||
override fun onAudioSinkError(audioSinkError: Exception) {
|
||||
if (isTimestampDiscontinuity(audioSinkError)) {
|
||||
suppressedErrorCount++
|
||||
return
|
||||
}
|
||||
listener.onAudioSinkError(audioSinkError)
|
||||
}
|
||||
currentSpeed = playbackParameters.speed
|
||||
super.setPlaybackParameters(playbackParameters)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun getCurrentPositionUs(sourceEnded: Boolean): Long {
|
||||
val delegatePos = super.getCurrentPositionUs(sourceEnded)
|
||||
if (delegatePos == Long.MIN_VALUE || refMediaTimeUs == Long.MIN_VALUE) {
|
||||
return delegatePos
|
||||
}
|
||||
override fun flush() {
|
||||
startMediaTimeUs = Long.MIN_VALUE
|
||||
refMediaTimeUs = Long.MIN_VALUE
|
||||
refRawPositionUs = 0
|
||||
recoveryDurationUs = 0L
|
||||
rawPositionUs.set(Long.MIN_VALUE)
|
||||
super.flush()
|
||||
}
|
||||
|
||||
val rawPos = rawPositionUs.get()
|
||||
if (rawPos <= 0) return delegatePos
|
||||
override fun reset() {
|
||||
startMediaTimeUs = Long.MIN_VALUE
|
||||
refMediaTimeUs = Long.MIN_VALUE
|
||||
refRawPositionUs = 0
|
||||
recoveryDurationUs = 0L
|
||||
currentSpeed = 1.0f
|
||||
rawPositionUs.set(Long.MIN_VALUE)
|
||||
suppressedErrorCount = 0
|
||||
super.reset()
|
||||
}
|
||||
|
||||
val expectedPos = refMediaTimeUs + ((rawPos - refRawPositionUs) * currentSpeed).toLong()
|
||||
return if (expectedPos > delegatePos + 30_000) expectedPos else delegatePos
|
||||
}
|
||||
private fun isTimestampDiscontinuity(e: Exception): Boolean {
|
||||
val name = e.javaClass.simpleName
|
||||
val msg = e.message ?: ""
|
||||
return name == "InvalidAudioTrackTimestampException" ||
|
||||
name == "UnexpectedDiscontinuityException" ||
|
||||
msg.contains("timestamp discontinuity", ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Suppress timestamp discontinuity errors ---
|
||||
|
||||
override fun setListener(listener: AudioSink.Listener) {
|
||||
super.setListener(@OptIn(UnstableApi::class) object : AudioSink.Listener {
|
||||
override fun onPositionDiscontinuity() = listener.onPositionDiscontinuity()
|
||||
override fun onPositionAdvancing(playoutStartSystemTimeUs: Long) = listener.onPositionAdvancing(playoutStartSystemTimeUs)
|
||||
override fun onUnderrun(bufferSize: Int, bufferSizeMs: Long, elapsedSinceLastFeedMs: Long) = listener.onUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs)
|
||||
override fun onSkipSilenceEnabledChanged(skipSilenceEnabled: Boolean) = listener.onSkipSilenceEnabledChanged(skipSilenceEnabled)
|
||||
override fun onOffloadBufferEmptying() = listener.onOffloadBufferEmptying()
|
||||
override fun onOffloadBufferFull() = listener.onOffloadBufferFull()
|
||||
override fun onAudioCapabilitiesChanged() = listener.onAudioCapabilitiesChanged()
|
||||
override fun onAudioTrackInitialized(audioTrackConfig: AudioSink.AudioTrackConfig) = listener.onAudioTrackInitialized(audioTrackConfig)
|
||||
override fun onAudioTrackReleased(audioTrackConfig: AudioSink.AudioTrackConfig) = listener.onAudioTrackReleased(audioTrackConfig)
|
||||
override fun onSilenceSkipped() = listener.onSilenceSkipped()
|
||||
override fun onAudioSessionIdChanged(audioSessionId: Int) = listener.onAudioSessionIdChanged(audioSessionId)
|
||||
|
||||
override fun onAudioSinkError(audioSinkError: Exception) {
|
||||
if (isTimestampDiscontinuity(audioSinkError)) {
|
||||
suppressedErrorCount++
|
||||
return
|
||||
}
|
||||
listener.onAudioSinkError(audioSinkError)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun flush() {
|
||||
startMediaTimeUs = Long.MIN_VALUE
|
||||
refMediaTimeUs = Long.MIN_VALUE
|
||||
refRawPositionUs = 0
|
||||
rawPositionUs.set(Long.MIN_VALUE)
|
||||
super.flush()
|
||||
}
|
||||
|
||||
override fun reset() {
|
||||
startMediaTimeUs = Long.MIN_VALUE
|
||||
refMediaTimeUs = Long.MIN_VALUE
|
||||
refRawPositionUs = 0
|
||||
currentSpeed = 1.0f
|
||||
rawPositionUs.set(Long.MIN_VALUE)
|
||||
suppressedErrorCount = 0
|
||||
super.reset()
|
||||
}
|
||||
|
||||
private fun isTimestampDiscontinuity(e: Exception): Boolean {
|
||||
val name = e.javaClass.simpleName
|
||||
val msg = e.message ?: ""
|
||||
return name == "InvalidAudioTrackTimestampException" ||
|
||||
name == "UnexpectedDiscontinuityException" ||
|
||||
msg.contains("timestamp discontinuity", ignoreCase = true)
|
||||
}
|
||||
/**
|
||||
* Wraps a text renderer to shift subtitle timing by [delayUs] microseconds.
|
||||
* Positive delay → subtitles appear later, negative → earlier.
|
||||
* Only render() needs the offset — the text renderer uses positionUs to decide
|
||||
* which cues are active; other Renderer methods are timing-independent.
|
||||
*/
|
||||
@OptIn(UnstableApi::class)
|
||||
internal class SubtitleDelayRenderer(
|
||||
private val delegate: Renderer,
|
||||
private val delayUs: AtomicLong
|
||||
) : Renderer by delegate {
|
||||
override fun render(positionUs: Long, elapsedRealtimeUs: Long) {
|
||||
delegate.render(positionUs - delayUs.get(), elapsedRealtimeUs)
|
||||
}
|
||||
}
|
||||
|
||||
// --- AudioOutput wrapping: shares raw position with PositionFixAudioSink ---
|
||||
// Also implements AudioTrack reuse across seeks to avoid expensive teardown/recreation.
|
||||
// DefaultAudioSink releases the AudioOutput on every flush (seek), which destroys the
|
||||
// AudioTrack and creates a new one. On Android TV with tunneled playback, this causes
|
||||
// 7-10s audio dropout while the hardware pipeline reinitializes (Sony Bravia, etc).
|
||||
// By flushing instead of releasing and caching the output, we skip the teardown cycle.
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private class RawPositionOutputProvider(
|
||||
private val delegate: AudioOutputProvider,
|
||||
private val rawPositionUs: AtomicLong
|
||||
private val delegate: AudioOutputProvider,
|
||||
private val rawPositionUs: AtomicLong,
|
||||
private val log: ((String, String, String) -> Unit)?
|
||||
) : AudioOutputProvider {
|
||||
|
||||
override fun getFormatSupport(config: AudioOutputProvider.FormatConfig) =
|
||||
delegate.getFormatSupport(config)
|
||||
private var cachedOutput: RawPositionAudioOutput? = null
|
||||
private var cachedConfig: AudioOutputProvider.OutputConfig? = null
|
||||
|
||||
override fun getOutputConfig(config: AudioOutputProvider.FormatConfig) =
|
||||
delegate.getOutputConfig(config)
|
||||
override fun getFormatSupport(config: AudioOutputProvider.FormatConfig) = delegate.getFormatSupport(config)
|
||||
|
||||
override fun getAudioOutput(config: AudioOutputProvider.OutputConfig): AudioOutput {
|
||||
val realOutput = delegate.getAudioOutput(config)
|
||||
return RawPositionAudioOutput(realOutput, rawPositionUs)
|
||||
override fun getOutputConfig(config: AudioOutputProvider.FormatConfig) = delegate.getOutputConfig(config)
|
||||
|
||||
override fun getAudioOutput(config: AudioOutputProvider.OutputConfig): AudioOutput {
|
||||
val cached = cachedOutput
|
||||
if (cached != null && cachedConfig == config) {
|
||||
cachedOutput = null
|
||||
return cached
|
||||
}
|
||||
cached?.forceRelease()
|
||||
cachedOutput = null
|
||||
|
||||
override fun addListener(listener: AudioOutputProvider.Listener) =
|
||||
delegate.addListener(listener)
|
||||
val realOutput = delegate.getAudioOutput(config)
|
||||
cachedConfig = config
|
||||
return RawPositionAudioOutput(realOutput, rawPositionUs, this, log)
|
||||
}
|
||||
|
||||
override fun removeListener(listener: AudioOutputProvider.Listener) =
|
||||
delegate.removeListener(listener)
|
||||
fun returnToCache(output: RawPositionAudioOutput) {
|
||||
val existing = cachedOutput
|
||||
if (existing != null && existing !== output) {
|
||||
existing.forceRelease()
|
||||
}
|
||||
cachedOutput = output
|
||||
}
|
||||
|
||||
override fun setClock(clock: Clock) = delegate.setClock(clock)
|
||||
override fun addListener(listener: AudioOutputProvider.Listener) = delegate.addListener(listener)
|
||||
|
||||
override fun release() = delegate.release()
|
||||
override fun removeListener(listener: AudioOutputProvider.Listener) = delegate.removeListener(listener)
|
||||
|
||||
override fun setClock(clock: Clock) = delegate.setClock(clock)
|
||||
|
||||
override fun release() {
|
||||
cachedOutput?.forceRelease()
|
||||
cachedOutput = null
|
||||
cachedConfig = null
|
||||
delegate.release()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
private class RawPositionAudioOutput(
|
||||
private val delegate: AudioOutput,
|
||||
private val rawPositionUs: AtomicLong
|
||||
private val delegate: AudioOutput,
|
||||
private val rawPositionUs: AtomicLong,
|
||||
private val provider: RawPositionOutputProvider,
|
||||
private val log: ((String, String, String) -> Unit)?
|
||||
) : AudioOutput {
|
||||
|
||||
override fun getPositionUs(): Long {
|
||||
val pos = delegate.getPositionUs()
|
||||
rawPositionUs.set(pos)
|
||||
return pos
|
||||
private var loggedFirstWrite = false
|
||||
private var writeCount = 0L
|
||||
private var writtenBytes = 0L
|
||||
private var failed = false
|
||||
|
||||
override fun getPositionUs(): Long {
|
||||
val pos = delegate.getPositionUs()
|
||||
rawPositionUs.set(pos)
|
||||
return pos
|
||||
}
|
||||
|
||||
override fun play() = delegate.play()
|
||||
override fun pause() = delegate.pause()
|
||||
|
||||
@Throws(AudioOutput.WriteException::class)
|
||||
override fun write(buffer: ByteBuffer, size: Int, presentationTimeUs: Long): Boolean {
|
||||
val before = buffer.position()
|
||||
val handled = try {
|
||||
delegate.write(buffer, size, presentationTimeUs)
|
||||
} catch (e: AudioOutput.WriteException) {
|
||||
failed = true
|
||||
rawPositionUs.set(Long.MIN_VALUE)
|
||||
log?.invoke("warn", "audio-output", "Write failed; AudioTrack will not be reused: ${e.message}")
|
||||
throw e
|
||||
}
|
||||
|
||||
override fun play() = delegate.play()
|
||||
override fun pause() = delegate.pause()
|
||||
|
||||
@Throws(AudioOutput.WriteException::class)
|
||||
override fun write(buffer: ByteBuffer, size: Int, presentationTimeUs: Long) =
|
||||
delegate.write(buffer, size, presentationTimeUs)
|
||||
|
||||
override fun flush() {
|
||||
rawPositionUs.set(Long.MIN_VALUE)
|
||||
delegate.flush()
|
||||
val written = buffer.position() - before
|
||||
if (written > 0) {
|
||||
writeCount++
|
||||
writtenBytes += written.toLong()
|
||||
if (!loggedFirstWrite) {
|
||||
loggedFirstWrite = true
|
||||
log?.invoke(
|
||||
"debug",
|
||||
"audio-output",
|
||||
"First write: wrote=${written}B, handled=$handled, pts=${presentationTimeUs}us, writes=$writeCount, total=${writtenBytes}B"
|
||||
)
|
||||
}
|
||||
}
|
||||
return handled
|
||||
}
|
||||
|
||||
override fun stop() = delegate.stop()
|
||||
override fun flush() {
|
||||
rawPositionUs.set(Long.MIN_VALUE)
|
||||
loggedFirstWrite = false
|
||||
writeCount = 0
|
||||
writtenBytes = 0
|
||||
delegate.flush()
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
rawPositionUs.set(Long.MIN_VALUE)
|
||||
delegate.release()
|
||||
override fun stop() = delegate.stop()
|
||||
|
||||
override fun release() {
|
||||
rawPositionUs.set(Long.MIN_VALUE)
|
||||
if (failed) {
|
||||
delegate.release()
|
||||
return
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 25) {
|
||||
delegate.stop()
|
||||
delegate.flush()
|
||||
provider.returnToCache(this)
|
||||
} else {
|
||||
delegate.release()
|
||||
}
|
||||
}
|
||||
|
||||
override fun setVolume(volume: Float) = delegate.setVolume(volume)
|
||||
override fun isOffloadedPlayback() = delegate.isOffloadedPlayback()
|
||||
override fun getAudioSessionId() = delegate.getAudioSessionId()
|
||||
override fun getSampleRate() = delegate.getSampleRate()
|
||||
override fun getBufferSizeInFrames() = delegate.getBufferSizeInFrames()
|
||||
override fun getPlaybackParameters() = delegate.getPlaybackParameters()
|
||||
override fun isStalled() = delegate.isStalled()
|
||||
override fun addListener(listener: AudioOutput.Listener) = delegate.addListener(listener)
|
||||
override fun removeListener(listener: AudioOutput.Listener) = delegate.removeListener(listener)
|
||||
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) =
|
||||
delegate.setPlaybackParameters(playbackParameters)
|
||||
override fun setOffloadDelayPadding(delayInFrames: Int, paddingInFrames: Int) =
|
||||
delegate.setOffloadDelayPadding(delayInFrames, paddingInFrames)
|
||||
override fun setOffloadEndOfStream() = delegate.setOffloadEndOfStream()
|
||||
override fun setPlayerId(playerId: PlayerId) = delegate.setPlayerId(playerId)
|
||||
override fun attachAuxEffect(effectId: Int) = delegate.attachAuxEffect(effectId)
|
||||
override fun setAuxEffectSendLevel(level: Float) = delegate.setAuxEffectSendLevel(level)
|
||||
override fun setPreferredDevice(preferredDevice: AudioDeviceInfo?) =
|
||||
delegate.setPreferredDevice(preferredDevice)
|
||||
fun forceRelease() {
|
||||
rawPositionUs.set(Long.MIN_VALUE)
|
||||
delegate.release()
|
||||
}
|
||||
|
||||
override fun setVolume(volume: Float) = delegate.setVolume(volume)
|
||||
override fun isOffloadedPlayback() = delegate.isOffloadedPlayback()
|
||||
override fun getAudioSessionId() = delegate.getAudioSessionId()
|
||||
override fun getSampleRate() = delegate.getSampleRate()
|
||||
override fun getBufferSizeInFrames() = delegate.getBufferSizeInFrames()
|
||||
override fun getPlaybackParameters() = delegate.getPlaybackParameters()
|
||||
override fun isStalled() = delegate.isStalled()
|
||||
override fun addListener(listener: AudioOutput.Listener) = delegate.addListener(listener)
|
||||
override fun removeListener(listener: AudioOutput.Listener) = delegate.removeListener(listener)
|
||||
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) = delegate.setPlaybackParameters(playbackParameters)
|
||||
override fun setOffloadDelayPadding(delayInFrames: Int, paddingInFrames: Int) = delegate.setOffloadDelayPadding(delayInFrames, paddingInFrames)
|
||||
override fun setOffloadEndOfStream() = delegate.setOffloadEndOfStream()
|
||||
override fun setPlayerId(playerId: PlayerId) = delegate.setPlayerId(playerId)
|
||||
override fun attachAuxEffect(effectId: Int) = delegate.attachAuxEffect(effectId)
|
||||
override fun setAuxEffectSendLevel(level: Float) = delegate.setAuxEffectSendLevel(level)
|
||||
override fun setPreferredDevice(preferredDevice: AudioDeviceInfo?) = delegate.setPreferredDevice(preferredDevice)
|
||||
}
|
||||
|
||||
@@ -16,108 +16,115 @@ import java.util.zip.Inflater
|
||||
* All buffers are reused across samples to minimize GC pressure on the hot path.
|
||||
*/
|
||||
class ZlibInflatingTrackOutput(
|
||||
private val delegate: TrackOutput,
|
||||
private val delegate: TrackOutput
|
||||
) : TrackOutput {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ZlibTrackOutput"
|
||||
private const val INITIAL_BUFFER_SIZE = 256 * 1024
|
||||
private const val INFLATE_CHUNK = 64 * 1024
|
||||
companion object {
|
||||
private const val TAG = "ZlibTrackOutput"
|
||||
private const val INITIAL_BUFFER_SIZE = 256 * 1024
|
||||
private const val INFLATE_CHUNK = 64 * 1024
|
||||
}
|
||||
|
||||
var active = false
|
||||
|
||||
private val inflater = Inflater()
|
||||
|
||||
// Reusable buffers — grown as needed, never shrunk
|
||||
private var compressedBuf = ByteArray(INITIAL_BUFFER_SIZE)
|
||||
private var compressedLen = 0
|
||||
private var inflateBuf = ByteArray(INITIAL_BUFFER_SIZE)
|
||||
private var readBuf = ByteArray(INFLATE_CHUNK)
|
||||
private val outputParsable = ParsableByteArray()
|
||||
private var buffering = false
|
||||
|
||||
override fun format(format: Format) = delegate.format(format)
|
||||
|
||||
override fun sampleData(
|
||||
input: DataReader,
|
||||
length: Int,
|
||||
allowEndOfInput: Boolean,
|
||||
sampleDataPart: Int
|
||||
): Int {
|
||||
if (!active) return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart)
|
||||
|
||||
buffering = true
|
||||
if (readBuf.size < length) readBuf = ByteArray(length)
|
||||
val bytesRead = input.read(readBuf, 0, length)
|
||||
if (bytesRead > 0) appendCompressed(readBuf, 0, bytesRead)
|
||||
return bytesRead
|
||||
}
|
||||
|
||||
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
|
||||
if (!active) {
|
||||
delegate.sampleData(data, length, sampleDataPart)
|
||||
return
|
||||
}
|
||||
|
||||
var active = false
|
||||
buffering = true
|
||||
ensureCompressedCapacity(compressedLen + length)
|
||||
data.readBytes(compressedBuf, compressedLen, length)
|
||||
compressedLen += length
|
||||
}
|
||||
|
||||
private val inflater = Inflater()
|
||||
|
||||
// Reusable buffers — grown as needed, never shrunk
|
||||
private var compressedBuf = ByteArray(INITIAL_BUFFER_SIZE)
|
||||
private var compressedLen = 0
|
||||
private var inflateBuf = ByteArray(INITIAL_BUFFER_SIZE)
|
||||
private var readBuf = ByteArray(INFLATE_CHUNK)
|
||||
private val outputParsable = ParsableByteArray()
|
||||
private var buffering = false
|
||||
|
||||
override fun format(format: Format) = delegate.format(format)
|
||||
|
||||
override fun sampleData(
|
||||
input: DataReader, length: Int, allowEndOfInput: Boolean, sampleDataPart: Int
|
||||
): Int {
|
||||
if (!active) return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart)
|
||||
|
||||
buffering = true
|
||||
if (readBuf.size < length) readBuf = ByteArray(length)
|
||||
val bytesRead = input.read(readBuf, 0, length)
|
||||
if (bytesRead > 0) appendCompressed(readBuf, 0, bytesRead)
|
||||
return bytesRead
|
||||
override fun sampleMetadata(
|
||||
timeUs: Long,
|
||||
flags: Int,
|
||||
size: Int,
|
||||
offset: Int,
|
||||
cryptoData: TrackOutput.CryptoData?
|
||||
) {
|
||||
if (!active || !buffering) {
|
||||
delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData)
|
||||
return
|
||||
}
|
||||
|
||||
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
|
||||
if (!active) {
|
||||
delegate.sampleData(data, length, sampleDataPart)
|
||||
return
|
||||
}
|
||||
buffering = false
|
||||
val srcLen = compressedLen
|
||||
compressedLen = 0
|
||||
|
||||
buffering = true
|
||||
ensureCompressedCapacity(compressedLen + length)
|
||||
data.readBytes(compressedBuf, compressedLen, length)
|
||||
compressedLen += length
|
||||
val inflatedLen = try {
|
||||
inflater.reset()
|
||||
inflater.setInput(compressedBuf, 0, srcLen)
|
||||
var written = 0
|
||||
while (!inflater.finished()) {
|
||||
if (written == inflateBuf.size) growInflateBuf()
|
||||
val count = inflater.inflate(inflateBuf, written, inflateBuf.size - written)
|
||||
if (count == 0 && !inflater.finished()) break
|
||||
written += count
|
||||
}
|
||||
written
|
||||
} catch (e: DataFormatException) {
|
||||
Log.e(TAG, "Zlib inflate failed (${srcLen}B), passing raw", e)
|
||||
// Fall back to raw compressed data
|
||||
ensureInflateCapacity(srcLen)
|
||||
System.arraycopy(compressedBuf, 0, inflateBuf, 0, srcLen)
|
||||
srcLen
|
||||
}
|
||||
|
||||
override fun sampleMetadata(
|
||||
timeUs: Long, flags: Int, size: Int, offset: Int, cryptoData: TrackOutput.CryptoData?
|
||||
) {
|
||||
if (!active || !buffering) {
|
||||
delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData)
|
||||
return
|
||||
}
|
||||
outputParsable.reset(inflateBuf, inflatedLen)
|
||||
delegate.sampleData(outputParsable, inflatedLen, TrackOutput.SAMPLE_DATA_PART_MAIN)
|
||||
delegate.sampleMetadata(timeUs, flags, inflatedLen, 0, cryptoData)
|
||||
}
|
||||
|
||||
buffering = false
|
||||
val srcLen = compressedLen
|
||||
compressedLen = 0
|
||||
private fun appendCompressed(src: ByteArray, offset: Int, length: Int) {
|
||||
ensureCompressedCapacity(compressedLen + length)
|
||||
System.arraycopy(src, offset, compressedBuf, compressedLen, length)
|
||||
compressedLen += length
|
||||
}
|
||||
|
||||
val inflatedLen = try {
|
||||
inflater.reset()
|
||||
inflater.setInput(compressedBuf, 0, srcLen)
|
||||
var written = 0
|
||||
while (!inflater.finished()) {
|
||||
if (written == inflateBuf.size) growInflateBuf()
|
||||
val count = inflater.inflate(inflateBuf, written, inflateBuf.size - written)
|
||||
if (count == 0 && !inflater.finished()) break
|
||||
written += count
|
||||
}
|
||||
written
|
||||
} catch (e: DataFormatException) {
|
||||
Log.e(TAG, "Zlib inflate failed (${srcLen}B), passing raw", e)
|
||||
// Fall back to raw compressed data
|
||||
ensureInflateCapacity(srcLen)
|
||||
System.arraycopy(compressedBuf, 0, inflateBuf, 0, srcLen)
|
||||
srcLen
|
||||
}
|
||||
|
||||
outputParsable.reset(inflateBuf, inflatedLen)
|
||||
delegate.sampleData(outputParsable, inflatedLen, TrackOutput.SAMPLE_DATA_PART_MAIN)
|
||||
delegate.sampleMetadata(timeUs, flags, inflatedLen, 0, cryptoData)
|
||||
private fun ensureCompressedCapacity(needed: Int) {
|
||||
if (compressedBuf.size < needed) {
|
||||
compressedBuf = compressedBuf.copyOf(maxOf(needed, compressedBuf.size * 2))
|
||||
}
|
||||
}
|
||||
|
||||
private fun appendCompressed(src: ByteArray, offset: Int, length: Int) {
|
||||
ensureCompressedCapacity(compressedLen + length)
|
||||
System.arraycopy(src, offset, compressedBuf, compressedLen, length)
|
||||
compressedLen += length
|
||||
private fun ensureInflateCapacity(needed: Int) {
|
||||
if (inflateBuf.size < needed) {
|
||||
inflateBuf = ByteArray(maxOf(needed, inflateBuf.size * 2))
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureCompressedCapacity(needed: Int) {
|
||||
if (compressedBuf.size < needed) {
|
||||
compressedBuf = compressedBuf.copyOf(maxOf(needed, compressedBuf.size * 2))
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureInflateCapacity(needed: Int) {
|
||||
if (inflateBuf.size < needed) {
|
||||
inflateBuf = ByteArray(maxOf(needed, inflateBuf.size * 2))
|
||||
}
|
||||
}
|
||||
|
||||
private fun growInflateBuf() {
|
||||
inflateBuf = inflateBuf.copyOf(inflateBuf.size * 2)
|
||||
}
|
||||
private fun growInflateBuf() {
|
||||
inflateBuf = inflateBuf.copyOf(inflateBuf.size * 2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ import androidx.media3.extractor.ExtractorOutput
|
||||
import androidx.media3.extractor.SeekMap
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import androidx.media3.extractor.mkv.MatroskaExtractor
|
||||
import androidx.media3.extractor.text.SubtitleParser
|
||||
import io.github.peerless2012.ass.media.AssHandler
|
||||
import io.github.peerless2012.ass.media.extractor.AssMatroskaExtractor
|
||||
import androidx.media3.extractor.text.SubtitleParser
|
||||
|
||||
/**
|
||||
* Extends AssMatroskaExtractor to add support for MKV ContentCompAlgo 0 (zlib).
|
||||
@@ -20,97 +20,97 @@ import androidx.media3.extractor.text.SubtitleParser
|
||||
* - Skips ContentCompSettings for zlib tracks (not applicable)
|
||||
*/
|
||||
class ZlibMatroskaExtractor(
|
||||
subtitleParserFactory: SubtitleParser.Factory,
|
||||
assHandler: AssHandler,
|
||||
subtitleParserFactory: SubtitleParser.Factory,
|
||||
assHandler: AssHandler
|
||||
) : AssMatroskaExtractor(subtitleParserFactory, assHandler) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ZlibMkvExtractor"
|
||||
companion object {
|
||||
private const val TAG = "ZlibMkvExtractor"
|
||||
|
||||
// Matroska EBML element IDs
|
||||
private const val ID_SEGMENT = 0x18538067
|
||||
private const val ID_TRACK_ENTRY = 0xAE
|
||||
private const val ID_CONTENT_COMPRESSION_ALGORITHM = 0x4254
|
||||
private const val ID_CONTENT_COMPRESSION_SETTINGS = 0x4255
|
||||
// Matroska EBML element IDs
|
||||
private const val ID_SEGMENT = 0x18538067
|
||||
private const val ID_TRACK_ENTRY = 0xAE
|
||||
private const val ID_CONTENT_COMPRESSION_ALGORITHM = 0x4254
|
||||
private const val ID_CONTENT_COMPRESSION_SETTINGS = 0x4255
|
||||
|
||||
private val extractorOutputField by lazy {
|
||||
MatroskaExtractor::class.java.getDeclaredField("extractorOutput").apply {
|
||||
isAccessible = true
|
||||
}
|
||||
}
|
||||
private val extractorOutputField by lazy {
|
||||
MatroskaExtractor::class.java.getDeclaredField("extractorOutput").apply {
|
||||
isAccessible = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var zlibOutput: ZlibExtractorOutputWrapper? = null
|
||||
private var currentTrackUsesZlib = false
|
||||
|
||||
override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) {
|
||||
super.startMasterElement(id, contentPosition, contentSize)
|
||||
|
||||
// After super installs AssSubtitleExtractorOutput, wrap it with our zlib layer
|
||||
if (id == ID_SEGMENT && zlibOutput == null) {
|
||||
val currentOutput = extractorOutputField.get(this) as ExtractorOutput
|
||||
val wrapper = ZlibExtractorOutputWrapper(currentOutput)
|
||||
zlibOutput = wrapper
|
||||
extractorOutputField.set(this, wrapper)
|
||||
Log.d(TAG, "Installed zlib ExtractorOutput wrapper")
|
||||
}
|
||||
}
|
||||
|
||||
override fun integerElement(id: Int, value: Long) {
|
||||
if (id == ID_CONTENT_COMPRESSION_ALGORITHM && value == 0L) {
|
||||
currentTrackUsesZlib = true
|
||||
Log.i(TAG, "Track uses ContentCompAlgo 0 (zlib), will inflate samples")
|
||||
// Tell parent it's header stripping (algo 3) to avoid ParserException
|
||||
super.integerElement(id, 3)
|
||||
return
|
||||
}
|
||||
super.integerElement(id, value)
|
||||
}
|
||||
|
||||
override fun binaryElement(id: Int, contentSize: Int, input: ExtractorInput) {
|
||||
if (id == ID_CONTENT_COMPRESSION_SETTINGS && currentTrackUsesZlib) {
|
||||
// Skip ContentCompSettings for zlib tracks — parent would store these as
|
||||
// sampleStrippedBytes and prepend them to every sample, corrupting output.
|
||||
input.skipFully(contentSize)
|
||||
return
|
||||
}
|
||||
super.binaryElement(id, contentSize, input)
|
||||
}
|
||||
|
||||
override fun endMasterElement(id: Int) {
|
||||
val wasZlib = currentTrackUsesZlib
|
||||
super.endMasterElement(id)
|
||||
|
||||
if (id == ID_TRACK_ENTRY && wasZlib) {
|
||||
zlibOutput?.activateLast()
|
||||
currentTrackUsesZlib = false
|
||||
Log.i(TAG, "Activated zlib inflation for track")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ExtractorOutput wrapper that wraps all TrackOutputs with ZlibInflatingTrackOutput.
|
||||
* Tracks are created inactive; activateLast() enables inflation for the most recently
|
||||
* created track (called when we know a track uses zlib compression).
|
||||
*/
|
||||
private class ZlibExtractorOutputWrapper(
|
||||
private val delegate: ExtractorOutput
|
||||
) : ExtractorOutput {
|
||||
|
||||
private var lastCreatedWrapper: ZlibInflatingTrackOutput? = null
|
||||
|
||||
override fun track(id: Int, type: Int): TrackOutput {
|
||||
val original = delegate.track(id, type)
|
||||
val wrapper = ZlibInflatingTrackOutput(original)
|
||||
lastCreatedWrapper = wrapper
|
||||
return wrapper
|
||||
}
|
||||
|
||||
private var zlibOutput: ZlibExtractorOutputWrapper? = null
|
||||
private var currentTrackUsesZlib = false
|
||||
|
||||
override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) {
|
||||
super.startMasterElement(id, contentPosition, contentSize)
|
||||
|
||||
// After super installs AssSubtitleExtractorOutput, wrap it with our zlib layer
|
||||
if (id == ID_SEGMENT && zlibOutput == null) {
|
||||
val currentOutput = extractorOutputField.get(this) as ExtractorOutput
|
||||
val wrapper = ZlibExtractorOutputWrapper(currentOutput)
|
||||
zlibOutput = wrapper
|
||||
extractorOutputField.set(this, wrapper)
|
||||
Log.d(TAG, "Installed zlib ExtractorOutput wrapper")
|
||||
}
|
||||
fun activateLast() {
|
||||
lastCreatedWrapper?.active = true
|
||||
}
|
||||
|
||||
override fun integerElement(id: Int, value: Long) {
|
||||
if (id == ID_CONTENT_COMPRESSION_ALGORITHM && value == 0L) {
|
||||
currentTrackUsesZlib = true
|
||||
Log.i(TAG, "Track uses ContentCompAlgo 0 (zlib), will inflate samples")
|
||||
// Tell parent it's header stripping (algo 3) to avoid ParserException
|
||||
super.integerElement(id, 3)
|
||||
return
|
||||
}
|
||||
super.integerElement(id, value)
|
||||
}
|
||||
|
||||
override fun binaryElement(id: Int, contentSize: Int, input: ExtractorInput) {
|
||||
if (id == ID_CONTENT_COMPRESSION_SETTINGS && currentTrackUsesZlib) {
|
||||
// Skip ContentCompSettings for zlib tracks — parent would store these as
|
||||
// sampleStrippedBytes and prepend them to every sample, corrupting output.
|
||||
input.skipFully(contentSize)
|
||||
return
|
||||
}
|
||||
super.binaryElement(id, contentSize, input)
|
||||
}
|
||||
|
||||
override fun endMasterElement(id: Int) {
|
||||
val wasZlib = currentTrackUsesZlib
|
||||
super.endMasterElement(id)
|
||||
|
||||
if (id == ID_TRACK_ENTRY && wasZlib) {
|
||||
zlibOutput?.activateLast()
|
||||
currentTrackUsesZlib = false
|
||||
Log.i(TAG, "Activated zlib inflation for track")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ExtractorOutput wrapper that wraps all TrackOutputs with ZlibInflatingTrackOutput.
|
||||
* Tracks are created inactive; activateLast() enables inflation for the most recently
|
||||
* created track (called when we know a track uses zlib compression).
|
||||
*/
|
||||
private class ZlibExtractorOutputWrapper(
|
||||
private val delegate: ExtractorOutput,
|
||||
) : ExtractorOutput {
|
||||
|
||||
private var lastCreatedWrapper: ZlibInflatingTrackOutput? = null
|
||||
|
||||
override fun track(id: Int, type: Int): TrackOutput {
|
||||
val original = delegate.track(id, type)
|
||||
val wrapper = ZlibInflatingTrackOutput(original)
|
||||
lastCreatedWrapper = wrapper
|
||||
return wrapper
|
||||
}
|
||||
|
||||
fun activateLast() {
|
||||
lastCreatedWrapper?.active = true
|
||||
}
|
||||
|
||||
override fun endTracks() = delegate.endTracks()
|
||||
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
|
||||
}
|
||||
override fun endTracks() = delegate.endTracks()
|
||||
override fun seekMap(seekMap: SeekMap) = delegate.seekMap(seekMap)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,301 +10,376 @@ import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MpvPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
EventChannel.StreamHandler, ActivityAware, com.edde746.plezy.shared.PlayerDelegate {
|
||||
class MpvPlayerPlugin :
|
||||
FlutterPlugin,
|
||||
MethodChannel.MethodCallHandler,
|
||||
EventChannel.StreamHandler,
|
||||
ActivityAware,
|
||||
com.edde746.plezy.shared.PlayerDelegate {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MpvPlayerPlugin"
|
||||
private const val METHOD_CHANNEL = "com.plezy/mpv_player"
|
||||
private const val EVENT_CHANNEL = "com.plezy/mpv_player/events"
|
||||
companion object {
|
||||
private const val TAG = "MpvPlayerPlugin"
|
||||
private const val METHOD_CHANNEL = "com.plezy/mpv_player"
|
||||
private const val EVENT_CHANNEL = "com.plezy/mpv_player/events"
|
||||
}
|
||||
|
||||
private lateinit var methodChannel: MethodChannel
|
||||
private lateinit var eventChannel: EventChannel
|
||||
private var eventSink: EventChannel.EventSink? = null
|
||||
private var playerCore: MpvPlayerCore? = null
|
||||
private var activity: Activity? = null
|
||||
private var activityBinding: ActivityPluginBinding? = null
|
||||
private val nameToId = mutableMapOf<String, Int>()
|
||||
private var sessionGeneration = 0
|
||||
|
||||
// Pending `MethodChannel.Result`s for an init that is currently in flight.
|
||||
// Concurrent `invoke('initialize')` calls share the same outcome instead
|
||||
// of each tearing down the in-flight core and starting their own — which
|
||||
// was the root cause of #930.
|
||||
private val pendingInitResults = mutableListOf<MethodChannel.Result>()
|
||||
|
||||
@Volatile private var isInitializing = false
|
||||
|
||||
// FlutterPlugin
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
|
||||
methodChannel.setMethodCallHandler(this)
|
||||
|
||||
eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL)
|
||||
eventChannel.setStreamHandler(this)
|
||||
|
||||
Log.d(TAG, "Attached to engine")
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
methodChannel.setMethodCallHandler(null)
|
||||
eventChannel.setStreamHandler(null)
|
||||
Log.d(TAG, "Detached from engine")
|
||||
}
|
||||
|
||||
// ActivityAware
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity
|
||||
activityBinding = binding
|
||||
Log.d(TAG, "Attached to activity")
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
++sessionGeneration
|
||||
playerCore?.dispose()
|
||||
playerCore = null
|
||||
// Any in-flight init callback would never fire (its scope is cancelled
|
||||
// by dispose), so close out queued callers explicitly.
|
||||
completePendingInits(success = false)
|
||||
activity = null
|
||||
activityBinding = null
|
||||
Log.d(TAG, "Detached from activity")
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity
|
||||
activityBinding = binding
|
||||
Log.d(TAG, "Reattached to activity for config changes")
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
activity = null
|
||||
activityBinding = null
|
||||
Log.d(TAG, "Detached from activity for config changes")
|
||||
}
|
||||
|
||||
// EventChannel.StreamHandler
|
||||
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||
eventSink = events
|
||||
Log.d(TAG, "Event stream connected")
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
eventSink = null
|
||||
Log.d(TAG, "Event stream disconnected")
|
||||
}
|
||||
|
||||
// MethodChannel.MethodCallHandler
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"initialize" -> handleInitialize(result)
|
||||
"dispose" -> handleDispose(result)
|
||||
"setProperty" -> handleSetProperty(call, result)
|
||||
"getProperty" -> handleGetProperty(call, result)
|
||||
"observeProperty" -> handleObserveProperty(call, result)
|
||||
"command" -> handleCommand(call, result)
|
||||
"setVisible" -> handleSetVisible(call, result)
|
||||
"updateFrame" -> handleUpdateFrame(result)
|
||||
"setVideoFrameRate" -> handleSetVideoFrameRate(call, result)
|
||||
"clearVideoFrameRate" -> handleClearVideoFrameRate(result)
|
||||
"requestAudioFocus" -> handleRequestAudioFocus(result)
|
||||
"abandonAudioFocus" -> handleAbandonAudioFocus(result)
|
||||
"openContentFd" -> handleOpenContentFd(call, result)
|
||||
"isInitialized" -> result.success(playerCore?.isInitialized ?: false)
|
||||
"setLogLevel" -> result.success(null)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInitialize(result: MethodChannel.Result) {
|
||||
val currentActivity = activity
|
||||
if (currentActivity == null) {
|
||||
result.error("NO_ACTIVITY", "Activity not available", null)
|
||||
return
|
||||
}
|
||||
|
||||
private lateinit var methodChannel: MethodChannel
|
||||
private lateinit var eventChannel: EventChannel
|
||||
private var eventSink: EventChannel.EventSink? = null
|
||||
private var playerCore: MpvPlayerCore? = null
|
||||
private var activity: Activity? = null
|
||||
private var activityBinding: ActivityPluginBinding? = null
|
||||
private val nameToId = mutableMapOf<String, Int>()
|
||||
private var sessionGeneration = 0
|
||||
|
||||
// FlutterPlugin
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
|
||||
methodChannel.setMethodCallHandler(this)
|
||||
|
||||
eventChannel = EventChannel(binding.binaryMessenger, EVENT_CHANNEL)
|
||||
eventChannel.setStreamHandler(this)
|
||||
|
||||
Log.d(TAG, "Attached to engine")
|
||||
if (playerCore?.isInitialized == true) {
|
||||
Log.d(TAG, "Already initialized")
|
||||
result.success(true)
|
||||
return
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
methodChannel.setMethodCallHandler(null)
|
||||
eventChannel.setStreamHandler(null)
|
||||
Log.d(TAG, "Detached from engine")
|
||||
// Coalesce concurrent inits: the second caller waits for the first
|
||||
// call's outcome instead of disposing the in-flight core. The Dart
|
||||
// side memoizes too, but this is defense in depth for any direct
|
||||
// `invoke('initialize')` that bypasses _ensureInitialized.
|
||||
synchronized(pendingInitResults) {
|
||||
pendingInitResults += result
|
||||
if (isInitializing) {
|
||||
Log.d(TAG, "Init already in flight, queuing caller")
|
||||
return
|
||||
}
|
||||
isInitializing = true
|
||||
}
|
||||
|
||||
// ActivityAware
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity
|
||||
activityBinding = binding
|
||||
Log.d(TAG, "Attached to activity")
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
++sessionGeneration
|
||||
playerCore?.dispose()
|
||||
playerCore = null
|
||||
activity = null
|
||||
activityBinding = null
|
||||
Log.d(TAG, "Detached from activity")
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity
|
||||
activityBinding = binding
|
||||
Log.d(TAG, "Reattached to activity for config changes")
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
activity = null
|
||||
activityBinding = null
|
||||
Log.d(TAG, "Detached from activity for config changes")
|
||||
}
|
||||
|
||||
// EventChannel.StreamHandler
|
||||
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||
eventSink = events
|
||||
Log.d(TAG, "Event stream connected")
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
eventSink = null
|
||||
Log.d(TAG, "Event stream disconnected")
|
||||
}
|
||||
|
||||
// MethodChannel.MethodCallHandler
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"initialize" -> handleInitialize(result)
|
||||
"dispose" -> handleDispose(result)
|
||||
"setProperty" -> handleSetProperty(call, result)
|
||||
"getProperty" -> handleGetProperty(call, result)
|
||||
"observeProperty" -> handleObserveProperty(call, result)
|
||||
"command" -> handleCommand(call, result)
|
||||
"setVisible" -> handleSetVisible(call, result)
|
||||
"setVideoFrameRate" -> handleSetVideoFrameRate(call, result)
|
||||
"clearVideoFrameRate" -> handleClearVideoFrameRate(result)
|
||||
"requestAudioFocus" -> handleRequestAudioFocus(result)
|
||||
"abandonAudioFocus" -> handleAbandonAudioFocus(result)
|
||||
"openContentFd" -> handleOpenContentFd(call, result)
|
||||
"isInitialized" -> result.success(playerCore?.isInitialized ?: false)
|
||||
"setLogLevel" -> result.success(null)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleInitialize(result: MethodChannel.Result) {
|
||||
val currentActivity = activity
|
||||
if (currentActivity == null) {
|
||||
result.error("NO_ACTIVITY", "Activity not available", null)
|
||||
return
|
||||
currentActivity.runOnUiThread {
|
||||
val gen: Int
|
||||
val core: MpvPlayerCore
|
||||
try {
|
||||
// Caller invariant: dispose() was already called explicitly,
|
||||
// OR `playerCore?.isInitialized == true` and we early-exited
|
||||
// above. We never tear down a core that's mid-initialization.
|
||||
if (playerCore != null && playerCore?.isInitialized != true) {
|
||||
Log.w(TAG, "Discarding stale uninitialized core before re-init")
|
||||
playerCore?.dispose()
|
||||
playerCore = null
|
||||
}
|
||||
|
||||
if (playerCore?.isInitialized == true) {
|
||||
Log.d(TAG, "Already initialized")
|
||||
result.success(true)
|
||||
return
|
||||
gen = ++sessionGeneration
|
||||
core = MpvPlayerCore(currentActivity).apply {
|
||||
delegate = this@MpvPlayerPlugin
|
||||
}
|
||||
playerCore = core
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to initialize: ${e.message}", e)
|
||||
completePendingInits(success = false, errorMessage = e.message)
|
||||
return@runOnUiThread
|
||||
}
|
||||
|
||||
currentActivity.runOnUiThread {
|
||||
try {
|
||||
// Dispose stale core idempotently
|
||||
playerCore?.dispose()
|
||||
playerCore = null
|
||||
|
||||
val gen = ++sessionGeneration
|
||||
val core = MpvPlayerCore(currentActivity).apply {
|
||||
delegate = this@MpvPlayerPlugin
|
||||
}
|
||||
playerCore = core
|
||||
|
||||
core.initialize { success ->
|
||||
if (gen != sessionGeneration || playerCore !== core) {
|
||||
Log.d(TAG, "Stale init callback (gen=$gen, current=$sessionGeneration)")
|
||||
result.success(false)
|
||||
return@initialize
|
||||
}
|
||||
// Start hidden - now safe because setVisible operates on the container,
|
||||
// not the SurfaceView directly (matching ExoPlayer's approach)
|
||||
core.setVisible(false)
|
||||
Log.d(TAG, "Initialized: $success")
|
||||
result.success(success)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to initialize: ${e.message}", e)
|
||||
result.error("INIT_FAILED", e.message, null)
|
||||
}
|
||||
core.initialize { success ->
|
||||
val stale = gen != sessionGeneration || playerCore !== core
|
||||
if (stale) {
|
||||
Log.d(TAG, "Stale init callback (gen=$gen, current=$sessionGeneration)")
|
||||
} else {
|
||||
// Start hidden - now safe because setVisible operates on the container,
|
||||
// not the SurfaceView directly (matching ExoPlayer's approach)
|
||||
core.setVisible(false)
|
||||
Log.d(TAG, "Initialized: $success")
|
||||
}
|
||||
completePendingInits(success = !stale && success)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDispose(result: MethodChannel.Result) {
|
||||
activity?.runOnUiThread {
|
||||
val core = playerCore
|
||||
++sessionGeneration
|
||||
playerCore = null
|
||||
|
||||
core?.dispose {
|
||||
Log.d(TAG, "Disposed")
|
||||
result.success(null)
|
||||
} ?: result.success(null)
|
||||
} ?: result.success(null)
|
||||
private fun completePendingInits(success: Boolean, errorMessage: String? = null) {
|
||||
val pending = synchronized(pendingInitResults) {
|
||||
isInitializing = false
|
||||
val copy = pendingInitResults.toList()
|
||||
pendingInitResults.clear()
|
||||
copy
|
||||
}
|
||||
for (r in pending) {
|
||||
if (errorMessage != null) {
|
||||
r.error("INIT_FAILED", errorMessage, null)
|
||||
} else {
|
||||
r.success(success)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSetProperty(call: MethodCall, result: MethodChannel.Result) {
|
||||
val name = call.argument<String>("name")
|
||||
val value = call.argument<String>("value")
|
||||
private fun handleDispose(result: MethodChannel.Result) {
|
||||
activity?.runOnUiThread {
|
||||
val core = playerCore
|
||||
++sessionGeneration
|
||||
playerCore = null
|
||||
|
||||
if (name == null || value == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'name' or 'value'", null)
|
||||
return
|
||||
}
|
||||
// Any in-flight init callback is cancelled with the scope, so
|
||||
// close out queued callers here instead of leaking them.
|
||||
completePendingInits(success = false)
|
||||
|
||||
playerCore?.setProperty(name, value)
|
||||
core?.dispose {
|
||||
Log.d(TAG, "Disposed")
|
||||
result.success(null)
|
||||
} ?: result.success(null)
|
||||
} ?: result.success(null)
|
||||
}
|
||||
|
||||
private fun handleSetProperty(call: MethodCall, result: MethodChannel.Result) {
|
||||
val name = call.argument<String>("name")
|
||||
val value = call.argument<String>("value")
|
||||
|
||||
if (name == null || value == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'name' or 'value'", null)
|
||||
return
|
||||
}
|
||||
|
||||
private fun handleGetProperty(call: MethodCall, result: MethodChannel.Result) {
|
||||
val name = call.argument<String>("name")
|
||||
playerCore?.setProperty(name, value)
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
if (name == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'name'", null)
|
||||
return
|
||||
private fun handleGetProperty(call: MethodCall, result: MethodChannel.Result) {
|
||||
val name = call.argument<String>("name")
|
||||
|
||||
if (name == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'name'", null)
|
||||
return
|
||||
}
|
||||
|
||||
val value = playerCore?.getProperty(name)
|
||||
result.success(value)
|
||||
}
|
||||
|
||||
private fun handleObserveProperty(call: MethodCall, result: MethodChannel.Result) {
|
||||
val name = call.argument<String>("name")
|
||||
val format = call.argument<String>("format")
|
||||
val id = call.argument<Int>("id")
|
||||
|
||||
if (name == null || format == null || id == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'name', 'format', or 'id'", null)
|
||||
return
|
||||
}
|
||||
|
||||
nameToId[name] = id
|
||||
playerCore?.observeProperty(name, format)
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun handleCommand(call: MethodCall, result: MethodChannel.Result) {
|
||||
val args = call.argument<List<String>>("args")
|
||||
|
||||
if (args == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'args'", null)
|
||||
return
|
||||
}
|
||||
|
||||
val core = playerCore
|
||||
if (core == null) {
|
||||
result.success(null)
|
||||
return
|
||||
}
|
||||
core.command(args.toTypedArray()) {
|
||||
result.success(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSetVisible(call: MethodCall, result: MethodChannel.Result) {
|
||||
val visible = call.argument<Boolean>("visible")
|
||||
|
||||
if (visible == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'visible'", null)
|
||||
return
|
||||
}
|
||||
|
||||
playerCore?.setVisible(visible)
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun handleUpdateFrame(result: MethodChannel.Result) {
|
||||
playerCore?.updateFrame()
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun handleSetVideoFrameRate(call: MethodCall, result: MethodChannel.Result) {
|
||||
val fps = call.argument<Double>("fps")?.toFloat() ?: 0f
|
||||
val duration = call.argument<Number>("duration")?.toLong() ?: 0L
|
||||
val extraDelayMs = call.argument<Number>("extraDelayMs")?.toLong() ?: 0L
|
||||
|
||||
Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs")
|
||||
val core = playerCore
|
||||
if (core == null) {
|
||||
result.success(false)
|
||||
return
|
||||
}
|
||||
core.setVideoFrameRate(fps, duration, extraDelayMs) { switched ->
|
||||
result.success(switched)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleClearVideoFrameRate(result: MethodChannel.Result) {
|
||||
Log.d(TAG, "clearVideoFrameRate")
|
||||
playerCore?.clearVideoFrameRate()
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun handleRequestAudioFocus(result: MethodChannel.Result) {
|
||||
Log.d(TAG, "requestAudioFocus")
|
||||
val granted = playerCore?.requestAudioFocus() ?: false
|
||||
result.success(granted)
|
||||
}
|
||||
|
||||
private fun handleAbandonAudioFocus(result: MethodChannel.Result) {
|
||||
Log.d(TAG, "abandonAudioFocus")
|
||||
playerCore?.abandonAudioFocus()
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun handleOpenContentFd(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uriString = call.argument<String>("uri")
|
||||
if (uriString == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'uri'", null)
|
||||
return
|
||||
}
|
||||
|
||||
val contentResolver = activity?.contentResolver
|
||||
if (contentResolver == null) {
|
||||
result.error("NO_ACTIVITY", "Activity not available", null)
|
||||
return
|
||||
}
|
||||
|
||||
// Open file descriptor off UI thread to prevent ANR on slow storage
|
||||
Thread {
|
||||
try {
|
||||
val uri = Uri.parse(uriString)
|
||||
val pfd = contentResolver.openFileDescriptor(uri, "r")
|
||||
if (pfd == null) {
|
||||
activity?.runOnUiThread {
|
||||
result.error("OPEN_FAILED", "Failed to open file descriptor for $uriString", null)
|
||||
}
|
||||
return@Thread
|
||||
}
|
||||
|
||||
val value = playerCore?.getProperty(name)
|
||||
result.success(value)
|
||||
}
|
||||
val fd = pfd.detachFd()
|
||||
Log.d(TAG, "Opened content FD $fd for $uriString")
|
||||
activity?.runOnUiThread { result.success(fd) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to open content FD: ${e.message}", e)
|
||||
activity?.runOnUiThread { result.error("OPEN_FAILED", e.message, null) }
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
private fun handleObserveProperty(call: MethodCall, result: MethodChannel.Result) {
|
||||
val name = call.argument<String>("name")
|
||||
val format = call.argument<String>("format")
|
||||
val id = call.argument<Int>("id")
|
||||
// PlayerDelegate
|
||||
|
||||
if (name == null || format == null || id == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'name', 'format', or 'id'", null)
|
||||
return
|
||||
}
|
||||
override fun onPropertyChange(name: String, value: Any?) {
|
||||
val propId = nameToId[name] ?: return
|
||||
eventSink?.success(listOf(propId, value))
|
||||
}
|
||||
|
||||
nameToId[name] = id
|
||||
playerCore?.observeProperty(name, format)
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun handleCommand(call: MethodCall, result: MethodChannel.Result) {
|
||||
val args = call.argument<List<String>>("args")
|
||||
|
||||
if (args == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'args'", null)
|
||||
return
|
||||
}
|
||||
|
||||
playerCore?.command(args.toTypedArray())
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun handleSetVisible(call: MethodCall, result: MethodChannel.Result) {
|
||||
val visible = call.argument<Boolean>("visible")
|
||||
|
||||
if (visible == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'visible'", null)
|
||||
return
|
||||
}
|
||||
|
||||
playerCore?.setVisible(visible)
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun handleSetVideoFrameRate(call: MethodCall, result: MethodChannel.Result) {
|
||||
val fps = call.argument<Double>("fps")?.toFloat() ?: 0f
|
||||
val duration = call.argument<Number>("duration")?.toLong() ?: 0L
|
||||
|
||||
Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration")
|
||||
playerCore?.setVideoFrameRate(fps, duration)
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun handleClearVideoFrameRate(result: MethodChannel.Result) {
|
||||
Log.d(TAG, "clearVideoFrameRate")
|
||||
playerCore?.clearVideoFrameRate()
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun handleRequestAudioFocus(result: MethodChannel.Result) {
|
||||
Log.d(TAG, "requestAudioFocus")
|
||||
val granted = playerCore?.requestAudioFocus() ?: false
|
||||
result.success(granted)
|
||||
}
|
||||
|
||||
private fun handleAbandonAudioFocus(result: MethodChannel.Result) {
|
||||
Log.d(TAG, "abandonAudioFocus")
|
||||
playerCore?.abandonAudioFocus()
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun handleOpenContentFd(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uriString = call.argument<String>("uri")
|
||||
if (uriString == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'uri'", null)
|
||||
return
|
||||
}
|
||||
|
||||
val contentResolver = activity?.contentResolver
|
||||
if (contentResolver == null) {
|
||||
result.error("NO_ACTIVITY", "Activity not available", null)
|
||||
return
|
||||
}
|
||||
|
||||
// Open file descriptor off UI thread to prevent ANR on slow storage
|
||||
Thread {
|
||||
try {
|
||||
val uri = Uri.parse(uriString)
|
||||
val pfd = contentResolver.openFileDescriptor(uri, "r")
|
||||
if (pfd == null) {
|
||||
activity?.runOnUiThread {
|
||||
result.error("OPEN_FAILED", "Failed to open file descriptor for $uriString", null)
|
||||
}
|
||||
return@Thread
|
||||
}
|
||||
|
||||
val fd = pfd.detachFd()
|
||||
Log.d(TAG, "Opened content FD $fd for $uriString")
|
||||
activity?.runOnUiThread { result.success(fd) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to open content FD: ${e.message}", e)
|
||||
activity?.runOnUiThread { result.error("OPEN_FAILED", e.message, null) }
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
// PlayerDelegate
|
||||
|
||||
override fun onPropertyChange(name: String, value: Any?) {
|
||||
val propId = nameToId[name] ?: return
|
||||
eventSink?.success(listOf(propId, value))
|
||||
}
|
||||
|
||||
override fun onEvent(name: String, data: Map<String, Any>?) {
|
||||
val event = mutableMapOf<String, Any>(
|
||||
"type" to "event",
|
||||
"name" to name
|
||||
)
|
||||
data?.let { event["data"] = it }
|
||||
eventSink?.success(event)
|
||||
}
|
||||
override fun onEvent(name: String, data: Map<String, Any>?) {
|
||||
val event = mutableMapOf<String, Any>(
|
||||
"type" to "event",
|
||||
"name" to name
|
||||
)
|
||||
data?.let { event["data"] = it }
|
||||
eventSink?.success(event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,97 +9,97 @@ import android.os.Handler
|
||||
import android.util.Log
|
||||
|
||||
class AudioFocusManager(
|
||||
context: Context,
|
||||
private val handler: Handler,
|
||||
private val onPause: () -> Unit,
|
||||
private val onResume: () -> Unit,
|
||||
private val isPaused: () -> Boolean,
|
||||
private val log: (String) -> Unit = { Log.d(TAG, it) }
|
||||
context: Context,
|
||||
private val handler: Handler,
|
||||
private val onPause: () -> Unit,
|
||||
private val onResume: () -> Unit,
|
||||
private val isPaused: () -> Boolean,
|
||||
private val log: (String) -> Unit = { Log.d(TAG, it) }
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "AudioFocusManager"
|
||||
}
|
||||
companion object {
|
||||
private const val TAG = "AudioFocusManager"
|
||||
}
|
||||
|
||||
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
private var audioFocusRequest: AudioFocusRequest? = null
|
||||
private var hasAudioFocus: Boolean = false
|
||||
var wasPlayingBeforeFocusLoss: Boolean = false
|
||||
private set
|
||||
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
private var audioFocusRequest: AudioFocusRequest? = null
|
||||
private var hasAudioFocus: Boolean = false
|
||||
var wasPlayingBeforeFocusLoss: Boolean = false
|
||||
private set
|
||||
|
||||
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
|
||||
when (focusChange) {
|
||||
AudioManager.AUDIOFOCUS_GAIN -> {
|
||||
log("Focus gained")
|
||||
hasAudioFocus = true
|
||||
if (wasPlayingBeforeFocusLoss) {
|
||||
onResume()
|
||||
wasPlayingBeforeFocusLoss = false
|
||||
}
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||
log("Focus lost permanently")
|
||||
hasAudioFocus = false
|
||||
wasPlayingBeforeFocusLoss = !isPaused()
|
||||
onPause()
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||
log("Focus lost transiently")
|
||||
hasAudioFocus = false
|
||||
wasPlayingBeforeFocusLoss = !isPaused()
|
||||
onPause()
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
||||
log("Focus lost transiently (can duck)")
|
||||
}
|
||||
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
|
||||
when (focusChange) {
|
||||
AudioManager.AUDIOFOCUS_GAIN -> {
|
||||
log("Focus gained")
|
||||
hasAudioFocus = true
|
||||
if (wasPlayingBeforeFocusLoss) {
|
||||
onResume()
|
||||
wasPlayingBeforeFocusLoss = false
|
||||
}
|
||||
}
|
||||
|
||||
fun requestAudioFocus(): Boolean {
|
||||
Log.d(TAG, "Requesting audio focus")
|
||||
|
||||
val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
|
||||
.build()
|
||||
)
|
||||
.setOnAudioFocusChangeListener(audioFocusChangeListener, handler)
|
||||
.build()
|
||||
|
||||
audioFocusRequest = focusRequest
|
||||
audioManager.requestAudioFocus(focusRequest)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
audioManager.requestAudioFocus(
|
||||
audioFocusChangeListener,
|
||||
AudioManager.STREAM_MUSIC,
|
||||
AudioManager.AUDIOFOCUS_GAIN
|
||||
)
|
||||
}
|
||||
|
||||
hasAudioFocus = (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
|
||||
Log.d(TAG, "Audio focus request result: $result, granted: $hasAudioFocus")
|
||||
return hasAudioFocus
|
||||
}
|
||||
|
||||
fun abandonAudioFocus() {
|
||||
Log.d(TAG, "Abandoning audio focus")
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
|
||||
audioFocusRequest = null
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
audioManager.abandonAudioFocus(audioFocusChangeListener)
|
||||
}
|
||||
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS -> {
|
||||
log("Focus lost permanently")
|
||||
hasAudioFocus = false
|
||||
wasPlayingBeforeFocusLoss = false
|
||||
wasPlayingBeforeFocusLoss = !isPaused()
|
||||
onPause()
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
|
||||
log("Focus lost transiently")
|
||||
hasAudioFocus = false
|
||||
wasPlayingBeforeFocusLoss = !isPaused()
|
||||
onPause()
|
||||
}
|
||||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
|
||||
log("Focus lost transiently (can duck)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestAudioFocus(): Boolean {
|
||||
Log.d(TAG, "Requesting audio focus")
|
||||
|
||||
val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
|
||||
.build()
|
||||
)
|
||||
.setOnAudioFocusChangeListener(audioFocusChangeListener, handler)
|
||||
.build()
|
||||
|
||||
audioFocusRequest = focusRequest
|
||||
audioManager.requestAudioFocus(focusRequest)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
audioManager.requestAudioFocus(
|
||||
audioFocusChangeListener,
|
||||
AudioManager.STREAM_MUSIC,
|
||||
AudioManager.AUDIOFOCUS_GAIN
|
||||
)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
abandonAudioFocus()
|
||||
hasAudioFocus = (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
|
||||
Log.d(TAG, "Audio focus request result: $result, granted: $hasAudioFocus")
|
||||
return hasAudioFocus
|
||||
}
|
||||
|
||||
fun abandonAudioFocus() {
|
||||
Log.d(TAG, "Abandoning audio focus")
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
|
||||
audioFocusRequest = null
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
audioManager.abandonAudioFocus(audioFocusChangeListener)
|
||||
}
|
||||
|
||||
hasAudioFocus = false
|
||||
wasPlayingBeforeFocusLoss = false
|
||||
}
|
||||
|
||||
fun release() {
|
||||
abandonAudioFocus()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.edde746.plezy.shared
|
||||
|
||||
import android.os.Build
|
||||
|
||||
object DeviceQuirks {
|
||||
val isEWaste: Boolean
|
||||
get() = isGooglePixelDevice || isGoogleTensorDevice
|
||||
|
||||
private val isGooglePixelDevice: Boolean
|
||||
get() = (
|
||||
Build.MANUFACTURER.equals("Google", ignoreCase = true) ||
|
||||
Build.BRAND.equals("google", ignoreCase = true)
|
||||
) &&
|
||||
Build.MODEL.contains("Pixel", ignoreCase = true)
|
||||
|
||||
private val isGoogleTensorDevice: Boolean
|
||||
get() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return false
|
||||
val soc = Build.SOC_MODEL
|
||||
return soc.startsWith("Tensor", ignoreCase = true) ||
|
||||
soc.startsWith("GS", ignoreCase = true)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.edde746.plezy.shared
|
||||
|
||||
import android.graphics.PixelFormat
|
||||
import android.os.Build
|
||||
import android.view.SurfaceView
|
||||
import android.view.TextureView
|
||||
import android.view.View
|
||||
@@ -8,52 +9,84 @@ import android.view.ViewGroup
|
||||
|
||||
object FlutterOverlayHelper {
|
||||
|
||||
/**
|
||||
* Find the FlutterView container in the content view hierarchy.
|
||||
* First tries to match by class name (works in debug builds),
|
||||
* then falls back to picking the last ViewGroup with children
|
||||
* (needed for release builds where FlutterView may be obfuscated).
|
||||
*/
|
||||
fun findFlutterContainer(contentView: ViewGroup, excludeView: View? = null): ViewGroup? {
|
||||
// First pass: look for FlutterView by name (debug builds)
|
||||
for (i in 0 until contentView.childCount) {
|
||||
val child = contentView.getChildAt(i)
|
||||
if (child is ViewGroup && child.javaClass.name.contains("FlutterView")) {
|
||||
return child
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for release (FlutterView may be obfuscated): pick the last ViewGroup
|
||||
// that is not our video container and has children.
|
||||
for (i in contentView.childCount - 1 downTo 0) {
|
||||
val child = contentView.getChildAt(i)
|
||||
if (child is ViewGroup && child != excludeView && child.childCount > 0) {
|
||||
return child
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
/**
|
||||
* Find the top-level container holding the Flutter render surface. Returns the
|
||||
* direct child of [contentView] that contains a FlutterSurfaceView/FlutterTextureView
|
||||
* at any depth — `bringChildToFront` only works for direct children, so this is the
|
||||
* node to pass to [configureFlutterZOrder].
|
||||
*
|
||||
* Searches any depth because an app may wrap Flutter (e.g. for key-event dispatch)
|
||||
* and push it below the direct-child level.
|
||||
*/
|
||||
fun findFlutterContainer(contentView: ViewGroup, excludeView: View? = null): ViewGroup? {
|
||||
for (i in contentView.childCount - 1 downTo 0) {
|
||||
val child = contentView.getChildAt(i)
|
||||
if (child === excludeView || child !is ViewGroup) continue
|
||||
if (findRenderSurface(child) != null) return child
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure z-ordering so the Flutter UI renders above the video surface.
|
||||
*
|
||||
* @param zOrderOnTop true for MPV (Flutter needs to be fully on top),
|
||||
* false for ExoPlayer (subtitle layer sits between video and Flutter)
|
||||
*/
|
||||
fun configureFlutterZOrder(contentView: ViewGroup, container: ViewGroup, zOrderOnTop: Boolean) {
|
||||
contentView.bringChildToFront(container)
|
||||
for (j in 0 until container.childCount) {
|
||||
val flutterChild = container.getChildAt(j)
|
||||
if (flutterChild is SurfaceView) {
|
||||
flutterChild.setZOrderOnTop(zOrderOnTop)
|
||||
flutterChild.setZOrderMediaOverlay(true)
|
||||
flutterChild.holder.setFormat(PixelFormat.TRANSLUCENT)
|
||||
break
|
||||
} else if (flutterChild is TextureView) {
|
||||
flutterChild.isOpaque = false
|
||||
break
|
||||
}
|
||||
}
|
||||
private fun findRenderSurface(root: ViewGroup): View? {
|
||||
for (i in 0 until root.childCount) {
|
||||
val child = root.getChildAt(i)
|
||||
if (child is SurfaceView || child is TextureView) return child
|
||||
if (child is ViewGroup) findRenderSurface(child)?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply [SurfaceView.setCompositionOrder] on API 36+; no-op on older APIs where
|
||||
* the legacy [SurfaceView.setZOrderOnTop]/[SurfaceView.setZOrderMediaOverlay]
|
||||
* bucket settings govern Z-order instead.
|
||||
*
|
||||
* The value maps directly to the SurfaceView's `mSubLayer`. Per AOSP semantics:
|
||||
* `mSubLayer >= 0` (non-negative `order`) is composited above the parent window
|
||||
* and skips the hole-punch in `draw()`; `mSubLayer < 0` (negative `order`) is
|
||||
* behind the parent window and the SurfaceView is made visible by punching a
|
||||
* transparent hole in the parent canvas. Valid range is `[-2, 2]`. Picking the
|
||||
* right sign matters: any view that draws on the parent canvas (e.g. Media3's
|
||||
* `CanvasSubtitleOutput` for SRT/VTT/SDH) is hidden by SurfaceViews with
|
||||
* non-negative orders that sit on top of the same area.
|
||||
*/
|
||||
fun applyCompositionOrder(view: SurfaceView, order: Int) {
|
||||
if (Build.VERSION.SDK_INT >= 36) view.compositionOrder = order
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure z-ordering so the Flutter UI renders above the video/subtitle surfaces.
|
||||
*
|
||||
* On API 36+ the value is applied via [SurfaceView.setCompositionOrder] and maps
|
||||
* directly to `mSubLayer` (see [applyCompositionOrder] for the sign semantics).
|
||||
* On pre-36 SurfaceView builds the value is mapped to the legacy on-top bucket
|
||||
* when positive. On TextureView builds the value is unused (view hierarchy order
|
||||
* handles it).
|
||||
*
|
||||
* Pass a non-negative value (e.g. `1`) so Flutter renders above the parent canvas
|
||||
* and lets the legacy transparent-mode `setZOrderOnTop(true)` semantics carry over.
|
||||
*/
|
||||
fun configureFlutterZOrder(contentView: ViewGroup, container: ViewGroup, compositionOrder: Int) {
|
||||
contentView.bringChildToFront(container)
|
||||
when (val surface = findRenderSurface(container)) {
|
||||
is SurfaceView -> {
|
||||
if (Build.VERSION.SDK_INT >= 36) {
|
||||
// Clear legacy bucket hints so compositionOrder is authoritative.
|
||||
// Flutter's FlutterSurfaceView sets setZOrderOnTop(true) in its
|
||||
// transparent-mode constructor, which otherwise pins it to z=1.
|
||||
surface.setZOrderOnTop(false)
|
||||
surface.setZOrderMediaOverlay(false)
|
||||
surface.compositionOrder = compositionOrder
|
||||
} else {
|
||||
// Pre-36 has 3 coarse sublayer buckets. Put Flutter in the on-top
|
||||
// bucket so it renders above the video (default) and libass subtitle
|
||||
// (media overlay) SurfaceViews. NB: setZOrderMediaOverlay overwrites
|
||||
// mSubLayer internally, so don't call it here or it cancels setZOrderOnTop.
|
||||
surface.setZOrderOnTop(compositionOrder > 0)
|
||||
}
|
||||
surface.holder.setFormat(PixelFormat.TRANSLUCENT)
|
||||
}
|
||||
is TextureView -> surface.isOpaque = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,179 +6,264 @@ import android.hardware.display.DisplayManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.util.Log
|
||||
import android.view.Surface
|
||||
import android.view.Display
|
||||
import android.view.WindowManager
|
||||
import androidx.annotation.RequiresApi
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class FrameRateManager(
|
||||
private val activity: Activity,
|
||||
private val handler: Handler,
|
||||
private val onDisplayChanged: () -> Unit,
|
||||
private val log: (String) -> Unit = { Log.d(TAG, it) }
|
||||
private val activity: Activity,
|
||||
private val handler: Handler,
|
||||
private val log: (String) -> Unit = { Log.d(TAG, it) }
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "FrameRateManager"
|
||||
private const val SHORT_VIDEO_LENGTH_MS = 300000L // 5 minutes
|
||||
companion object {
|
||||
private const val TAG = "FrameRateManager"
|
||||
private const val DISPLAY_SETTLE_MS = 2000L
|
||||
private const val WATCHDOG_MARGIN_MS = 3000L
|
||||
private const val RATE_TOLERANCE = 0.1f
|
||||
}
|
||||
|
||||
private data class RefreshRateMatch(
|
||||
val reason: String,
|
||||
val priority: Int,
|
||||
val error: Float
|
||||
)
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private data class DisplayModeCandidate(
|
||||
val mode: Display.Mode,
|
||||
val match: RefreshRateMatch
|
||||
)
|
||||
|
||||
private var currentVideoFps: Float = 0f
|
||||
private var displayListener: DisplayManager.DisplayListener? = null
|
||||
private var pendingSettleRunnable: Runnable? = null
|
||||
private var watchdogRunnable: Runnable? = null
|
||||
private var pendingCompletion: ((switched: Boolean) -> Unit)? = null
|
||||
|
||||
private fun getDisplayManager(): DisplayManager = activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
|
||||
|
||||
// Request a display frame-rate switch. Invokes [onComplete] once, either:
|
||||
// - immediately with `switched=false` when no switch is needed (invalid fps,
|
||||
// no matching mode, or already matching); or
|
||||
// - after the real DisplayListener event + [DISPLAY_SETTLE_MS] + the caller's
|
||||
// [extraDelayMs], with `switched=true`; or
|
||||
// - via a watchdog if the real event never arrives, so the caller doesn't hang.
|
||||
//
|
||||
// The caller is responsible for pausing playback before calling and resuming
|
||||
// it after [onComplete] fires.
|
||||
fun setVideoFrameRate(
|
||||
fps: Float,
|
||||
videoDurationMs: Long,
|
||||
extraDelayMs: Long,
|
||||
onComplete: (switched: Boolean) -> Unit
|
||||
) {
|
||||
currentVideoFps = fps
|
||||
if (fps <= 0f) {
|
||||
Log.d(TAG, "setVideoFrameRate: Invalid fps ($fps), skipping")
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
|
||||
private var currentVideoFps: Float = 0f
|
||||
private var displayListener: DisplayManager.DisplayListener? = null
|
||||
log(
|
||||
"request fps=$fps, duration=${videoDurationMs}ms, extraDelayMs=$extraDelayMs, " +
|
||||
"API=${Build.VERSION.SDK_INT}, currentMode=${currentModeDescription()}"
|
||||
)
|
||||
|
||||
private fun getDisplayManager(): DisplayManager {
|
||||
return activity.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
setDisplayMode(fps, extraDelayMs, onComplete)
|
||||
} else {
|
||||
onComplete(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearVideoFrameRate() {
|
||||
Log.d(TAG, "clearVideoFrameRate")
|
||||
currentVideoFps = 0f
|
||||
// Resolve any pending setVideoFrameRate future as "not switched" so
|
||||
// the Dart caller's await doesn't hang on player dispose.
|
||||
firePendingCompletion("clear", switched = false)
|
||||
// Restore default display mode on API M (preferredDisplayModeId persists)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
activity.window?.attributes?.let { attrs ->
|
||||
attrs.preferredDisplayModeId = 0
|
||||
activity.window?.attributes = attrs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Release pending callbacks/listener without restoring the display mode.
|
||||
// Used by player-core dispose paths so a backend handoff (e.g. ExoPlayer→MPV
|
||||
// audio fallback) doesn't clobber the just-applied refresh-rate switch —
|
||||
// window-scoped preferredDisplayModeId persists across the SurfaceView swap,
|
||||
// letting MPV inherit the rate without a second HDMI renegotiation.
|
||||
fun releasePending() {
|
||||
Log.d(TAG, "releasePending")
|
||||
currentVideoFps = 0f
|
||||
firePendingCompletion("release", switched = false)
|
||||
}
|
||||
|
||||
private fun cancelPendingCallbacks() {
|
||||
pendingSettleRunnable?.let { handler.removeCallbacks(it) }
|
||||
watchdogRunnable?.let { handler.removeCallbacks(it) }
|
||||
pendingSettleRunnable = null
|
||||
watchdogRunnable = null
|
||||
}
|
||||
|
||||
private fun firePendingCompletion(reason: String, switched: Boolean) {
|
||||
cancelPendingCallbacks()
|
||||
displayListener?.let {
|
||||
getDisplayManager().unregisterDisplayListener(it)
|
||||
displayListener = null
|
||||
}
|
||||
val cb = pendingCompletion ?: return
|
||||
pendingCompletion = null
|
||||
log("complete reason=$reason, switched=$switched, currentMode=${currentModeDescription()}")
|
||||
cb(switched)
|
||||
}
|
||||
|
||||
private fun registerDisplayListener(fps: Float, extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) {
|
||||
// Resolve any previous pending op before starting a new one.
|
||||
firePendingCompletion("superseded", switched = false)
|
||||
pendingCompletion = onComplete
|
||||
|
||||
displayListener = object : DisplayManager.DisplayListener {
|
||||
override fun onDisplayAdded(displayId: Int) = Unit
|
||||
override fun onDisplayRemoved(displayId: Int) = Unit
|
||||
override fun onDisplayChanged(displayId: Int) {
|
||||
// Unregister immediately so a chatty display (e.g. several
|
||||
// onDisplayChanged events during HDMI renegotiation) doesn't
|
||||
// queue multiple settle callbacks.
|
||||
getDisplayManager().unregisterDisplayListener(this)
|
||||
displayListener = null
|
||||
|
||||
val settle = Runnable { firePendingCompletion("display settled", switched = currentRateMatch(fps) != null) }
|
||||
pendingSettleRunnable = settle
|
||||
handler.postDelayed(settle, DISPLAY_SETTLE_MS + extraDelayMs)
|
||||
}
|
||||
}
|
||||
getDisplayManager().registerDisplayListener(displayListener, handler)
|
||||
|
||||
// Watchdog: if the TV never signals a display change (silently ignoring
|
||||
// the mode request), still complete after a bounded wait so the caller
|
||||
// doesn't hang.
|
||||
val watchdog = Runnable { firePendingCompletion("watchdog", switched = currentRateMatch(fps) != null) }
|
||||
watchdogRunnable = watchdog
|
||||
handler.postDelayed(watchdog, DISPLAY_SETTLE_MS + extraDelayMs + WATCHDOG_MARGIN_MS)
|
||||
}
|
||||
|
||||
private fun matchRefreshRate(refreshRate: Float, fps: Float): RefreshRateMatch? {
|
||||
if (refreshRate <= 0f || fps <= 0f) return null
|
||||
|
||||
val exactError = abs(refreshRate - fps)
|
||||
if (exactError < RATE_TOLERANCE) {
|
||||
return RefreshRateMatch(reason = "exact", priority = 0, error = exactError)
|
||||
}
|
||||
|
||||
fun setVideoFrameRate(fps: Float, videoDurationMs: Long, surface: Surface?) {
|
||||
currentVideoFps = fps
|
||||
if (fps <= 0f) {
|
||||
Log.d(TAG, "setVideoFrameRate: Invalid fps ($fps), skipping")
|
||||
return
|
||||
}
|
||||
|
||||
log("fps=$fps, duration=${videoDurationMs}ms, API=${Build.VERSION.SDK_INT}")
|
||||
|
||||
when {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
if (surface == null) {
|
||||
Log.d(TAG, "setVideoFrameRate: Surface not available")
|
||||
return
|
||||
}
|
||||
setFrameRateS(fps, surface, videoDurationMs)
|
||||
}
|
||||
// API R's Surface.setFrameRate() only supports seamless switching (no
|
||||
// CHANGE_FRAME_RATE_ALWAYS), so 60→24Hz won't switch. Fall through to
|
||||
// preferredDisplayModeId which directly sets the display mode.
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> setFrameRateM(fps)
|
||||
}
|
||||
val multiple = (refreshRate / fps).roundToInt()
|
||||
if (multiple > 1) {
|
||||
val multipleError = abs(refreshRate - (fps * multiple))
|
||||
if (multipleError < RATE_TOLERANCE) {
|
||||
return RefreshRateMatch(reason = "${multiple}x", priority = 1, error = multipleError)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearVideoFrameRate() {
|
||||
Log.d(TAG, "clearVideoFrameRate")
|
||||
currentVideoFps = 0f
|
||||
displayListener?.let {
|
||||
getDisplayManager().unregisterDisplayListener(it)
|
||||
displayListener = null
|
||||
}
|
||||
// Restore default display mode on API M (preferredDisplayModeId persists)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
activity.window?.attributes?.let { attrs ->
|
||||
attrs.preferredDisplayModeId = 0
|
||||
activity.window?.attributes = attrs
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun currentRateMatch(fps: Float): RefreshRateMatch? {
|
||||
val current = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
currentDisplayMode()?.refreshRate
|
||||
} else {
|
||||
null
|
||||
} ?: return null
|
||||
return matchRefreshRate(current, fps)
|
||||
}
|
||||
|
||||
private fun currentModeDescription(): String = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
describeMode(currentDisplayMode())
|
||||
} else {
|
||||
"unavailable"
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun currentDisplay(): Display? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
activity.display
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
(activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun currentDisplayMode(): Display.Mode? = currentDisplay()?.mode
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun describeMode(mode: Display.Mode?): String {
|
||||
if (mode == null) return "unknown"
|
||||
return "#${mode.modeId} ${mode.physicalWidth}x${mode.physicalHeight}@${mode.refreshRate}Hz"
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun describeSupportedModes(modes: Array<Display.Mode>): String = modes.joinToString(prefix = "[", postfix = "]") { describeMode(it) }
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun findBestModeMatch(fps: Float, currentMode: Display.Mode, supportedModes: Array<Display.Mode>): DisplayModeCandidate? = supportedModes.asSequence()
|
||||
.filter { mode ->
|
||||
mode.physicalHeight == currentMode.physicalHeight &&
|
||||
mode.physicalWidth == currentMode.physicalWidth
|
||||
}
|
||||
.mapNotNull { mode -> matchRefreshRate(mode.refreshRate, fps)?.let { DisplayModeCandidate(mode, it) } }
|
||||
.minWithOrNull(
|
||||
compareBy<DisplayModeCandidate> { it.match.priority }
|
||||
.thenBy { it.match.error }
|
||||
.thenBy { abs(it.mode.refreshRate - currentMode.refreshRate) }
|
||||
)
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun setDisplayMode(fps: Float, extraDelayMs: Long, onComplete: (switched: Boolean) -> Unit) {
|
||||
log("setDisplayMode fps=$fps")
|
||||
val display = currentDisplay()
|
||||
if (display == null) {
|
||||
log("display unavailable")
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
|
||||
private fun registerDisplayListener() {
|
||||
displayListener?.let {
|
||||
getDisplayManager().unregisterDisplayListener(it)
|
||||
}
|
||||
val supportedModes = display.supportedModes
|
||||
if (supportedModes == null) {
|
||||
log("supported display modes unavailable")
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
val currentMode = display.mode
|
||||
log("supported modes=${describeSupportedModes(supportedModes)}")
|
||||
|
||||
displayListener = object : DisplayManager.DisplayListener {
|
||||
override fun onDisplayAdded(displayId: Int) = Unit
|
||||
override fun onDisplayRemoved(displayId: Int) = Unit
|
||||
override fun onDisplayChanged(displayId: Int) {
|
||||
handler.postDelayed({
|
||||
onDisplayChanged()
|
||||
}, 2000L)
|
||||
getDisplayManager().unregisterDisplayListener(this)
|
||||
displayListener = null
|
||||
}
|
||||
}
|
||||
getDisplayManager().registerDisplayListener(displayListener, handler)
|
||||
val modeMatch = findBestModeMatch(fps, currentMode, supportedModes)
|
||||
if (modeMatch == null) {
|
||||
log("no matching display mode for ${fps}fps at ${currentMode.physicalWidth}x${currentMode.physicalHeight}")
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.S)
|
||||
private fun setFrameRateS(fps: Float, surface: Surface, videoDurationMs: Long) {
|
||||
Log.d(TAG, "setFrameRateS: fps=$fps, duration=${videoDurationMs}ms")
|
||||
|
||||
if (videoDurationMs < SHORT_VIDEO_LENGTH_MS) {
|
||||
Log.d(TAG, "Short video, using seamless-only switching")
|
||||
surface.setFrameRate(
|
||||
fps,
|
||||
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
|
||||
Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
var seamless = false
|
||||
activity.display?.mode?.alternativeRefreshRates?.let { refreshRates ->
|
||||
for (rate in refreshRates) {
|
||||
if (fps.toString().startsWith(rate.toString()) ||
|
||||
rate.toString().startsWith(fps.toString()) ||
|
||||
rate % fps == 0f) {
|
||||
seamless = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (seamless) {
|
||||
log("Seamless switch available for ${fps}fps")
|
||||
surface.setFrameRate(
|
||||
fps,
|
||||
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
|
||||
Surface.CHANGE_FRAME_RATE_ALWAYS
|
||||
)
|
||||
registerDisplayListener()
|
||||
} else {
|
||||
val userPreference = getDisplayManager().matchContentFrameRateUserPreference
|
||||
if (userPreference == DisplayManager.MATCH_CONTENT_FRAMERATE_ALWAYS) {
|
||||
Log.d(TAG, "User preference allows non-seamless switch")
|
||||
surface.setFrameRate(
|
||||
fps,
|
||||
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
|
||||
Surface.CHANGE_FRAME_RATE_ALWAYS
|
||||
)
|
||||
registerDisplayListener()
|
||||
} else {
|
||||
Log.d(TAG, "Non-seamless switch not allowed, using seamless-only")
|
||||
surface.setFrameRate(
|
||||
fps,
|
||||
Surface.FRAME_RATE_COMPATIBILITY_FIXED_SOURCE,
|
||||
Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS
|
||||
)
|
||||
}
|
||||
}
|
||||
val modeToUse = modeMatch.mode
|
||||
if (modeToUse.modeId == currentMode.modeId) {
|
||||
log("current mode already matches ${fps}fps (${modeMatch.match.reason}), no switch needed")
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.M)
|
||||
private fun setFrameRateM(fps: Float) {
|
||||
Log.d(TAG, "setFrameRateM: fps=$fps")
|
||||
val wm = activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
@Suppress("DEPRECATION")
|
||||
val display = wm.defaultDisplay ?: return
|
||||
|
||||
display.supportedModes?.let { supportedModes ->
|
||||
val currentMode = display.mode
|
||||
var modeToUse = currentMode
|
||||
|
||||
for (mode in supportedModes) {
|
||||
if (mode.physicalHeight != currentMode.physicalHeight ||
|
||||
mode.physicalWidth != currentMode.physicalWidth) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (BigDecimal(fps.toString()).setScale(1, RoundingMode.FLOOR) ==
|
||||
BigDecimal(mode.refreshRate.toString()).setScale(1, RoundingMode.FLOOR)) {
|
||||
modeToUse = mode
|
||||
break
|
||||
} else if ((mode.refreshRate % fps).let { it < 0.1f || (fps - it) < 0.1f }) {
|
||||
modeToUse = mode
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (modeToUse != currentMode) {
|
||||
Log.d(TAG, "Switching to mode ${modeToUse.modeId} (${modeToUse.refreshRate}Hz)")
|
||||
activity.window?.attributes?.let { attrs ->
|
||||
attrs.preferredDisplayModeId = modeToUse.modeId
|
||||
activity.window?.attributes = attrs
|
||||
}
|
||||
registerDisplayListener()
|
||||
}
|
||||
}
|
||||
log(
|
||||
"switching to ${describeMode(modeToUse)} for ${fps}fps " +
|
||||
"(${modeMatch.match.reason}, error=${modeMatch.match.error})"
|
||||
)
|
||||
val window = activity.window
|
||||
if (window == null) {
|
||||
log("window unavailable")
|
||||
onComplete(false)
|
||||
return
|
||||
}
|
||||
registerDisplayListener(fps, extraDelayMs, onComplete)
|
||||
window.attributes = window.attributes.apply { preferredDisplayModeId = modeToUse.modeId }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.edde746.plezy.shared
|
||||
|
||||
interface PlayerDelegate {
|
||||
fun onPropertyChange(name: String, value: Any?)
|
||||
fun onEvent(name: String, data: Map<String, Any>?)
|
||||
fun onPropertyChange(name: String, value: Any?)
|
||||
fun onEvent(name: String, data: Map<String, Any>?)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ package com.edde746.plezy.shared
|
||||
import android.graphics.Color
|
||||
|
||||
object ThemeHelper {
|
||||
fun themeColor(mode: String?): Int? = when (mode) {
|
||||
"oled" -> Color.BLACK
|
||||
"dark" -> Color.parseColor("#0E0F12")
|
||||
"light" -> Color.parseColor("#F7F7F8")
|
||||
else -> null
|
||||
}
|
||||
fun themeColor(mode: String?): Int? = when (mode) {
|
||||
"oled" -> Color.BLACK
|
||||
"dark" -> Color.parseColor("#0E0F12")
|
||||
"light" -> Color.parseColor("#F7F7F8")
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,167 +16,169 @@ import java.util.concurrent.Executors
|
||||
* Flutter plugin for Android TV Watch Next integration.
|
||||
* Syncs Plex "On Deck" content to the Android TV launcher's Watch Next row.
|
||||
*/
|
||||
class WatchNextPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
class WatchNextPlugin :
|
||||
FlutterPlugin,
|
||||
MethodChannel.MethodCallHandler {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WatchNextPlugin"
|
||||
private const val METHOD_CHANNEL = "com.plezy/watch_next"
|
||||
companion object {
|
||||
private const val TAG = "WatchNextPlugin"
|
||||
private const val METHOD_CHANNEL = "com.plezy/watch_next"
|
||||
|
||||
private var pendingDeepLink: String? = null
|
||||
|
||||
/**
|
||||
* Parse a Watch Next deep link intent.
|
||||
* Returns the content ID if this was a Watch Next intent, null otherwise.
|
||||
*/
|
||||
fun handleIntent(intent: Intent?): String? {
|
||||
val data = intent?.data ?: return null
|
||||
if (data.scheme == "plezy" && data.authority == "play") {
|
||||
return data.getQueryParameter("content_id")
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var methodChannel: MethodChannel
|
||||
private var applicationContext: Context? = null
|
||||
private var watchNextProvider: WatchNextProvider? = null
|
||||
private val ioExecutor by lazy { Executors.newSingleThreadExecutor() }
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
applicationContext = binding.applicationContext
|
||||
watchNextProvider = WatchNextProvider(binding.applicationContext)
|
||||
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
|
||||
methodChannel.setMethodCallHandler(this)
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
methodChannel.setMethodCallHandler(null)
|
||||
applicationContext = null
|
||||
watchNextProvider = null
|
||||
ioExecutor.shutdown()
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"isSupported" -> handleIsSupported(result)
|
||||
"sync" -> handleSync(call, result)
|
||||
"clear" -> handleClear(result)
|
||||
"remove" -> handleRemove(call, result)
|
||||
"getInitialDeepLink" -> handleGetInitialDeepLink(result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleIsSupported(result: MethodChannel.Result) {
|
||||
val context = applicationContext
|
||||
if (context == null) {
|
||||
result.success(false)
|
||||
return
|
||||
}
|
||||
result.success(context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
|
||||
}
|
||||
|
||||
private fun handleSync(call: MethodCall, result: MethodChannel.Result) {
|
||||
val provider = watchNextProvider
|
||||
if (provider == null) {
|
||||
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
|
||||
return
|
||||
}
|
||||
|
||||
val itemsData = call.argument<List<Map<String, Any?>>>("items")
|
||||
if (itemsData == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'items' argument", null)
|
||||
return
|
||||
}
|
||||
|
||||
val items = itemsData.mapNotNull { parseWatchNextItem(it) }
|
||||
executeOnIo(result) { provider.syncWatchNextPrograms(items) }
|
||||
}
|
||||
|
||||
private fun handleClear(result: MethodChannel.Result) {
|
||||
val provider = watchNextProvider
|
||||
if (provider == null) {
|
||||
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
|
||||
return
|
||||
}
|
||||
executeOnIo(result) { provider.clearAll() }
|
||||
}
|
||||
|
||||
private fun handleRemove(call: MethodCall, result: MethodChannel.Result) {
|
||||
val provider = watchNextProvider
|
||||
if (provider == null) {
|
||||
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
|
||||
return
|
||||
}
|
||||
|
||||
val contentId = call.argument<String>("contentId")
|
||||
if (contentId == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'contentId' argument", null)
|
||||
return
|
||||
}
|
||||
executeOnIo(result) { provider.removeItem(contentId) }
|
||||
}
|
||||
|
||||
private fun executeOnIo(result: MethodChannel.Result, block: () -> Any?) {
|
||||
try {
|
||||
ioExecutor.execute {
|
||||
try {
|
||||
val value = block()
|
||||
mainHandler.post { result.success(value) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "IO operation failed: ${e.message}", e)
|
||||
mainHandler.post { result.error("IO_ERROR", e.message, null) }
|
||||
}
|
||||
}
|
||||
} catch (e: java.util.concurrent.RejectedExecutionException) {
|
||||
result.error("SHUTDOWN", "Plugin is shutting down", null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleGetInitialDeepLink(result: MethodChannel.Result) {
|
||||
val contentId = pendingDeepLink
|
||||
pendingDeepLink = null
|
||||
result.success(contentId)
|
||||
}
|
||||
|
||||
private fun parseWatchNextItem(data: Map<String, Any?>): WatchNextProvider.WatchNextItem? {
|
||||
val contentId = data["contentId"] as? String ?: return null
|
||||
val title = data["title"] as? String ?: return null
|
||||
|
||||
val typeString = data["type"] as? String ?: "movie"
|
||||
val type = when (typeString.lowercase()) {
|
||||
"episode" -> TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE
|
||||
"movie" -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE
|
||||
else -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE
|
||||
}
|
||||
|
||||
return WatchNextProvider.WatchNextItem(
|
||||
contentId = contentId,
|
||||
title = title,
|
||||
episodeTitle = data["episodeTitle"] as? String,
|
||||
description = data["description"] as? String,
|
||||
posterUri = data["posterUri"] as? String,
|
||||
type = type,
|
||||
duration = (data["duration"] as? Number)?.toLong() ?: 0L,
|
||||
lastPlaybackPosition = (data["lastPlaybackPosition"] as? Number)?.toLong() ?: 0L,
|
||||
lastEngagementTime = (data["lastEngagementTime"] as? Number)?.toLong() ?: System.currentTimeMillis(),
|
||||
seriesTitle = data["seriesTitle"] as? String,
|
||||
seasonNumber = (data["seasonNumber"] as? Number)?.toInt(),
|
||||
episodeNumber = (data["episodeNumber"] as? Number)?.toInt()
|
||||
)
|
||||
}
|
||||
private var pendingDeepLink: String? = null
|
||||
|
||||
/**
|
||||
* Store a deep link content ID for delivery to Flutter.
|
||||
* Called from MainActivity on intent receipt.
|
||||
* Parse a Watch Next deep link intent.
|
||||
* Returns the content ID if this was a Watch Next intent, null otherwise.
|
||||
*/
|
||||
fun notifyDeepLink(contentId: String) {
|
||||
pendingDeepLink = contentId
|
||||
try {
|
||||
methodChannel.invokeMethod("onWatchNextTap", mapOf("contentId" to contentId))
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Method channel not ready, stored as pending deep link")
|
||||
}
|
||||
fun handleIntent(intent: Intent?): String? {
|
||||
val data = intent?.data ?: return null
|
||||
if (data.scheme == "plezy" && data.authority == "play") {
|
||||
return data.getQueryParameter("content_id")
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var methodChannel: MethodChannel
|
||||
private var applicationContext: Context? = null
|
||||
private var watchNextProvider: WatchNextProvider? = null
|
||||
private val ioExecutor by lazy { Executors.newSingleThreadExecutor() }
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
applicationContext = binding.applicationContext
|
||||
watchNextProvider = WatchNextProvider(binding.applicationContext)
|
||||
methodChannel = MethodChannel(binding.binaryMessenger, METHOD_CHANNEL)
|
||||
methodChannel.setMethodCallHandler(this)
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
methodChannel.setMethodCallHandler(null)
|
||||
applicationContext = null
|
||||
watchNextProvider = null
|
||||
ioExecutor.shutdown()
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"isSupported" -> handleIsSupported(result)
|
||||
"sync" -> handleSync(call, result)
|
||||
"clear" -> handleClear(result)
|
||||
"remove" -> handleRemove(call, result)
|
||||
"getInitialDeepLink" -> handleGetInitialDeepLink(result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleIsSupported(result: MethodChannel.Result) {
|
||||
val context = applicationContext
|
||||
if (context == null) {
|
||||
result.success(false)
|
||||
return
|
||||
}
|
||||
result.success(context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
|
||||
}
|
||||
|
||||
private fun handleSync(call: MethodCall, result: MethodChannel.Result) {
|
||||
val provider = watchNextProvider
|
||||
if (provider == null) {
|
||||
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
|
||||
return
|
||||
}
|
||||
|
||||
val itemsData = call.argument<List<Map<String, Any?>>>("items")
|
||||
if (itemsData == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'items' argument", null)
|
||||
return
|
||||
}
|
||||
|
||||
val items = itemsData.mapNotNull { parseWatchNextItem(it) }
|
||||
executeOnIo(result) { provider.syncWatchNextPrograms(items) }
|
||||
}
|
||||
|
||||
private fun handleClear(result: MethodChannel.Result) {
|
||||
val provider = watchNextProvider
|
||||
if (provider == null) {
|
||||
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
|
||||
return
|
||||
}
|
||||
executeOnIo(result) { provider.clearAll() }
|
||||
}
|
||||
|
||||
private fun handleRemove(call: MethodCall, result: MethodChannel.Result) {
|
||||
val provider = watchNextProvider
|
||||
if (provider == null) {
|
||||
result.error("NOT_INITIALIZED", "WatchNextProvider not initialized", null)
|
||||
return
|
||||
}
|
||||
|
||||
val contentId = call.argument<String>("contentId")
|
||||
if (contentId == null) {
|
||||
result.error("INVALID_ARGS", "Missing 'contentId' argument", null)
|
||||
return
|
||||
}
|
||||
executeOnIo(result) { provider.removeItem(contentId) }
|
||||
}
|
||||
|
||||
private fun executeOnIo(result: MethodChannel.Result, block: () -> Any?) {
|
||||
try {
|
||||
ioExecutor.execute {
|
||||
try {
|
||||
val value = block()
|
||||
mainHandler.post { result.success(value) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "IO operation failed: ${e.message}", e)
|
||||
mainHandler.post { result.error("IO_ERROR", e.message, null) }
|
||||
}
|
||||
}
|
||||
} catch (e: java.util.concurrent.RejectedExecutionException) {
|
||||
result.error("SHUTDOWN", "Plugin is shutting down", null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleGetInitialDeepLink(result: MethodChannel.Result) {
|
||||
val contentId = pendingDeepLink
|
||||
pendingDeepLink = null
|
||||
result.success(contentId)
|
||||
}
|
||||
|
||||
private fun parseWatchNextItem(data: Map<String, Any?>): WatchNextProvider.WatchNextItem? {
|
||||
val contentId = data["contentId"] as? String ?: return null
|
||||
val title = data["title"] as? String ?: return null
|
||||
|
||||
val typeString = data["type"] as? String ?: "movie"
|
||||
val type = when (typeString.lowercase()) {
|
||||
"episode" -> TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE
|
||||
"movie" -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE
|
||||
else -> TvContractCompat.WatchNextPrograms.TYPE_MOVIE
|
||||
}
|
||||
|
||||
return WatchNextProvider.WatchNextItem(
|
||||
contentId = contentId,
|
||||
title = title,
|
||||
episodeTitle = data["episodeTitle"] as? String,
|
||||
description = data["description"] as? String,
|
||||
posterUri = data["posterUri"] as? String,
|
||||
type = type,
|
||||
duration = (data["duration"] as? Number)?.toLong() ?: 0L,
|
||||
lastPlaybackPosition = (data["lastPlaybackPosition"] as? Number)?.toLong() ?: 0L,
|
||||
lastEngagementTime = (data["lastEngagementTime"] as? Number)?.toLong() ?: System.currentTimeMillis(),
|
||||
seriesTitle = data["seriesTitle"] as? String,
|
||||
seasonNumber = (data["seasonNumber"] as? Number)?.toInt(),
|
||||
episodeNumber = (data["episodeNumber"] as? Number)?.toInt()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a deep link content ID for delivery to Flutter.
|
||||
* Called from MainActivity on intent receipt.
|
||||
*/
|
||||
fun notifyDeepLink(contentId: String) {
|
||||
pendingDeepLink = contentId
|
||||
try {
|
||||
methodChannel.invokeMethod("onWatchNextTap", mapOf("contentId" to contentId))
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "Method channel not ready, stored as pending deep link")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,154 +14,151 @@ import androidx.tvprovider.media.tv.WatchNextProgram
|
||||
*/
|
||||
class WatchNextProvider(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WatchNextProvider"
|
||||
}
|
||||
companion object {
|
||||
private const val TAG = "WatchNextProvider"
|
||||
}
|
||||
|
||||
data class WatchNextItem(
|
||||
val contentId: String,
|
||||
val title: String,
|
||||
val episodeTitle: String?,
|
||||
val description: String?,
|
||||
val posterUri: String?,
|
||||
val type: Int,
|
||||
val duration: Long,
|
||||
val lastPlaybackPosition: Long,
|
||||
val lastEngagementTime: Long,
|
||||
val seriesTitle: String?,
|
||||
val seasonNumber: Int?,
|
||||
val episodeNumber: Int?
|
||||
data class WatchNextItem(
|
||||
val contentId: String,
|
||||
val title: String,
|
||||
val episodeTitle: String?,
|
||||
val description: String?,
|
||||
val posterUri: String?,
|
||||
val type: Int,
|
||||
val duration: Long,
|
||||
val lastPlaybackPosition: Long,
|
||||
val lastEngagementTime: Long,
|
||||
val seriesTitle: String?,
|
||||
val seasonNumber: Int?,
|
||||
val episodeNumber: Int?
|
||||
)
|
||||
|
||||
/**
|
||||
* Sync items to Watch Next row.
|
||||
* Uses applyBatch to delete + insert in a single transaction so the
|
||||
* launcher receives one content-change notification with the full set.
|
||||
*/
|
||||
fun syncWatchNextPrograms(items: List<WatchNextItem>): Boolean = try {
|
||||
val ops = ArrayList<ContentProviderOperation>()
|
||||
|
||||
ops.add(
|
||||
ContentProviderOperation.newDelete(
|
||||
TvContractCompat.WatchNextPrograms.CONTENT_URI
|
||||
).build()
|
||||
)
|
||||
|
||||
/**
|
||||
* Sync items to Watch Next row.
|
||||
* Uses applyBatch to delete + insert in a single transaction so the
|
||||
* launcher receives one content-change notification with the full set.
|
||||
*/
|
||||
fun syncWatchNextPrograms(items: List<WatchNextItem>): Boolean {
|
||||
return try {
|
||||
val ops = ArrayList<ContentProviderOperation>()
|
||||
for (item in items) {
|
||||
val program = buildProgram(item)
|
||||
ops.add(
|
||||
ContentProviderOperation.newInsert(
|
||||
TvContractCompat.WatchNextPrograms.CONTENT_URI
|
||||
).withValues(program.toContentValues()).build()
|
||||
)
|
||||
}
|
||||
|
||||
ops.add(
|
||||
ContentProviderOperation.newDelete(
|
||||
TvContractCompat.WatchNextPrograms.CONTENT_URI
|
||||
).build()
|
||||
context.contentResolver.applyBatch(TvContractCompat.AUTHORITY, ops)
|
||||
Log.d(TAG, "Synced ${items.size} Watch Next entries")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to sync Watch Next programs", e)
|
||||
false
|
||||
}
|
||||
|
||||
fun clearAll(): Boolean = try {
|
||||
context.contentResolver.delete(
|
||||
TvContractCompat.WatchNextPrograms.CONTENT_URI,
|
||||
null,
|
||||
null
|
||||
)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to clear Watch Next entries", e)
|
||||
false
|
||||
}
|
||||
|
||||
fun removeItem(contentId: String): Boolean {
|
||||
return try {
|
||||
val cursor = context.contentResolver.query(
|
||||
TvContractCompat.WatchNextPrograms.CONTENT_URI,
|
||||
arrayOf(
|
||||
TvContractCompat.WatchNextPrograms._ID,
|
||||
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID
|
||||
),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
cursor?.use {
|
||||
val idIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms._ID)
|
||||
val providerIdIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID)
|
||||
|
||||
if (idIndex < 0 || providerIdIndex < 0) return false
|
||||
|
||||
while (it.moveToNext()) {
|
||||
if (it.getString(providerIdIndex) == contentId) {
|
||||
val id = it.getLong(idIndex)
|
||||
val deleteUri = ContentUris.withAppendedId(
|
||||
TvContractCompat.WatchNextPrograms.CONTENT_URI,
|
||||
id
|
||||
)
|
||||
|
||||
for (item in items) {
|
||||
val program = buildProgram(item)
|
||||
ops.add(
|
||||
ContentProviderOperation.newInsert(
|
||||
TvContractCompat.WatchNextPrograms.CONTENT_URI
|
||||
).withValues(program.toContentValues()).build()
|
||||
)
|
||||
}
|
||||
|
||||
context.contentResolver.applyBatch(TvContractCompat.AUTHORITY, ops)
|
||||
Log.d(TAG, "Synced ${items.size} Watch Next entries")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to sync Watch Next programs", e)
|
||||
false
|
||||
context.contentResolver.delete(deleteUri, null, null)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to remove Watch Next item: $contentId", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildProgram(item: WatchNextItem): WatchNextProgram {
|
||||
val watchNextType = if (item.lastPlaybackPosition > 0) {
|
||||
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE
|
||||
} else {
|
||||
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_NEXT
|
||||
}
|
||||
|
||||
fun clearAll(): Boolean {
|
||||
return try {
|
||||
context.contentResolver.delete(
|
||||
TvContractCompat.WatchNextPrograms.CONTENT_URI,
|
||||
null,
|
||||
null
|
||||
)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to clear Watch Next entries", e)
|
||||
false
|
||||
}
|
||||
val builder = WatchNextProgram.Builder()
|
||||
.setType(item.type)
|
||||
.setWatchNextType(watchNextType)
|
||||
.setTitle(item.title)
|
||||
.setInternalProviderId(item.contentId)
|
||||
.setLastEngagementTimeUtcMillis(item.lastEngagementTime)
|
||||
|
||||
item.description?.let { builder.setDescription(it) }
|
||||
|
||||
item.posterUri?.let { uri ->
|
||||
try {
|
||||
builder.setPosterArtUri(Uri.parse(uri))
|
||||
builder.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse poster URI: $uri", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeItem(contentId: String): Boolean {
|
||||
return try {
|
||||
val cursor = context.contentResolver.query(
|
||||
TvContractCompat.WatchNextPrograms.CONTENT_URI,
|
||||
arrayOf(
|
||||
TvContractCompat.WatchNextPrograms._ID,
|
||||
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID
|
||||
),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
|
||||
cursor?.use {
|
||||
val idIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms._ID)
|
||||
val providerIdIndex = it.getColumnIndex(TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID)
|
||||
|
||||
if (idIndex < 0 || providerIdIndex < 0) return false
|
||||
|
||||
while (it.moveToNext()) {
|
||||
if (it.getString(providerIdIndex) == contentId) {
|
||||
val id = it.getLong(idIndex)
|
||||
val deleteUri = ContentUris.withAppendedId(
|
||||
TvContractCompat.WatchNextPrograms.CONTENT_URI,
|
||||
id
|
||||
)
|
||||
context.contentResolver.delete(deleteUri, null, null)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to remove Watch Next item: $contentId", e)
|
||||
false
|
||||
}
|
||||
if (item.duration > 0) {
|
||||
builder.setDurationMillis(item.duration.toInt())
|
||||
if (item.lastPlaybackPosition > 0) {
|
||||
builder.setLastPlaybackPositionMillis(item.lastPlaybackPosition.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildProgram(item: WatchNextItem): WatchNextProgram {
|
||||
val watchNextType = if (item.lastPlaybackPosition > 0)
|
||||
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE
|
||||
else
|
||||
TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_NEXT
|
||||
|
||||
val builder = WatchNextProgram.Builder()
|
||||
.setType(item.type)
|
||||
.setWatchNextType(watchNextType)
|
||||
.setTitle(item.title)
|
||||
.setInternalProviderId(item.contentId)
|
||||
.setLastEngagementTimeUtcMillis(item.lastEngagementTime)
|
||||
|
||||
item.description?.let { builder.setDescription(it) }
|
||||
|
||||
item.posterUri?.let { uri ->
|
||||
try {
|
||||
builder.setPosterArtUri(Uri.parse(uri))
|
||||
builder.setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse poster URI: $uri", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (item.duration > 0) {
|
||||
builder.setDurationMillis(item.duration.toInt())
|
||||
if (item.lastPlaybackPosition > 0) {
|
||||
builder.setLastPlaybackPositionMillis(item.lastPlaybackPosition.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
if (item.type == TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE) {
|
||||
item.episodeTitle?.let { builder.setEpisodeTitle(it) }
|
||||
item.seasonNumber?.let { builder.setSeasonNumber(it) }
|
||||
item.episodeNumber?.let { builder.setEpisodeNumber(it) }
|
||||
}
|
||||
|
||||
val intentUri = Uri.Builder()
|
||||
.scheme("plezy")
|
||||
.authority("play")
|
||||
.appendQueryParameter("content_id", item.contentId)
|
||||
.build()
|
||||
builder.setIntentUri(intentUri)
|
||||
|
||||
return builder.build()
|
||||
if (item.type == TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE) {
|
||||
item.episodeTitle?.let { builder.setEpisodeTitle(it) }
|
||||
item.seasonNumber?.let { builder.setSeasonNumber(it) }
|
||||
item.episodeNumber?.let { builder.setEpisodeNumber(it) }
|
||||
}
|
||||
|
||||
val intentUri = Uri.Builder()
|
||||
.scheme("plezy")
|
||||
.authority("play")
|
||||
.appendQueryParameter("content_id", item.contentId)
|
||||
.build()
|
||||
builder.setIntentUri(intentUri)
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
require 'dotenv/load'
|
||||
Dotenv.load("../../.env")
|
||||
require 'shellwords'
|
||||
|
||||
PROJECT_ROOT = File.expand_path("../..", __dir__)
|
||||
PROJECT_ROOT_ARG = Shellwords.escape(PROJECT_ROOT)
|
||||
|
||||
Dotenv.load(File.join(PROJECT_ROOT, ".env"))
|
||||
|
||||
default_platform(:android)
|
||||
|
||||
@@ -7,7 +12,7 @@ platform :android do
|
||||
desc "Build and deploy to Google Play Store"
|
||||
lane :release do
|
||||
# Get version from pubspec.yaml
|
||||
pubspec_path = "#{ENV['PWD']}/../pubspec.yaml"
|
||||
pubspec_path = File.join(PROJECT_ROOT, "pubspec.yaml")
|
||||
pubspec_content = File.read(pubspec_path)
|
||||
version_match = pubspec_content.match(/version:\s*(.+)\+(\d+)/)
|
||||
|
||||
@@ -19,11 +24,11 @@ platform :android do
|
||||
UI.user_error!("Could not extract version from pubspec.yaml")
|
||||
end
|
||||
|
||||
debug_info_dir = "./debug-info/#{version_name}+#{version_code}"
|
||||
git_commit = `git rev-parse --short HEAD`.strip
|
||||
git_commit = `git -C #{PROJECT_ROOT_ARG} rev-parse --short HEAD`.strip
|
||||
|
||||
# Build the Flutter app
|
||||
sh("cd #{ENV['PWD']}/.. && flutter build appbundle --dart-define=ENABLE_IN_APP_REVIEW=true --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --obfuscate --split-debug-info=#{debug_info_dir}/aab")
|
||||
sh("cd #{PROJECT_ROOT_ARG} && flutter build appbundle --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=play-store --dart-define=SENTRY_DIST=play-store --obfuscate --split-debug-info=debug-info/android-aab --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-aab/obfuscation.map.json")
|
||||
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=play-store ./scripts/upload-symbols.sh android-aab")
|
||||
|
||||
upload_to_play_store(
|
||||
track: "production",
|
||||
@@ -33,7 +38,8 @@ platform :android do
|
||||
)
|
||||
|
||||
# Build universal APK for Amazon Appstore (doesn't accept AABs, excludes x86 via env var)
|
||||
sh("cd #{ENV['PWD']}/.. && AMAZON=1 flutter build apk --release --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --obfuscate --split-debug-info=#{debug_info_dir}/apk")
|
||||
sh("cd #{PROJECT_ROOT_ARG} && AMAZON=1 flutter build apk --release --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=amazon --dart-define=SENTRY_DIST=amazon --obfuscate --split-debug-info=debug-info/android-apk --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-apk/obfuscation.map.json")
|
||||
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=amazon ./scripts/upload-symbols.sh android-apk")
|
||||
|
||||
upload_to_amazon_appstore(
|
||||
apk_paths: [
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="75 75 363 363"><path fill="#02a9ff" d="M321.92 323.27V136.6c0-10.698-5.887-16.602-16.558-16.602h-36.433c-10.672 0-16.561 5.904-16.561 16.602v88.651c0 2.497 23.996 14.089 24.623 16.541 18.282 71.61 3.972 128.92-13.359 131.6 28.337 1.405 31.455 15.064 10.348 5.731 3.229-38.209 15.828-38.134 52.049-1.406.31.317 7.427 15.282 7.87 15.282h85.545c10.672 0 16.558-5.9 16.558-16.6v-36.524c0-10.698-5.886-16.602-16.558-16.602z"/><path fill="currentColor" d="M170.68 120 74.999 393h74.338l16.192-47.222h80.96L262.315 393h73.968l-95.314-273zm11.776 165.28 23.183-75.629 25.393 75.629z"/></svg>
|
||||
|
After Width: | Height: | Size: 633 B |
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||
<path d="M24.2116 49.1581C22.6599 46.0424 32.8378 27.5879 35.9999 27.5879C39.1666 27.5895 49.3228 46.0764 47.7882 49.1581C46.2536 52.2398 25.7632 52.2738 24.2116 49.1581Z"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.481861 64.9951C-4.19479 55.6047 26.4765 0 36 0C45.5328 0 76.153 55.713 71.5274 64.9951C66.9018 74.2773 5.15852 74.3856 0.481861 64.9951ZM12.7358 56.847C15.8005 62.9995 56.2536 62.9314 59.2843 56.847C62.3149 50.761 42.2515 14.2605 36.0093 14.2605C29.767 14.2605 9.67118 50.6944 12.7358 56.847Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 614 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="25 37 900 900"><path fill="currentColor" d="M360.18,640.05h-93.37v-215.38h-.85l-71.97,95.92-70.16-95.92h-.96v215.38H29.5v-356.23h88.57l75.91,104.66,77.61-104.66h88.58v356.23Z"/><path fill="currentColor" d="M667.97,640.05h-93.37v-112.96h-101.99c.5,36.48,12.42,74.14,35.77,112.96l-75.8,50.14c-11.5-17.25-21.42-35.61-29.76-55.09-8.34-19.48-14.71-39.57-19.11-60.26-4.4-20.69-6.6-41.26-6.6-61.7s2.31-42.21,6.92-62.76c4.61-20.55,11.71-40.12,21.29-58.72,9.58-18.59,21.58-35.1,35.98-49.51,15.83-15.97,32.77-28.19,50.84-36.68,18.06-8.48,36.89-14.21,56.48-17.19,19.59-2.98,42.09-4.47,67.5-4.47h45.78l21.83,80.27h-73.25c-30.38,0-54.35,4.36-71.92,13.08-17.57,8.72-29.85,18.47-36.84,29.24-6.99,10.77-13.65,24.24-19.96,40.4h92.84v-71.76h93.37v264.99Z"/><path fill="currentColor" d="M797.69,559.78h122.54l-21.83,80.28h-194.08v-356.23h93.37v275.96Z"/></svg>
|
||||
|
After Width: | Height: | Size: 891 B |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="636.71 89.58 269.72 269.72" xmlns="http://www.w3.org/2000/svg" fill="currentColor">
|
||||
<path d="M774.47 359.3H677.74L768.67 224.5L677.74 89.58H774.47L865.4 224.5L774.47 359.3Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 199 B |
|
After Width: | Height: | Size: 121 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><linearGradient id="g" x1="0" y1="24" x2="24" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ab543a"/><stop offset="1" stop-color="#ff7e57"/></linearGradient></defs><path fill="url(#g)" d="M12 10.9c-.61 0-1.1.49-1.1 1.1s.49 1.1 1.1 1.1c.61 0 1.1-.49 1.1-1.1s-.49-1.1-1.1-1.1zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm2.19 12.19L6 18l3.81-8.19L18 6l-3.81 8.19z"/></svg>
|
||||
|
After Width: | Height: | Size: 481 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><linearGradient id="g" x1="0" y1="24" x2="24" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ab543a"/><stop offset="1" stop-color="#ff7e57"/></linearGradient></defs><path fill="url(#g)" d="M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z"/></svg>
|
||||
|
After Width: | Height: | Size: 405 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><linearGradient id="g" x1="0" y1="24" x2="24" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ab543a"/><stop offset="1" stop-color="#ff7e57"/></linearGradient></defs><path fill="url(#g)" d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>
|
||||
|
After Width: | Height: | Size: 327 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><linearGradient id="g" x1="0" y1="24" x2="24" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ab543a"/><stop offset="1" stop-color="#ff7e57"/></linearGradient></defs><path fill="url(#g)" d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/></svg>
|
||||
|
After Width: | Height: | Size: 578 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><linearGradient id="g" x1="0" y1="24" x2="24" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ab543a"/><stop offset="1" stop-color="#ff7e57"/></linearGradient></defs><path fill="url(#g)" d="M21 6h-7.59l3.29-3.29L16 2l-4 4-4-4-.71.71L10.59 6H3c-1.1 0-2 .89-2 2v12c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8c0-1.11-.9-2-2-2zm0 14H3V8h18v12zM9 10v8l7-4z"/></svg>
|
||||
|
After Width: | Height: | Size: 437 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><linearGradient id="g" x1="0" y1="24" x2="24" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ab543a"/><stop offset="1" stop-color="#ff7e57"/></linearGradient></defs><path fill="url(#g)" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/></svg>
|
||||
|
After Width: | Height: | Size: 376 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><defs><linearGradient id="g" x1="0" y1="24" x2="24" y2="0" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ab543a"/><stop offset="1" stop-color="#ff7e57"/></linearGradient></defs><path fill="url(#g)" d="M12 12.75c1.63 0 3.07.39 4.24.9 1.08.48 1.76 1.56 1.76 2.73V18H6v-1.61c0-1.18.68-2.26 1.76-2.73 1.17-.52 2.61-.91 4.24-.91zM4 13c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm1.13 1.1c-.37-.06-.74-.1-1.13-.1-.99 0-1.93.21-2.78.58C.48 14.9 0 15.62 0 16.43V18h4.5v-1.61c0-.83.23-1.61.63-2.29zM20 13c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm4 3.43c0-.81-.48-1.53-1.22-1.85-.85-.37-1.79-.58-2.78-.58-.39 0-.76.04-1.13.1.4.68.63 1.46.63 2.29V18H24v-1.57zM12 6c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3z"/></svg>
|
||||
|
After Width: | Height: | Size: 794 B |
@@ -1,15 +0,0 @@
|
||||
## iOS
|
||||
|
||||
| Home | Library | Media Card | Season |
|
||||
|------|---------|------------|--------|
|
||||
|  |  |  |  |
|
||||
|
||||
## macOS
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
Before Width: | Height: | Size: 3.8 MiB |
|
Before Width: | Height: | Size: 2.4 MiB |
|
Before Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 4.4 MiB |
|
Before Width: | Height: | Size: 3.4 MiB |
|
Before Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="205 196 1124 1124"><path fill="currentColor" d="M205.832 970.284c0 35.322 1.82 65.9 5.433 91.774 6.709 53.176 28.198 94.359 64.507 123.51 33.579 27.302 88.907 45.656 165.984 55.065 63.178 7.983 165.983 11.993 308.45 11.993 234.287 0 377.455-7.982 429.428-23.947 52.859-15.964 90.936-45.77 114.232-89.46 23.295-44.153 34.944-113.446 34.944-207.843 0-84.525-11.907-145.798-35.645-183.858-15.702-25.836-36.64-45.772-62.885-59.885-26.244-14.074-61.52-24.64-105.9-31.697-43.937-7.018-145.452-12.917-304.58-17.622-175.752-5.63-274.576-11.49-296.545-17.584-26.024-7.519-39-28.188-39-62.006 0-35.205 12.312-57.03 36.972-65.514 21.97-7.018 92.115-10.566 210.476-10.566 119.65 0 194.955 1.427 225.92 4.203 34.942 3.317 55.584 18.04 61.85 44.26 2.25 8.908 3.835 24.833 4.72 47.777h271c.368-22.442.589-38.83.589-49.127 0-95.475-21.527-161.676-64.58-198.657-31.847-26.683-83.858-45.386-156.032-56.183-54.7-7.944-148.4-11.915-281.099-11.915-204.024 0-333.149 4.704-387.407 14.075-60.082 10.797-104.942 33.047-134.505 66.748-39.477 44.538-59.198 121.158-59.198 229.823 0 51.555 4.017 93.278 12.09 125.129 14.338 57.649 44.822 98.407 91.451 122.276 36.309 18.741 98.861 29.538 187.622 32.353 34.058.926 108.444 4.204 223.228 9.833 111.172 5.63 184.23 8.445 219.212 8.445 50.645.926 82.678 8.213 96.168 21.787 9.843 9.832 14.783 26.915 14.783 51.286 0 35.63-7.852 58.612-23.518 68.908-11.206 7.018-34.502 11.953-69.887 14.73-18.837.963-87.84 1.658-207.011 2.12-86.954-.462-138.265-.924-153.93-1.387-31.369-.926-53.338-2.584-65.871-4.936-12.532-2.314-23.738-6.44-33.58-12.996-18.835-13.573-28.014-44.036-27.571-91.389H205.832v50.824-.347z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><circle cx="24" cy="24" r="24" fill="transparent"/><path fill="currentColor" d="M45.99 14.39 30.17 30.22l-1.46-1.46 16.31-16.32c-.15-.3-.32-.58-.5-.87l-17.2 17.2 2.16 2.16-1.46 1.46-3.62-3.62L43.34 9.82c-.18-.27-.38-.53-.6-.78L23.01 28.78l4.31 4.31-1.46 1.46-14.4-14.4 1.46-1.46 8.62 8.62L41.37 7.46c-.12-.14-.26-.28-.4-.42C36.62 2.69 30.62 0 24 0S11.38 2.69 7.03 7.03C2.69 11.37 0 17.37 0 24c0 13.25 10.75 24 24 24 6.62 0 12.62-2.69 16.97-7.03C45.31 36.64 48 30.64 48 24c0-3.42-.71-6.67-2.01-9.61ZM21.54 25.9l-7.93-7.92 1.46-1.46L23 24.44l-1.46 1.46Zm2.16-2.16-7.93-7.92 1.46-1.46 7.93 7.92-1.46 1.46Zm.3 19.65c-10.7 0-19.39-8.7-19.39-19.39S13.3 4.61 24 4.61c2.91 0 5.72.63 8.33 1.88l-.89 1.88c-2.33-1.11-4.84-1.68-7.43-1.68-9.55 0-17.33 7.77-17.33 17.32s7.78 17.33 17.33 17.33c6.82 0 13.03-4.03 15.82-10.25l1.89.84C38.6 38.9 31.65 43.41 24.01 43.41Z"/></svg>
|
||||
|
After Width: | Height: | Size: 921 B |
@@ -0,0 +1,17 @@
|
||||
targets:
|
||||
$default:
|
||||
builders:
|
||||
json_serializable:
|
||||
options:
|
||||
create_to_json: true
|
||||
explicit_to_json: true
|
||||
field_rename: none
|
||||
include_if_null: true
|
||||
checked: false
|
||||
freezed:
|
||||
options:
|
||||
copy_with: true
|
||||
equal: true
|
||||
to_string: true
|
||||
slang_build_runner:
|
||||
enabled: false
|
||||
@@ -1,141 +1,34 @@
|
||||
PODS:
|
||||
- background_downloader (0.0.1):
|
||||
- Flutter
|
||||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- device_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- file_picker (0.0.1):
|
||||
- cupertino_http (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- Flutter (1.0.0)
|
||||
- in_app_review (2.0.0):
|
||||
- os_media_controls (0.2.4):
|
||||
- Flutter
|
||||
- mobile_scanner (7.0.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- os_media_controls (0.0.1):
|
||||
- Flutter
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- Sentry/HybridSDK (8.58.0)
|
||||
- sentry_flutter (9.15.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- Sentry/HybridSDK (= 8.58.0)
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqflite_darwin (0.0.4):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqlite3 (3.52.0):
|
||||
- sqlite3/common (= 3.52.0)
|
||||
- sqlite3/common (3.52.0)
|
||||
- sqlite3/dbstatvtab (3.52.0):
|
||||
- sqlite3/common
|
||||
- sqlite3/fts5 (3.52.0):
|
||||
- sqlite3/common
|
||||
- sqlite3/math (3.52.0):
|
||||
- sqlite3/common
|
||||
- sqlite3/perf-threadsafe (3.52.0):
|
||||
- sqlite3/common
|
||||
- sqlite3/rtree (3.52.0):
|
||||
- sqlite3/common
|
||||
- sqlite3/session (3.52.0):
|
||||
- sqlite3/common
|
||||
- sqlite3_flutter_libs (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqlite3 (~> 3.52.0)
|
||||
- sqlite3/dbstatvtab
|
||||
- sqlite3/fts5
|
||||
- sqlite3/math
|
||||
- sqlite3/perf-threadsafe
|
||||
- sqlite3/rtree
|
||||
- sqlite3/session
|
||||
- universal_gamepad (0.1.0):
|
||||
- Flutter
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- wakelock_plus (0.0.1):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- background_downloader (from `.symlinks/plugins/background_downloader/ios`)
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||
- cupertino_http (from `.symlinks/plugins/cupertino_http/darwin`)
|
||||
- Flutter (from `Flutter`)
|
||||
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
|
||||
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
|
||||
- os_media_controls (from `.symlinks/plugins/os_media_controls/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`)
|
||||
- universal_gamepad (from `.symlinks/plugins/universal_gamepad/ios`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- Sentry
|
||||
- sqlite3
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
background_downloader:
|
||||
:path: ".symlinks/plugins/background_downloader/ios"
|
||||
connectivity_plus:
|
||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||
device_info_plus:
|
||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||
file_picker:
|
||||
:path: ".symlinks/plugins/file_picker/ios"
|
||||
cupertino_http:
|
||||
:path: ".symlinks/plugins/cupertino_http/darwin"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
in_app_review:
|
||||
:path: ".symlinks/plugins/in_app_review/ios"
|
||||
mobile_scanner:
|
||||
:path: ".symlinks/plugins/mobile_scanner/darwin"
|
||||
os_media_controls:
|
||||
:path: ".symlinks/plugins/os_media_controls/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
sentry_flutter:
|
||||
:path: ".symlinks/plugins/sentry_flutter/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
sqflite_darwin:
|
||||
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
||||
sqlite3_flutter_libs:
|
||||
:path: ".symlinks/plugins/sqlite3_flutter_libs/darwin"
|
||||
universal_gamepad:
|
||||
:path: ".symlinks/plugins/universal_gamepad/ios"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
wakelock_plus:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
background_downloader: 50e91d979067b82081aba359d7d916b3ba5fadad
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||
file_picker: 8fc6fe5e42585a217d44d22f79ec046cb8d81140
|
||||
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
in_app_review: 7dd1ea365263f834b8464673f9df72c80c17c937
|
||||
mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
|
||||
os_media_controls: 86dceab6245a5325af90fc0fdebe243c42d789b4
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
Sentry: d587a8fe91ca13503ecd69a1905f3e8a0fcf61be
|
||||
sentry_flutter: 8939e491bc1511868118c96cae47d945e6f69798
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
sqlite3: a51c07cf16e023d6c48abd5e5791a61a47354921
|
||||
sqlite3_flutter_libs: b3e120efe9a82017e5552a620f696589ed4f62ab
|
||||
universal_gamepad: e10172778a8a399cce234494968f38724974919e
|
||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
os_media_controls: 048eb9a75974191b2496d85575b6edcefc572d4e
|
||||
universal_gamepad: 838bbb70d37d8c7c719038aa397214f2c4c4f866
|
||||
|
||||
PODFILE CHECKSUM: 16bb7f67e16d8aaa2bfb97666803fbfb291b6559
|
||||
|
||||
|
||||
@@ -8,20 +8,22 @@
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||
2CA552536A23AFD33344616B /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 200265C4B9C45EDD150FB4C3 /* Pods_RunnerTests.framework */; };
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
69E1B4BB161DC9D84D1DFC62 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BB346A1D0705AB171F80B11B /* Pods_Runner.framework */; };
|
||||
6A48DFB02EA70C7100C1F7CD /* plezy.icon in Resources */ = {isa = PBXBuildFile; fileRef = 6A48DFAF2EA70C7100C1F7CD /* plezy.icon */; };
|
||||
6A8A46212EDB320D0057B88C /* MPVKit in Frameworks */ = {isa = PBXBuildFile; productRef = 6A8A46202EDB320D0057B88C /* MPVKit */; };
|
||||
6A8A46252EDB370C0057B88C /* MpvPlayerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */; };
|
||||
6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */; };
|
||||
702A247AA28B055172318FF9 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 61B22BEA638334DA7861BCF6 /* Pods_Runner.framework */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
748A259343D7FF8DFD2C3910 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 75D2EEFFFFE26C0F3CD48133 /* Pods_RunnerTests.framework */; };
|
||||
92F969587D0E464D999910F5 /* MpvPipController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92F969587D0E464D999910F4 /* MpvPipController.swift */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
B1D51A6A2F00110000000001 /* MpvPlayerCoreBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */; };
|
||||
B1D51A6A2F00110000000005 /* MpvPlayerPluginShared.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */; };
|
||||
B1D51A6A2F00110000000008 /* ExternalDisplayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -48,24 +50,22 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
030239DB288C484414523AFE /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
200265C4B9C45EDD150FB4C3 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
230D46AA7CEA8CB81C420035 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
28ABDA890783B15438AA8137 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3A9EF9720208222F20B1495D /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
5C4660106A040D374746C713 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
61B22BEA638334DA7861BCF6 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
6A48DFAF2EA70C7100C1F7CD /* plezy.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = plezy.icon; sourceTree = "<group>"; };
|
||||
6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerCore.swift; sourceTree = "<group>"; };
|
||||
6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPlayerPlugin.swift; sourceTree = "<group>"; };
|
||||
6E3FE696F0F85B37E212CCEE /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
6FAF62B46A8A7A431F17FCD8 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; 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>"; };
|
||||
75D2EEFFFFE26C0F3CD48133 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
792502911151E64A2B752567 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
7F4B79C5218D46EDB4E433B0 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
92F969587D0E464D999910F4 /* MpvPipController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MpvPipController.swift; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
@@ -75,7 +75,11 @@
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerCoreBase.swift; path = ../shared/apple/MpvPlayer/MpvPlayerCoreBase.swift; sourceTree = SOURCE_ROOT; };
|
||||
B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MpvPlayerPluginShared.swift; path = ../shared/apple/MpvPlayer/MpvPlayerPluginShared.swift; sourceTree = SOURCE_ROOT; };
|
||||
C208E69E3A3B935CE009B4D5 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalDisplayManager.swift; sourceTree = "<group>"; };
|
||||
BB346A1D0705AB171F80B11B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
DE48FF2E074644167608B23D /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
F3553CDE70191F0FC81A782E /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -83,7 +87,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
2CA552536A23AFD33344616B /* Pods_RunnerTests.framework in Frameworks */,
|
||||
748A259343D7FF8DFD2C3910 /* Pods_RunnerTests.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -91,23 +95,15 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||
6A8A46212EDB320D0057B88C /* MPVKit in Frameworks */,
|
||||
702A247AA28B055172318FF9 /* Pods_Runner.framework in Frameworks */,
|
||||
69E1B4BB161DC9D84D1DFC62 /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
19B1F8004DD75C8EEB4662AB /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
61B22BEA638334DA7861BCF6 /* Pods_Runner.framework */,
|
||||
200265C4B9C45EDD150FB4C3 /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -116,11 +112,21 @@
|
||||
path = RunnerTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3DBA2EB0C0219E797C60DE48 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BB346A1D0705AB171F80B11B /* Pods_Runner.framework */,
|
||||
75D2EEFFFFE26C0F3CD48133 /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
6A8A46242EDB370C0057B88C /* MpvPlayer */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B1D51A6A2F00110000000002 /* MpvPlayerCoreBase.swift */,
|
||||
B1D51A6A2F00110000000006 /* MpvPlayerPluginShared.swift */,
|
||||
B1D51A6A2F00110000000007 /* ExternalDisplayManager.swift */,
|
||||
6A8A46222EDB370C0057B88C /* MpvPlayerCore.swift */,
|
||||
6A8A46232EDB370C0057B88C /* MpvPlayerPlugin.swift */,
|
||||
92F969587D0E464D999910F4 /* MpvPipController.swift */,
|
||||
@@ -131,12 +137,12 @@
|
||||
875C8AEFBBA3759A1849D674 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
6E3FE696F0F85B37E212CCEE /* Pods-Runner.debug.xcconfig */,
|
||||
5C4660106A040D374746C713 /* Pods-Runner.release.xcconfig */,
|
||||
C208E69E3A3B935CE009B4D5 /* Pods-Runner.profile.xcconfig */,
|
||||
030239DB288C484414523AFE /* Pods-RunnerTests.debug.xcconfig */,
|
||||
230D46AA7CEA8CB81C420035 /* Pods-RunnerTests.release.xcconfig */,
|
||||
6FAF62B46A8A7A431F17FCD8 /* Pods-RunnerTests.profile.xcconfig */,
|
||||
7F4B79C5218D46EDB4E433B0 /* Pods-Runner.debug.xcconfig */,
|
||||
F3553CDE70191F0FC81A782E /* Pods-Runner.release.xcconfig */,
|
||||
DE48FF2E074644167608B23D /* Pods-Runner.profile.xcconfig */,
|
||||
28ABDA890783B15438AA8137 /* Pods-RunnerTests.debug.xcconfig */,
|
||||
3A9EF9720208222F20B1495D /* Pods-RunnerTests.release.xcconfig */,
|
||||
792502911151E64A2B752567 /* Pods-RunnerTests.profile.xcconfig */,
|
||||
);
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
@@ -144,6 +150,7 @@
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
@@ -161,7 +168,7 @@
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
875C8AEFBBA3759A1849D674 /* Pods */,
|
||||
6A48DFAF2EA70C7100C1F7CD /* plezy.icon */,
|
||||
19B1F8004DD75C8EEB4662AB /* Frameworks */,
|
||||
3DBA2EB0C0219E797C60DE48 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -196,7 +203,7 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
C5965E09A75FF4B9D47A6A5A /* [CP] Check Pods Manifest.lock */,
|
||||
87A3350E66D519725E5CD790 /* [CP] Check Pods Manifest.lock */,
|
||||
331C807D294A63A400263BE5 /* Sources */,
|
||||
331C807F294A63A400263BE5 /* Resources */,
|
||||
582EBCC1C90BEBEC365D972D /* Frameworks */,
|
||||
@@ -212,17 +219,20 @@
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
);
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
A7BAF2DBCB81960BDC752F05 /* [CP] Check Pods Manifest.lock */,
|
||||
6838C6976E568CE05DBDE228 /* [CP] Check Pods Manifest.lock */,
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
87CA3559B76E3AB2BA77B235 /* [CP] Embed Pods Frameworks */,
|
||||
898098BAEC57934A6822754B /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -263,6 +273,7 @@
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
|
||||
6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */,
|
||||
);
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
@@ -313,7 +324,51 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||
};
|
||||
87CA3559B76E3AB2BA77B235 /* [CP] Embed Pods Frameworks */ = {
|
||||
6838C6976E568CE05DBDE228 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
87A3350E66D519725E5CD790 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
898098BAEC57934A6822754B /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@@ -345,50 +400,6 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
A7BAF2DBCB81960BDC752F05 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
C5965E09A75FF4B9D47A6A5A /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -406,6 +417,7 @@
|
||||
files = (
|
||||
B1D51A6A2F00110000000001 /* MpvPlayerCoreBase.swift in Sources */,
|
||||
B1D51A6A2F00110000000005 /* MpvPlayerPluginShared.swift in Sources */,
|
||||
B1D51A6A2F00110000000008 /* ExternalDisplayManager.swift in Sources */,
|
||||
6A8A46252EDB370C0057B88C /* MpvPlayerPlugin.swift in Sources */,
|
||||
6A8A46262EDB370C0057B88C /* MpvPlayerCore.swift in Sources */,
|
||||
92F969587D0E464D999910F5 /* MpvPipController.swift in Sources */,
|
||||
@@ -524,7 +536,7 @@
|
||||
};
|
||||
331C8088294A63A400263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 030239DB288C484414523AFE /* Pods-RunnerTests.debug.xcconfig */;
|
||||
baseConfigurationReference = 28ABDA890783B15438AA8137 /* Pods-RunnerTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@@ -542,7 +554,7 @@
|
||||
};
|
||||
331C8089294A63A400263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 230D46AA7CEA8CB81C420035 /* Pods-RunnerTests.release.xcconfig */;
|
||||
baseConfigurationReference = 3A9EF9720208222F20B1495D /* Pods-RunnerTests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@@ -558,7 +570,7 @@
|
||||
};
|
||||
331C808A294A63A400263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 6FAF62B46A8A7A431F17FCD8 /* Pods-RunnerTests.profile.xcconfig */;
|
||||
baseConfigurationReference = 792502911151E64A2B752567 /* Pods-RunnerTests.profile.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@@ -777,7 +789,7 @@
|
||||
repositoryURL = "https://github.com/edde746/MPVKit";
|
||||
requirement = {
|
||||
kind = revision;
|
||||
revision = b2179eec26c6ceda339e6c6b512e457341768761;
|
||||
revision = 1b0134a2ea04a3b967f61a726b5864351280b420;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
@@ -788,7 +800,17 @@
|
||||
package = 6A8A461F2EDB320D0057B88C /* XCRemoteSwiftPackageReference "MPVKit" */;
|
||||
productName = MPVKit;
|
||||
};
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
};
|
||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,84 @@
|
||||
{
|
||||
"originHash" : "c535d3cdb62bd4e11e67f3c9e68290fa7ee9306634c5f56d2b0396eb75ed41ee",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "csqlite",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/simolus3/CSQLite.git",
|
||||
"state" : {
|
||||
"revision" : "1ee46d19a4f451a7aa64ffc64fc99b4748131e62"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkcamera",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKCamera",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkimagepickercontroller",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKImagePickerController",
|
||||
"state" : {
|
||||
"branch" : "4.3.9",
|
||||
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkphotogallery",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "mpvkit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/edde746/MPVKit",
|
||||
"state" : {
|
||||
"revision" : "b2179eec26c6ceda339e6c6b512e457341768761"
|
||||
"revision" : "1b0134a2ea04a3b967f61a726b5864351280b420"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sdwebimage",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SDWebImage/SDWebImage",
|
||||
"state" : {
|
||||
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
|
||||
"version" : "5.21.7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sentry-cocoa",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/getsentry/sentry-cocoa",
|
||||
"state" : {
|
||||
"revision" : "16cd512711375fa73f25ae5e373f596bdf4251ae",
|
||||
"version" : "8.58.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftygif",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/kirualex/SwiftyGif.git",
|
||||
"state" : {
|
||||
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
|
||||
"version" : "5.4.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "tocropviewcontroller",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/TimOliver/TOCropViewController",
|
||||
"state" : {
|
||||
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
|
||||
"version" : "2.8.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
"version" : 2
|
||||
}
|
||||
|
||||
@@ -5,6 +5,24 @@
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<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 ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
|
||||
@@ -1,14 +1,84 @@
|
||||
{
|
||||
"originHash" : "c535d3cdb62bd4e11e67f3c9e68290fa7ee9306634c5f56d2b0396eb75ed41ee",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "csqlite",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/simolus3/CSQLite.git",
|
||||
"state" : {
|
||||
"revision" : "1ee46d19a4f451a7aa64ffc64fc99b4748131e62"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkcamera",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKCamera",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkimagepickercontroller",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKImagePickerController",
|
||||
"state" : {
|
||||
"branch" : "4.3.9",
|
||||
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "dkphotogallery",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
|
||||
"state" : {
|
||||
"branch" : "master",
|
||||
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "mpvkit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/edde746/MPVKit",
|
||||
"state" : {
|
||||
"revision" : "b2179eec26c6ceda339e6c6b512e457341768761"
|
||||
"revision" : "1b0134a2ea04a3b967f61a726b5864351280b420"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sdwebimage",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SDWebImage/SDWebImage",
|
||||
"state" : {
|
||||
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
|
||||
"version" : "5.21.7"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sentry-cocoa",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/getsentry/sentry-cocoa",
|
||||
"state" : {
|
||||
"revision" : "16cd512711375fa73f25ae5e373f596bdf4251ae",
|
||||
"version" : "8.58.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftygif",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/kirualex/SwiftyGif.git",
|
||||
"state" : {
|
||||
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
|
||||
"version" : "5.4.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "tocropviewcontroller",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/TimOliver/TOCropViewController",
|
||||
"state" : {
|
||||
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
|
||||
"version" : "2.8.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
"version" : 2
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
@@ -33,12 +35,12 @@
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_plexmediasvr._tcp</string>
|
||||
</array>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>This app needs to connect to your Plex Media Server on your local network.</string>
|
||||
<string>This app needs to connect to your media servers on your local network.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Plezy uses location access when iOS requires it to detect your local network and connect to your media servers.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Plezy needs photo library access so you can choose images to upload as media artwork.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Camera is used to scan QR codes for Companion Remote pairing.</string>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
@@ -70,7 +72,7 @@
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<true/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
@@ -86,6 +88,28 @@
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UIWindowSceneSessionRoleExternalDisplay</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ExternalDisplaySceneDelegate</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>external-display</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UIWindowSceneSessionRoleExternalDisplayNonInteractive</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ExternalDisplaySceneDelegate</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>external-display</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import UIKit
|
||||
|
||||
final class ExternalDisplayManager {
|
||||
static let shared = ExternalDisplayManager()
|
||||
|
||||
private weak var activeCore: MpvPlayerCore?
|
||||
private var externalScene: UIWindowScene?
|
||||
private var externalWindow: UIWindow?
|
||||
private var externalRootViewController: UIViewController?
|
||||
|
||||
private init() {}
|
||||
|
||||
static func isExternalDisplayRole(_ role: UISceneSession.Role) -> Bool {
|
||||
role.rawValue == "UIWindowSceneSessionRoleExternalDisplay"
|
||||
|| role.rawValue == "UIWindowSceneSessionRoleExternalDisplayNonInteractive"
|
||||
}
|
||||
|
||||
static var hasActiveApplicationScene: Bool {
|
||||
applicationWindowScenes.contains { $0.activationState == .foregroundActive }
|
||||
}
|
||||
|
||||
static func mainApplicationWindow() -> UIWindow? {
|
||||
let scenes = applicationWindowScenes.sorted {
|
||||
scenePriority($0.activationState) < scenePriority($1.activationState)
|
||||
}
|
||||
|
||||
for scene in scenes {
|
||||
if let window = scene.windows.first(where: { $0.isKeyWindow }) {
|
||||
return window
|
||||
}
|
||||
}
|
||||
|
||||
for scene in scenes {
|
||||
if let window = scene.windows.first(where: { !$0.isHidden }) {
|
||||
return window
|
||||
}
|
||||
}
|
||||
|
||||
return scenes.first?.windows.first
|
||||
}
|
||||
|
||||
private static var applicationWindowScenes: [UIWindowScene] {
|
||||
UIApplication.shared.connectedScenes.compactMap { scene in
|
||||
guard let windowScene = scene as? UIWindowScene,
|
||||
!isExternalDisplayRole(windowScene.session.role)
|
||||
else { return nil }
|
||||
return windowScene
|
||||
}
|
||||
}
|
||||
|
||||
private static func scenePriority(_ state: UIScene.ActivationState) -> Int {
|
||||
switch state {
|
||||
case .foregroundActive:
|
||||
return 0
|
||||
case .foregroundInactive:
|
||||
return 1
|
||||
case .background:
|
||||
return 2
|
||||
case .unattached:
|
||||
return 3
|
||||
@unknown default:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
var videoSuperview: UIView? {
|
||||
externalRootViewController?.view
|
||||
}
|
||||
|
||||
func attach(core: MpvPlayerCore) {
|
||||
activeCore = core
|
||||
discoverExistingExternalScene()
|
||||
activeCore?.externalDisplayDidChange()
|
||||
}
|
||||
|
||||
func detach(core: MpvPlayerCore) {
|
||||
if activeCore === core {
|
||||
activeCore = nil
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func connect(scene: UIWindowScene) -> UIWindow? {
|
||||
guard Self.isExternalDisplayRole(scene.session.role) else { return nil }
|
||||
|
||||
externalScene = scene
|
||||
let window = ensureWindow(for: scene)
|
||||
activeCore?.externalDisplayDidChange()
|
||||
print("[ExternalDisplayManager] External display connected")
|
||||
return window
|
||||
}
|
||||
|
||||
func disconnect(scene: UIScene) {
|
||||
guard scene === externalScene else { return }
|
||||
|
||||
externalWindow?.isHidden = true
|
||||
externalWindow = nil
|
||||
externalRootViewController = nil
|
||||
externalScene = nil
|
||||
activeCore?.externalDisplayDidChange()
|
||||
print("[ExternalDisplayManager] External display disconnected")
|
||||
}
|
||||
|
||||
func update(scene: UIWindowScene) {
|
||||
guard scene === externalScene else { return }
|
||||
externalRootViewController?.view.frame = scene.coordinateSpace.bounds
|
||||
activeCore?.externalDisplayDidChange()
|
||||
}
|
||||
|
||||
private func discoverExistingExternalScene() {
|
||||
guard externalScene == nil else { return }
|
||||
|
||||
for scene in UIApplication.shared.connectedScenes {
|
||||
guard let windowScene = scene as? UIWindowScene,
|
||||
Self.isExternalDisplayRole(windowScene.session.role)
|
||||
else { continue }
|
||||
_ = connect(scene: windowScene)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureWindow(for scene: UIWindowScene) -> UIWindow {
|
||||
if let existing = externalWindow, existing.windowScene === scene {
|
||||
return existing
|
||||
}
|
||||
|
||||
externalWindow?.isHidden = true
|
||||
|
||||
let rootViewController = UIViewController()
|
||||
rootViewController.view.frame = scene.coordinateSpace.bounds
|
||||
rootViewController.view.backgroundColor = .black
|
||||
|
||||
let window = UIWindow(windowScene: scene)
|
||||
window.rootViewController = rootViewController
|
||||
window.backgroundColor = .black
|
||||
window.isHidden = false
|
||||
|
||||
externalRootViewController = rootViewController
|
||||
externalWindow = window
|
||||
return window
|
||||
}
|
||||
}
|
||||
|
||||
class ExternalDisplaySceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
var window: UIWindow?
|
||||
|
||||
func scene(
|
||||
_ scene: UIScene,
|
||||
willConnectTo session: UISceneSession,
|
||||
options connectionOptions: UIScene.ConnectionOptions
|
||||
) {
|
||||
guard let windowScene = scene as? UIWindowScene,
|
||||
ExternalDisplayManager.isExternalDisplayRole(session.role)
|
||||
else { return }
|
||||
|
||||
window = ExternalDisplayManager.shared.connect(scene: windowScene)
|
||||
}
|
||||
|
||||
func sceneDidDisconnect(_ scene: UIScene) {
|
||||
ExternalDisplayManager.shared.disconnect(scene: scene)
|
||||
window = nil
|
||||
}
|
||||
|
||||
func windowScene(
|
||||
_ windowScene: UIWindowScene,
|
||||
didUpdate previousCoordinateSpace: UICoordinateSpace,
|
||||
interfaceOrientation previousInterfaceOrientation: UIInterfaceOrientation,
|
||||
traitCollection previousTraitCollection: UITraitCollection
|
||||
) {
|
||||
ExternalDisplayManager.shared.update(scene: windowScene)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,44 @@
|
||||
import AVFoundation
|
||||
import AVKit
|
||||
import UIKit
|
||||
|
||||
/// Delegate to notify the plugin of PiP lifecycle events
|
||||
protocol MpvPipDelegate: AnyObject {
|
||||
#if os(tvOS)
|
||||
// tvOS stub: AVPictureInPictureController has different constraints on tvOS
|
||||
// and is not supported by the Plezy flow. Provide a no-op shell so callers
|
||||
// in MpvPlayerPlugin compile unchanged; isSupported reports false so PiP is
|
||||
// never attempted at runtime.
|
||||
protocol MpvPipDelegate: AnyObject {
|
||||
func pipWillStart()
|
||||
func pipDidStart()
|
||||
func pipDidStop(restored: Bool)
|
||||
func pipDidFailToStart(error: Error?)
|
||||
func pipSetPlaying(_ playing: Bool)
|
||||
func pipSkip(byInterval seconds: Double, completion: @escaping () -> Void)
|
||||
var isPipPlaying: Bool { get }
|
||||
var pipDuration: Double { get }
|
||||
}
|
||||
|
||||
class MpvPipController: NSObject {
|
||||
static var isSupported: Bool { false }
|
||||
weak var delegate: MpvPipDelegate?
|
||||
var isPipActive: Bool { false }
|
||||
var autoStartEnabled: Bool { false }
|
||||
init(sampleBufferDisplayLayer: AVSampleBufferDisplayLayer) { super.init() }
|
||||
func setup(with layer: CALayer, containerView: UIView) {}
|
||||
func setAutoStart(_ enabled: Bool) {}
|
||||
func warmLayer(currentTime: Double, isPlaying: Bool) {}
|
||||
func startPip(waitForFrame: Bool = true, completion: @escaping (Bool) -> Void) {
|
||||
completion(false)
|
||||
}
|
||||
func stopPip() {}
|
||||
func invalidatePlaybackState() {}
|
||||
func syncTimebase(currentTime: Double, isPlaying: Bool) {}
|
||||
func teardown() {}
|
||||
}
|
||||
#else
|
||||
|
||||
/// Delegate to notify the plugin of PiP lifecycle events
|
||||
protocol MpvPipDelegate: AnyObject {
|
||||
/// Called when PiP is about to start (system or app-initiated)
|
||||
func pipWillStart()
|
||||
func pipDidStart()
|
||||
@@ -12,358 +48,258 @@ protocol MpvPipDelegate: AnyObject {
|
||||
/// Forward play/pause commands from PiP overlay to mpv
|
||||
func pipSetPlaying(_ playing: Bool)
|
||||
/// Forward skip forward/backward commands from PiP overlay to mpv
|
||||
func pipSkip(byInterval seconds: Double)
|
||||
func pipSkip(byInterval seconds: Double, completion: @escaping () -> Void)
|
||||
/// Query whether mpv is currently playing
|
||||
var isPipPlaying: Bool { get }
|
||||
/// Get total duration in seconds
|
||||
var pipDuration: Double { get }
|
||||
}
|
||||
}
|
||||
|
||||
/// Encapsulates all iOS Picture-in-Picture logic using AVSampleBufferDisplayLayer.
|
||||
/// Requires iOS 15+ for the ContentSource API; on older versions, `setup()` is a no-op.
|
||||
class MpvPipController: NSObject {
|
||||
/// Encapsulates all iOS Picture-in-Picture logic using AVSampleBufferDisplayLayer.
|
||||
/// Requires iOS 15+ for the ContentSource API; on older versions, `setup()` is a no-op.
|
||||
class MpvPipController: NSObject {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var pipController: AVPictureInPictureController?
|
||||
private let sampleBufferLayer = AVSampleBufferDisplayLayer()
|
||||
private var containerView: UIView?
|
||||
private weak var sampleBufferLayer: AVSampleBufferDisplayLayer?
|
||||
weak var delegate: MpvPipDelegate?
|
||||
|
||||
/// Pointer to the sample buffer layer for passing to mpv as `wid`
|
||||
var layerPointer: UnsafeMutableRawPointer {
|
||||
Unmanaged.passUnretained(sampleBufferLayer).toOpaque()
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
setup()
|
||||
init(sampleBufferDisplayLayer: AVSampleBufferDisplayLayer) {
|
||||
self.sampleBufferLayer = sampleBufferDisplayLayer
|
||||
super.init()
|
||||
setup()
|
||||
}
|
||||
|
||||
private func setup() {
|
||||
guard #available(iOS 15.0, *) else { return }
|
||||
guard #available(iOS 15.0, *) else { return }
|
||||
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
} catch {
|
||||
print("[MpvPipController] Failed to configure audio session: \(error)")
|
||||
}
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
} catch {
|
||||
print("[MpvPipController] Failed to configure audio session: \(error)")
|
||||
}
|
||||
|
||||
// The sample buffer layer must be in a visible view hierarchy for
|
||||
// isPictureInPicturePossible to become true.
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = windowScene.windows.first(where: { $0.isKeyWindow }) {
|
||||
let view = UIView(frame: window.bounds)
|
||||
view.clipsToBounds = true
|
||||
view.isUserInteractionEnabled = false
|
||||
sampleBufferLayer.frame = view.bounds
|
||||
view.layer.addSublayer(sampleBufferLayer)
|
||||
window.addSubview(view)
|
||||
containerView = view
|
||||
}
|
||||
|
||||
createPipController()
|
||||
createPipController()
|
||||
}
|
||||
|
||||
/// Helper that conforms to the iOS 15+ delegate protocols
|
||||
private var delegateHelper: AnyObject?
|
||||
|
||||
private func createPipController() {
|
||||
guard #available(iOS 15.0, *) else { return }
|
||||
let helper = PipDelegateHelper(controller: self)
|
||||
let contentSource = AVPictureInPictureController.ContentSource(
|
||||
sampleBufferDisplayLayer: sampleBufferLayer,
|
||||
playbackDelegate: helper
|
||||
)
|
||||
self.delegateHelper = helper
|
||||
pipController = AVPictureInPictureController(contentSource: contentSource)
|
||||
pipController?.delegate = helper
|
||||
if #available(iOS 14.2, *) {
|
||||
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
|
||||
}
|
||||
guard #available(iOS 15.0, *), let sampleBufferLayer else { return }
|
||||
let helper = PipDelegateHelper(controller: self)
|
||||
let contentSource = AVPictureInPictureController.ContentSource(
|
||||
sampleBufferDisplayLayer: sampleBufferLayer,
|
||||
playbackDelegate: helper
|
||||
)
|
||||
self.delegateHelper = helper
|
||||
pipController = AVPictureInPictureController(contentSource: contentSource)
|
||||
pipController?.delegate = helper
|
||||
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
|
||||
}
|
||||
|
||||
/// Enable/disable system auto-PiP (starts PiP automatically on background transition)
|
||||
func setAutoStart(_ enabled: Bool) {
|
||||
guard #available(iOS 14.2, *) else { return }
|
||||
pipController?.canStartPictureInPictureAutomaticallyFromInline = enabled
|
||||
guard #available(iOS 14.2, *) else { return }
|
||||
pipController?.canStartPictureInPictureAutomaticallyFromInline = enabled
|
||||
}
|
||||
|
||||
/// Push a black frame to the sample buffer layer so PiP has content
|
||||
/// to display immediately (before vo_pip decodes the first real frame).
|
||||
func pushBlankFrame(width: Int32 = 1920, height: Int32 = 1080) {
|
||||
var pixelBuffer: CVPixelBuffer?
|
||||
let attrs: [String: Any] = [
|
||||
kCVPixelBufferIOSurfacePropertiesKey as String: [:],
|
||||
]
|
||||
let status = CVPixelBufferCreate(
|
||||
kCFAllocatorDefault, Int(width), Int(height),
|
||||
kCVPixelFormatType_32BGRA, attrs as CFDictionary, &pixelBuffer
|
||||
)
|
||||
guard status == kCVReturnSuccess, let pb = pixelBuffer else { return }
|
||||
|
||||
// Fill with black
|
||||
CVPixelBufferLockBaseAddress(pb, [])
|
||||
if let base = CVPixelBufferGetBaseAddress(pb) {
|
||||
memset(base, 0, CVPixelBufferGetDataSize(pb))
|
||||
}
|
||||
CVPixelBufferUnlockBaseAddress(pb, [])
|
||||
|
||||
// Use current timebase time for PTS (if available) so the frame isn't stale
|
||||
let pts: CMTime
|
||||
if let tb = sampleBufferLayer.controlTimebase {
|
||||
pts = CMTimebaseGetTime(tb)
|
||||
} else {
|
||||
pts = CMTime(value: 0, timescale: 30)
|
||||
}
|
||||
|
||||
var timing = CMSampleTimingInfo(
|
||||
duration: CMTime(value: 1, timescale: 30),
|
||||
presentationTimeStamp: pts,
|
||||
decodeTimeStamp: .invalid
|
||||
)
|
||||
|
||||
// Create format description and sample buffer
|
||||
var formatDesc: CMVideoFormatDescription?
|
||||
CMVideoFormatDescriptionCreateForImageBuffer(
|
||||
allocator: kCFAllocatorDefault,
|
||||
imageBuffer: pb,
|
||||
formatDescriptionOut: &formatDesc
|
||||
)
|
||||
guard let fmt = formatDesc else { return }
|
||||
|
||||
var sampleBuffer: CMSampleBuffer?
|
||||
CMSampleBufferCreateReadyWithImageBuffer(
|
||||
allocator: kCFAllocatorDefault,
|
||||
imageBuffer: pb,
|
||||
formatDescription: fmt,
|
||||
sampleTiming: &timing,
|
||||
sampleBufferOut: &sampleBuffer
|
||||
)
|
||||
guard let sb = sampleBuffer else { return }
|
||||
|
||||
// Set DisplayImmediately so it shows regardless of timebase timing
|
||||
if let attachments = CMSampleBufferGetSampleAttachmentsArray(sb, createIfNecessary: true) as? [NSMutableDictionary],
|
||||
let dict = attachments.first {
|
||||
dict[kCMSampleAttachmentKey_DisplayImmediately] = true
|
||||
}
|
||||
|
||||
sampleBufferLayer.enqueue(sb)
|
||||
}
|
||||
|
||||
/// Sync the layer's controlTimebase with the actual playback position.
|
||||
/// This makes the PiP progress bar show the correct time.
|
||||
/// MPVKit owns the sample-buffer layer timebase. PiP only reads it.
|
||||
func syncTimebase(currentTime: Double, isPlaying: Bool) {
|
||||
guard let timebase = sampleBufferLayer.controlTimebase else { return }
|
||||
let cmTime = CMTime(seconds: currentTime, preferredTimescale: 1000)
|
||||
CMTimebaseSetTime(timebase, time: cmTime)
|
||||
CMTimebaseSetRate(timebase, rate: isPlaying ? 1.0 : 0.0)
|
||||
}
|
||||
|
||||
/// Ensure the layer has a timebase and blank frame so the system considers
|
||||
/// PiP possible (required for canStartPictureInPictureAutomaticallyFromInline).
|
||||
/// Ensure the layer has MPVKit's renderer-owned timebase before PiP starts.
|
||||
func warmLayer(currentTime: Double, isPlaying: Bool) {
|
||||
if sampleBufferLayer.controlTimebase == nil {
|
||||
var timebase: CMTimebase?
|
||||
CMTimebaseCreateWithSourceClock(
|
||||
allocator: kCFAllocatorDefault,
|
||||
sourceClock: CMClockGetHostTimeClock(),
|
||||
timebaseOut: &timebase
|
||||
)
|
||||
if let tb = timebase {
|
||||
sampleBufferLayer.controlTimebase = tb
|
||||
}
|
||||
}
|
||||
syncTimebase(currentTime: currentTime, isPlaying: isPlaying)
|
||||
pushBlankFrame()
|
||||
if sampleBufferLayer?.controlTimebase == nil {
|
||||
print("[MpvPipController] Waiting for MPVKit renderer timebase before PiP")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
static var isSupported: Bool {
|
||||
guard #available(iOS 15.0, *) else { return false }
|
||||
return AVPictureInPictureController.isPictureInPictureSupported()
|
||||
guard #available(iOS 15.0, *) else { return false }
|
||||
return AVPictureInPictureController.isPictureInPictureSupported()
|
||||
}
|
||||
|
||||
/// Start PiP. When `waitForFrame` is false (auto-PiP), skips the frame
|
||||
/// readiness check since the scene is about to deactivate.
|
||||
func startPip(waitForFrame: Bool = true, completion: @escaping (Bool) -> Void) {
|
||||
guard let pipController = pipController else {
|
||||
completion(false)
|
||||
return
|
||||
guard let pipController = pipController else {
|
||||
completion(false)
|
||||
return
|
||||
}
|
||||
|
||||
var attempts = 0
|
||||
func tryStart() {
|
||||
let possible = pipController.isPictureInPicturePossible
|
||||
let hasTimebase = self.sampleBufferLayer?.controlTimebase != nil
|
||||
|
||||
let hasFrame: Bool
|
||||
if !waitForFrame {
|
||||
hasFrame = true // Skip frame check for auto-PiP
|
||||
} else if #available(iOS 17.4, *) {
|
||||
hasFrame = self.sampleBufferLayer?.isReadyForDisplay ?? false
|
||||
} else {
|
||||
hasFrame = true
|
||||
}
|
||||
|
||||
var attempts = 0
|
||||
func tryStart() {
|
||||
let possible = pipController.isPictureInPicturePossible
|
||||
let hasTimebase = sampleBufferLayer.controlTimebase != nil
|
||||
|
||||
let hasFrame: Bool
|
||||
if !waitForFrame {
|
||||
hasFrame = true // Skip frame check for auto-PiP
|
||||
} else if #available(iOS 17.4, *) {
|
||||
hasFrame = sampleBufferLayer.isReadyForDisplay
|
||||
} else {
|
||||
hasFrame = true
|
||||
}
|
||||
|
||||
if possible && hasTimebase && hasFrame {
|
||||
print("[MpvPipController] vo_pip ready after \(attempts) retries, starting PiP")
|
||||
pipController.startPictureInPicture()
|
||||
completion(true)
|
||||
} else if attempts < 40 {
|
||||
attempts += 1
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { tryStart() }
|
||||
} else {
|
||||
print("[MpvPipController] PiP not ready after \(attempts) retries (possible=\(possible), timebase=\(hasTimebase))")
|
||||
completion(false)
|
||||
}
|
||||
if possible && hasTimebase && hasFrame {
|
||||
print("[MpvPipController] vo_avfoundation ready after \(attempts) retries, starting PiP")
|
||||
pipController.startPictureInPicture()
|
||||
completion(true)
|
||||
} else if attempts < 40 {
|
||||
attempts += 1
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { tryStart() }
|
||||
} else {
|
||||
print(
|
||||
"[MpvPipController] PiP not ready after \(attempts) retries (possible=\(possible), timebase=\(hasTimebase))"
|
||||
)
|
||||
completion(false)
|
||||
}
|
||||
tryStart()
|
||||
}
|
||||
tryStart()
|
||||
}
|
||||
|
||||
func stopPip() {
|
||||
pipController?.stopPictureInPicture()
|
||||
pipController?.stopPictureInPicture()
|
||||
}
|
||||
|
||||
/// Invalidate the playback state so PiP updates its UI (play/pause button)
|
||||
func invalidatePlaybackState() {
|
||||
pipController?.invalidatePlaybackState()
|
||||
guard #available(iOS 15.0, *) else { return }
|
||||
pipController?.invalidatePlaybackState()
|
||||
}
|
||||
|
||||
/// Fully tear down PiP — removes the container view from the window and
|
||||
/// destroys the AVPictureInPictureController so the system can no longer
|
||||
/// trigger auto-PiP after the player is disposed.
|
||||
/// Fully tear down PiP without touching the shared inline display layer.
|
||||
func teardown() {
|
||||
pipController?.stopPictureInPicture()
|
||||
if #available(iOS 14.2, *) {
|
||||
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
|
||||
}
|
||||
pipController = nil
|
||||
delegateHelper = nil
|
||||
sampleBufferLayer.flushAndRemoveImage()
|
||||
sampleBufferLayer.controlTimebase = nil
|
||||
sampleBufferLayer.removeFromSuperlayer()
|
||||
containerView?.removeFromSuperview()
|
||||
containerView = nil
|
||||
pipController?.stopPictureInPicture()
|
||||
if #available(iOS 14.2, *) {
|
||||
pipController?.canStartPictureInPictureAutomaticallyFromInline = false
|
||||
}
|
||||
pipController = nil
|
||||
delegateHelper = nil
|
||||
}
|
||||
|
||||
/// Flush enqueued sample buffers from the layer to free video frame memory
|
||||
func flushLayer() {
|
||||
sampleBufferLayer.flushAndRemoveImage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PiP Delegate Helper (iOS 15+)
|
||||
// MARK: - PiP Delegate Helper (iOS 15+)
|
||||
|
||||
/// Separate class conforming to AVPictureInPictureControllerDelegate and
|
||||
/// AVPictureInPictureSampleBufferPlaybackDelegate since these require iOS 15+
|
||||
/// availability for the ContentSource-based delegate methods.
|
||||
@available(iOS 15.0, *)
|
||||
private class PipDelegateHelper: NSObject, AVPictureInPictureControllerDelegate,
|
||||
/// Separate class conforming to AVPictureInPictureControllerDelegate and
|
||||
/// AVPictureInPictureSampleBufferPlaybackDelegate since these require iOS 15+
|
||||
/// availability for the ContentSource-based delegate methods.
|
||||
@available(iOS 15.0, *)
|
||||
private class PipDelegateHelper: NSObject, AVPictureInPictureControllerDelegate,
|
||||
AVPictureInPictureSampleBufferPlaybackDelegate
|
||||
{
|
||||
{
|
||||
weak var controller: MpvPipController?
|
||||
private var isRestoring = false
|
||||
|
||||
init(controller: MpvPipController) {
|
||||
self.controller = controller
|
||||
super.init()
|
||||
self.controller = controller
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - AVPictureInPictureControllerDelegate
|
||||
|
||||
func pictureInPictureControllerWillStartPictureInPicture(
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
) {
|
||||
print("[MpvPipController] PiP will start")
|
||||
controller?.delegate?.pipWillStart()
|
||||
print("[MpvPipController] PiP will start")
|
||||
controller?.delegate?.pipWillStart()
|
||||
}
|
||||
|
||||
func pictureInPictureControllerDidStartPictureInPicture(
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
) {
|
||||
print("[MpvPipController] PiP did start")
|
||||
controller?.delegate?.pipDidStart()
|
||||
print("[MpvPipController] PiP did start")
|
||||
controller?.delegate?.pipDidStart()
|
||||
}
|
||||
|
||||
func pictureInPictureControllerDidStopPictureInPicture(
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
) {
|
||||
let restored = isRestoring
|
||||
isRestoring = false
|
||||
print("[MpvPipController] PiP did stop (restored: \(restored))")
|
||||
controller?.delegate?.pipDidStop(restored: restored)
|
||||
let restored = isRestoring
|
||||
isRestoring = false
|
||||
print("[MpvPipController] PiP did stop (restored: \(restored))")
|
||||
controller?.delegate?.pipDidStop(restored: restored)
|
||||
}
|
||||
|
||||
func pictureInPictureController(
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
failedToStartPictureInPictureWithError error: Error
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
failedToStartPictureInPictureWithError error: Error
|
||||
) {
|
||||
print("[MpvPipController] PiP failed to start: \(error)")
|
||||
controller?.delegate?.pipDidFailToStart(error: error)
|
||||
print("[MpvPipController] PiP failed to start: \(error)")
|
||||
controller?.delegate?.pipDidFailToStart(error: error)
|
||||
}
|
||||
|
||||
func pictureInPictureController(
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler:
|
||||
@escaping (Bool) -> Void
|
||||
) {
|
||||
print("[MpvPipController] PiP restore user interface")
|
||||
isRestoring = true
|
||||
completionHandler(true)
|
||||
print("[MpvPipController] PiP restore user interface")
|
||||
isRestoring = true
|
||||
completionHandler(true)
|
||||
}
|
||||
|
||||
func pictureInPictureControllerWillStopPictureInPicture(
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
) {
|
||||
print("[MpvPipController] PiP will stop")
|
||||
print("[MpvPipController] PiP will stop")
|
||||
}
|
||||
|
||||
// MARK: - AVPictureInPictureSampleBufferPlaybackDelegate
|
||||
|
||||
func pictureInPictureController(
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
setPlaying playing: Bool
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
setPlaying playing: Bool
|
||||
) {
|
||||
print("[MpvPipController] PiP setPlaying: \(playing)")
|
||||
controller?.delegate?.pipSetPlaying(playing)
|
||||
print("[MpvPipController] PiP setPlaying: \(playing)")
|
||||
controller?.delegate?.pipSetPlaying(playing)
|
||||
}
|
||||
|
||||
func pictureInPictureControllerTimeRangeForPlayback(
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
) -> CMTimeRange {
|
||||
let duration = controller?.delegate?.pipDuration ?? 0
|
||||
if duration > 0 {
|
||||
return CMTimeRange(
|
||||
start: .zero,
|
||||
duration: CMTime(seconds: duration, preferredTimescale: 1000)
|
||||
)
|
||||
}
|
||||
return CMTimeRange(start: .zero, duration: CMTime(seconds: 1, preferredTimescale: 1))
|
||||
let duration = controller?.delegate?.pipDuration ?? 0
|
||||
if duration > 0 {
|
||||
return CMTimeRange(
|
||||
start: .zero,
|
||||
duration: CMTime(seconds: duration, preferredTimescale: 1000)
|
||||
)
|
||||
}
|
||||
return CMTimeRange(start: .zero, duration: CMTime(seconds: 1, preferredTimescale: 1))
|
||||
}
|
||||
|
||||
func pictureInPictureControllerIsPlaybackPaused(
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
_ pictureInPictureController: AVPictureInPictureController
|
||||
) -> Bool {
|
||||
return !(controller?.delegate?.isPipPlaying ?? false)
|
||||
return !(controller?.delegate?.isPipPlaying ?? false)
|
||||
}
|
||||
|
||||
func pictureInPictureController(
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
didTransitionToRenderSize newRenderSize: CMVideoDimensions
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
didTransitionToRenderSize newRenderSize: CMVideoDimensions
|
||||
) {}
|
||||
|
||||
func pictureInPictureController(
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
skipByInterval skipInterval: CMTime,
|
||||
completion completionHandler: @escaping () -> Void
|
||||
_ pictureInPictureController: AVPictureInPictureController,
|
||||
skipByInterval skipInterval: CMTime,
|
||||
completion completionHandler: @escaping () -> Void
|
||||
) {
|
||||
let seconds = CMTimeGetSeconds(skipInterval)
|
||||
print("[MpvPipController] PiP skip by \(seconds)s")
|
||||
controller?.delegate?.pipSkip(byInterval: seconds)
|
||||
let seconds = CMTimeGetSeconds(skipInterval)
|
||||
print("[MpvPipController] PiP skip by \(seconds)s")
|
||||
guard let delegate = controller?.delegate else {
|
||||
completionHandler()
|
||||
return
|
||||
}
|
||||
delegate.pipSkip(byInterval: seconds, completion: completionHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif // !os(tvOS)
|
||||
|
||||
@@ -1,196 +1,638 @@
|
||||
import Libmpv
|
||||
import AVFoundation
|
||||
#if os(tvOS)
|
||||
import AVKit
|
||||
#endif
|
||||
import QuartzCore
|
||||
import UIKit
|
||||
|
||||
/// Core MPV player using Metal rendering for iOS.
|
||||
/// Core MPV player using AVFoundation sample-buffer rendering for iOS/tvOS.
|
||||
class MpvPlayerCore: MpvPlayerCoreBase {
|
||||
|
||||
private var containerView: UIView?
|
||||
private weak var window: UIWindow?
|
||||
private var containerView: UIView?
|
||||
private weak var window: UIWindow?
|
||||
private var mainBlankView: UIView?
|
||||
private var isVisible = false
|
||||
private var isDisposed = false
|
||||
private var activeDisplayCriteriaKey: String?
|
||||
|
||||
var isPipStarting = false
|
||||
var isPipStarting = false
|
||||
|
||||
func initialize(in window: UIWindow) -> Bool {
|
||||
guard !isInitialized else {
|
||||
print("[MpvPlayerCore] Already initialized")
|
||||
return true
|
||||
}
|
||||
|
||||
self.window = window
|
||||
|
||||
let container = UIView(frame: window.bounds)
|
||||
container.backgroundColor = .clear
|
||||
container.isUserInteractionEnabled = false
|
||||
|
||||
let layer = MpvMetalLayer()
|
||||
layer.frame = container.bounds
|
||||
layer.contentsScale = UIScreen.main.nativeScale
|
||||
layer.framebufferOnly = true
|
||||
layer.backgroundColor = UIColor.black.cgColor
|
||||
|
||||
container.layer.addSublayer(layer)
|
||||
containerView = container
|
||||
metalLayer = layer
|
||||
|
||||
window.insertSubview(container, at: 0)
|
||||
|
||||
guard setupMpv() else {
|
||||
print("[MpvPlayerCore] Failed to setup MPV")
|
||||
layer.removeFromSuperlayer()
|
||||
container.removeFromSuperview()
|
||||
metalLayer = nil
|
||||
containerView = nil
|
||||
return false
|
||||
}
|
||||
|
||||
setupNotifications()
|
||||
|
||||
isInitialized = true
|
||||
print("[MpvPlayerCore] Initialized successfully with MPV")
|
||||
return true
|
||||
func initialize(in window: UIWindow) -> Bool {
|
||||
guard !isInitialized else {
|
||||
print("[MpvPlayerCore] Already initialized")
|
||||
return true
|
||||
}
|
||||
|
||||
func switchToPipVO(layerPtr: UnsafeMutableRawPointer) -> Bool {
|
||||
guard let mpv else { return false }
|
||||
self.window = window
|
||||
|
||||
print("[MpvPlayerCore] Switching to pip VO for PiP")
|
||||
let container = UIView(frame: window.bounds)
|
||||
container.backgroundColor = .black
|
||||
container.isUserInteractionEnabled = false
|
||||
|
||||
metalLayer?.removeFromSuperlayer()
|
||||
let layer = MpvVideoLayer()
|
||||
layer.frame = container.bounds
|
||||
layer.contentsScale = window.screen.nativeScale
|
||||
layer.isOpaque = true
|
||||
layer.backgroundColor = UIColor.black.cgColor
|
||||
layer.videoGravity = .resizeAspect
|
||||
|
||||
mpv_set_property_string(mpv, "vid", "no")
|
||||
container.layer.addSublayer(layer)
|
||||
containerView = container
|
||||
videoLayer = layer
|
||||
|
||||
var pointer = Int64(Int(bitPattern: layerPtr))
|
||||
mpv_set_property(mpv, "wid", MPV_FORMAT_INT64, &pointer)
|
||||
window.insertSubview(container, at: 0)
|
||||
|
||||
mpv_set_property_string(mpv, "vo", "pip")
|
||||
mpv_set_property_string(mpv, "vid", "auto")
|
||||
|
||||
print("[MpvPlayerCore] Switched to pip VO successfully")
|
||||
return true
|
||||
guard setupMpv() else {
|
||||
print("[MpvPlayerCore] Failed to setup MPV")
|
||||
layer.removeFromSuperlayer()
|
||||
container.removeFromSuperview()
|
||||
videoLayer = nil
|
||||
containerView = nil
|
||||
return false
|
||||
}
|
||||
|
||||
func switchToGpuNextVO() -> Bool {
|
||||
guard let mpv, let metalLayer else { return false }
|
||||
setupNotifications()
|
||||
#if os(iOS)
|
||||
ExternalDisplayManager.shared.attach(core: self)
|
||||
#endif
|
||||
|
||||
print("[MpvPlayerCore] Switching back to gpu-next VO")
|
||||
isInitialized = true
|
||||
print("[MpvPlayerCore] Initialized successfully with MPV")
|
||||
return true
|
||||
}
|
||||
|
||||
mpv_set_property_string(mpv, "vid", "no")
|
||||
var sampleBufferDisplayLayer: MpvVideoLayer? { videoLayer }
|
||||
|
||||
var layer = metalLayer
|
||||
mpv_set_property(mpv, "wid", MPV_FORMAT_INT64, &layer)
|
||||
func setVisible(_ visible: Bool) {
|
||||
guard containerView != nil else { return }
|
||||
|
||||
applyGpuNextOptions()
|
||||
mpv_set_property_string(mpv, "vid", "auto")
|
||||
isVisible = visible
|
||||
if visible { refreshExternalDisplayAttachment() }
|
||||
setContainerHidden(!visible)
|
||||
if !visible { mainBlankView?.isHidden = true }
|
||||
}
|
||||
|
||||
if metalLayer.superlayer == nil, let containerView {
|
||||
containerView.layer.addSublayer(metalLayer)
|
||||
}
|
||||
func updateFrame(_ frame: CGRect? = nil) {
|
||||
guard let videoLayer, let containerView else { return }
|
||||
|
||||
print("[MpvPlayerCore] Switched back to gpu-next VO successfully")
|
||||
return true
|
||||
withoutLayerAnimations {
|
||||
if let frame {
|
||||
containerView.frame = frame
|
||||
videoLayer.frame = containerView.bounds
|
||||
} else if let superview = containerView.superview {
|
||||
containerView.frame = superview.bounds
|
||||
videoLayer.frame = containerView.bounds
|
||||
} else if let window {
|
||||
containerView.frame = window.bounds
|
||||
videoLayer.frame = containerView.bounds
|
||||
}
|
||||
|
||||
mainBlankView?.frame = window?.bounds ?? .zero
|
||||
|
||||
let screen = containerView.window?.screen ?? window?.screen ?? UIScreen.main
|
||||
let scale = screen.nativeScale > 0 ? screen.nativeScale : screen.scale
|
||||
videoLayer.contentsScale = scale
|
||||
}
|
||||
|
||||
func setVisible(_ visible: Bool) {
|
||||
guard let containerView else { return }
|
||||
#if os(iOS)
|
||||
updateEDRMode(sigPeak: lastSigPeak)
|
||||
#endif
|
||||
}
|
||||
|
||||
if visible {
|
||||
containerView.removeFromSuperview()
|
||||
window?.insertSubview(containerView, at: 0)
|
||||
}
|
||||
func externalDisplayDidChange() {
|
||||
refreshExternalDisplayAttachment()
|
||||
}
|
||||
|
||||
containerView.isHidden = !visible
|
||||
private func refreshExternalDisplayAttachment() {
|
||||
guard containerView != nil else { return }
|
||||
|
||||
let externalSuperview = externalVideoSuperview
|
||||
|
||||
if let externalSuperview {
|
||||
moveContainerView(to: externalSuperview)
|
||||
setMainBlankViewVisible(true)
|
||||
} else if isVisible, let window {
|
||||
moveContainerView(to: window)
|
||||
setMainBlankViewVisible(false)
|
||||
} else {
|
||||
setMainBlankViewVisible(false)
|
||||
}
|
||||
|
||||
func updateFrame(_ frame: CGRect? = nil) {
|
||||
guard let metalLayer, let containerView else { return }
|
||||
setContainerHidden(!isVisible)
|
||||
updateFrame()
|
||||
}
|
||||
|
||||
if let frame {
|
||||
containerView.frame = frame
|
||||
metalLayer.frame = containerView.bounds
|
||||
} else if let window {
|
||||
containerView.frame = window.bounds
|
||||
metalLayer.frame = containerView.bounds
|
||||
private var externalVideoSuperview: UIView? {
|
||||
#if os(iOS)
|
||||
isVisible && !isPipActive && !isPipStarting
|
||||
? ExternalDisplayManager.shared.videoSuperview
|
||||
: nil
|
||||
#else
|
||||
nil
|
||||
#endif
|
||||
}
|
||||
|
||||
private func moveContainerView(to superview: UIView) {
|
||||
guard let containerView else { return }
|
||||
|
||||
withoutLayerAnimations {
|
||||
if containerView.superview !== superview {
|
||||
containerView.removeFromSuperview()
|
||||
superview.insertSubview(containerView, at: 0)
|
||||
} else if superview.subviews.first !== containerView {
|
||||
superview.insertSubview(containerView, at: 0)
|
||||
}
|
||||
|
||||
containerView.frame = superview.bounds
|
||||
containerView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
}
|
||||
}
|
||||
|
||||
private func setMainBlankViewVisible(_ visible: Bool) {
|
||||
guard visible, let window else {
|
||||
mainBlankView?.removeFromSuperview()
|
||||
mainBlankView = nil
|
||||
return
|
||||
}
|
||||
|
||||
let blankView = mainBlankView ?? UIView(frame: window.bounds)
|
||||
withoutLayerAnimations {
|
||||
blankView.backgroundColor = .black
|
||||
blankView.isUserInteractionEnabled = false
|
||||
blankView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
blankView.frame = window.bounds
|
||||
|
||||
if blankView.superview !== window {
|
||||
blankView.removeFromSuperview()
|
||||
window.insertSubview(blankView, at: 0)
|
||||
} else if window.subviews.first !== blankView {
|
||||
window.insertSubview(blankView, at: 0)
|
||||
}
|
||||
|
||||
blankView.isHidden = false
|
||||
}
|
||||
mainBlankView = blankView
|
||||
}
|
||||
|
||||
private func setContainerHidden(_ hidden: Bool) {
|
||||
withoutLayerAnimations {
|
||||
containerView?.isHidden = hidden
|
||||
}
|
||||
}
|
||||
|
||||
private func withoutLayerAnimations(_ updates: () -> Void) {
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
updates()
|
||||
CATransaction.commit()
|
||||
}
|
||||
|
||||
/// Nudge mpv to present the current paused frame after leaving PiP.
|
||||
func forceDraw() {
|
||||
command(["seek", "0", "relative+exact"])
|
||||
}
|
||||
|
||||
private func restoreVideoPresentation() {
|
||||
guard !isPipActive else { return }
|
||||
|
||||
refreshExternalDisplayAttachment()
|
||||
recoverDisplayLayerIfNeeded()
|
||||
updateEDRMode(sigPeak: lastSigPeak)
|
||||
if isPaused { forceDraw() }
|
||||
}
|
||||
|
||||
private func recoverDisplayLayerIfNeeded() {
|
||||
guard let videoLayer else { return }
|
||||
|
||||
var requiresFlush = false
|
||||
if #available(iOS 14.0, tvOS 14.0, *) {
|
||||
requiresFlush = videoLayer.requiresFlushToResumeDecoding
|
||||
}
|
||||
let status = videoLayer.status
|
||||
guard requiresFlush || status == .failed else { return }
|
||||
|
||||
videoLayer.flush()
|
||||
}
|
||||
|
||||
override func updateEDRMode(sigPeak: Double) {
|
||||
#if os(iOS)
|
||||
guard let videoLayer else { return }
|
||||
|
||||
let shouldEnableEDR = hdrEnabled && sigPeak > 1.0
|
||||
if #available(iOS 26.0, *) {
|
||||
withoutLayerAnimations {
|
||||
videoLayer.preferredDynamicRange = shouldEnableEDR ? .high : .standard
|
||||
}
|
||||
} else if #available(iOS 17.0, *) {
|
||||
withoutLayerAnimations {
|
||||
videoLayer.wantsExtendedDynamicRangeContent = shouldEnableEDR
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
let scale = UIScreen.main.nativeScale
|
||||
metalLayer.drawableSize = CGSize(
|
||||
width: metalLayer.frame.width * scale,
|
||||
height: metalLayer.frame.height * scale
|
||||
@discardableResult
|
||||
override func updateDisplayCriteria(
|
||||
doviProfile: Int64,
|
||||
doviLevel: Int64,
|
||||
doviCompatibilityId: Int64?,
|
||||
fps: Double,
|
||||
width: Int32,
|
||||
height: Int32,
|
||||
sigPeak: Double,
|
||||
gamma: String?,
|
||||
primaries: String?,
|
||||
colorMatrix: String?
|
||||
) -> Bool {
|
||||
#if os(tvOS)
|
||||
guard let window = containerView?.window ?? self.window else { return false }
|
||||
let displayManager = window.avDisplayManager
|
||||
|
||||
if width <= 0 || height <= 0 {
|
||||
clearDisplayCriteria(displayManager, reason: "no video dimensions")
|
||||
return false
|
||||
}
|
||||
|
||||
let refreshRate = Float(fps > 0 ? fps : 0)
|
||||
let sourceHasDolbyVision = doviProfile > 0
|
||||
guard sourceHasDolbyVision || sigPeak > 0 || gamma != nil || primaries != nil || colorMatrix != nil else {
|
||||
clearDisplayCriteria(displayManager, reason: "no display metadata")
|
||||
return false
|
||||
}
|
||||
|
||||
let sourceBaseRange = Self.resolveBaseDisplayDynamicRange(
|
||||
sigPeak: sigPeak,
|
||||
gamma: gamma,
|
||||
primaries: primaries,
|
||||
colorMatrix: colorMatrix,
|
||||
doviCompatibilityId: doviCompatibilityId
|
||||
)
|
||||
let sourceRange: DisplayDynamicRange = sourceHasDolbyVision ? .dolbyVision : sourceBaseRange
|
||||
// The HDR toggle (`hdrEnabled`) is authoritative on tvOS: when it's off,
|
||||
// drive the HDMI link in SDR regardless of the source — Dolby Vision
|
||||
// included — so turning HDR off actually leaves DV mode (issue #1262).
|
||||
// This is the only path that gates the HDMI mode on tvOS;
|
||||
// target-colorspace-hint is inert in the avfoundation VO and EDR is iOS.
|
||||
var displayRange: DisplayDynamicRange
|
||||
if !hdrEnabled {
|
||||
displayRange = .sdr
|
||||
} else if sourceHasDolbyVision {
|
||||
displayRange = .dolbyVision
|
||||
} else {
|
||||
displayRange = Self.supportedDisplayDynamicRange(for: sourceBaseRange)
|
||||
}
|
||||
guard displayManager.isDisplayCriteriaMatchingEnabled else {
|
||||
clearDisplayCriteria(displayManager, reason: "matching disabled")
|
||||
return false
|
||||
}
|
||||
guard #available(tvOS 17.0, *) else {
|
||||
clearDisplayCriteria(displayManager, reason: "display criteria unavailable")
|
||||
return false
|
||||
}
|
||||
|
||||
var formatDescription = Self.makeDisplayFormatDescription(
|
||||
dynamicRange: displayRange,
|
||||
width: width,
|
||||
height: height,
|
||||
doviProfile: doviProfile,
|
||||
doviLevel: doviLevel,
|
||||
doviCompatibilityId: doviCompatibilityId)
|
||||
if formatDescription == nil, sourceHasDolbyVision, hdrEnabled {
|
||||
displayRange = sourceBaseRange
|
||||
formatDescription = Self.makeDisplayFormatDescription(
|
||||
dynamicRange: displayRange,
|
||||
width: width,
|
||||
height: height,
|
||||
doviProfile: doviProfile,
|
||||
doviLevel: doviLevel,
|
||||
doviCompatibilityId: doviCompatibilityId)
|
||||
}
|
||||
|
||||
guard let formatDescription else {
|
||||
clearDisplayCriteria(displayManager, reason: "format description failed")
|
||||
return false
|
||||
}
|
||||
|
||||
let criteriaKey =
|
||||
"\(displayRange.rawValue)|\(refreshRate)|\(width)x\(height)|\(doviProfile)|\(doviLevel)|\(doviCompatibilityId ?? -1)"
|
||||
if activeDisplayCriteriaKey == criteriaKey { return true }
|
||||
|
||||
displayManager.preferredDisplayCriteria = AVDisplayCriteria(
|
||||
refreshRate: refreshRate,
|
||||
formatDescription: formatDescription
|
||||
)
|
||||
activeDisplayCriteriaKey = criteriaKey
|
||||
print(
|
||||
"[MpvPlayerCore] preferredDisplayCriteria set to \(displayRange.rawValue) (source: \(sourceRange.rawValue), fps: \(refreshRate), \(width)x\(height), DV profile: \(doviProfile), level: \(doviLevel), compat: \(doviCompatibilityId ?? -1))"
|
||||
)
|
||||
return true
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
private enum DisplayDynamicRange: String {
|
||||
case sdr = "SDR"
|
||||
case hdr10 = "HDR10"
|
||||
case hlg = "HLG"
|
||||
case dolbyVision = "Dolby Vision"
|
||||
}
|
||||
|
||||
private func clearDisplayCriteria(_ displayManager: AVDisplayManager, reason: String) {
|
||||
if activeDisplayCriteriaKey != nil || displayManager.preferredDisplayCriteria != nil {
|
||||
displayManager.preferredDisplayCriteria = nil
|
||||
activeDisplayCriteriaKey = nil
|
||||
print("[MpvPlayerCore] preferredDisplayCriteria cleared (\(reason))")
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveBaseDisplayDynamicRange(
|
||||
sigPeak: Double,
|
||||
gamma: String?,
|
||||
primaries: String?,
|
||||
colorMatrix: String?,
|
||||
doviCompatibilityId: Int64?
|
||||
) -> DisplayDynamicRange {
|
||||
let normalizedGamma = normalizeColorTag(gamma)
|
||||
let normalizedPrimaries = normalizeColorTag(primaries)
|
||||
let normalizedColorMatrix = normalizeColorTag(colorMatrix)
|
||||
|
||||
if normalizedGamma.contains("hlg") || normalizedGamma.contains("arib") {
|
||||
return .hlg
|
||||
}
|
||||
if normalizedGamma.contains("pq") || normalizedGamma.contains("smpte2084")
|
||||
|| normalizedGamma.contains("st2084") || sigPeak > 1.0
|
||||
|| normalizedPrimaries.contains("bt2020") || normalizedColorMatrix.contains("bt2020")
|
||||
{
|
||||
return .hdr10
|
||||
}
|
||||
switch doviCompatibilityId {
|
||||
case 1, 6:
|
||||
return .hdr10
|
||||
case 4:
|
||||
return .hlg
|
||||
case 2:
|
||||
return .sdr
|
||||
default:
|
||||
break
|
||||
}
|
||||
return .sdr
|
||||
}
|
||||
|
||||
private static func normalizeColorTag(_ value: String?) -> String {
|
||||
value?.lowercased().filter { $0.isLetter || $0.isNumber } ?? ""
|
||||
}
|
||||
|
||||
private static func supportedDisplayDynamicRange(for range: DisplayDynamicRange) -> DisplayDynamicRange {
|
||||
let availableModes = AVPlayer.availableHDRModes
|
||||
switch range {
|
||||
case .dolbyVision:
|
||||
if availableModes.contains(.dolbyVision) { return .dolbyVision }
|
||||
if availableModes.contains(.hdr10) { return .hdr10 }
|
||||
if availableModes.contains(.hlg) { return .hlg }
|
||||
return .sdr
|
||||
case .hdr10:
|
||||
return availableModes.contains(.hdr10) ? .hdr10 : .sdr
|
||||
case .hlg:
|
||||
return availableModes.contains(.hlg) ? .hlg : .sdr
|
||||
case .sdr:
|
||||
return .sdr
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeDisplayFormatDescription(
|
||||
dynamicRange: DisplayDynamicRange,
|
||||
width: Int32,
|
||||
height: Int32,
|
||||
doviProfile: Int64,
|
||||
doviLevel: Int64,
|
||||
doviCompatibilityId: Int64?
|
||||
) -> CMVideoFormatDescription? {
|
||||
if dynamicRange == .dolbyVision {
|
||||
// Profile 8.x always carries a compatibility id; profile 5 has none.
|
||||
// We assume bl_signal_compatibility_id = 1 (HDR10 base) for profile 8
|
||||
// because mpv does not expose the compat id and that's by far the
|
||||
// most common case.
|
||||
let fallbackCompat: Int64 = doviProfile == 8 ? 1 : 0
|
||||
let compat = UInt8(truncatingIfNeeded: doviCompatibilityId ?? fallbackCompat)
|
||||
return makeDolbyVisionFormatDescription(
|
||||
width: width,
|
||||
height: height,
|
||||
profile: UInt8(truncatingIfNeeded: doviProfile),
|
||||
level: UInt8(truncatingIfNeeded: doviLevel),
|
||||
compatibility: compat
|
||||
)
|
||||
}
|
||||
|
||||
let extensions: [CFString: Any]
|
||||
switch dynamicRange {
|
||||
case .hdr10:
|
||||
extensions = [
|
||||
kCMFormatDescriptionExtension_ColorPrimaries:
|
||||
kCMFormatDescriptionColorPrimaries_ITU_R_2020,
|
||||
kCMFormatDescriptionExtension_TransferFunction:
|
||||
kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ,
|
||||
kCMFormatDescriptionExtension_YCbCrMatrix:
|
||||
kCMFormatDescriptionYCbCrMatrix_ITU_R_2020,
|
||||
]
|
||||
case .hlg:
|
||||
extensions = [
|
||||
kCMFormatDescriptionExtension_ColorPrimaries:
|
||||
kCMFormatDescriptionColorPrimaries_ITU_R_2020,
|
||||
kCMFormatDescriptionExtension_TransferFunction:
|
||||
kCMFormatDescriptionTransferFunction_ITU_R_2100_HLG,
|
||||
kCMFormatDescriptionExtension_YCbCrMatrix:
|
||||
kCMFormatDescriptionYCbCrMatrix_ITU_R_2020,
|
||||
]
|
||||
case .sdr:
|
||||
extensions = [
|
||||
kCMFormatDescriptionExtension_ColorPrimaries:
|
||||
kCMFormatDescriptionColorPrimaries_ITU_R_709_2,
|
||||
kCMFormatDescriptionExtension_TransferFunction:
|
||||
kCMFormatDescriptionTransferFunction_ITU_R_709_2,
|
||||
kCMFormatDescriptionExtension_YCbCrMatrix:
|
||||
kCMFormatDescriptionYCbCrMatrix_ITU_R_709_2,
|
||||
]
|
||||
case .dolbyVision:
|
||||
return nil
|
||||
}
|
||||
|
||||
var fd: CMVideoFormatDescription?
|
||||
let status = CMVideoFormatDescriptionCreate(
|
||||
allocator: kCFAllocatorDefault,
|
||||
codecType: kCMVideoCodecType_HEVC,
|
||||
width: width,
|
||||
height: height,
|
||||
extensions: extensions as CFDictionary,
|
||||
formatDescriptionOut: &fd
|
||||
)
|
||||
return status == noErr ? fd : nil
|
||||
}
|
||||
|
||||
/// Nudge mpv to present the current paused frame after switching back from PiP.
|
||||
func forceDraw() {
|
||||
command(["seek", "0", "relative+exact"])
|
||||
/// Build a synthetic 'dvh1' `CMVideoFormatDescription` from the Dolby Vision
|
||||
/// metadata mpv exposes. Used solely as a hint object for
|
||||
/// `AVDisplayCriteria(refreshRate:formatDescription:)` — it is never
|
||||
/// enqueued onto the sample-buffer layer.
|
||||
private static func makeDolbyVisionFormatDescription(
|
||||
width: Int32,
|
||||
height: Int32,
|
||||
profile: UInt8,
|
||||
level: UInt8,
|
||||
compatibility: UInt8
|
||||
) -> CMVideoFormatDescription? {
|
||||
// 24-byte Dolby Vision configuration record (dvcC ≤ profile 7, dvvC ≥ 8).
|
||||
// Layout from ETSI TS 103 572 §7.1.1 — same packing as FFmpeg's
|
||||
// videotoolbox_dovi_extradata_create (in 0002 patch):
|
||||
// [0] dv_version_major (= 1)
|
||||
// [1] dv_version_minor (= 0)
|
||||
// [2..3] big-endian uint16: profile<<9 | level<<3 | rpu<<2 | el<<1 | bl
|
||||
// [4] compatibility<<4 | md_compression<<2
|
||||
// [5..23] reserved zero
|
||||
var dovi = [UInt8](repeating: 0, count: 24)
|
||||
dovi[0] = 1
|
||||
dovi[1] = 0
|
||||
let flags: UInt16 =
|
||||
(UInt16(profile) & 0x7f) << 9
|
||||
| (UInt16(level) & 0x3f) << 3
|
||||
| (1 << 2) // rpu_present_flag
|
||||
| (1 << 0) // bl_present_flag
|
||||
dovi[2] = UInt8((flags >> 8) & 0xff)
|
||||
dovi[3] = UInt8(flags & 0xff)
|
||||
dovi[4] = (compatibility & 0x0f) << 4
|
||||
|
||||
// CoreMedia carries codec-specific boxes under
|
||||
// kCMFormatDescriptionExtension_SampleDescriptionExtensionAtoms.
|
||||
let recordKey: CFString = (profile > 7 ? "dvvC" : "dvcC") as CFString
|
||||
let atoms: [CFString: Any] = [recordKey: Data(dovi) as CFData]
|
||||
|
||||
let extensions: [CFString: Any] = [
|
||||
kCMFormatDescriptionExtension_SampleDescriptionExtensionAtoms: atoms as CFDictionary,
|
||||
kCMFormatDescriptionExtension_ColorPrimaries:
|
||||
kCMFormatDescriptionColorPrimaries_ITU_R_2020,
|
||||
kCMFormatDescriptionExtension_TransferFunction:
|
||||
kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ,
|
||||
kCMFormatDescriptionExtension_YCbCrMatrix:
|
||||
kCMFormatDescriptionYCbCrMatrix_ITU_R_2020,
|
||||
]
|
||||
|
||||
var fd: CMVideoFormatDescription?
|
||||
let status = CMVideoFormatDescriptionCreate(
|
||||
allocator: kCFAllocatorDefault,
|
||||
codecType: kCMVideoCodecType_DolbyVisionHEVC, // 'dvh1'
|
||||
width: width,
|
||||
height: height,
|
||||
extensions: extensions as CFDictionary,
|
||||
formatDescriptionOut: &fd
|
||||
)
|
||||
return status == noErr ? fd : nil
|
||||
}
|
||||
#endif
|
||||
|
||||
func dispose() {
|
||||
// Guard double-dispose: the plugin calls dispose() then drops the
|
||||
// strong ref, which fires deinit → dispose() again. The second call
|
||||
// would re-enter and crash on weak-ref formation during dealloc.
|
||||
guard !isDisposed else { return }
|
||||
isDisposed = true
|
||||
|
||||
// Reset the HDMI mode hint synchronously while self is still alive
|
||||
// and on main. An async-to-main dispatch here would be drained after
|
||||
// dealloc (the plugin sets playerCore = nil right after this call
|
||||
// returns), leaving the link stuck at the last clip's refresh rate.
|
||||
updateDisplayCriteria(
|
||||
doviProfile: 0, doviLevel: 0, doviCompatibilityId: nil,
|
||||
fps: 0, width: 0, height: 0, sigPeak: 0,
|
||||
gamma: nil, primaries: nil, colorMatrix: nil)
|
||||
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
#if os(iOS)
|
||||
ExternalDisplayManager.shared.detach(core: self)
|
||||
#endif
|
||||
disposeSharedState(destroySynchronously: false)
|
||||
|
||||
videoLayer?.removeFromSuperlayer()
|
||||
videoLayer = nil
|
||||
containerView?.removeFromSuperview()
|
||||
containerView = nil
|
||||
mainBlankView?.removeFromSuperview()
|
||||
mainBlankView = nil
|
||||
isInitialized = false
|
||||
print("[MpvPlayerCore] Disposed")
|
||||
}
|
||||
|
||||
deinit {
|
||||
dispose()
|
||||
}
|
||||
|
||||
private func setupNotifications() {
|
||||
#if os(iOS)
|
||||
let scene = window?.windowScene
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(enterBackground),
|
||||
name: UIScene.didEnterBackgroundNotification,
|
||||
object: scene
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(enterForeground),
|
||||
name: UIScene.willEnterForegroundNotification,
|
||||
object: scene
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(sceneDidActivate),
|
||||
name: UIScene.didActivateNotification,
|
||||
object: scene
|
||||
)
|
||||
#else
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(enterBackground),
|
||||
name: UIApplication.didEnterBackgroundNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(enterForeground),
|
||||
name: UIApplication.willEnterForegroundNotification,
|
||||
object: nil
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
@objc private func enterBackground() {
|
||||
isBackgrounded = true
|
||||
if isPipActive || isPipStarting {
|
||||
print("[MpvPlayerCore] Entering background - PiP active/starting, keeping video")
|
||||
return
|
||||
}
|
||||
|
||||
override func updateEDRMode(sigPeak: Double) {
|
||||
guard let metalLayer else { return }
|
||||
print("[MpvPlayerCore] Entering background - disabling video")
|
||||
setProperty("vid", value: "no")
|
||||
}
|
||||
|
||||
var edrHeadroom: CGFloat = 1.0
|
||||
if #available(iOS 16.0, *) {
|
||||
edrHeadroom = containerView?.window?.screen.potentialEDRHeadroom ?? 1.0
|
||||
metalLayer.wantsExtendedDynamicRangeContent =
|
||||
hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0
|
||||
}
|
||||
|
||||
let shouldEnableEDR = hdrEnabled && sigPeak > 1.0 && edrHeadroom > 1.0
|
||||
print(
|
||||
"[MpvPlayerCore] EDR mode: \(shouldEnableEDR) (hdrEnabled: \(hdrEnabled), sigPeak: \(sigPeak), headroom: \(edrHeadroom))"
|
||||
)
|
||||
@objc private func enterForeground() {
|
||||
isBackgrounded = false
|
||||
if isPipActive {
|
||||
print("[MpvPlayerCore] Entering foreground - PiP active, skipping vid restore")
|
||||
return
|
||||
}
|
||||
|
||||
func dispose() {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
disposeSharedState(destroySynchronously: false)
|
||||
print("[MpvPlayerCore] Entering foreground - enabling video")
|
||||
setProperty("vid", value: "auto")
|
||||
}
|
||||
|
||||
metalLayer?.removeFromSuperlayer()
|
||||
metalLayer = nil
|
||||
containerView?.removeFromSuperview()
|
||||
containerView = nil
|
||||
isInitialized = false
|
||||
print("[MpvPlayerCore] Disposed")
|
||||
}
|
||||
|
||||
deinit {
|
||||
dispose()
|
||||
}
|
||||
|
||||
private func setupNotifications() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(enterBackground),
|
||||
name: UIApplication.didEnterBackgroundNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(enterForeground),
|
||||
name: UIApplication.willEnterForegroundNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func enterBackground() {
|
||||
if isPipActive || isPipStarting {
|
||||
print("[MpvPlayerCore] Entering background - PiP active/starting, keeping video")
|
||||
return
|
||||
}
|
||||
|
||||
print("[MpvPlayerCore] Entering background - disabling video")
|
||||
if mpv != nil {
|
||||
mpv_set_option_string(mpv, "vid", "no")
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func enterForeground() {
|
||||
if isPipActive {
|
||||
print("[MpvPlayerCore] Entering foreground - PiP active, skipping vid restore")
|
||||
return
|
||||
}
|
||||
|
||||
print("[MpvPlayerCore] Entering foreground - enabling video")
|
||||
if mpv != nil {
|
||||
mpv_set_option_string(mpv, "vid", "auto")
|
||||
}
|
||||
#if os(iOS)
|
||||
@objc private func sceneDidActivate() {
|
||||
isBackgrounded = false
|
||||
if isPipActive {
|
||||
return
|
||||
}
|
||||
|
||||
setProperty("vid", value: "auto")
|
||||
restoreVideoPresentation()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -5,392 +5,521 @@ import AVKit
|
||||
/// Flutter plugin that bridges MPV player to Dart via method and event channels
|
||||
class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginShared {
|
||||
|
||||
// MARK: - Properties
|
||||
// MARK: - Properties
|
||||
|
||||
private var playerCore: MpvPlayerCore?
|
||||
var eventSink: FlutterEventSink?
|
||||
private weak var registrar: FlutterPluginRegistrar?
|
||||
var nameToId: [String: Int] = [:]
|
||||
private var playerCore: MpvPlayerCore?
|
||||
var eventSink: FlutterEventSink?
|
||||
private weak var registrar: FlutterPluginRegistrar?
|
||||
var nameToId: [String: Int] = [:]
|
||||
|
||||
// MpvPluginShared conformance
|
||||
var coreBase: MpvPlayerCoreBase? { playerCore }
|
||||
func setPlayerVisible(_ visible: Bool) { playerCore?.setVisible(visible) }
|
||||
func updatePlayerFrame() { playerCore?.updateFrame() }
|
||||
// MpvPluginShared conformance
|
||||
var coreBase: MpvPlayerCoreBase? { playerCore }
|
||||
func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible _: Bool) { playerCore?.setVisible(visible) }
|
||||
func updatePlayerFrame() { playerCore?.updateFrame() }
|
||||
|
||||
// PiP
|
||||
private var pipController: MpvPipController?
|
||||
private var pipChannel: FlutterMethodChannel?
|
||||
private var autoPipEnabled = false
|
||||
private var isManualPipRequest = false
|
||||
private var pipTimebaseSyncTimer: Timer?
|
||||
private var pendingInlineRestoreAfterPip = false
|
||||
private var sceneActivationObserverRegistered = false
|
||||
// PiP
|
||||
private var pipController: MpvPipController?
|
||||
private var pipChannel: FlutterMethodChannel?
|
||||
private var autoPipEnabled = false
|
||||
private var isManualPipRequest = false
|
||||
private var pipTimebaseSyncTimer: Timer?
|
||||
private var pendingInlineRestoreAfterPip = false
|
||||
private var sceneActivationObserverRegistered = false
|
||||
|
||||
// MARK: - FlutterPlugin Registration
|
||||
// MARK: - FlutterPlugin Registration
|
||||
|
||||
static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let methodChannel = FlutterMethodChannel(
|
||||
name: "com.plezy/mpv_player",
|
||||
binaryMessenger: registrar.messenger()
|
||||
)
|
||||
let eventChannel = FlutterEventChannel(
|
||||
name: "com.plezy/mpv_player/events",
|
||||
binaryMessenger: registrar.messenger()
|
||||
)
|
||||
let pipChannel = FlutterMethodChannel(
|
||||
name: "com.plezy/pip",
|
||||
binaryMessenger: registrar.messenger()
|
||||
)
|
||||
static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let methodChannel = FlutterMethodChannel(
|
||||
name: "com.plezy/mpv_player",
|
||||
binaryMessenger: registrar.messenger()
|
||||
)
|
||||
let eventChannel = FlutterEventChannel(
|
||||
name: "com.plezy/mpv_player/events",
|
||||
binaryMessenger: registrar.messenger()
|
||||
)
|
||||
let pipChannel = FlutterMethodChannel(
|
||||
name: "com.plezy/pip",
|
||||
binaryMessenger: registrar.messenger()
|
||||
)
|
||||
|
||||
let instance = MpvPlayerPlugin()
|
||||
instance.registrar = registrar
|
||||
instance.pipChannel = pipChannel
|
||||
let instance = MpvPlayerPlugin()
|
||||
instance.registrar = registrar
|
||||
instance.pipChannel = pipChannel
|
||||
|
||||
registrar.addMethodCallDelegate(instance, channel: methodChannel)
|
||||
eventChannel.setStreamHandler(instance)
|
||||
pipChannel.setMethodCallHandler(instance.handlePipCall)
|
||||
registrar.addMethodCallDelegate(instance, channel: methodChannel)
|
||||
eventChannel.setStreamHandler(instance)
|
||||
pipChannel.setMethodCallHandler(instance.handlePipCall)
|
||||
}
|
||||
|
||||
// MARK: - FlutterStreamHandler
|
||||
|
||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
|
||||
-> FlutterError?
|
||||
{
|
||||
self.eventSink = events
|
||||
return nil
|
||||
}
|
||||
|
||||
func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
||||
self.eventSink = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - FlutterPlugin Method Handler
|
||||
|
||||
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "initialize":
|
||||
handleInitialize(result: result)
|
||||
case "dispose":
|
||||
handleDispose(result: result)
|
||||
case "setProperty":
|
||||
handleSetProperty(call: call, result: result)
|
||||
case "getProperty":
|
||||
handleGetProperty(call: call, result: result)
|
||||
case "observeProperty":
|
||||
handleObserveProperty(call: call, result: result)
|
||||
case "command":
|
||||
handleCommand(call: call, result: result)
|
||||
case "setDisplayCriteria":
|
||||
handleSetDisplayCriteria(call: call, result: result)
|
||||
case "setVisible":
|
||||
handleSetVisible(call: call, result: result)
|
||||
case "isInitialized":
|
||||
result(playerCore?.isInitialized ?? false)
|
||||
case "updateFrame":
|
||||
handleUpdateFrame(result: result)
|
||||
case "setLogLevel":
|
||||
handleSetLogLevel(call: call, result: result)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - FlutterStreamHandler
|
||||
// MARK: - PiP
|
||||
|
||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
|
||||
self.eventSink = events
|
||||
return nil
|
||||
private func ensurePipController() -> MpvPipController? {
|
||||
if let existing = pipController { return existing }
|
||||
guard let layer = playerCore?.sampleBufferDisplayLayer else { return nil }
|
||||
let controller = MpvPipController(sampleBufferDisplayLayer: layer)
|
||||
controller.delegate = self
|
||||
pipController = controller
|
||||
return controller
|
||||
}
|
||||
|
||||
private func registerSceneActivationObserver() {
|
||||
guard !sceneActivationObserverRegistered else { return }
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(sceneDidActivate),
|
||||
name: UIScene.didActivateNotification,
|
||||
object: nil
|
||||
)
|
||||
sceneActivationObserverRegistered = true
|
||||
}
|
||||
|
||||
private func unregisterSceneActivationObserver() {
|
||||
guard sceneActivationObserverRegistered else { return }
|
||||
NotificationCenter.default.removeObserver(
|
||||
self, name: UIScene.didActivateNotification, object: nil)
|
||||
sceneActivationObserverRegistered = false
|
||||
}
|
||||
|
||||
private var isSceneActive: Bool {
|
||||
#if os(iOS)
|
||||
ExternalDisplayManager.hasActiveApplicationScene
|
||||
#else
|
||||
UIApplication.shared.connectedScenes.contains { $0.activationState == .foregroundActive }
|
||||
#endif
|
||||
}
|
||||
|
||||
private func restoreInlinePlayerAfterPip() {
|
||||
guard pendingInlineRestoreAfterPip,
|
||||
let playerCore = playerCore,
|
||||
!playerCore.isPipActive,
|
||||
!playerCore.isPipStarting
|
||||
else { return }
|
||||
|
||||
print("[MpvPlayerPlugin] Restoring inline player after PiP")
|
||||
playerCore.setVisible(true)
|
||||
playerCore.updateFrame()
|
||||
if playerCore.isPaused {
|
||||
playerCore.forceDraw()
|
||||
}
|
||||
pendingInlineRestoreAfterPip = false
|
||||
}
|
||||
|
||||
func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
||||
self.eventSink = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - FlutterPlugin Method Handler
|
||||
|
||||
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "initialize":
|
||||
handleInitialize(result: result)
|
||||
case "dispose":
|
||||
handleDispose(result: result)
|
||||
case "setProperty":
|
||||
handleSetProperty(call: call, result: result)
|
||||
case "getProperty":
|
||||
handleGetProperty(call: call, result: result)
|
||||
case "observeProperty":
|
||||
handleObserveProperty(call: call, result: result)
|
||||
case "command":
|
||||
handleCommand(call: call, result: result)
|
||||
case "setVisible":
|
||||
handleSetVisible(call: call, result: result)
|
||||
case "isInitialized":
|
||||
result(playerCore?.isInitialized ?? false)
|
||||
case "updateFrame":
|
||||
handleUpdateFrame(result: result)
|
||||
case "setLogLevel":
|
||||
handleSetLogLevel(call: call, result: result)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PiP
|
||||
|
||||
private func ensurePipController() -> MpvPipController {
|
||||
if let existing = pipController { return existing }
|
||||
let controller = MpvPipController()
|
||||
controller.delegate = self
|
||||
pipController = controller
|
||||
return controller
|
||||
}
|
||||
|
||||
private func registerSceneActivationObserver() {
|
||||
guard !sceneActivationObserverRegistered else { return }
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(sceneDidActivate),
|
||||
name: UIScene.didActivateNotification,
|
||||
object: nil
|
||||
)
|
||||
sceneActivationObserverRegistered = true
|
||||
}
|
||||
|
||||
private func unregisterSceneActivationObserver() {
|
||||
guard sceneActivationObserverRegistered else { return }
|
||||
NotificationCenter.default.removeObserver(self, name: UIScene.didActivateNotification, object: nil)
|
||||
sceneActivationObserverRegistered = false
|
||||
}
|
||||
|
||||
private var isSceneActive: Bool {
|
||||
UIApplication.shared.connectedScenes.contains { $0.activationState == .foregroundActive }
|
||||
}
|
||||
|
||||
private func restoreInlinePlayerAfterPip() {
|
||||
guard pendingInlineRestoreAfterPip,
|
||||
let playerCore = playerCore,
|
||||
!playerCore.isPipActive,
|
||||
!playerCore.isPipStarting else { return }
|
||||
|
||||
print("[MpvPlayerPlugin] Restoring inline player after PiP")
|
||||
playerCore.setVisible(true)
|
||||
playerCore.updateFrame()
|
||||
if playerCore.isPaused {
|
||||
playerCore.forceDraw()
|
||||
}
|
||||
pendingInlineRestoreAfterPip = false
|
||||
}
|
||||
|
||||
private func handlePipCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { result(nil); return }
|
||||
switch call.method {
|
||||
case "isSupported":
|
||||
result(MpvPipController.isSupported)
|
||||
case "enter":
|
||||
self.enterPip(manual: true, result: result)
|
||||
case "setAutoPipReady":
|
||||
if let args = call.arguments as? [String: Any], let ready = args["ready"] as? Bool {
|
||||
self.autoPipEnabled = ready
|
||||
if ready {
|
||||
let pip = self.ensurePipController()
|
||||
pip.setAutoStart(true)
|
||||
// Warm the layer so the system considers PiP possible
|
||||
if let pc = self.playerCore {
|
||||
pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused)
|
||||
}
|
||||
} else {
|
||||
self.pipController?.setAutoStart(false)
|
||||
}
|
||||
}
|
||||
result(nil)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
private func handlePipCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { result(nil); return }
|
||||
switch call.method {
|
||||
case "isSupported":
|
||||
result(MpvPipController.isSupported)
|
||||
case "enter":
|
||||
self.enterPip(manual: true, result: result)
|
||||
case "setAutoPipReady":
|
||||
if let args = call.arguments as? [String: Any], let ready = args["ready"] as? Bool {
|
||||
self.autoPipEnabled = ready
|
||||
if ready {
|
||||
guard let pip = self.ensurePipController() else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch to PiP VO and prepare the sample buffer layer for PiP display.
|
||||
/// Returns the MpvPipController on success, nil on failure.
|
||||
@discardableResult
|
||||
private func switchToPipAndPrepare() -> MpvPipController? {
|
||||
guard let playerCore = playerCore else { return nil }
|
||||
let pip = ensurePipController()
|
||||
guard playerCore.switchToPipVO(layerPtr: pip.layerPointer) else { return nil }
|
||||
pendingInlineRestoreAfterPip = false
|
||||
playerCore.isPipStarting = true
|
||||
pip.pushBlankFrame()
|
||||
pip.syncTimebase(currentTime: playerCore.timePos, isPlaying: !playerCore.isPaused)
|
||||
pip.invalidatePlaybackState()
|
||||
return pip
|
||||
}
|
||||
|
||||
/// Manual PiP entry (button press). Auto-PiP is handled by the system via
|
||||
/// canStartPictureInPictureAutomaticallyFromInline + pipWillStart delegate.
|
||||
private func enterPip(manual: Bool, result: FlutterResult? = nil) {
|
||||
guard MpvPipController.isSupported else {
|
||||
result?(["success": false, "errorCode": "ios_version", "errorMessage": "Requires iOS 15.0+"])
|
||||
return
|
||||
}
|
||||
guard playerCore != nil else {
|
||||
result?(["success": false, "errorCode": "failed", "errorMessage": "Player not initialized"])
|
||||
return
|
||||
}
|
||||
guard let pip = switchToPipAndPrepare() else {
|
||||
result?(["success": false, "errorCode": "vo_switch_failed", "errorMessage": "Failed to switch VO"])
|
||||
return
|
||||
}
|
||||
|
||||
isManualPipRequest = manual
|
||||
pip.startPip(waitForFrame: manual) { [weak self] started in
|
||||
if started {
|
||||
result?(["success": true])
|
||||
} else {
|
||||
self?.cleanupPip(notify: false)
|
||||
result?(["success": false, "errorCode": "failed", "errorMessage": "PiP failed to start"])
|
||||
pip.setAutoStart(true)
|
||||
// Warm the layer so the system considers PiP possible
|
||||
if let pc = self.playerCore {
|
||||
pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused)
|
||||
}
|
||||
} else {
|
||||
self.pipController?.setAutoStart(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified cleanup for all PiP exit paths
|
||||
private func cleanupPip(notify: Bool, pause: Bool = false) {
|
||||
playerCore?.isPipStarting = false
|
||||
playerCore?.isPipActive = false
|
||||
isManualPipRequest = false
|
||||
stopPipTimebaseSync()
|
||||
pipController?.flushLayer()
|
||||
let restoredInlineVO = playerCore?.switchToGpuNextVO() ?? false
|
||||
if pause { playerCore?.setProperty("pause", value: "yes") }
|
||||
pendingInlineRestoreAfterPip = restoredInlineVO
|
||||
if pendingInlineRestoreAfterPip {
|
||||
if isSceneActive {
|
||||
restoreInlinePlayerAfterPip()
|
||||
} else {
|
||||
print("[MpvPlayerPlugin] Deferring inline restore until scene activation")
|
||||
}
|
||||
}
|
||||
if notify { pipChannel?.invokeMethod("onPipChanged", arguments: false) }
|
||||
}
|
||||
|
||||
/// Scene became active — restore inline playback if needed and re-warm the
|
||||
/// sample-buffer layer so future auto-PiP remains possible.
|
||||
@objc private func sceneDidActivate() {
|
||||
restoreInlinePlayerAfterPip()
|
||||
if autoPipEnabled, let pip = pipController, let pc = playerCore, !pc.isPipActive {
|
||||
pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timebase Sync
|
||||
|
||||
private func syncPipTimebase() {
|
||||
guard let playerCore = playerCore, let pipController = pipController else { return }
|
||||
pipController.syncTimebase(
|
||||
currentTime: playerCore.timePos,
|
||||
isPlaying: !playerCore.isPaused
|
||||
)
|
||||
}
|
||||
|
||||
private func startPipTimebaseSync() {
|
||||
stopPipTimebaseSync()
|
||||
pipTimebaseSyncTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
|
||||
self?.syncPipTimebase()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopPipTimebaseSync() {
|
||||
pipTimebaseSyncTimer?.invalidate()
|
||||
pipTimebaseSyncTimer = nil
|
||||
}
|
||||
|
||||
// MARK: - Platform-Specific Method Handlers
|
||||
|
||||
private func handleInitialize(result: @escaping FlutterResult) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else {
|
||||
result(FlutterError(code: "ERROR", message: "Plugin deallocated", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
if self.playerCore?.isInitialized == true {
|
||||
self.registerSceneActivationObserver()
|
||||
result(true)
|
||||
return
|
||||
}
|
||||
|
||||
guard let window = self.findKeyWindow() else {
|
||||
result(FlutterError(code: "NO_WINDOW", message: "Could not find key window", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
let core = MpvPlayerCore()
|
||||
core.delegate = self
|
||||
|
||||
guard core.initialize(in: window) else {
|
||||
result(FlutterError(code: "MPV_INIT_FAILED", message: "Failed to initialize MPV", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
self.playerCore = core
|
||||
self.registerSceneActivationObserver()
|
||||
core.setVisible(false)
|
||||
result(true)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDispose(result: @escaping FlutterResult) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { result(nil); return }
|
||||
self.pipController?.teardown()
|
||||
self.pipController = nil
|
||||
self.autoPipEnabled = false
|
||||
self.pendingInlineRestoreAfterPip = false
|
||||
self.unregisterSceneActivationObserver()
|
||||
self.stopPipTimebaseSync()
|
||||
self.playerCore?.dispose()
|
||||
self.playerCore = nil
|
||||
result(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSetProperty(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let name = args["name"] as? String,
|
||||
let value = args["value"] as? String else {
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "Missing 'name' or 'value' argument", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
playerCore?.setProperty(name, value: value)
|
||||
|
||||
if name == "pause" {
|
||||
pipController?.invalidatePlaybackState()
|
||||
if playerCore?.isPipActive == true { syncPipTimebase() }
|
||||
}
|
||||
|
||||
result(nil)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepare the shared AVFoundation display layer for PiP display.
|
||||
/// Returns the MpvPipController on success, nil on failure.
|
||||
@discardableResult
|
||||
private func preparePip() -> MpvPipController? {
|
||||
guard let playerCore = playerCore else { return nil }
|
||||
guard let pip = ensurePipController() else { return nil }
|
||||
pendingInlineRestoreAfterPip = false
|
||||
playerCore.setPipSubtitleCompositing(true)
|
||||
playerCore.isPipStarting = true
|
||||
pip.syncTimebase(currentTime: playerCore.timePos, isPlaying: !playerCore.isPaused)
|
||||
pip.invalidatePlaybackState()
|
||||
return pip
|
||||
}
|
||||
|
||||
/// Manual PiP entry (button press). Auto-PiP is handled by the system via
|
||||
/// canStartPictureInPictureAutomaticallyFromInline + pipWillStart delegate.
|
||||
private func enterPip(manual: Bool, result: FlutterResult? = nil) {
|
||||
guard MpvPipController.isSupported else {
|
||||
result?([
|
||||
"success": false, "errorCode": "ios_version", "errorMessage": "Requires iOS 15.0+",
|
||||
])
|
||||
return
|
||||
}
|
||||
guard playerCore != nil else {
|
||||
result?([
|
||||
"success": false, "errorCode": "failed", "errorMessage": "Player not initialized",
|
||||
])
|
||||
return
|
||||
}
|
||||
guard let pip = preparePip() else {
|
||||
result?([
|
||||
"success": false, "errorCode": "pip_prepare_failed",
|
||||
"errorMessage": "Failed to prepare PiP",
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
isManualPipRequest = manual
|
||||
pip.startPip(waitForFrame: manual) { [weak self] started in
|
||||
if started {
|
||||
result?(["success": true])
|
||||
} else {
|
||||
self?.cleanupPip(notify: false)
|
||||
result?([
|
||||
"success": false, "errorCode": "failed", "errorMessage": "PiP failed to start",
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func findKeyWindow() -> UIWindow? {
|
||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = windowScene.windows.first(where: { $0.isKeyWindow }) else {
|
||||
return nil
|
||||
/// Unified cleanup for all PiP exit paths
|
||||
private func cleanupPip(notify: Bool, pause: Bool = false) {
|
||||
playerCore?.setPipSubtitleCompositing(false)
|
||||
playerCore?.isPipStarting = false
|
||||
playerCore?.isPipActive = false
|
||||
isManualPipRequest = false
|
||||
stopPipTimebaseSync()
|
||||
if pause {
|
||||
playerCore?.setPropertyAsync("pause", value: "yes") { [weak self] _ in
|
||||
self?.pipController?.invalidatePlaybackState()
|
||||
self?.syncPipTimebase()
|
||||
}
|
||||
}
|
||||
pendingInlineRestoreAfterPip = true
|
||||
if pendingInlineRestoreAfterPip {
|
||||
if isSceneActive {
|
||||
restoreInlinePlayerAfterPip()
|
||||
} else {
|
||||
print("[MpvPlayerPlugin] Deferring inline restore until scene activation")
|
||||
}
|
||||
}
|
||||
if notify { pipChannel?.invokeMethod("onPipChanged", arguments: false) }
|
||||
}
|
||||
|
||||
/// Scene became active — restore inline playback if needed and re-warm the
|
||||
/// sample-buffer layer so future auto-PiP remains possible.
|
||||
@objc private func sceneDidActivate() {
|
||||
restoreInlinePlayerAfterPip()
|
||||
if autoPipEnabled, let pip = pipController, let pc = playerCore, !pc.isPipActive {
|
||||
pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timebase Sync
|
||||
|
||||
private func syncPipTimebase() {
|
||||
guard let playerCore = playerCore, let pipController = pipController else { return }
|
||||
pipController.syncTimebase(
|
||||
currentTime: playerCore.timePos,
|
||||
isPlaying: !playerCore.isPaused
|
||||
)
|
||||
}
|
||||
|
||||
private func startPipTimebaseSync() {
|
||||
stopPipTimebaseSync()
|
||||
pipTimebaseSyncTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) {
|
||||
[weak self] _ in
|
||||
self?.syncPipTimebase()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopPipTimebaseSync() {
|
||||
pipTimebaseSyncTimer?.invalidate()
|
||||
pipTimebaseSyncTimer = nil
|
||||
}
|
||||
|
||||
// MARK: - Platform-Specific Method Handlers
|
||||
|
||||
private func handleInitialize(result: @escaping FlutterResult) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else {
|
||||
result(FlutterError(code: "ERROR", message: "Plugin deallocated", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
if self.playerCore?.isInitialized == true {
|
||||
self.registerSceneActivationObserver()
|
||||
result(true)
|
||||
return
|
||||
}
|
||||
|
||||
guard let window = self.findKeyWindow() else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "NO_WINDOW", message: "Could not find key window", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
let core = MpvPlayerCore()
|
||||
core.delegate = self
|
||||
|
||||
guard core.initialize(in: window) else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "MPV_INIT_FAILED", message: "Failed to initialize MPV", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
self.playerCore = core
|
||||
self.registerSceneActivationObserver()
|
||||
core.setVisible(false)
|
||||
result(true)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDispose(result: @escaping FlutterResult) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { result(nil); return }
|
||||
self.pipController?.teardown()
|
||||
self.pipController = nil
|
||||
self.autoPipEnabled = false
|
||||
self.pendingInlineRestoreAfterPip = false
|
||||
self.unregisterSceneActivationObserver()
|
||||
self.stopPipTimebaseSync()
|
||||
self.playerCore?.dispose()
|
||||
self.playerCore = nil
|
||||
result(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSetProperty(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [String: Any],
|
||||
let name = args["name"] as? String,
|
||||
let value = args["value"] as? String
|
||||
else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "INVALID_ARGS", message: "Missing 'name' or 'value' argument",
|
||||
details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
guard let core = playerCore else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
|
||||
core.setPropertyAsync(name, value: value) { [weak self] _ in
|
||||
if name == "pause" {
|
||||
self?.pipController?.invalidatePlaybackState()
|
||||
if core.isPipActive == true { self?.syncPipTimebase() }
|
||||
}
|
||||
result(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSetDisplayCriteria(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
guard let args = call.arguments as? [String: Any] else {
|
||||
result(FlutterError(code: "INVALID_ARGS", message: "Missing arguments", details: nil))
|
||||
return
|
||||
}
|
||||
|
||||
guard let core = playerCore else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard let raw = args["criteria"] as? [String: Any] else {
|
||||
DispatchQueue.main.async {
|
||||
core.setServerDisplayCriteria(nil)
|
||||
result(nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let criteria = ServerDisplayCriteria(
|
||||
doviProfile: int64Value(raw["doviProfile"]) ?? 0,
|
||||
doviLevel: int64Value(raw["doviLevel"]) ?? 0,
|
||||
doviCompatibilityId: int64Value(raw["doviCompatibilityId"]),
|
||||
fps: doubleValue(raw["fps"]) ?? 0,
|
||||
width: Int32(truncatingIfNeeded: int64Value(raw["width"]) ?? 0),
|
||||
height: Int32(truncatingIfNeeded: int64Value(raw["height"]) ?? 0),
|
||||
gamma: stringValue(raw["transfer"]),
|
||||
primaries: stringValue(raw["primaries"]),
|
||||
colorMatrix: stringValue(raw["matrix"])
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
core.setServerDisplayCriteria(criteria)
|
||||
result(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func int64Value(_ value: Any?) -> Int64? {
|
||||
switch value {
|
||||
case let value as Int64:
|
||||
return value
|
||||
case let value as Int:
|
||||
return Int64(value)
|
||||
case let value as NSNumber:
|
||||
return value.int64Value
|
||||
case let value as String:
|
||||
return Int64(value)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func doubleValue(_ value: Any?) -> Double? {
|
||||
switch value {
|
||||
case let value as Double:
|
||||
return value
|
||||
case let value as NSNumber:
|
||||
return value.doubleValue
|
||||
case let value as String:
|
||||
return Double(value)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func stringValue(_ value: Any?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let string = String(describing: value).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return string.isEmpty ? nil : string
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func findKeyWindow() -> UIWindow? {
|
||||
#if os(iOS)
|
||||
return ExternalDisplayManager.mainApplicationWindow()
|
||||
#else
|
||||
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
|
||||
|
||||
for scene in scenes {
|
||||
if let window = scene.windows.first(where: { $0.isKeyWindow }) {
|
||||
return window
|
||||
}
|
||||
return window
|
||||
}
|
||||
}
|
||||
|
||||
for scene in scenes {
|
||||
if let window = scene.windows.first(where: { !$0.isHidden }) {
|
||||
return window
|
||||
}
|
||||
}
|
||||
|
||||
return scenes.first?.windows.first
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MpvPipDelegate
|
||||
|
||||
extension MpvPlayerPlugin: MpvPipDelegate {
|
||||
|
||||
func pipWillStart() {
|
||||
// If PiP was system-initiated (not via our enterPip), switch VO now
|
||||
guard let playerCore = playerCore, !playerCore.isPipStarting else { return }
|
||||
print("[MpvPlayerPlugin] System-initiated PiP detected, switching VO")
|
||||
if switchToPipAndPrepare() == nil {
|
||||
print("[MpvPlayerPlugin] VO switch failed for system-initiated PiP")
|
||||
pipController?.stopPip()
|
||||
}
|
||||
func pipWillStart() {
|
||||
// If PiP was system-initiated (not via our enterPip), prepare the shared layer now.
|
||||
guard let playerCore = playerCore, !playerCore.isPipStarting else { return }
|
||||
print("[MpvPlayerPlugin] System-initiated PiP detected, preparing shared layer")
|
||||
if preparePip() == nil {
|
||||
print("[MpvPlayerPlugin] PiP preparation failed for system-initiated PiP")
|
||||
pipController?.stopPip()
|
||||
}
|
||||
}
|
||||
|
||||
func pipDidStart() {
|
||||
playerCore?.isPipStarting = false
|
||||
playerCore?.isPipActive = true
|
||||
pendingInlineRestoreAfterPip = false
|
||||
pipChannel?.invokeMethod("onPipChanged", arguments: true)
|
||||
syncPipTimebase()
|
||||
startPipTimebaseSync()
|
||||
func pipDidStart() {
|
||||
playerCore?.isPipStarting = false
|
||||
playerCore?.isPipActive = true
|
||||
pendingInlineRestoreAfterPip = false
|
||||
pipChannel?.invokeMethod("onPipChanged", arguments: true)
|
||||
syncPipTimebase()
|
||||
startPipTimebaseSync()
|
||||
|
||||
if isManualPipRequest {
|
||||
isManualPipRequest = false
|
||||
UIControl().sendAction(#selector(URLSessionTask.suspend), to: UIApplication.shared, for: nil)
|
||||
}
|
||||
if isManualPipRequest {
|
||||
isManualPipRequest = false
|
||||
UIControl().sendAction(
|
||||
#selector(URLSessionTask.suspend), to: UIApplication.shared, for: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func pipDidStop(restored: Bool) {
|
||||
cleanupPip(notify: true, pause: !restored)
|
||||
func pipDidStop(restored: Bool) {
|
||||
cleanupPip(notify: true, pause: !restored)
|
||||
}
|
||||
|
||||
func pipDidFailToStart(error: Error?) {
|
||||
cleanupPip(notify: true)
|
||||
}
|
||||
|
||||
func pipSetPlaying(_ playing: Bool) {
|
||||
playerCore?.setPropertyAsync("pause", value: playing ? "no" : "yes") { [weak self] _ in
|
||||
self?.pipController?.invalidatePlaybackState()
|
||||
self?.syncPipTimebase()
|
||||
}
|
||||
}
|
||||
|
||||
func pipDidFailToStart(error: Error?) {
|
||||
cleanupPip(notify: true)
|
||||
func pipSkip(byInterval seconds: Double, completion: @escaping () -> Void) {
|
||||
guard let playerCore = playerCore else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
|
||||
func pipSetPlaying(_ playing: Bool) {
|
||||
playerCore?.setProperty("pause", value: playing ? "no" : "yes")
|
||||
pipController?.invalidatePlaybackState()
|
||||
syncPipTimebase()
|
||||
let newTime = max(0, playerCore.timePos + seconds)
|
||||
playerCore.commandAsync(["seek", String(newTime), "absolute"]) { [weak self] _ in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
self?.pipController?.invalidatePlaybackState()
|
||||
completion()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pipSkip(byInterval seconds: Double) {
|
||||
guard let playerCore = playerCore else { return }
|
||||
let newTime = max(0, playerCore.timePos + seconds)
|
||||
playerCore.command(["seek", String(newTime), "absolute"])
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||
self?.syncPipTimebase()
|
||||
self?.pipController?.invalidatePlaybackState()
|
||||
}
|
||||
}
|
||||
|
||||
var isPipPlaying: Bool { !(playerCore?.isPaused ?? true) }
|
||||
var pipDuration: Double { playerCore?.duration ?? 0 }
|
||||
var isPipPlaying: Bool { !(playerCore?.isPaused ?? true) }
|
||||
var pipDuration: Double { playerCore?.duration ?? 0 }
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
require 'dotenv/load'
|
||||
Dotenv.load("../../.env")
|
||||
require 'shellwords'
|
||||
|
||||
PROJECT_ROOT = File.expand_path("../..", __dir__)
|
||||
PROJECT_ROOT_ARG = Shellwords.escape(PROJECT_ROOT)
|
||||
|
||||
Dotenv.load(File.join(PROJECT_ROOT, ".env"))
|
||||
|
||||
default_platform(:ios)
|
||||
|
||||
platform :ios do
|
||||
desc "Build and deploy to App Store"
|
||||
lane :deploy_appstore do
|
||||
git_commit = `git rev-parse --short HEAD`.strip
|
||||
git_commit = `git -C #{PROJECT_ROOT_ARG} rev-parse --short HEAD`.strip
|
||||
|
||||
# Build the Flutter app
|
||||
sh("cd #{ENV['PWD']}/.. && flutter build ipa --dart-define=ENABLE_IN_APP_REVIEW=true --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit}")
|
||||
sh("cd #{PROJECT_ROOT_ARG} && flutter build ipa --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=app-store --dart-define=SENTRY_DIST=app-store --split-debug-info=debug-info/ios")
|
||||
|
||||
# Upload debug symbols
|
||||
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=app-store ./scripts/upload-symbols.sh ios")
|
||||
|
||||
# Upload to App Store
|
||||
upload_to_app_store(
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import '../media/media_backend.dart';
|
||||
import '../models/plex/plex_home_user.dart';
|
||||
import '../services/plex_auth_service.dart';
|
||||
|
||||
/// Identifier of a backend kind a [Connection] points at. Lighter-weight than
|
||||
/// [MediaBackend] for places that only care about persistence/auth shape
|
||||
/// (e.g. database column values).
|
||||
enum ConnectionKind {
|
||||
plex,
|
||||
jellyfin;
|
||||
|
||||
String get id => switch (this) {
|
||||
ConnectionKind.plex => 'plex',
|
||||
ConnectionKind.jellyfin => 'jellyfin',
|
||||
};
|
||||
|
||||
static ConnectionKind fromId(String id) => switch (id) {
|
||||
'plex' => ConnectionKind.plex,
|
||||
'jellyfin' => ConnectionKind.jellyfin,
|
||||
_ => throw ArgumentError('Unknown ConnectionKind id: $id'),
|
||||
};
|
||||
|
||||
MediaBackend get backend => switch (this) {
|
||||
ConnectionKind.plex => MediaBackend.plex,
|
||||
ConnectionKind.jellyfin => MediaBackend.jellyfin,
|
||||
};
|
||||
}
|
||||
|
||||
/// Health snapshot for a connection. Updated by the orchestrator each time a
|
||||
/// session is established or refreshed.
|
||||
enum ConnectionStatus { unknown, online, offline, authError, disabled }
|
||||
|
||||
/// A media server connection — a unit of authentication the user added.
|
||||
///
|
||||
/// A `PlexAccountConnection` carries one Plex account + its discovered servers + an
|
||||
/// optional active Home profile. A `JellyfinConnection` is a single server +
|
||||
/// user. Most users only ever add one connection.
|
||||
sealed class Connection {
|
||||
String get id;
|
||||
ConnectionKind get kind;
|
||||
String get displayName;
|
||||
ConnectionStatus get status;
|
||||
DateTime get createdAt;
|
||||
DateTime? get lastAuthenticatedAt;
|
||||
|
||||
/// Backend kind as a [MediaBackend] — for UI that branches on backend
|
||||
/// (badges, etc.). Just a passthrough to [kind.backend].
|
||||
MediaBackend get backend => kind.backend;
|
||||
|
||||
/// Primary label shown in connection-list UIs. Plex shows the active
|
||||
/// profile/account name; Jellyfin shows the server name.
|
||||
String get displayLabel;
|
||||
|
||||
/// Secondary line shown beneath [displayLabel] in connection-list UIs.
|
||||
/// Plex: server count; Jellyfin: `userName · baseUrl`. May be null when
|
||||
/// no useful subtitle exists.
|
||||
String? get displaySubtitle;
|
||||
|
||||
/// Backend-specific config payload, persisted as JSON. Each subclass
|
||||
/// defines the schema.
|
||||
Map<String, Object?> toConfigJson();
|
||||
}
|
||||
|
||||
/// A Plex account connection.
|
||||
///
|
||||
/// Fields here mirror what [PlexAuthService] gathers during PIN OAuth: an
|
||||
/// account token (long-lived), the per-device client identifier (so plex.tv
|
||||
/// doesn't see a "new device" each launch), and the optional Home user the
|
||||
/// user has switched into.
|
||||
class PlexAccountConnection extends Connection {
|
||||
@override
|
||||
final String id;
|
||||
|
||||
@override
|
||||
final ConnectionStatus status;
|
||||
|
||||
@override
|
||||
final DateTime createdAt;
|
||||
|
||||
@override
|
||||
final DateTime? lastAuthenticatedAt;
|
||||
|
||||
/// plex.tv account access token.
|
||||
final String accountToken;
|
||||
|
||||
/// Per-device client identifier. Stable across launches.
|
||||
final String clientIdentifier;
|
||||
|
||||
/// Display name shown for this connection (typically the Plex account email
|
||||
/// or username, fallback "Plex").
|
||||
final String accountLabel;
|
||||
|
||||
/// Active Home user, or `null` for the main account.
|
||||
final PlexHomeUser? activeProfile;
|
||||
|
||||
/// Servers discovered for this account (cached). Populated by the auth
|
||||
/// flow and refreshed periodically.
|
||||
final List<PlexServer> servers;
|
||||
|
||||
PlexAccountConnection({
|
||||
required this.id,
|
||||
required this.accountToken,
|
||||
required this.clientIdentifier,
|
||||
required this.accountLabel,
|
||||
this.activeProfile,
|
||||
this.servers = const [],
|
||||
this.status = ConnectionStatus.unknown,
|
||||
required this.createdAt,
|
||||
this.lastAuthenticatedAt,
|
||||
});
|
||||
|
||||
@override
|
||||
ConnectionKind get kind => ConnectionKind.plex;
|
||||
|
||||
@override
|
||||
String get displayName => activeProfile != null && activeProfile!.title.isNotEmpty
|
||||
? '${activeProfile!.title} · $accountLabel'
|
||||
: accountLabel;
|
||||
|
||||
@override
|
||||
String get displayLabel => displayName;
|
||||
|
||||
@override
|
||||
String? get displaySubtitle => servers.length == 1 ? '1 Plex server' : '${servers.length} Plex servers';
|
||||
|
||||
PlexAccountConnection copyWith({
|
||||
String? id,
|
||||
String? accountToken,
|
||||
String? clientIdentifier,
|
||||
String? accountLabel,
|
||||
PlexHomeUser? activeProfile,
|
||||
bool clearActiveProfile = false,
|
||||
List<PlexServer>? servers,
|
||||
ConnectionStatus? status,
|
||||
DateTime? createdAt,
|
||||
DateTime? lastAuthenticatedAt,
|
||||
}) {
|
||||
return PlexAccountConnection(
|
||||
id: id ?? this.id,
|
||||
accountToken: accountToken ?? this.accountToken,
|
||||
clientIdentifier: clientIdentifier ?? this.clientIdentifier,
|
||||
accountLabel: accountLabel ?? this.accountLabel,
|
||||
activeProfile: clearActiveProfile ? null : (activeProfile ?? this.activeProfile),
|
||||
servers: servers ?? this.servers,
|
||||
status: status ?? this.status,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
lastAuthenticatedAt: lastAuthenticatedAt ?? this.lastAuthenticatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, Object?> toConfigJson() {
|
||||
return {
|
||||
'accountToken': accountToken,
|
||||
'clientIdentifier': clientIdentifier,
|
||||
'accountLabel': accountLabel,
|
||||
'activeProfile': activeProfile?.toJson(),
|
||||
'servers': servers.map((s) => s.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
factory PlexAccountConnection.fromConfigJson({
|
||||
required String id,
|
||||
required Map<String, Object?> json,
|
||||
required ConnectionStatus status,
|
||||
required DateTime createdAt,
|
||||
DateTime? lastAuthenticatedAt,
|
||||
}) {
|
||||
final profileJson = json['activeProfile'];
|
||||
final activeProfile = profileJson is Map<String, dynamic> ? PlexHomeUser.fromJson(profileJson) : null;
|
||||
final serversJson = json['servers'];
|
||||
final servers = serversJson is List
|
||||
? serversJson.whereType<Map<String, dynamic>>().map(PlexServer.fromJson).toList()
|
||||
: <PlexServer>[];
|
||||
return PlexAccountConnection(
|
||||
id: id,
|
||||
accountToken: json['accountToken'] as String? ?? '',
|
||||
clientIdentifier: json['clientIdentifier'] as String? ?? '',
|
||||
accountLabel: json['accountLabel'] as String? ?? 'Plex',
|
||||
activeProfile: activeProfile,
|
||||
servers: servers,
|
||||
status: status,
|
||||
createdAt: createdAt,
|
||||
lastAuthenticatedAt: lastAuthenticatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A single-server Jellyfin connection.
|
||||
class JellyfinConnection extends Connection {
|
||||
@override
|
||||
final String id;
|
||||
|
||||
@override
|
||||
final ConnectionStatus status;
|
||||
|
||||
@override
|
||||
final DateTime createdAt;
|
||||
|
||||
@override
|
||||
final DateTime? lastAuthenticatedAt;
|
||||
|
||||
/// Active server base URL, no trailing slash. e.g. `https://jellyfin.home.lan`.
|
||||
final String baseUrl;
|
||||
|
||||
/// Candidate server URLs for this Jellyfin server, with [baseUrl] first.
|
||||
/// Existing installs only have [baseUrl]; deserialization backfills this.
|
||||
final List<String> baseUrls;
|
||||
|
||||
/// Server's reported name (System/Info).
|
||||
final String serverName;
|
||||
|
||||
/// Server's machine identifier (System/Info `Id`).
|
||||
final String serverMachineId;
|
||||
|
||||
/// Authenticated Jellyfin user id (UUID).
|
||||
final String userId;
|
||||
|
||||
/// Authenticated user's display name.
|
||||
final String userName;
|
||||
|
||||
/// Long-lived access token from `/Users/AuthenticateByName`.
|
||||
final String accessToken;
|
||||
|
||||
/// Per-device client identifier (same value sent in the
|
||||
/// `Authorization: MediaBrowser DeviceId="..."` header).
|
||||
final String deviceId;
|
||||
|
||||
/// Whether this user is a Jellyfin admin (`/Users/{id}.Policy.IsAdministrator`).
|
||||
/// Captured at auth time so the UI can gate admin-only entries (delete,
|
||||
/// match/unmatch, edit metadata) without an extra round-trip.
|
||||
final bool isAdministrator;
|
||||
|
||||
JellyfinConnection({
|
||||
required this.id,
|
||||
required this.baseUrl,
|
||||
List<String>? baseUrls,
|
||||
required this.serverName,
|
||||
required this.serverMachineId,
|
||||
required this.userId,
|
||||
required this.userName,
|
||||
required this.accessToken,
|
||||
required this.deviceId,
|
||||
this.isAdministrator = false,
|
||||
this.status = ConnectionStatus.unknown,
|
||||
required this.createdAt,
|
||||
this.lastAuthenticatedAt,
|
||||
}) : baseUrls = _normalizeBaseUrls(baseUrl, baseUrls);
|
||||
|
||||
@override
|
||||
ConnectionKind get kind => ConnectionKind.jellyfin;
|
||||
|
||||
@override
|
||||
String get displayName => '$userName · $serverName';
|
||||
|
||||
@override
|
||||
String get displayLabel => serverName;
|
||||
|
||||
@override
|
||||
String? get displaySubtitle {
|
||||
final extraCount = baseUrls.length - 1;
|
||||
final suffix = extraCount > 0 ? ' +$extraCount' : '';
|
||||
return '$userName · ${_truncateUrl(baseUrl)}$suffix';
|
||||
}
|
||||
|
||||
static String _truncateUrl(String url) {
|
||||
if (url.length <= 40) return url;
|
||||
return '${url.substring(0, 37)}…';
|
||||
}
|
||||
|
||||
static List<String> _normalizeBaseUrls(String activeBaseUrl, List<String>? urls) {
|
||||
final result = <String>[];
|
||||
final seen = <String>{};
|
||||
|
||||
void add(String url) {
|
||||
final trimmed = url.trim();
|
||||
if (trimmed.isEmpty || !seen.add(trimmed)) return;
|
||||
result.add(trimmed);
|
||||
}
|
||||
|
||||
add(activeBaseUrl);
|
||||
for (final url in urls ?? const <String>[]) {
|
||||
add(url);
|
||||
}
|
||||
return List.unmodifiable(result);
|
||||
}
|
||||
|
||||
JellyfinConnection copyWith({
|
||||
String? id,
|
||||
String? baseUrl,
|
||||
List<String>? baseUrls,
|
||||
String? serverName,
|
||||
String? serverMachineId,
|
||||
String? userId,
|
||||
String? userName,
|
||||
String? accessToken,
|
||||
String? deviceId,
|
||||
bool? isAdministrator,
|
||||
ConnectionStatus? status,
|
||||
DateTime? createdAt,
|
||||
DateTime? lastAuthenticatedAt,
|
||||
}) {
|
||||
final nextBaseUrl = baseUrl ?? this.baseUrl;
|
||||
return JellyfinConnection(
|
||||
id: id ?? this.id,
|
||||
baseUrl: nextBaseUrl,
|
||||
baseUrls: baseUrls ?? this.baseUrls,
|
||||
serverName: serverName ?? this.serverName,
|
||||
serverMachineId: serverMachineId ?? this.serverMachineId,
|
||||
userId: userId ?? this.userId,
|
||||
userName: userName ?? this.userName,
|
||||
accessToken: accessToken ?? this.accessToken,
|
||||
deviceId: deviceId ?? this.deviceId,
|
||||
isAdministrator: isAdministrator ?? this.isAdministrator,
|
||||
status: status ?? this.status,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
lastAuthenticatedAt: lastAuthenticatedAt ?? this.lastAuthenticatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, Object?> toConfigJson() {
|
||||
return {
|
||||
'baseUrl': baseUrl,
|
||||
'baseUrls': baseUrls,
|
||||
'serverName': serverName,
|
||||
'serverMachineId': serverMachineId,
|
||||
'userId': userId,
|
||||
'userName': userName,
|
||||
'accessToken': accessToken,
|
||||
'deviceId': deviceId,
|
||||
'isAdministrator': isAdministrator,
|
||||
};
|
||||
}
|
||||
|
||||
factory JellyfinConnection.fromConfigJson({
|
||||
required String id,
|
||||
required Map<String, Object?> json,
|
||||
required ConnectionStatus status,
|
||||
required DateTime createdAt,
|
||||
DateTime? lastAuthenticatedAt,
|
||||
}) {
|
||||
final rawBaseUrls = json['baseUrls'];
|
||||
final baseUrls = rawBaseUrls is List ? rawBaseUrls.whereType<String>().toList(growable: false) : const <String>[];
|
||||
final rawBaseUrl = json['baseUrl'] as String?;
|
||||
final baseUrl = rawBaseUrl != null && rawBaseUrl.isNotEmpty
|
||||
? rawBaseUrl
|
||||
: (baseUrls.isNotEmpty ? baseUrls.first : '');
|
||||
return JellyfinConnection(
|
||||
id: id,
|
||||
baseUrl: baseUrl,
|
||||
baseUrls: baseUrls,
|
||||
serverName: json['serverName'] as String? ?? 'Jellyfin',
|
||||
serverMachineId: json['serverMachineId'] as String? ?? '',
|
||||
userId: json['userId'] as String? ?? '',
|
||||
userName: json['userName'] as String? ?? '',
|
||||
accessToken: json['accessToken'] as String? ?? '',
|
||||
deviceId: json['deviceId'] as String? ?? '',
|
||||
isAdministrator: json['isAdministrator'] as bool? ?? false,
|
||||
status: status,
|
||||
createdAt: createdAt,
|
||||
lastAuthenticatedAt: lastAuthenticatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'connection.dart';
|
||||
|
||||
/// Backend-neutral auth service interface. Each backend's implementation
|
||||
/// (`PlexConnectionAuthService`, `JellyfinConnectionAuthService`) drives its
|
||||
/// own UX (PIN flow vs. password) but produces the same opaque
|
||||
/// [Connection] record at the end.
|
||||
abstract class ConnectionAuthService {
|
||||
/// Best-effort check that an existing token still works. Returns false on
|
||||
/// 401/403; throws on transport failures the caller should retry.
|
||||
Future<bool> validate(Connection connection);
|
||||
|
||||
/// Refresh whatever side-channel state belongs to a connection — for Plex
|
||||
/// that's the discovered server list and Home users; for Jellyfin it's a
|
||||
/// no-op once auth has succeeded. Returns the updated connection.
|
||||
Future<Connection> refresh(Connection connection);
|
||||
|
||||
/// Revoke the token server-side and forget local credentials. The caller
|
||||
/// is responsible for removing the row from [ConnectionRegistry].
|
||||
Future<void> signOut(Connection connection);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../models/plex/plex_home.dart';
|
||||
import '../models/plex/plex_home_user.dart';
|
||||
import '../profiles/profile.dart';
|
||||
import '../profiles/profile_registry.dart';
|
||||
import '../services/plex_auth_service.dart';
|
||||
import '../services/server_registry.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'connection.dart';
|
||||
import 'connection_registry.dart';
|
||||
|
||||
/// One-shot helpers that bridge between the legacy single-Plex-account
|
||||
/// SharedPreferences state (`StorageService.plexToken` +
|
||||
/// `currentUserUUID` + `homeUsersCache` + `ServerRegistry.getServers()`)
|
||||
/// and the new [ConnectionRegistry] world.
|
||||
///
|
||||
/// Plex Home users are NOT persisted here — the bootstrap copies the
|
||||
/// legacy `homeUsersCache` into the per-connection
|
||||
/// `plex_home_users_{connectionId}` SharedPreferences slot so
|
||||
/// [PlexHomeService] picks it up on cold start.
|
||||
class ConnectionBootstrap {
|
||||
ConnectionBootstrap({
|
||||
required this.storage,
|
||||
required this.connectionRegistry,
|
||||
required this.serverRegistry,
|
||||
required this.profileRegistry,
|
||||
Future<List<PlexHomeUser>> Function(String accountToken)? plexHomeUserFetcher,
|
||||
Future<Map<String, dynamic>> Function(String accountToken)? plexUserInfoFetcher,
|
||||
}) : _plexHomeUserFetcher = plexHomeUserFetcher ?? _fetchPlexHomeUsers,
|
||||
_plexUserInfoFetcher = plexUserInfoFetcher ?? _fetchPlexUserInfo;
|
||||
|
||||
final StorageService storage;
|
||||
final ConnectionRegistry connectionRegistry;
|
||||
final ServerRegistry serverRegistry;
|
||||
final ProfileRegistry profileRegistry;
|
||||
final Future<List<PlexHomeUser>> Function(String accountToken) _plexHomeUserFetcher;
|
||||
final Future<Map<String, dynamic>> Function(String accountToken) _plexUserInfoFetcher;
|
||||
|
||||
static const String _keyProfileMigrationV1Done = 'profile_migration_v1_done';
|
||||
|
||||
/// Run all idempotent boot-time migrations. Best-effort — errors are
|
||||
/// logged but never thrown.
|
||||
Future<void> run() async {
|
||||
await seedFromDevTokenDefine();
|
||||
final hadLegacyPlexToken = (storage.getPlexToken() ?? '').isNotEmpty;
|
||||
final hadLegacyProfileState = _hasLegacyProfileState();
|
||||
final migratedAccount = await migrateLegacyPlexAccount();
|
||||
final account = await _firstPlexAccount(migratedAccount);
|
||||
final alreadyMigrated = storage.prefs.getBool(_keyProfileMigrationV1Done) ?? false;
|
||||
if (!alreadyMigrated) {
|
||||
if (account == null && (hadLegacyPlexToken || hadLegacyProfileState)) {
|
||||
appLogger.w('Migration: legacy profile state present but Plex account was not migrated; will retry later');
|
||||
return;
|
||||
}
|
||||
// Drop any plex_home rows left over from the pre-refactor data
|
||||
// model — Plex Home users are now fetched live, never persisted.
|
||||
await profileRegistry.dropAllPlexHomeRows();
|
||||
if (account != null) {
|
||||
final prepared = await _preparePlexVirtualProfile(account);
|
||||
if (!prepared) {
|
||||
if (migratedAccount != null && hadLegacyPlexToken) {
|
||||
await connectionRegistry.remove(migratedAccount.id);
|
||||
}
|
||||
appLogger.w('Migration: could not hydrate Plex Home profiles for ${account.id}; will retry later');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (hadLegacyPlexToken) {
|
||||
await storage.clearLegacyPlexToken();
|
||||
}
|
||||
await storage.clearServersList();
|
||||
await storage.prefs.setBool(_keyProfileMigrationV1Done, true);
|
||||
} else {
|
||||
await storage.clearServersList();
|
||||
await _recoverLegacyProfilePromotionIfNeeded(account);
|
||||
await _migrateLegacyPlexHomeUsersCacheForExistingAccount(account);
|
||||
}
|
||||
}
|
||||
|
||||
bool _hasLegacyProfileState() {
|
||||
return (storage.getCurrentUserUUID() ?? '').isNotEmpty ||
|
||||
(storage.prefs.getString('home_users_cache') ?? '').isNotEmpty;
|
||||
}
|
||||
|
||||
/// Screenshot automation injects a Plex token via the `PLEX_TOKEN`
|
||||
/// dart-define so the app boots already-signed-in. Inserts a
|
||||
/// [PlexAccountConnection] directly when the env var is non-empty AND
|
||||
/// the registry doesn't already have a Plex account, fetching the user
|
||||
/// info + servers like the auth screen does. No-op in normal builds.
|
||||
Future<void> seedFromDevTokenDefine() async {
|
||||
const devToken = String.fromEnvironment('PLEX_TOKEN');
|
||||
if (devToken.isEmpty) return;
|
||||
final existing = await connectionRegistry.list();
|
||||
if (existing.whereType<PlexAccountConnection>().isNotEmpty) return;
|
||||
|
||||
try {
|
||||
final auth = await PlexAuthService.create();
|
||||
try {
|
||||
final info = await auth.getUserInfo(devToken);
|
||||
final servers = await auth.fetchServers(devToken);
|
||||
final clientId = await storage.getOrCreateClientIdentifier();
|
||||
final conn = PlexAccountConnection(
|
||||
id: 'plex.$clientId',
|
||||
accountToken: devToken,
|
||||
clientIdentifier: clientId,
|
||||
accountLabel: (info['username'] as String?) ?? (info['email'] as String?) ?? 'Plex',
|
||||
servers: servers,
|
||||
createdAt: DateTime.now(),
|
||||
lastAuthenticatedAt: DateTime.now(),
|
||||
);
|
||||
await connectionRegistry.upsert(conn);
|
||||
appLogger.i('Seeded Plex account from PLEX_TOKEN dart-define as ${conn.id}');
|
||||
} finally {
|
||||
auth.dispose();
|
||||
}
|
||||
} catch (e, st) {
|
||||
appLogger.w('PLEX_TOKEN seed failed', error: e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
/// If a legacy Plex token sits in [StorageService] without a corresponding
|
||||
/// row in [ConnectionRegistry], wrap it as a [PlexAccountConnection] and
|
||||
/// insert. Best-effort: errors are logged but never thrown.
|
||||
Future<PlexAccountConnection?> migrateLegacyPlexAccount() async {
|
||||
final token = storage.getPlexToken();
|
||||
if (token == null || token.isEmpty) return null;
|
||||
|
||||
final existing = await connectionRegistry.list();
|
||||
final alreadyMigrated = existing
|
||||
.whereType<PlexAccountConnection>()
|
||||
.where((c) => c.accountToken == token)
|
||||
.firstOrNull;
|
||||
if (alreadyMigrated != null) {
|
||||
return alreadyMigrated;
|
||||
}
|
||||
|
||||
try {
|
||||
final clientId = await storage.getOrCreateClientIdentifier();
|
||||
final servers = await serverRegistry.getServers();
|
||||
|
||||
String accountLabel = 'Plex';
|
||||
String accountUuid = '';
|
||||
try {
|
||||
final info = await _plexUserInfoFetcher(token);
|
||||
accountLabel = (info['username'] as String?) ?? (info['email'] as String?) ?? 'Plex';
|
||||
accountUuid = (info['uuid'] as String?)?.trim() ?? '';
|
||||
} catch (e) {
|
||||
appLogger.d('Plex migration: account label lookup failed (using fallback): $e');
|
||||
}
|
||||
|
||||
final conn = PlexAccountConnection(
|
||||
id: 'plex.${accountUuid.isNotEmpty ? accountUuid : clientId}',
|
||||
accountToken: token,
|
||||
clientIdentifier: clientId,
|
||||
accountLabel: accountLabel,
|
||||
servers: servers,
|
||||
createdAt: DateTime.now(),
|
||||
lastAuthenticatedAt: DateTime.now(),
|
||||
);
|
||||
await connectionRegistry.upsert(conn);
|
||||
appLogger.i('Migrated legacy Plex account into ConnectionRegistry as ${conn.id}');
|
||||
return conn;
|
||||
} catch (e, st) {
|
||||
appLogger.w('Plex account migration failed', error: e, stackTrace: st);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Hydrate Plex Home users for [account] and select a virtual Plex Home
|
||||
/// profile. Plex users are never persisted as local Plezy profiles.
|
||||
Future<bool> _preparePlexVirtualProfile(PlexAccountConnection account) async {
|
||||
final copied = await _migrateLegacyPlexHomeUsersCache(account.id);
|
||||
var users = copied ? _readPlexHomeUsersCache(account.id) : null;
|
||||
users ??= await _fetchAndCachePlexHomeUsers(account);
|
||||
final hydratedUsers = users;
|
||||
if (hydratedUsers.isEmpty) return false;
|
||||
|
||||
final legacyActiveUuid = storage.getCurrentUserUUID();
|
||||
PlexHomeUser selected;
|
||||
if (legacyActiveUuid != null && legacyActiveUuid.isNotEmpty) {
|
||||
selected = hydratedUsers.firstWhere(
|
||||
(u) => u.uuid == legacyActiveUuid,
|
||||
orElse: () => _preferredPlexHomeUser(hydratedUsers),
|
||||
);
|
||||
if (selected.uuid != legacyActiveUuid) {
|
||||
appLogger.w('Migration: legacy Plex Home UUID $legacyActiveUuid not found; using ${selected.uuid} instead');
|
||||
}
|
||||
} else {
|
||||
selected = _preferredPlexHomeUser(hydratedUsers);
|
||||
}
|
||||
|
||||
final activeProfileId = plexHomeProfileId(accountConnectionId: account.id, homeUserUuid: selected.uuid);
|
||||
await storage.setActiveProfileId(activeProfileId);
|
||||
await storage.clearCurrentUserUUID();
|
||||
await Future.wait([storage.prefs.remove('home_users_cache'), storage.prefs.remove('home_users_cache_expiry')]);
|
||||
appLogger.i('Migration: selected Plex Home profile ${selected.displayName} → $activeProfileId');
|
||||
return true;
|
||||
}
|
||||
|
||||
PlexHomeUser _preferredPlexHomeUser(List<PlexHomeUser> users) {
|
||||
return users.firstWhere((u) => u.admin, orElse: () => users.first);
|
||||
}
|
||||
|
||||
Future<bool> _migrateLegacyPlexHomeUsersCache(String connectionId) async {
|
||||
final raw = storage.prefs.getString('home_users_cache');
|
||||
if (raw == null || raw.isEmpty) return false;
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map<String, dynamic>) return false;
|
||||
final home = PlexHome.fromJson(decoded);
|
||||
if (home.users.isEmpty) return false;
|
||||
await storage.savePlexHomeUsersCache(connectionId, home.users.map((u) => u.toJson()).toList());
|
||||
appLogger.i('Migration: copied ${home.users.length} Plex Home users into cache for $connectionId');
|
||||
return true;
|
||||
} catch (e, st) {
|
||||
appLogger.w('Plex Home cache migration failed', error: e, stackTrace: st);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
List<PlexHomeUser>? _readPlexHomeUsersCache(String connectionId) {
|
||||
final raw = storage.getPlexHomeUsersCacheJson(connectionId);
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) return null;
|
||||
return decoded.whereType<Map<String, dynamic>>().map(PlexHomeUser.fromJson).toList();
|
||||
} catch (e, st) {
|
||||
appLogger.w('Migration: failed to read Plex Home cache for $connectionId', error: e, stackTrace: st);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<PlexHomeUser>> _fetchAndCachePlexHomeUsers(PlexAccountConnection account) async {
|
||||
try {
|
||||
final users = await _plexHomeUserFetcher(account.accountToken);
|
||||
if (users.isNotEmpty) {
|
||||
await storage.savePlexHomeUsersCache(account.id, users.map((u) => u.toJson()).toList());
|
||||
appLogger.i('Migration: fetched ${users.length} Plex Home users for ${account.id}');
|
||||
}
|
||||
return users;
|
||||
} catch (e, st) {
|
||||
appLogger.w('Migration: Plex Home fetch failed for ${account.id}', error: e, stackTrace: st);
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _migrateLegacyPlexHomeUsersCacheForExistingAccount(PlexAccountConnection? account) async {
|
||||
if (storage.prefs.getString('home_users_cache') == null) return;
|
||||
var target = account;
|
||||
if (target == null) {
|
||||
final connections = await connectionRegistry.list();
|
||||
for (final conn in connections) {
|
||||
if (conn is PlexAccountConnection) {
|
||||
target = conn;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (target == null) return;
|
||||
final copied = await _migrateLegacyPlexHomeUsersCache(target.id);
|
||||
if (copied) {
|
||||
await Future.wait([storage.prefs.remove('home_users_cache'), storage.prefs.remove('home_users_cache_expiry')]);
|
||||
}
|
||||
}
|
||||
|
||||
Future<PlexAccountConnection?> _firstPlexAccount(PlexAccountConnection? preferred) async {
|
||||
if (preferred != null) return preferred;
|
||||
final connections = await connectionRegistry.list();
|
||||
for (final conn in connections) {
|
||||
if (conn is PlexAccountConnection) return conn;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _recoverLegacyProfilePromotionIfNeeded(PlexAccountConnection? account) async {
|
||||
if (!_hasLegacyProfileState()) return;
|
||||
final target = await _firstPlexAccount(account);
|
||||
if (target == null) return;
|
||||
await _preparePlexVirtualProfile(target);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<PlexHomeUser>> _fetchPlexHomeUsers(String accountToken) async {
|
||||
final auth = await PlexAuthService.create();
|
||||
try {
|
||||
final home = await auth.getHomeUsers(accountToken);
|
||||
return home.users;
|
||||
} finally {
|
||||
auth.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _fetchPlexUserInfo(String accountToken) async {
|
||||
final auth = await PlexAuthService.create();
|
||||
try {
|
||||
return await auth.getUserInfo(accountToken);
|
||||
} finally {
|
||||
auth.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
import '../database/app_database.dart';
|
||||
import '../services/credential_vault.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'connection.dart';
|
||||
|
||||
/// CRUD over the persisted [Connections] table. The registry is the source
|
||||
/// of truth for which connections the user has added; the runtime
|
||||
/// `MultiServerManager` populates per-server clients from these records.
|
||||
///
|
||||
/// Single-connection users get a default automatically — power users with
|
||||
/// multiple connections can override it via [setDefault].
|
||||
class ConnectionRegistry {
|
||||
ConnectionRegistry(this._db);
|
||||
|
||||
final AppDatabase _db;
|
||||
|
||||
/// Emits the current set of connections after every mutation. Drift's
|
||||
/// `watch()` provides this for free.
|
||||
Stream<List<Connection>> watchConnections() {
|
||||
return (_db.select(_db.connections)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).watch().asyncMap(
|
||||
(rows) async => (await Future.wait(rows.map(_rowToConnection))).whereType<Connection>().toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// One-shot fetch of all stored connections.
|
||||
Future<List<Connection>> list() async {
|
||||
final rows = await (_db.select(_db.connections)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).get();
|
||||
return (await Future.wait(rows.map(_rowToConnection))).whereType<Connection>().toList();
|
||||
}
|
||||
|
||||
/// Lookup a connection by id.
|
||||
Future<Connection?> get(String id) async {
|
||||
final row = await (_db.select(_db.connections)..where((t) => t.id.equals(id))).getSingleOrNull();
|
||||
if (row == null) return null;
|
||||
return _rowToConnection(row);
|
||||
}
|
||||
|
||||
/// Returns the user's preferred connection — either the row marked
|
||||
/// [Connections.isDefault], or the only row if exactly one exists, or
|
||||
/// null when no connections are stored.
|
||||
Future<Connection?> getDefault() async {
|
||||
final rows = await _db.select(_db.connections).get();
|
||||
if (rows.isEmpty) return null;
|
||||
final flagged = rows.firstWhereOrNull((r) => r.isDefault);
|
||||
final picked = flagged ?? (rows.length == 1 ? rows.single : null);
|
||||
return picked == null ? null : _rowToConnection(picked);
|
||||
}
|
||||
|
||||
/// Insert or replace [connection]. If this is the first stored connection
|
||||
/// it is automatically marked default; re-upserting an existing row keeps
|
||||
/// the row's current `isDefault` (so token/metadata refreshes don't clear
|
||||
/// the default flag).
|
||||
Future<void> upsert(Connection connection) async {
|
||||
final existing = await (_db.select(_db.connections)..where((t) => t.id.equals(connection.id))).getSingleOrNull();
|
||||
final bool isDefault;
|
||||
if (existing != null) {
|
||||
isDefault = existing.isDefault;
|
||||
} else {
|
||||
final any =
|
||||
await (_db.selectOnly(_db.connections)
|
||||
..addColumns([_db.connections.id])
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
isDefault = any == null;
|
||||
}
|
||||
final protectedConfig = await CredentialVault.protectConnectionConfig(
|
||||
connection.kind.id,
|
||||
connection.toConfigJson(),
|
||||
);
|
||||
final row = ConnectionsCompanion(
|
||||
id: Value(connection.id),
|
||||
kind: Value(connection.kind.id),
|
||||
displayName: Value(connection.displayName),
|
||||
configJson: Value(jsonEncode(protectedConfig)),
|
||||
isDefault: Value(isDefault),
|
||||
createdAt: Value(connection.createdAt.millisecondsSinceEpoch),
|
||||
lastAuthenticatedAt: Value(connection.lastAuthenticatedAt?.millisecondsSinceEpoch),
|
||||
);
|
||||
await _db.into(_db.connections).insertOnConflictUpdate(row);
|
||||
appLogger.d('ConnectionRegistry: upserted ${connection.kind.id}/${connection.id}');
|
||||
}
|
||||
|
||||
/// Remove a stored connection. If the removed row was the default, the
|
||||
/// oldest remaining connection (if any) becomes default.
|
||||
Future<void> remove(String id) async {
|
||||
await (_db.delete(_db.connections)..where((t) => t.id.equals(id))).go();
|
||||
final remaining = await (_db.select(_db.connections)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).get();
|
||||
if (remaining.isNotEmpty && !remaining.any((r) => r.isDefault)) {
|
||||
await (_db.update(
|
||||
_db.connections,
|
||||
)..where((t) => t.id.equals(remaining.first.id))).write(const ConnectionsCompanion(isDefault: Value(true)));
|
||||
}
|
||||
appLogger.d('ConnectionRegistry: removed $id');
|
||||
}
|
||||
|
||||
/// Set [id] as the default connection. Clears the flag on all others.
|
||||
Future<void> setDefault(String id) async {
|
||||
await _db.transaction(() async {
|
||||
await _db.update(_db.connections).write(const ConnectionsCompanion(isDefault: Value(false)));
|
||||
await (_db.update(
|
||||
_db.connections,
|
||||
)..where((t) => t.id.equals(id))).write(const ConnectionsCompanion(isDefault: Value(true)));
|
||||
});
|
||||
}
|
||||
|
||||
/// Update only the auth-related metadata on an existing row (token,
|
||||
/// `lastAuthenticatedAt`). Used by the auth flow after a successful
|
||||
/// silent refresh without touching the rest of the config.
|
||||
Future<void> recordAuthSuccess(String id, DateTime at) async {
|
||||
await (_db.update(_db.connections)..where((t) => t.id.equals(id))).write(
|
||||
ConnectionsCompanion(lastAuthenticatedAt: Value(at.millisecondsSinceEpoch)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
await _db.delete(_db.connections).go();
|
||||
}
|
||||
|
||||
/// All Plex accounts in insertion order. Convenience over
|
||||
/// `(await list()).whereType<PlexAccountConnection>()` — cuts ~3 lines from
|
||||
/// every caller that needs to filter by backend.
|
||||
Future<List<PlexAccountConnection>> listPlexAccounts() async {
|
||||
final all = await list();
|
||||
return all.whereType<PlexAccountConnection>().toList();
|
||||
}
|
||||
|
||||
/// All Jellyfin connections in insertion order. Symmetric helper to
|
||||
/// [listPlexAccounts].
|
||||
Future<List<JellyfinConnection>> listJellyfin() async {
|
||||
final all = await list();
|
||||
return all.whereType<JellyfinConnection>().toList();
|
||||
}
|
||||
|
||||
/// Lookup a [PlexAccountConnection] by id. Returns `null` if no row
|
||||
/// matches OR the row exists but isn't a Plex account.
|
||||
Future<PlexAccountConnection?> getPlexAccount(String id) async {
|
||||
final c = await get(id);
|
||||
return c is PlexAccountConnection ? c : null;
|
||||
}
|
||||
|
||||
/// Lookup a [JellyfinConnection] by id. Returns `null` if no row matches
|
||||
/// OR the row exists but isn't a Jellyfin connection.
|
||||
Future<JellyfinConnection?> getJellyfin(String id) async {
|
||||
final c = await get(id);
|
||||
return c is JellyfinConnection ? c : null;
|
||||
}
|
||||
|
||||
Future<Connection?> _rowToConnection(ConnectionRow row) async {
|
||||
try {
|
||||
final json = jsonDecode(row.configJson) as Map<String, dynamic>;
|
||||
final kind = ConnectionKind.fromId(row.kind);
|
||||
final revealed = await CredentialVault.revealConnectionConfig(kind.id, json);
|
||||
final createdAt = DateTime.fromMillisecondsSinceEpoch(row.createdAt);
|
||||
final lastAuth = row.lastAuthenticatedAt == null
|
||||
? null
|
||||
: DateTime.fromMillisecondsSinceEpoch(row.lastAuthenticatedAt!);
|
||||
final connection = switch (kind) {
|
||||
ConnectionKind.plex => PlexAccountConnection.fromConfigJson(
|
||||
id: row.id,
|
||||
json: revealed.config,
|
||||
status: ConnectionStatus.unknown,
|
||||
createdAt: createdAt,
|
||||
lastAuthenticatedAt: lastAuth,
|
||||
),
|
||||
ConnectionKind.jellyfin => JellyfinConnection.fromConfigJson(
|
||||
id: row.id,
|
||||
json: revealed.config,
|
||||
status: ConnectionStatus.unknown,
|
||||
createdAt: createdAt,
|
||||
lastAuthenticatedAt: lastAuth,
|
||||
),
|
||||
};
|
||||
if (revealed.migrated) {
|
||||
await upsert(connection);
|
||||
}
|
||||
return connection;
|
||||
} catch (e, st) {
|
||||
appLogger.e('ConnectionRegistry: failed to decode connection ${row.id}', error: e, stackTrace: st);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
// Generated from USB HID Usage Tables — do not edit by hand.
|
||||
|
||||
/// Human-readable labels for physical keyboard keys, keyed by USB HID usage code.
|
||||
const hidKeyLabels = <int, String>{
|
||||
// Special
|
||||
0x00000010: 'Hyper',
|
||||
0x00000011: 'Super',
|
||||
0x00000012: 'Fn',
|
||||
0x00000013: 'Fn Lock',
|
||||
0x00000014: 'Suspend',
|
||||
0x00000015: 'Resume',
|
||||
0x00000016: 'Turbo',
|
||||
0x00000017: 'Privacy Screen Toggle',
|
||||
0x00000018: 'Microphone Mute',
|
||||
// Generic Desktop
|
||||
0x00010082: 'Sleep',
|
||||
0x00010083: 'Wake Up',
|
||||
0x000100b5: 'Display Toggle',
|
||||
// Game Buttons
|
||||
0x0005ff01: 'Game Button 1',
|
||||
0x0005ff02: 'Game Button 2',
|
||||
0x0005ff03: 'Game Button 3',
|
||||
0x0005ff04: 'Game Button 4',
|
||||
0x0005ff05: 'Game Button 5',
|
||||
0x0005ff06: 'Game Button 6',
|
||||
0x0005ff07: 'Game Button 7',
|
||||
0x0005ff08: 'Game Button 8',
|
||||
0x0005ff09: 'Game Button 9',
|
||||
0x0005ff0a: 'Game Button 10',
|
||||
0x0005ff0b: 'Game Button 11',
|
||||
0x0005ff0c: 'Game Button 12',
|
||||
0x0005ff0d: 'Game Button 13',
|
||||
0x0005ff0e: 'Game Button 14',
|
||||
0x0005ff0f: 'Game Button 15',
|
||||
0x0005ff10: 'Game Button 16',
|
||||
0x0005ff11: 'Game Button A',
|
||||
0x0005ff12: 'Game Button B',
|
||||
0x0005ff13: 'Game Button C',
|
||||
0x0005ff14: 'Game Button Left 1',
|
||||
0x0005ff15: 'Game Button Left 2',
|
||||
0x0005ff16: 'Game Button Mode',
|
||||
0x0005ff17: 'Game Button Right 1',
|
||||
0x0005ff18: 'Game Button Right 2',
|
||||
0x0005ff19: 'Game Button Select',
|
||||
0x0005ff1a: 'Game Button Start',
|
||||
0x0005ff1b: 'Game Button Thumb Left',
|
||||
0x0005ff1c: 'Game Button Thumb Right',
|
||||
0x0005ff1d: 'Game Button X',
|
||||
0x0005ff1e: 'Game Button Y',
|
||||
0x0005ff1f: 'Game Button Z',
|
||||
// Keyboard/Keypad
|
||||
0x00070004: 'A',
|
||||
0x00070005: 'B',
|
||||
0x00070006: 'C',
|
||||
0x00070007: 'D',
|
||||
0x00070008: 'E',
|
||||
0x00070009: 'F',
|
||||
0x0007000a: 'G',
|
||||
0x0007000b: 'H',
|
||||
0x0007000c: 'I',
|
||||
0x0007000d: 'J',
|
||||
0x0007000e: 'K',
|
||||
0x0007000f: 'L',
|
||||
0x00070010: 'M',
|
||||
0x00070011: 'N',
|
||||
0x00070012: 'O',
|
||||
0x00070013: 'P',
|
||||
0x00070014: 'Q',
|
||||
0x00070015: 'R',
|
||||
0x00070016: 'S',
|
||||
0x00070017: 'T',
|
||||
0x00070018: 'U',
|
||||
0x00070019: 'V',
|
||||
0x0007001a: 'W',
|
||||
0x0007001b: 'X',
|
||||
0x0007001c: 'Y',
|
||||
0x0007001d: 'Z',
|
||||
0x0007001e: '1',
|
||||
0x0007001f: '2',
|
||||
0x00070020: '3',
|
||||
0x00070021: '4',
|
||||
0x00070022: '5',
|
||||
0x00070023: '6',
|
||||
0x00070024: '7',
|
||||
0x00070025: '8',
|
||||
0x00070026: '9',
|
||||
0x00070027: '0',
|
||||
0x00070028: 'Enter',
|
||||
0x00070029: 'Escape',
|
||||
0x0007002a: 'Backspace',
|
||||
0x0007002b: 'Tab',
|
||||
0x0007002c: 'Space',
|
||||
0x0007002d: '-',
|
||||
0x0007002e: '=',
|
||||
0x0007002f: '[',
|
||||
0x00070030: ']',
|
||||
0x00070031: r'\',
|
||||
0x00070033: ';',
|
||||
0x00070034: "'",
|
||||
0x00070035: '`',
|
||||
0x00070036: ',',
|
||||
0x00070037: '.',
|
||||
0x00070038: '/',
|
||||
0x00070039: 'Caps Lock',
|
||||
0x0007003a: 'F1',
|
||||
0x0007003b: 'F2',
|
||||
0x0007003c: 'F3',
|
||||
0x0007003d: 'F4',
|
||||
0x0007003e: 'F5',
|
||||
0x0007003f: 'F6',
|
||||
0x00070040: 'F7',
|
||||
0x00070041: 'F8',
|
||||
0x00070042: 'F9',
|
||||
0x00070043: 'F10',
|
||||
0x00070044: 'F11',
|
||||
0x00070045: 'F12',
|
||||
0x00070046: 'Print Screen',
|
||||
0x00070047: 'Scroll Lock',
|
||||
0x00070048: 'Pause',
|
||||
0x00070049: 'Insert',
|
||||
0x0007004a: 'Home',
|
||||
0x0007004b: 'Page Up',
|
||||
0x0007004c: 'Delete',
|
||||
0x0007004d: 'End',
|
||||
0x0007004e: 'Page Down',
|
||||
0x0007004f: 'Arrow Right',
|
||||
0x00070050: 'Arrow Left',
|
||||
0x00070051: 'Arrow Down',
|
||||
0x00070052: 'Arrow Up',
|
||||
0x00070053: 'Num Lock',
|
||||
0x00070054: 'Numpad /',
|
||||
0x00070055: 'Numpad *',
|
||||
0x00070056: 'Numpad -',
|
||||
0x00070057: 'Numpad +',
|
||||
0x00070058: 'Numpad Enter',
|
||||
0x00070059: 'Numpad 1',
|
||||
0x0007005a: 'Numpad 2',
|
||||
0x0007005b: 'Numpad 3',
|
||||
0x0007005c: 'Numpad 4',
|
||||
0x0007005d: 'Numpad 5',
|
||||
0x0007005e: 'Numpad 6',
|
||||
0x0007005f: 'Numpad 7',
|
||||
0x00070060: 'Numpad 8',
|
||||
0x00070061: 'Numpad 9',
|
||||
0x00070062: 'Numpad 0',
|
||||
0x00070063: 'Numpad .',
|
||||
0x00070064: 'Intl Backslash',
|
||||
0x00070065: 'Context Menu',
|
||||
0x00070066: 'Power',
|
||||
0x00070067: 'Numpad =',
|
||||
0x00070068: 'F13',
|
||||
0x00070069: 'F14',
|
||||
0x0007006a: 'F15',
|
||||
0x0007006b: 'F16',
|
||||
0x0007006c: 'F17',
|
||||
0x0007006d: 'F18',
|
||||
0x0007006e: 'F19',
|
||||
0x0007006f: 'F20',
|
||||
0x00070070: 'F21',
|
||||
0x00070071: 'F22',
|
||||
0x00070072: 'F23',
|
||||
0x00070073: 'F24',
|
||||
0x00070074: 'Open',
|
||||
0x00070075: 'Help',
|
||||
0x00070077: 'Select',
|
||||
0x00070079: 'Again',
|
||||
0x0007007a: 'Undo',
|
||||
0x0007007b: 'Cut',
|
||||
0x0007007c: 'Copy',
|
||||
0x0007007d: 'Paste',
|
||||
0x0007007e: 'Find',
|
||||
0x0007007f: 'Volume Mute',
|
||||
0x00070080: 'Volume Up',
|
||||
0x00070081: 'Volume Down',
|
||||
0x00070085: 'Numpad ,',
|
||||
0x00070087: 'Intl Ro',
|
||||
0x00070088: 'Kana Mode',
|
||||
0x00070089: 'Intl Yen',
|
||||
0x0007008a: 'Convert',
|
||||
0x0007008b: 'Non Convert',
|
||||
0x00070090: 'Lang 1',
|
||||
0x00070091: 'Lang 2',
|
||||
0x00070092: 'Lang 3',
|
||||
0x00070093: 'Lang 4',
|
||||
0x00070094: 'Lang 5',
|
||||
0x0007009b: 'Abort',
|
||||
0x000700a3: 'Props',
|
||||
0x000700b6: 'Numpad (',
|
||||
0x000700b7: 'Numpad )',
|
||||
0x000700bb: 'Numpad Backspace',
|
||||
0x000700d0: 'Numpad Memory Store',
|
||||
0x000700d1: 'Numpad Memory Recall',
|
||||
0x000700d2: 'Numpad Memory Clear',
|
||||
0x000700d3: 'Numpad Memory Add',
|
||||
0x000700d4: 'Numpad Memory Subtract',
|
||||
0x000700d7: 'Numpad Sign Change',
|
||||
0x000700d8: 'Numpad Clear',
|
||||
0x000700d9: 'Numpad Clear Entry',
|
||||
0x000700e0: 'Ctrl',
|
||||
0x000700e1: 'Shift',
|
||||
0x000700e2: 'Alt',
|
||||
0x000700e3: 'Meta',
|
||||
0x000700e4: 'Ctrl',
|
||||
0x000700e5: 'Shift',
|
||||
0x000700e6: 'Alt',
|
||||
0x000700e7: 'Meta',
|
||||
// Consumer
|
||||
0x000c0030: 'Power',
|
||||
0x000c0032: 'Sleep',
|
||||
0x000c0040: 'Menu',
|
||||
0x000c0041: 'Menu Pick',
|
||||
0x000c0042: 'Menu Up',
|
||||
0x000c0043: 'Menu Down',
|
||||
0x000c0044: 'Menu Left',
|
||||
0x000c0045: 'Menu Right',
|
||||
0x000c0046: 'Menu Escape',
|
||||
0x000c0047: 'Menu Value Increase',
|
||||
0x000c0048: 'Menu Value Decrease',
|
||||
0x000c0060: 'Info',
|
||||
0x000c0061: 'Closed Caption',
|
||||
0x000c0062: 'Closed Caption Select',
|
||||
0x000c0063: 'VCR/TV',
|
||||
0x000c0064: 'Broadcast Mode',
|
||||
0x000c0065: 'Snapshot',
|
||||
0x000c0066: 'Still',
|
||||
0x000c0067: 'PiP Toggle',
|
||||
0x000c0068: 'PiP Swap',
|
||||
0x000c0069: 'Red Button',
|
||||
0x000c006a: 'Green Button',
|
||||
0x000c006b: 'Blue Button',
|
||||
0x000c006c: 'Yellow Button',
|
||||
0x000c006d: 'Aspect',
|
||||
0x000c006e: '3D Mode Select',
|
||||
0x000c006f: 'Brightness Up',
|
||||
0x000c0070: 'Brightness Down',
|
||||
0x000c0072: 'Brightness Toggle',
|
||||
0x000c0073: 'Brightness Min',
|
||||
0x000c0074: 'Brightness Max',
|
||||
0x000c0075: 'Brightness Auto',
|
||||
0x000c0079: 'Kbd Illum Up',
|
||||
0x000c007a: 'Kbd Illum Down',
|
||||
0x000c0082: 'Mode Step',
|
||||
0x000c0083: 'Recall Last',
|
||||
0x000c0084: 'Enter Channel',
|
||||
0x000c0085: 'Order Movie',
|
||||
0x000c0086: 'Channel',
|
||||
0x000c0088: 'Media Computer',
|
||||
0x000c0089: 'Media TV',
|
||||
0x000c008a: 'Media WWW',
|
||||
0x000c008b: 'Media DVD',
|
||||
0x000c008c: 'Media Telephone',
|
||||
0x000c008d: 'Program Guide',
|
||||
0x000c008e: 'Media Video Phone',
|
||||
0x000c008f: 'Media Games',
|
||||
0x000c0090: 'Media Messages',
|
||||
0x000c0091: 'Media CD',
|
||||
0x000c0092: 'Media VCR',
|
||||
0x000c0093: 'Media Tuner',
|
||||
0x000c0094: 'Quit',
|
||||
0x000c0095: 'Help',
|
||||
0x000c0096: 'Media Tape',
|
||||
0x000c0097: 'Media Cable',
|
||||
0x000c0098: 'Media Satellite',
|
||||
0x000c0099: 'Media Security',
|
||||
0x000c009a: 'Media Home',
|
||||
0x000c009b: 'Media Call',
|
||||
0x000c009c: 'Channel Up',
|
||||
0x000c009d: 'Channel Down',
|
||||
0x000c009e: 'Media SAP',
|
||||
0x000c00b0: 'Play',
|
||||
0x000c00b1: 'Pause',
|
||||
0x000c00b2: 'Record',
|
||||
0x000c00b3: 'Fast Forward',
|
||||
0x000c00b4: 'Rewind',
|
||||
0x000c00b5: 'Next Track',
|
||||
0x000c00b6: 'Previous Track',
|
||||
0x000c00b7: 'Stop',
|
||||
0x000c00b8: 'Eject',
|
||||
0x000c00b9: 'Random Play',
|
||||
0x000c00ba: 'Select Disc',
|
||||
0x000c00bb: 'Enter Disc',
|
||||
0x000c00bc: 'Repeat',
|
||||
0x000c00c0: 'Frame Forward',
|
||||
0x000c00c1: 'Frame Back',
|
||||
0x000c00cc: 'Stop/Eject',
|
||||
0x000c00cd: 'Play/Pause',
|
||||
0x000c00ce: 'Play/Skip',
|
||||
0x000c00cf: 'Voice Command',
|
||||
0x000c00e0: 'Volume',
|
||||
0x000c00e2: 'Mute',
|
||||
0x000c00e5: 'Bass Boost',
|
||||
0x000c00e6: 'Surround Mode',
|
||||
0x000c00e7: 'Loudness',
|
||||
0x000c00e9: 'Volume Up',
|
||||
0x000c00ea: 'Volume Down',
|
||||
0x000c0183: 'Media Select',
|
||||
0x000c0184: 'Launch Word Processor',
|
||||
0x000c0186: 'Launch Spreadsheet',
|
||||
0x000c018a: 'Launch Mail',
|
||||
0x000c018d: 'Launch Contacts',
|
||||
0x000c018e: 'Launch Calendar',
|
||||
0x000c0192: 'Launch Calculator',
|
||||
0x000c0194: 'Launch File Browser',
|
||||
0x000c0196: 'Launch Browser',
|
||||
0x000c019c: 'Log Off',
|
||||
0x000c019e: 'Lock Screen',
|
||||
0x000c019f: 'Launch Control Panel',
|
||||
0x000c01a2: 'Select Task',
|
||||
0x000c01a7: 'Launch Documents',
|
||||
0x000c01ab: 'Spell Check',
|
||||
0x000c01ae: 'Launch Keyboard Layout',
|
||||
0x000c01b1: 'Launch Screen Saver',
|
||||
0x000c01b7: 'Launch Audio Browser',
|
||||
0x000c01cb: 'Launch Assistant',
|
||||
0x000c0201: 'New',
|
||||
0x000c0203: 'Close',
|
||||
0x000c0207: 'Save',
|
||||
0x000c0208: 'Print',
|
||||
0x000c0221: 'Browser Search',
|
||||
0x000c0223: 'Browser Home',
|
||||
0x000c0224: 'Browser Back',
|
||||
0x000c0225: 'Browser Forward',
|
||||
0x000c0226: 'Browser Stop',
|
||||
0x000c0227: 'Browser Refresh',
|
||||
0x000c022a: 'Browser Favorites',
|
||||
0x000c022d: 'Zoom In',
|
||||
0x000c022e: 'Zoom Out',
|
||||
0x000c0232: 'Zoom Toggle',
|
||||
0x000c0279: 'Redo',
|
||||
0x000c0289: 'Mail Reply',
|
||||
0x000c028b: 'Mail Forward',
|
||||
0x000c028c: 'Mail Send',
|
||||
0x000c029d: 'Keyboard Layout Select',
|
||||
0x000c029f: 'Show All Windows',
|
||||
};
|
||||
@@ -0,0 +1,406 @@
|
||||
// Generated from iso_639_codes.json — do not edit by hand.
|
||||
|
||||
class LanguageEntry {
|
||||
final String code1;
|
||||
final String code2;
|
||||
final String? code2B;
|
||||
final String name;
|
||||
const LanguageEntry(this.code1, this.code2, this.code2B, this.name);
|
||||
}
|
||||
|
||||
const languageEntries = <String, LanguageEntry>{
|
||||
'aa': LanguageEntry('aa', 'aar', null, 'Afar'),
|
||||
'ab': LanguageEntry('ab', 'abk', null, 'Abkhaz'),
|
||||
'ae': LanguageEntry('ae', 'ave', null, 'Avestan'),
|
||||
'af': LanguageEntry('af', 'afr', null, 'Afrikaans'),
|
||||
'ak': LanguageEntry('ak', 'aka', null, 'Akan'),
|
||||
'am': LanguageEntry('am', 'amh', null, 'Amharic'),
|
||||
'an': LanguageEntry('an', 'arg', null, 'Aragonese'),
|
||||
'ar': LanguageEntry('ar', 'ara', null, 'Arabic'),
|
||||
'as': LanguageEntry('as', 'asm', null, 'Assamese'),
|
||||
'av': LanguageEntry('av', 'ava', null, 'Avaric'),
|
||||
'ay': LanguageEntry('ay', 'aym', null, 'Aymara'),
|
||||
'az': LanguageEntry('az', 'aze', null, 'Azerbaijani'),
|
||||
'ba': LanguageEntry('ba', 'bak', null, 'Bashkir'),
|
||||
'be': LanguageEntry('be', 'bel', null, 'Belarusian'),
|
||||
'bg': LanguageEntry('bg', 'bul', null, 'Bulgarian'),
|
||||
'bh': LanguageEntry('bh', 'bih', null, 'Bihari'),
|
||||
'bi': LanguageEntry('bi', 'bis', null, 'Bislama'),
|
||||
'bm': LanguageEntry('bm', 'bam', null, 'Bambara'),
|
||||
'bn': LanguageEntry('bn', 'ben', null, 'Bengali, Bangla'),
|
||||
'bo': LanguageEntry('bo', 'bod', 'tib', 'Tibetan Standard, Tibetan, Central'),
|
||||
'br': LanguageEntry('br', 'bre', null, 'Breton'),
|
||||
'bs': LanguageEntry('bs', 'bos', null, 'Bosnian'),
|
||||
'ca': LanguageEntry('ca', 'cat', null, 'Catalan'),
|
||||
'ce': LanguageEntry('ce', 'che', null, 'Chechen'),
|
||||
'ch': LanguageEntry('ch', 'cha', null, 'Chamorro'),
|
||||
'co': LanguageEntry('co', 'cos', null, 'Corsican'),
|
||||
'cr': LanguageEntry('cr', 'cre', null, 'Cree'),
|
||||
'cs': LanguageEntry('cs', 'ces', 'cze', 'Czech'),
|
||||
'cu': LanguageEntry('cu', 'chu', null, 'Old Church Slavonic, Church Slavonic, Old Bulgarian'),
|
||||
'cv': LanguageEntry('cv', 'chv', null, 'Chuvash'),
|
||||
'cy': LanguageEntry('cy', 'cym', 'wel', 'Welsh'),
|
||||
'da': LanguageEntry('da', 'dan', null, 'Danish'),
|
||||
'de': LanguageEntry('de', 'deu', 'ger', 'German'),
|
||||
'dv': LanguageEntry('dv', 'div', null, 'Divehi, Dhivehi, Maldivian'),
|
||||
'dz': LanguageEntry('dz', 'dzo', null, 'Dzongkha'),
|
||||
'ee': LanguageEntry('ee', 'ewe', null, 'Ewe'),
|
||||
'el': LanguageEntry('el', 'ell', 'gre', 'Greek (modern)'),
|
||||
'en': LanguageEntry('en', 'eng', null, 'English'),
|
||||
'eo': LanguageEntry('eo', 'epo', null, 'Esperanto'),
|
||||
'es': LanguageEntry('es', 'spa', null, 'Spanish'),
|
||||
'et': LanguageEntry('et', 'est', null, 'Estonian'),
|
||||
'eu': LanguageEntry('eu', 'eus', 'baq', 'Basque'),
|
||||
'fa': LanguageEntry('fa', 'fas', 'per', 'Persian (Farsi)'),
|
||||
'ff': LanguageEntry('ff', 'ful', null, 'Fula, Fulah, Pulaar, Pular'),
|
||||
'fi': LanguageEntry('fi', 'fin', null, 'Finnish'),
|
||||
'fj': LanguageEntry('fj', 'fij', null, 'Fijian'),
|
||||
'fo': LanguageEntry('fo', 'fao', null, 'Faroese'),
|
||||
'fr': LanguageEntry('fr', 'fra', 'fre', 'French'),
|
||||
'fy': LanguageEntry('fy', 'fry', null, 'Western Frisian'),
|
||||
'ga': LanguageEntry('ga', 'gle', null, 'Irish'),
|
||||
'gd': LanguageEntry('gd', 'gla', null, 'Scottish Gaelic, Gaelic'),
|
||||
'gl': LanguageEntry('gl', 'glg', null, 'Galician'),
|
||||
'gn': LanguageEntry('gn', 'grn', null, 'Guaraní'),
|
||||
'gu': LanguageEntry('gu', 'guj', null, 'Gujarati'),
|
||||
'gv': LanguageEntry('gv', 'glv', null, 'Manx'),
|
||||
'ha': LanguageEntry('ha', 'hau', null, 'Hausa'),
|
||||
'he': LanguageEntry('he', 'heb', null, 'Hebrew (modern)'),
|
||||
'hi': LanguageEntry('hi', 'hin', null, 'Hindi'),
|
||||
'ho': LanguageEntry('ho', 'hmo', null, 'Hiri Motu'),
|
||||
'hr': LanguageEntry('hr', 'hrv', null, 'Croatian'),
|
||||
'ht': LanguageEntry('ht', 'hat', null, 'Haitian, Haitian Creole'),
|
||||
'hu': LanguageEntry('hu', 'hun', null, 'Hungarian'),
|
||||
'hy': LanguageEntry('hy', 'hye', 'arm', 'Armenian'),
|
||||
'hz': LanguageEntry('hz', 'her', null, 'Herero'),
|
||||
'ia': LanguageEntry('ia', 'ina', null, 'Interlingua'),
|
||||
'id': LanguageEntry('id', 'ind', null, 'Indonesian'),
|
||||
'ie': LanguageEntry('ie', 'ile', null, 'Interlingue'),
|
||||
'ig': LanguageEntry('ig', 'ibo', null, 'Igbo'),
|
||||
'ii': LanguageEntry('ii', 'iii', null, 'Nuosu'),
|
||||
'ik': LanguageEntry('ik', 'ipk', null, 'Inupiaq'),
|
||||
'io': LanguageEntry('io', 'ido', null, 'Ido'),
|
||||
'is': LanguageEntry('is', 'isl', 'ice', 'Icelandic'),
|
||||
'it': LanguageEntry('it', 'ita', null, 'Italian'),
|
||||
'iu': LanguageEntry('iu', 'iku', null, 'Inuktitut'),
|
||||
'ja': LanguageEntry('ja', 'jpn', null, 'Japanese'),
|
||||
'jv': LanguageEntry('jv', 'jav', null, 'Javanese'),
|
||||
'ka': LanguageEntry('ka', 'kat', 'geo', 'Georgian'),
|
||||
'kg': LanguageEntry('kg', 'kon', null, 'Kongo'),
|
||||
'ki': LanguageEntry('ki', 'kik', null, 'Kikuyu, Gikuyu'),
|
||||
'kj': LanguageEntry('kj', 'kua', null, 'Kwanyama, Kuanyama'),
|
||||
'kk': LanguageEntry('kk', 'kaz', null, 'Kazakh'),
|
||||
'kl': LanguageEntry('kl', 'kal', null, 'Kalaallisut, Greenlandic'),
|
||||
'km': LanguageEntry('km', 'khm', null, 'Khmer'),
|
||||
'kn': LanguageEntry('kn', 'kan', null, 'Kannada'),
|
||||
'ko': LanguageEntry('ko', 'kor', null, 'Korean'),
|
||||
'kr': LanguageEntry('kr', 'kau', null, 'Kanuri'),
|
||||
'ks': LanguageEntry('ks', 'kas', null, 'Kashmiri'),
|
||||
'ku': LanguageEntry('ku', 'kur', null, 'Kurdish'),
|
||||
'kv': LanguageEntry('kv', 'kom', null, 'Komi'),
|
||||
'kw': LanguageEntry('kw', 'cor', null, 'Cornish'),
|
||||
'ky': LanguageEntry('ky', 'kir', null, 'Kyrgyz'),
|
||||
'la': LanguageEntry('la', 'lat', null, 'Latin'),
|
||||
'lb': LanguageEntry('lb', 'ltz', null, 'Luxembourgish, Letzeburgesch'),
|
||||
'lg': LanguageEntry('lg', 'lug', null, 'Ganda'),
|
||||
'li': LanguageEntry('li', 'lim', null, 'Limburgish, Limburgan, Limburger'),
|
||||
'ln': LanguageEntry('ln', 'lin', null, 'Lingala'),
|
||||
'lo': LanguageEntry('lo', 'lao', null, 'Lao'),
|
||||
'lt': LanguageEntry('lt', 'lit', null, 'Lithuanian'),
|
||||
'lu': LanguageEntry('lu', 'lub', null, 'Luba-Katanga'),
|
||||
'lv': LanguageEntry('lv', 'lav', null, 'Latvian'),
|
||||
'mg': LanguageEntry('mg', 'mlg', null, 'Malagasy'),
|
||||
'mh': LanguageEntry('mh', 'mah', null, 'Marshallese'),
|
||||
'mi': LanguageEntry('mi', 'mri', 'mao', 'Māori'),
|
||||
'mk': LanguageEntry('mk', 'mkd', 'mac', 'Macedonian'),
|
||||
'ml': LanguageEntry('ml', 'mal', null, 'Malayalam'),
|
||||
'mn': LanguageEntry('mn', 'mon', null, 'Mongolian'),
|
||||
'mr': LanguageEntry('mr', 'mar', null, 'Marathi (Marāṭhī)'),
|
||||
'ms': LanguageEntry('ms', 'msa', 'may', 'Malay'),
|
||||
'mt': LanguageEntry('mt', 'mlt', null, 'Maltese'),
|
||||
'my': LanguageEntry('my', 'mya', 'bur', 'Burmese'),
|
||||
'na': LanguageEntry('na', 'nau', null, 'Nauruan'),
|
||||
'nb': LanguageEntry('nb', 'nob', null, 'Norwegian Bokmål'),
|
||||
'nd': LanguageEntry('nd', 'nde', null, 'Northern Ndebele'),
|
||||
'ne': LanguageEntry('ne', 'nep', null, 'Nepali'),
|
||||
'ng': LanguageEntry('ng', 'ndo', null, 'Ndonga'),
|
||||
'nl': LanguageEntry('nl', 'nld', 'dut', 'Dutch'),
|
||||
'nn': LanguageEntry('nn', 'nno', null, 'Norwegian Nynorsk'),
|
||||
'no': LanguageEntry('no', 'nor', null, 'Norwegian'),
|
||||
'nr': LanguageEntry('nr', 'nbl', null, 'Southern Ndebele'),
|
||||
'nv': LanguageEntry('nv', 'nav', null, 'Navajo, Navaho'),
|
||||
'ny': LanguageEntry('ny', 'nya', null, 'Chichewa, Chewa, Nyanja'),
|
||||
'oc': LanguageEntry('oc', 'oci', null, 'Occitan'),
|
||||
'oj': LanguageEntry('oj', 'oji', null, 'Ojibwe, Ojibwa'),
|
||||
'om': LanguageEntry('om', 'orm', null, 'Oromo'),
|
||||
'or': LanguageEntry('or', 'ori', null, 'Oriya'),
|
||||
'os': LanguageEntry('os', 'oss', null, 'Ossetian, Ossetic'),
|
||||
'pa': LanguageEntry('pa', 'pan', null, '(Eastern) Punjabi'),
|
||||
'pi': LanguageEntry('pi', 'pli', null, 'Pāli'),
|
||||
'pl': LanguageEntry('pl', 'pol', null, 'Polish'),
|
||||
'ps': LanguageEntry('ps', 'pus', null, 'Pashto, Pushto'),
|
||||
'pt': LanguageEntry('pt', 'por', null, 'Portuguese'),
|
||||
'qu': LanguageEntry('qu', 'que', null, 'Quechua'),
|
||||
'rm': LanguageEntry('rm', 'roh', null, 'Romansh'),
|
||||
'rn': LanguageEntry('rn', 'run', null, 'Kirundi'),
|
||||
'ro': LanguageEntry('ro', 'ron', 'rum', 'Romanian'),
|
||||
'ru': LanguageEntry('ru', 'rus', null, 'Russian'),
|
||||
'rw': LanguageEntry('rw', 'kin', null, 'Kinyarwanda'),
|
||||
'sa': LanguageEntry('sa', 'san', null, 'Sanskrit (Saṁskṛta)'),
|
||||
'sc': LanguageEntry('sc', 'srd', null, 'Sardinian'),
|
||||
'sd': LanguageEntry('sd', 'snd', null, 'Sindhi'),
|
||||
'se': LanguageEntry('se', 'sme', null, 'Northern Sami'),
|
||||
'sg': LanguageEntry('sg', 'sag', null, 'Sango'),
|
||||
'si': LanguageEntry('si', 'sin', null, 'Sinhalese, Sinhala'),
|
||||
'sk': LanguageEntry('sk', 'slk', 'slo', 'Slovak'),
|
||||
'sl': LanguageEntry('sl', 'slv', null, 'Slovene'),
|
||||
'sm': LanguageEntry('sm', 'smo', null, 'Samoan'),
|
||||
'sn': LanguageEntry('sn', 'sna', null, 'Shona'),
|
||||
'so': LanguageEntry('so', 'som', null, 'Somali'),
|
||||
'sq': LanguageEntry('sq', 'sqi', 'alb', 'Albanian'),
|
||||
'sr': LanguageEntry('sr', 'srp', null, 'Serbian'),
|
||||
'ss': LanguageEntry('ss', 'ssw', null, 'Swati'),
|
||||
'st': LanguageEntry('st', 'sot', null, 'Southern Sotho'),
|
||||
'su': LanguageEntry('su', 'sun', null, 'Sundanese'),
|
||||
'sv': LanguageEntry('sv', 'swe', null, 'Swedish'),
|
||||
'sw': LanguageEntry('sw', 'swa', null, 'Swahili'),
|
||||
'ta': LanguageEntry('ta', 'tam', null, 'Tamil'),
|
||||
'te': LanguageEntry('te', 'tel', null, 'Telugu'),
|
||||
'tg': LanguageEntry('tg', 'tgk', null, 'Tajik'),
|
||||
'th': LanguageEntry('th', 'tha', null, 'Thai'),
|
||||
'ti': LanguageEntry('ti', 'tir', null, 'Tigrinya'),
|
||||
'tk': LanguageEntry('tk', 'tuk', null, 'Turkmen'),
|
||||
'tl': LanguageEntry('tl', 'tgl', null, 'Tagalog'),
|
||||
'tn': LanguageEntry('tn', 'tsn', null, 'Tswana'),
|
||||
'to': LanguageEntry('to', 'ton', null, 'Tonga (Tonga Islands)'),
|
||||
'tr': LanguageEntry('tr', 'tur', null, 'Turkish'),
|
||||
'ts': LanguageEntry('ts', 'tso', null, 'Tsonga'),
|
||||
'tt': LanguageEntry('tt', 'tat', null, 'Tatar'),
|
||||
'tw': LanguageEntry('tw', 'twi', null, 'Twi'),
|
||||
'ty': LanguageEntry('ty', 'tah', null, 'Tahitian'),
|
||||
'ug': LanguageEntry('ug', 'uig', null, 'Uyghur'),
|
||||
'uk': LanguageEntry('uk', 'ukr', null, 'Ukrainian'),
|
||||
'ur': LanguageEntry('ur', 'urd', null, 'Urdu'),
|
||||
'uz': LanguageEntry('uz', 'uzb', null, 'Uzbek'),
|
||||
've': LanguageEntry('ve', 'ven', null, 'Venda'),
|
||||
'vi': LanguageEntry('vi', 'vie', null, 'Vietnamese'),
|
||||
'vo': LanguageEntry('vo', 'vol', null, 'Volapük'),
|
||||
'wa': LanguageEntry('wa', 'wln', null, 'Walloon'),
|
||||
'wo': LanguageEntry('wo', 'wol', null, 'Wolof'),
|
||||
'xh': LanguageEntry('xh', 'xho', null, 'Xhosa'),
|
||||
'yi': LanguageEntry('yi', 'yid', null, 'Yiddish'),
|
||||
'yo': LanguageEntry('yo', 'yor', null, 'Yoruba'),
|
||||
'za': LanguageEntry('za', 'zha', null, 'Zhuang, Chuang'),
|
||||
'zh': LanguageEntry('zh', 'zho', 'chi', 'Chinese'),
|
||||
'zu': LanguageEntry('zu', 'zul', null, 'Zulu'),
|
||||
};
|
||||
|
||||
const code2ToCode1 = <String, String>{
|
||||
'aar': 'aa',
|
||||
'abk': 'ab',
|
||||
'ave': 'ae',
|
||||
'afr': 'af',
|
||||
'aka': 'ak',
|
||||
'amh': 'am',
|
||||
'arg': 'an',
|
||||
'ara': 'ar',
|
||||
'asm': 'as',
|
||||
'ava': 'av',
|
||||
'aym': 'ay',
|
||||
'aze': 'az',
|
||||
'bak': 'ba',
|
||||
'bel': 'be',
|
||||
'bul': 'bg',
|
||||
'bih': 'bh',
|
||||
'bis': 'bi',
|
||||
'bam': 'bm',
|
||||
'ben': 'bn',
|
||||
'bod': 'bo',
|
||||
'bre': 'br',
|
||||
'bos': 'bs',
|
||||
'cat': 'ca',
|
||||
'che': 'ce',
|
||||
'cha': 'ch',
|
||||
'cos': 'co',
|
||||
'cre': 'cr',
|
||||
'ces': 'cs',
|
||||
'chu': 'cu',
|
||||
'chv': 'cv',
|
||||
'cym': 'cy',
|
||||
'dan': 'da',
|
||||
'deu': 'de',
|
||||
'div': 'dv',
|
||||
'dzo': 'dz',
|
||||
'ewe': 'ee',
|
||||
'ell': 'el',
|
||||
'eng': 'en',
|
||||
'epo': 'eo',
|
||||
'spa': 'es',
|
||||
'est': 'et',
|
||||
'eus': 'eu',
|
||||
'fas': 'fa',
|
||||
'ful': 'ff',
|
||||
'fin': 'fi',
|
||||
'fij': 'fj',
|
||||
'fao': 'fo',
|
||||
'fra': 'fr',
|
||||
'fry': 'fy',
|
||||
'gle': 'ga',
|
||||
'gla': 'gd',
|
||||
'glg': 'gl',
|
||||
'grn': 'gn',
|
||||
'guj': 'gu',
|
||||
'glv': 'gv',
|
||||
'hau': 'ha',
|
||||
'heb': 'he',
|
||||
'hin': 'hi',
|
||||
'hmo': 'ho',
|
||||
'hrv': 'hr',
|
||||
'hat': 'ht',
|
||||
'hun': 'hu',
|
||||
'hye': 'hy',
|
||||
'her': 'hz',
|
||||
'ina': 'ia',
|
||||
'ind': 'id',
|
||||
'ile': 'ie',
|
||||
'ibo': 'ig',
|
||||
'iii': 'ii',
|
||||
'ipk': 'ik',
|
||||
'ido': 'io',
|
||||
'isl': 'is',
|
||||
'ita': 'it',
|
||||
'iku': 'iu',
|
||||
'jpn': 'ja',
|
||||
'jav': 'jv',
|
||||
'kat': 'ka',
|
||||
'kon': 'kg',
|
||||
'kik': 'ki',
|
||||
'kua': 'kj',
|
||||
'kaz': 'kk',
|
||||
'kal': 'kl',
|
||||
'khm': 'km',
|
||||
'kan': 'kn',
|
||||
'kor': 'ko',
|
||||
'kau': 'kr',
|
||||
'kas': 'ks',
|
||||
'kur': 'ku',
|
||||
'kom': 'kv',
|
||||
'cor': 'kw',
|
||||
'kir': 'ky',
|
||||
'lat': 'la',
|
||||
'ltz': 'lb',
|
||||
'lug': 'lg',
|
||||
'lim': 'li',
|
||||
'lin': 'ln',
|
||||
'lao': 'lo',
|
||||
'lit': 'lt',
|
||||
'lub': 'lu',
|
||||
'lav': 'lv',
|
||||
'mlg': 'mg',
|
||||
'mah': 'mh',
|
||||
'mri': 'mi',
|
||||
'mkd': 'mk',
|
||||
'mal': 'ml',
|
||||
'mon': 'mn',
|
||||
'mar': 'mr',
|
||||
'msa': 'ms',
|
||||
'mlt': 'mt',
|
||||
'mya': 'my',
|
||||
'nau': 'na',
|
||||
'nob': 'nb',
|
||||
'nde': 'nd',
|
||||
'nep': 'ne',
|
||||
'ndo': 'ng',
|
||||
'nld': 'nl',
|
||||
'nno': 'nn',
|
||||
'nor': 'no',
|
||||
'nbl': 'nr',
|
||||
'nav': 'nv',
|
||||
'nya': 'ny',
|
||||
'oci': 'oc',
|
||||
'oji': 'oj',
|
||||
'orm': 'om',
|
||||
'ori': 'or',
|
||||
'oss': 'os',
|
||||
'pan': 'pa',
|
||||
'pli': 'pi',
|
||||
'pol': 'pl',
|
||||
'pus': 'ps',
|
||||
'por': 'pt',
|
||||
'que': 'qu',
|
||||
'roh': 'rm',
|
||||
'run': 'rn',
|
||||
'ron': 'ro',
|
||||
'rus': 'ru',
|
||||
'kin': 'rw',
|
||||
'san': 'sa',
|
||||
'srd': 'sc',
|
||||
'snd': 'sd',
|
||||
'sme': 'se',
|
||||
'sag': 'sg',
|
||||
'sin': 'si',
|
||||
'slk': 'sk',
|
||||
'slv': 'sl',
|
||||
'smo': 'sm',
|
||||
'sna': 'sn',
|
||||
'som': 'so',
|
||||
'sqi': 'sq',
|
||||
'srp': 'sr',
|
||||
'ssw': 'ss',
|
||||
'sot': 'st',
|
||||
'sun': 'su',
|
||||
'swe': 'sv',
|
||||
'swa': 'sw',
|
||||
'tam': 'ta',
|
||||
'tel': 'te',
|
||||
'tgk': 'tg',
|
||||
'tha': 'th',
|
||||
'tir': 'ti',
|
||||
'tuk': 'tk',
|
||||
'tgl': 'tl',
|
||||
'tsn': 'tn',
|
||||
'ton': 'to',
|
||||
'tur': 'tr',
|
||||
'tso': 'ts',
|
||||
'tat': 'tt',
|
||||
'twi': 'tw',
|
||||
'tah': 'ty',
|
||||
'uig': 'ug',
|
||||
'ukr': 'uk',
|
||||
'urd': 'ur',
|
||||
'uzb': 'uz',
|
||||
'ven': 've',
|
||||
'vie': 'vi',
|
||||
'vol': 'vo',
|
||||
'wln': 'wa',
|
||||
'wol': 'wo',
|
||||
'xho': 'xh',
|
||||
'yid': 'yi',
|
||||
'yor': 'yo',
|
||||
'zha': 'za',
|
||||
'zho': 'zh',
|
||||
'zul': 'zu',
|
||||
};
|
||||
|
||||
const code2BToCode1 = <String, String>{
|
||||
'tib': 'bo',
|
||||
'cze': 'cs',
|
||||
'wel': 'cy',
|
||||
'ger': 'de',
|
||||
'gre': 'el',
|
||||
'baq': 'eu',
|
||||
'per': 'fa',
|
||||
'fre': 'fr',
|
||||
'arm': 'hy',
|
||||
'ice': 'is',
|
||||
'geo': 'ka',
|
||||
'mao': 'mi',
|
||||
'mac': 'mk',
|
||||
'may': 'ms',
|
||||
'bur': 'my',
|
||||
'dut': 'nl',
|
||||
'rum': 'ro',
|
||||
'slo': 'sk',
|
||||
'alb': 'sq',
|
||||
'chi': 'zh',
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:io';
|
||||
import '../media/ids.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
@@ -11,17 +13,68 @@ import '../utils/global_key_utils.dart';
|
||||
|
||||
part 'app_database.g.dart';
|
||||
|
||||
// Simplified database with API cache for offline support
|
||||
@DriftDatabase(tables: [DownloadedMedia, DownloadQueue, ApiCache, OfflineWatchProgress])
|
||||
/// Action queued in the offline watch-progress sync table. The serialized
|
||||
/// form ([id]) is what gets persisted in [OfflineWatchProgress.actionType];
|
||||
/// keep these strings stable across renames so existing rows resolve.
|
||||
enum OfflineActionType {
|
||||
progress,
|
||||
watched,
|
||||
unwatched;
|
||||
|
||||
/// Stable string id used for persistence. Survives an enum-name rename
|
||||
/// (e.g. `progress` → `inProgress`) — `.name` would corrupt every row.
|
||||
String get id => switch (this) {
|
||||
OfflineActionType.progress => 'progress',
|
||||
OfflineActionType.watched => 'watched',
|
||||
OfflineActionType.unwatched => 'unwatched',
|
||||
};
|
||||
|
||||
/// Inverse of [id]. Throws on unknown so a typo in production doesn't
|
||||
/// silently fall back to the wrong action.
|
||||
static OfflineActionType fromId(String id) => switch (id) {
|
||||
'progress' => OfflineActionType.progress,
|
||||
'watched' => OfflineActionType.watched,
|
||||
'unwatched' => OfflineActionType.unwatched,
|
||||
_ => throw ArgumentError('Unknown OfflineActionType id: $id'),
|
||||
};
|
||||
}
|
||||
|
||||
@DriftDatabase(
|
||||
tables: [
|
||||
DownloadedMedia,
|
||||
DownloadOwners,
|
||||
DownloadQueue,
|
||||
ApiCache,
|
||||
OfflineWatchProgress,
|
||||
SyncRules,
|
||||
Connections,
|
||||
Profiles,
|
||||
ProfileConnections,
|
||||
],
|
||||
)
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase() : super(_openConnection());
|
||||
|
||||
/// Test-only constructor — inject an in-memory [QueryExecutor]
|
||||
/// (e.g. `NativeDatabase.memory()`) so tests don't touch real disk.
|
||||
@visibleForTesting
|
||||
AppDatabase.forTesting(super.e);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 8; // Added bgTaskId column to DownloadedMedia
|
||||
int get schemaVersion => 15;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
return MigrationStrategy(
|
||||
// Enforce ProfileConnections → Profiles/Connections cascades.
|
||||
// Drift turns FKs *off* during migrations, so the per-connection
|
||||
// pragma we set in `_openConnection` is wiped on first open. This
|
||||
// hook runs after migrations and re-enables it for subsequent
|
||||
// queries — also applies to in-memory test databases that don't go
|
||||
// through `_openConnection`.
|
||||
beforeOpen: (details) async {
|
||||
await customStatement('PRAGMA foreign_keys = ON');
|
||||
},
|
||||
onCreate: (Migrator m) async {
|
||||
await m.createAll();
|
||||
},
|
||||
@@ -32,59 +85,287 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
if (from < 8) {
|
||||
appLogger.i('Adding bgTaskId column to DownloadedMedia (v8 migration)');
|
||||
try {
|
||||
await m.addColumn(downloadedMedia, downloadedMedia.bgTaskId);
|
||||
} catch (e) {
|
||||
appLogger.w('bgTaskId column may already exist: $e');
|
||||
await _ignoreAlreadyExists(
|
||||
'DownloadedMedia.bgTaskId column',
|
||||
() => m.addColumn(downloadedMedia, downloadedMedia.bgTaskId),
|
||||
);
|
||||
}
|
||||
if (from < 9) {
|
||||
appLogger.i('Adding mediaIndex column to DownloadedMedia (v9 migration)');
|
||||
await _ignoreAlreadyExists(
|
||||
'DownloadedMedia.mediaIndex column',
|
||||
() => m.addColumn(downloadedMedia, downloadedMedia.mediaIndex),
|
||||
);
|
||||
}
|
||||
if (from < 10) {
|
||||
appLogger.i('Adding SyncRules table (v10 migration)');
|
||||
await m.createTable(syncRules);
|
||||
}
|
||||
if (from < 11) {
|
||||
appLogger.i('Adding enabled column to SyncRules (v11 migration)');
|
||||
await _ignoreAlreadyExists('SyncRules.enabled column', () => m.addColumn(syncRules, syncRules.enabled));
|
||||
}
|
||||
if (from < 12) {
|
||||
appLogger.i('Adding downloadFilter column to SyncRules (v12 migration)');
|
||||
await _ignoreAlreadyExists(
|
||||
'SyncRules.downloadFilter column',
|
||||
() => m.addColumn(syncRules, syncRules.downloadFilter),
|
||||
);
|
||||
}
|
||||
if (from < 13) {
|
||||
appLogger.i('Adding indexes on DownloadedMedia hot-queried columns (v13 migration)');
|
||||
final indexes = {
|
||||
'idx_downloaded_media_status': idxDownloadedMediaStatus,
|
||||
'idx_downloaded_media_server': idxDownloadedMediaServer,
|
||||
'idx_downloaded_media_parent': idxDownloadedMediaParent,
|
||||
'idx_downloaded_media_grandparent': idxDownloadedMediaGrandparent,
|
||||
};
|
||||
for (final entry in indexes.entries) {
|
||||
await _ignoreAlreadyExists('Index ${entry.key}', () => m.create(entry.value));
|
||||
}
|
||||
}
|
||||
if (from < 14) {
|
||||
appLogger.i(
|
||||
'Adding Connections, Profiles, ProfileConnections, DownloadOwners + scope/profile columns (v14 migration)',
|
||||
);
|
||||
|
||||
await m.createTable(connections);
|
||||
await _ignoreAlreadyExists('Index idx_connections_kind', () => m.create(idxConnectionsKind));
|
||||
|
||||
await m.createTable(profiles);
|
||||
await _ignoreAlreadyExists('Index idx_profiles_kind', () => m.create(idxProfilesKind));
|
||||
|
||||
await m.createTable(profileConnections);
|
||||
await _ignoreAlreadyExists(
|
||||
'Index idx_profile_connections_connection_id',
|
||||
() => m.create(idxProfileConnectionsConnectionId),
|
||||
);
|
||||
await _ignoreAlreadyExists(
|
||||
'Index idx_profile_connections_profile_id',
|
||||
() => m.create(idxProfileConnectionsProfileId),
|
||||
);
|
||||
|
||||
await _ignoreAlreadyExists('DownloadOwners table', () => m.createTable(downloadOwners));
|
||||
await _ignoreAlreadyExists('Index idx_download_owners_profile', () => m.create(idxDownloadOwnersProfile));
|
||||
await _ignoreAlreadyExists(
|
||||
'Index idx_download_owners_global_key',
|
||||
() => m.create(idxDownloadOwnersGlobalKey),
|
||||
);
|
||||
|
||||
await _ignoreAlreadyExists(
|
||||
'DownloadedMedia.clientScopeId column',
|
||||
() => m.addColumn(downloadedMedia, downloadedMedia.clientScopeId),
|
||||
);
|
||||
await _ignoreAlreadyExists(
|
||||
'OfflineWatchProgress.clientScopeId column',
|
||||
() => m.addColumn(offlineWatchProgress, offlineWatchProgress.clientScopeId),
|
||||
);
|
||||
await _ignoreAlreadyExists('SyncRules.profileId column', () => m.addColumn(syncRules, syncRules.profileId));
|
||||
await _ignoreAlreadyExists(
|
||||
'OfflineWatchProgress.profileId column',
|
||||
() => m.addColumn(offlineWatchProgress, offlineWatchProgress.profileId),
|
||||
);
|
||||
|
||||
await customStatement('''
|
||||
UPDATE downloaded_media
|
||||
SET client_scope_id = (
|
||||
SELECT id FROM connections
|
||||
WHERE kind = 'jellyfin'
|
||||
AND substr(id, 1, length(downloaded_media.server_id) + 1) = downloaded_media.server_id || '/'
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE client_scope_id IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM connections
|
||||
WHERE kind = 'jellyfin'
|
||||
AND substr(id, 1, length(downloaded_media.server_id) + 1) = downloaded_media.server_id || '/'
|
||||
)
|
||||
''');
|
||||
await customStatement('''
|
||||
UPDATE offline_watch_progress
|
||||
SET client_scope_id = (
|
||||
SELECT id FROM connections
|
||||
WHERE kind = 'jellyfin'
|
||||
AND substr(id, 1, length(offline_watch_progress.server_id) + 1) = offline_watch_progress.server_id || '/'
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE client_scope_id IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM connections
|
||||
WHERE kind = 'jellyfin'
|
||||
AND substr(id, 1, length(offline_watch_progress.server_id) + 1) = offline_watch_progress.server_id || '/'
|
||||
)
|
||||
''');
|
||||
|
||||
await _ignoreAlreadyExists(
|
||||
'Index idx_offline_watch_progress_server',
|
||||
() => m.create(idxOfflineWatchProgressServer),
|
||||
);
|
||||
await _ignoreAlreadyExists('Index idx_sync_rules_profile', () => m.create(idxSyncRulesProfile));
|
||||
await _ignoreAlreadyExists(
|
||||
'Index idx_offline_watch_progress_profile',
|
||||
() => m.create(idxOfflineWatchProgressProfile),
|
||||
);
|
||||
}
|
||||
if (from < 15) {
|
||||
appLogger.i('Adding mediaSourceId column to DownloadedMedia (v15 migration)');
|
||||
await _ignoreAlreadyExists(
|
||||
'DownloadedMedia.mediaSourceId column',
|
||||
() => m.addColumn(downloadedMedia, downloadedMedia.mediaSourceId),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Offline Watch Progress Operations
|
||||
// ============================================================
|
||||
Future<void> _ignoreAlreadyExists(String label, Future<void> Function() operation) async {
|
||||
try {
|
||||
await operation();
|
||||
} catch (e) {
|
||||
final message = e.toString().toLowerCase();
|
||||
if (message.contains('already exists') || message.contains('duplicate column name')) {
|
||||
appLogger.w('$label already exists during migration: $e');
|
||||
return;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Expression<bool> _clientScopePredicate(GeneratedColumn<String> column, String? clientScopeId) {
|
||||
return clientScopeId == null ? column.isNull() : column.equals(clientScopeId);
|
||||
}
|
||||
|
||||
Expression<bool> _nullableTextPredicate(GeneratedColumn<String> column, String? value) {
|
||||
return value == null ? column.isNull() : column.equals(value);
|
||||
}
|
||||
|
||||
/// Get all pending offline watch actions for sync
|
||||
Future<List<OfflineWatchProgressItem>> getPendingWatchActions() {
|
||||
return (select(offlineWatchProgress)..orderBy([(t) => OrderingTerm.asc(t.createdAt)])).get();
|
||||
Future<List<OfflineWatchProgressItem>> getPendingWatchActions({String? profileId}) {
|
||||
final query = select(offlineWatchProgress)..orderBy([(t) => OrderingTerm.asc(t.createdAt)]);
|
||||
if (profileId != null) {
|
||||
query.where((t) => t.profileId.equals(profileId));
|
||||
}
|
||||
return query.get();
|
||||
}
|
||||
|
||||
/// Claim pre-v18 offline watch actions for [profileId]. Those rows predate
|
||||
/// profile ownership and have `NULL profile_id`; the first active profile
|
||||
/// inherits them so already-watched offline progress is not stranded.
|
||||
Future<void> adoptLegacyOfflineWatchActionsForProfile(String profileId) async {
|
||||
if (profileId.isEmpty) return;
|
||||
await (update(
|
||||
offlineWatchProgress,
|
||||
)..where((t) => t.profileId.isNull())).write(OfflineWatchProgressCompanion(profileId: Value(profileId)));
|
||||
}
|
||||
|
||||
/// Get pending watch actions for a specific server
|
||||
Future<List<OfflineWatchProgressItem>> getPendingWatchActionsForServer(String serverId) {
|
||||
Future<List<OfflineWatchProgressItem>> getPendingWatchActionsForServer(ServerId serverId, {String? profileId}) {
|
||||
return (select(offlineWatchProgress)
|
||||
..where((t) => t.serverId.equals(serverId))
|
||||
..where(
|
||||
(t) =>
|
||||
t.serverId.equals(serverId) &
|
||||
(profileId == null ? const Constant(true) : t.profileId.equals(profileId)),
|
||||
)
|
||||
..orderBy([(t) => OrderingTerm.asc(t.createdAt)]))
|
||||
.get();
|
||||
}
|
||||
|
||||
/// Get the latest action for a specific item
|
||||
Future<OfflineWatchProgressItem?> getLatestWatchAction(String globalKey) {
|
||||
Future<OfflineWatchProgressItem?> getLatestWatchAction(
|
||||
String globalKey, {
|
||||
String? profileId,
|
||||
bool filterProfile = false,
|
||||
String? clientScopeId,
|
||||
bool filterClientScope = false,
|
||||
}) {
|
||||
return (select(offlineWatchProgress)
|
||||
..where((t) => t.globalKey.equals(globalKey))
|
||||
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])
|
||||
..where(
|
||||
(t) =>
|
||||
t.globalKey.equals(globalKey) &
|
||||
(filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)) &
|
||||
(filterClientScope ? _clientScopePredicate(t.clientScopeId, clientScopeId) : const Constant(true)),
|
||||
)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.updatedAt), (t) => OrderingTerm.desc(t.id)])
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<List<OfflineWatchProgressItem>> getWatchActionsForKey(
|
||||
String globalKey, {
|
||||
String? profileId,
|
||||
bool filterProfile = false,
|
||||
String? clientScopeId,
|
||||
bool filterClientScope = false,
|
||||
}) {
|
||||
return (select(offlineWatchProgress)
|
||||
..where(
|
||||
(t) =>
|
||||
t.globalKey.equals(globalKey) &
|
||||
(filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)) &
|
||||
(filterClientScope ? _clientScopePredicate(t.clientScopeId, clientScopeId) : const Constant(true)),
|
||||
)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.updatedAt), (t) => OrderingTerm.desc(t.id)]))
|
||||
.get();
|
||||
}
|
||||
|
||||
Future<Map<String, List<OfflineWatchProgressItem>>> getWatchActionsForKeys(
|
||||
Set<String> globalKeys, {
|
||||
String? profileId,
|
||||
bool filterProfile = false,
|
||||
Map<String, String?>? clientScopeIdsByGlobalKey,
|
||||
}) async {
|
||||
if (globalKeys.isEmpty) return const {};
|
||||
final rows =
|
||||
await (select(offlineWatchProgress)
|
||||
..where(
|
||||
(t) =>
|
||||
t.globalKey.isIn(globalKeys) &
|
||||
(filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)),
|
||||
)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.updatedAt), (t) => OrderingTerm.desc(t.id)]))
|
||||
.get();
|
||||
|
||||
final result = <String, List<OfflineWatchProgressItem>>{};
|
||||
for (final action in rows) {
|
||||
if (clientScopeIdsByGlobalKey != null && clientScopeIdsByGlobalKey.containsKey(action.globalKey)) {
|
||||
final expectedScope = clientScopeIdsByGlobalKey[action.globalKey];
|
||||
if (!_clientScopeValuesMatch(action.clientScopeId, expectedScope)) continue;
|
||||
}
|
||||
result.putIfAbsent(action.globalKey, () => <OfflineWatchProgressItem>[]).add(action);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Get the latest actions for multiple items in a single query
|
||||
///
|
||||
/// Returns a map of globalKey -> latest action for each key.
|
||||
/// Keys with no actions will not be present in the returned map.
|
||||
Future<Map<String, OfflineWatchProgressItem>> getLatestWatchActionsForKeys(Set<String> globalKeys) async {
|
||||
Future<Map<String, OfflineWatchProgressItem>> getLatestWatchActionsForKeys(
|
||||
Set<String> globalKeys, {
|
||||
String? profileId,
|
||||
bool filterProfile = false,
|
||||
Map<String, String?>? clientScopeIdsByGlobalKey,
|
||||
}) async {
|
||||
if (globalKeys.isEmpty) return {};
|
||||
|
||||
// Query all actions for the given keys
|
||||
final allActions =
|
||||
await (select(offlineWatchProgress)
|
||||
..where((t) => t.globalKey.isIn(globalKeys))
|
||||
..orderBy([(t) => OrderingTerm.desc(t.updatedAt)]))
|
||||
..where(
|
||||
(t) =>
|
||||
t.globalKey.isIn(globalKeys) &
|
||||
(filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)),
|
||||
)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.updatedAt), (t) => OrderingTerm.desc(t.id)]))
|
||||
.get();
|
||||
|
||||
// Group by globalKey and take the latest (first due to ordering)
|
||||
final result = <String, OfflineWatchProgressItem>{};
|
||||
for (final action in allActions) {
|
||||
if (clientScopeIdsByGlobalKey != null && clientScopeIdsByGlobalKey.containsKey(action.globalKey)) {
|
||||
final expectedScope = clientScopeIdsByGlobalKey[action.globalKey];
|
||||
if (!_clientScopeValuesMatch(action.clientScopeId, expectedScope)) continue;
|
||||
}
|
||||
// Only keep the first (latest) action for each key
|
||||
result.putIfAbsent(action.globalKey, () => action);
|
||||
}
|
||||
@@ -92,69 +373,101 @@ class AppDatabase extends _$AppDatabase {
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Insert or update a progress action (merges with existing)
|
||||
Future<void> upsertProgressAction({
|
||||
required String serverId,
|
||||
required String ratingKey,
|
||||
required int viewOffset,
|
||||
required int duration,
|
||||
required bool shouldMarkWatched,
|
||||
}) async {
|
||||
final globalKey = buildGlobalKey(serverId, ratingKey);
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
// Check for existing progress entry
|
||||
final existing =
|
||||
await (select(offlineWatchProgress)
|
||||
..where((t) => t.globalKey.equals(globalKey) & t.actionType.equals('progress'))
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
|
||||
if (existing != null) {
|
||||
// Update existing progress entry
|
||||
await (update(offlineWatchProgress)..where((t) => t.id.equals(existing.id))).write(
|
||||
OfflineWatchProgressCompanion(
|
||||
viewOffset: Value(viewOffset),
|
||||
duration: Value(duration),
|
||||
shouldMarkWatched: Value(shouldMarkWatched),
|
||||
updatedAt: Value(now),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Insert new progress entry
|
||||
await into(offlineWatchProgress).insert(
|
||||
OfflineWatchProgressCompanion.insert(
|
||||
serverId: serverId,
|
||||
ratingKey: ratingKey,
|
||||
globalKey: globalKey,
|
||||
actionType: 'progress',
|
||||
viewOffset: Value(viewOffset),
|
||||
duration: Value(duration),
|
||||
shouldMarkWatched: Value(shouldMarkWatched),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
);
|
||||
}
|
||||
bool _clientScopeValuesMatch(String? actual, String? expected) {
|
||||
final normalizedActual = actual == null || actual.isEmpty ? null : actual;
|
||||
final normalizedExpected = expected == null || expected.isEmpty ? null : expected;
|
||||
return normalizedActual == normalizedExpected;
|
||||
}
|
||||
|
||||
/// Insert a manual watch action (watched or unwatched)
|
||||
/// Removes conflicting actions for the same item
|
||||
/// Insert or update a progress action (merges with existing).
|
||||
Future<void> upsertProgressAction({
|
||||
String? profileId,
|
||||
required ServerId serverId,
|
||||
String? clientScopeId,
|
||||
required String ratingKey,
|
||||
required int viewOffset,
|
||||
required int? duration,
|
||||
required bool shouldMarkWatched,
|
||||
}) async {
|
||||
final globalKey = buildGlobalKey(ServerId(serverId), ratingKey);
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
await transaction(() async {
|
||||
final existing =
|
||||
await (select(offlineWatchProgress)
|
||||
..where(
|
||||
(t) =>
|
||||
t.globalKey.equals(globalKey) &
|
||||
_nullableTextPredicate(t.profileId, profileId) &
|
||||
_clientScopePredicate(t.clientScopeId, clientScopeId) &
|
||||
t.actionType.equals(OfflineActionType.progress.id),
|
||||
)
|
||||
..orderBy([(t) => OrderingTerm.asc(t.id)]))
|
||||
.get();
|
||||
|
||||
final keep = existing.isEmpty ? null : existing.first;
|
||||
if (keep != null) {
|
||||
await (update(offlineWatchProgress)..where((t) => t.id.equals(keep.id))).write(
|
||||
OfflineWatchProgressCompanion(
|
||||
viewOffset: Value(viewOffset),
|
||||
duration: Value(duration),
|
||||
shouldMarkWatched: Value(shouldMarkWatched),
|
||||
profileId: Value(profileId),
|
||||
clientScopeId: Value(clientScopeId),
|
||||
updatedAt: Value(now),
|
||||
),
|
||||
);
|
||||
final duplicateIds = existing.skip(1).map((row) => row.id).toList(growable: false);
|
||||
if (duplicateIds.isNotEmpty) {
|
||||
await (delete(offlineWatchProgress)..where((t) => t.id.isIn(duplicateIds))).go();
|
||||
}
|
||||
} else {
|
||||
await into(offlineWatchProgress).insert(
|
||||
OfflineWatchProgressCompanion.insert(
|
||||
serverId: serverId,
|
||||
profileId: Value(profileId),
|
||||
clientScopeId: Value(clientScopeId),
|
||||
ratingKey: ratingKey,
|
||||
globalKey: globalKey,
|
||||
actionType: OfflineActionType.progress.id,
|
||||
viewOffset: Value(viewOffset),
|
||||
duration: Value(duration),
|
||||
shouldMarkWatched: Value(shouldMarkWatched),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Insert a manual watch action (watched or unwatched).
|
||||
/// Removes conflicting actions for the same item.
|
||||
Future<void> insertWatchAction({
|
||||
required String serverId,
|
||||
String? profileId,
|
||||
required ServerId serverId,
|
||||
String? clientScopeId,
|
||||
required String ratingKey,
|
||||
required String actionType, // 'watched' or 'unwatched'
|
||||
}) async {
|
||||
final globalKey = buildGlobalKey(serverId, ratingKey);
|
||||
final globalKey = buildGlobalKey(ServerId(serverId), ratingKey);
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
// Remove conflicting actions (opposite action type and progress)
|
||||
await (delete(offlineWatchProgress)..where((t) => t.globalKey.equals(globalKey))).go();
|
||||
await (delete(offlineWatchProgress)..where(
|
||||
(t) =>
|
||||
t.globalKey.equals(globalKey) &
|
||||
_nullableTextPredicate(t.profileId, profileId) &
|
||||
_clientScopePredicate(t.clientScopeId, clientScopeId),
|
||||
))
|
||||
.go();
|
||||
|
||||
// Insert the new action
|
||||
await into(offlineWatchProgress).insert(
|
||||
OfflineWatchProgressCompanion.insert(
|
||||
serverId: serverId,
|
||||
profileId: Value(profileId),
|
||||
clientScopeId: Value(clientScopeId),
|
||||
ratingKey: ratingKey,
|
||||
globalKey: globalKey,
|
||||
actionType: actionType,
|
||||
@@ -181,10 +494,15 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
|
||||
/// Get count of pending sync items
|
||||
Future<int> getPendingSyncCount() async {
|
||||
final count = await (selectOnly(offlineWatchProgress)..addColumns([offlineWatchProgress.id.count()]))
|
||||
.map((row) => row.read(offlineWatchProgress.id.count()))
|
||||
.getSingle();
|
||||
Future<int> getPendingSyncCount({String? profileId, int? maxSyncAttempts}) async {
|
||||
final query = selectOnly(offlineWatchProgress)..addColumns([offlineWatchProgress.id.count()]);
|
||||
if (profileId != null) {
|
||||
query.where(offlineWatchProgress.profileId.equals(profileId));
|
||||
}
|
||||
if (maxSyncAttempts != null) {
|
||||
query.where(offlineWatchProgress.syncAttempts.isSmallerThanValue(maxSyncAttempts));
|
||||
}
|
||||
final count = await query.map((row) => row.read(offlineWatchProgress.id.count())).getSingle();
|
||||
return count ?? 0;
|
||||
}
|
||||
|
||||
@@ -193,9 +511,105 @@ class AppDatabase extends _$AppDatabase {
|
||||
return delete(offlineWatchProgress).go();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Downloaded Media Queries for Watch State Sync
|
||||
// ============================================================
|
||||
Future<List<SyncRuleItem>> getSyncRules({String? profileId}) {
|
||||
final query = select(syncRules);
|
||||
if (profileId != null) {
|
||||
query.where((t) => t.profileId.equals(profileId));
|
||||
}
|
||||
return query.get();
|
||||
}
|
||||
|
||||
Future<SyncRuleItem?> getSyncRule(String globalKey) {
|
||||
return (select(syncRules)..where((t) => t.globalKey.equals(globalKey))).getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<void> insertSyncRule({
|
||||
String profileId = '',
|
||||
required ServerId serverId,
|
||||
required String ratingKey,
|
||||
required String globalKey,
|
||||
required String targetType,
|
||||
required int episodeCount,
|
||||
int mediaIndex = 0,
|
||||
String downloadFilter = 'unwatched',
|
||||
}) async {
|
||||
// [insertOnConflictUpdate] defaults the conflict target to the primary
|
||||
// key (`id`), which is auto-incremented — the conflict never triggers
|
||||
// and the row's UNIQUE [globalKey] constraint blows up instead. Drive
|
||||
// the upsert off the public [globalKey] so re-creating a rule for the same
|
||||
// shared target updates the existing row.
|
||||
await into(syncRules).insert(
|
||||
SyncRulesCompanion.insert(
|
||||
serverId: serverId,
|
||||
profileId: Value(profileId),
|
||||
ratingKey: ratingKey,
|
||||
globalKey: globalKey,
|
||||
targetType: targetType,
|
||||
episodeCount: episodeCount,
|
||||
createdAt: DateTime.now().millisecondsSinceEpoch,
|
||||
mediaIndex: Value(mediaIndex),
|
||||
downloadFilter: Value(downloadFilter),
|
||||
),
|
||||
onConflict: DoUpdate(
|
||||
(_) => SyncRulesCompanion(
|
||||
serverId: Value(serverId),
|
||||
profileId: Value(profileId),
|
||||
ratingKey: Value(ratingKey),
|
||||
targetType: Value(targetType),
|
||||
episodeCount: Value(episodeCount),
|
||||
mediaIndex: Value(mediaIndex),
|
||||
downloadFilter: Value(downloadFilter),
|
||||
),
|
||||
target: [syncRules.globalKey],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Claim pre-v16 public sync rules for [profileId]. Rules created before
|
||||
/// profile ownership have an empty profile id and a public global key.
|
||||
Future<void> adoptLegacySyncRulesForProfile(String profileId) async {
|
||||
if (profileId.isEmpty) return;
|
||||
final legacyRules = await (select(syncRules)..where((t) => t.profileId.equals(''))).get();
|
||||
for (final rule in legacyRules) {
|
||||
final scopedKey = buildProfileScopedGlobalKey(profileId, ServerId(rule.serverId), rule.ratingKey);
|
||||
final duplicate = await getSyncRule(scopedKey);
|
||||
if (duplicate != null) {
|
||||
await (delete(syncRules)..where((t) => t.id.equals(rule.id))).go();
|
||||
continue;
|
||||
}
|
||||
await (update(syncRules)..where((t) => t.id.equals(rule.id))).write(
|
||||
SyncRulesCompanion(profileId: Value(profileId), globalKey: Value(scopedKey)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateSyncRuleCount(String globalKey, int episodeCount) async {
|
||||
await (update(
|
||||
syncRules,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(episodeCount: Value(episodeCount)));
|
||||
}
|
||||
|
||||
Future<void> updateSyncRuleFilter(String globalKey, String downloadFilter) async {
|
||||
await (update(
|
||||
syncRules,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(downloadFilter: Value(downloadFilter)));
|
||||
}
|
||||
|
||||
Future<void> updateSyncRuleEnabled(String globalKey, bool enabled) async {
|
||||
await (update(
|
||||
syncRules,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(enabled: Value(enabled)));
|
||||
}
|
||||
|
||||
Future<void> updateSyncRuleLastExecuted(String globalKey) async {
|
||||
await (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
SyncRulesCompanion(lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteSyncRule(String globalKey) async {
|
||||
await (delete(syncRules)..where((t) => t.globalKey.equals(globalKey))).go();
|
||||
}
|
||||
|
||||
/// Get all downloaded media items (for syncing watch states)
|
||||
Future<List<DownloadedMediaItem>> getAllDownloadedMetadata() {
|
||||
@@ -211,23 +625,77 @@ LazyDatabase _openConnection() {
|
||||
|
||||
final file = File(p.join(dbFolder.path, 'plezy_downloads.db'));
|
||||
|
||||
// Ensure directory exists
|
||||
if (!await file.parent.exists()) {
|
||||
await file.parent.create(recursive: true);
|
||||
}
|
||||
|
||||
// Migrate from old location on desktop (was in Documents subfolder)
|
||||
if (!Platform.isAndroid && !Platform.isIOS && !await file.exists()) {
|
||||
final oldFolder = await getApplicationDocumentsDirectory();
|
||||
final oldFile = File(p.join(oldFolder.path, 'plezy_downloads.db'));
|
||||
if (await oldFile.exists()) {
|
||||
await oldFile.rename(file.path);
|
||||
}
|
||||
await migrateLegacyDesktopDatabase(target: file);
|
||||
}
|
||||
|
||||
return NativeDatabase.createInBackground(file, setup: (db) {
|
||||
db.execute('PRAGMA journal_mode=WAL');
|
||||
db.execute('PRAGMA synchronous=NORMAL');
|
||||
});
|
||||
return NativeDatabase.createInBackground(
|
||||
file,
|
||||
setup: (db) {
|
||||
db.execute('PRAGMA journal_mode=WAL');
|
||||
db.execute('PRAGMA synchronous=NORMAL');
|
||||
// Enforce ProfileConnections → Profiles/Connections cascades.
|
||||
// SQLite requires this on every connection — it's not persisted.
|
||||
db.execute('PRAGMA foreign_keys = ON');
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Move the legacy desktop DB from `Documents/` to `ApplicationSupport/`.
|
||||
/// `File.rename` only works within a single volume — Windows users with
|
||||
/// OneDrive-redirected Documents (or any cross-drive setup) hit
|
||||
/// `ERROR_NOT_SAME_DEVICE` (errno 17), and the uncaught throw used to
|
||||
/// strand the splash on "Loading servers..." forever (#1022). Falls back
|
||||
/// to copy + delete on any [FileSystemException] and swallows all errors
|
||||
/// so a failed migration never propagates fatally.
|
||||
///
|
||||
/// [sourceOverride] and [renameOverride] are test seams — production
|
||||
/// callers leave them null.
|
||||
Future<void> migrateLegacyDesktopDatabase({
|
||||
required File target,
|
||||
File? sourceOverride,
|
||||
Future<void> Function(File source, String targetPath)? renameOverride,
|
||||
}) async {
|
||||
final File oldFile;
|
||||
if (sourceOverride != null) {
|
||||
oldFile = sourceOverride;
|
||||
} else {
|
||||
final oldFolder = await getApplicationDocumentsDirectory();
|
||||
oldFile = File(p.join(oldFolder.path, 'plezy_downloads.db'));
|
||||
}
|
||||
if (!await oldFile.exists()) return;
|
||||
|
||||
try {
|
||||
if (renameOverride != null) {
|
||||
await renameOverride(oldFile, target.path);
|
||||
} else {
|
||||
await oldFile.rename(target.path);
|
||||
}
|
||||
appLogger.i('Moved legacy DB from ${oldFile.path} → ${target.path}');
|
||||
return;
|
||||
} on FileSystemException catch (e) {
|
||||
appLogger.w('Legacy DB rename failed (osError=${e.osError?.errorCode}); falling back to copy', error: e);
|
||||
}
|
||||
|
||||
try {
|
||||
await oldFile.copy(target.path);
|
||||
try {
|
||||
await oldFile.delete();
|
||||
} catch (e) {
|
||||
// Leaving the source behind is non-fatal — the new file is canonical.
|
||||
appLogger.w('Legacy DB copied but old file delete failed: $e');
|
||||
}
|
||||
appLogger.i('Copied legacy DB from ${oldFile.path} → ${target.path}');
|
||||
} catch (e, st) {
|
||||
// Copy itself failed (disk full, source locked by OneDrive sync,
|
||||
// permissions). Leave both files alone — drift will create a fresh
|
||||
// empty DB at the new location, and a future relaunch can retry.
|
||||
appLogger.e('Legacy DB migration failed entirely', error: e, stackTrace: st);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,110 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import '../media/ids.dart';
|
||||
|
||||
import 'app_database.dart';
|
||||
import '../models/download_models.dart';
|
||||
import '../profiles/profile.dart';
|
||||
|
||||
/// Extension methods on AppDatabase for download operations
|
||||
extension DownloadDatabaseOperations on AppDatabase {
|
||||
/// Insert a new download into the database
|
||||
Future<void> addDownloadOwner({required String profileId, required String globalKey}) async {
|
||||
if (profileId.isEmpty) return;
|
||||
await into(downloadOwners).insert(
|
||||
DownloadOwnersCompanion.insert(
|
||||
profileId: profileId,
|
||||
globalKey: globalKey,
|
||||
createdAt: DateTime.now().millisecondsSinceEpoch,
|
||||
),
|
||||
mode: InsertMode.insertOrIgnore,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeDownloadOwner({required String profileId, required String globalKey}) async {
|
||||
await (delete(downloadOwners)..where((t) => t.profileId.equals(profileId) & t.globalKey.equals(globalKey))).go();
|
||||
}
|
||||
|
||||
Future<void> removeDownloadOwnersForProfile(String profileId) async {
|
||||
if (profileId.isEmpty) return;
|
||||
await (delete(downloadOwners)..where((t) => t.profileId.equals(profileId))).go();
|
||||
}
|
||||
|
||||
Future<Set<String>> getDownloadOwnerKeysForProfile(String profileId) async {
|
||||
if (profileId.isEmpty) return const {};
|
||||
final rows = await (select(downloadOwners)..where((t) => t.profileId.equals(profileId))).get();
|
||||
return rows.map((row) => row.globalKey).toSet();
|
||||
}
|
||||
|
||||
Future<int> getDownloadOwnerCount(String globalKey) async {
|
||||
return (await _validDownloadOwnerRows(globalKey)).length;
|
||||
}
|
||||
|
||||
Future<bool> hasDownloadOwner(String globalKey, {String? excludingProfileId}) async {
|
||||
final rows = await _validDownloadOwnerRows(globalKey, excludingProfileId: excludingProfileId);
|
||||
return rows.isNotEmpty;
|
||||
}
|
||||
|
||||
Future<List<DownloadOwnerItem>> _validDownloadOwnerRows(String globalKey, {String? excludingProfileId}) async {
|
||||
final rows = await (select(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).get();
|
||||
if (rows.isEmpty) return const [];
|
||||
final candidates = rows
|
||||
.where((row) => excludingProfileId == null || excludingProfileId.isEmpty || row.profileId != excludingProfileId)
|
||||
.toList(growable: false);
|
||||
if (candidates.isEmpty) return const [];
|
||||
|
||||
final localProfileRows = await select(profiles).get();
|
||||
final localProfileIds = localProfileRows.map((row) => row.id).toSet();
|
||||
final connectionRows = await select(connections).get();
|
||||
final connectionIds = connectionRows.map((row) => row.id).toSet();
|
||||
return candidates
|
||||
.where((row) {
|
||||
if (localProfileIds.contains(row.profileId)) return true;
|
||||
final plexHome = parsePlexHomeProfileId(row.profileId);
|
||||
if (plexHome != null) return connectionIds.contains(plexHome.accountConnectionId);
|
||||
return localProfileIds.isEmpty;
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// Claim pre-v17 shared download rows for [profileId]. Rows that already
|
||||
/// have any owner are left untouched so later profiles do not inherit them.
|
||||
Future<void> adoptLegacyDownloadsForProfile(String profileId) async {
|
||||
if (profileId.isEmpty) return;
|
||||
final rows = await select(downloadedMedia).get();
|
||||
for (final row in rows) {
|
||||
if (await getDownloadOwnerCount(row.globalKey) == 0) {
|
||||
await addDownloadOwner(profileId: profileId, globalKey: row.globalKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> insertDownload({
|
||||
required String serverId,
|
||||
required ServerId serverId,
|
||||
String? clientScopeId,
|
||||
required String ratingKey,
|
||||
required String globalKey,
|
||||
required String type,
|
||||
String? parentRatingKey,
|
||||
String? grandparentRatingKey,
|
||||
required int status,
|
||||
int mediaIndex = 0,
|
||||
String? mediaSourceId,
|
||||
}) async {
|
||||
await into(downloadedMedia).insert(
|
||||
DownloadedMediaCompanion.insert(
|
||||
serverId: serverId,
|
||||
clientScopeId: Value(clientScopeId),
|
||||
ratingKey: ratingKey,
|
||||
globalKey: globalKey,
|
||||
type: type,
|
||||
parentRatingKey: Value(parentRatingKey),
|
||||
grandparentRatingKey: Value(grandparentRatingKey),
|
||||
status: status,
|
||||
mediaIndex: Value(mediaIndex),
|
||||
mediaSourceId: Value(mediaSourceId),
|
||||
),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
}
|
||||
|
||||
/// Add item to download queue
|
||||
Future<void> addToQueue({
|
||||
required String mediaGlobalKey,
|
||||
int priority = 0,
|
||||
@@ -51,7 +126,6 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
/// Get next item from queue (highest priority, oldest first)
|
||||
/// Only returns items that are not paused
|
||||
Future<DownloadQueueItem?> getNextQueueItem() async {
|
||||
// Join with downloadedMedia to check status and filter out paused items
|
||||
final query = select(
|
||||
downloadQueue,
|
||||
).join([innerJoin(downloadedMedia, downloadedMedia.globalKey.equalsExp(downloadQueue.mediaGlobalKey))]);
|
||||
@@ -68,14 +142,18 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
return result?.readTable(downloadQueue);
|
||||
}
|
||||
|
||||
/// Update download status
|
||||
Future<void> updateDownloadStatus(String globalKey, int status) async {
|
||||
await (update(
|
||||
downloadedMedia,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(status: Value(status)));
|
||||
}
|
||||
|
||||
/// Update download progress
|
||||
Future<void> updateDownloadMediaSource(String globalKey, String? mediaSourceId) async {
|
||||
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
DownloadedMediaCompanion(mediaSourceId: Value(mediaSourceId)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> updateDownloadProgress(String globalKey, int progress, int downloadedBytes, int totalBytes) async {
|
||||
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
DownloadedMediaCompanion(
|
||||
@@ -86,7 +164,6 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
/// Update video file path
|
||||
Future<void> updateVideoFilePath(String globalKey, String filePath) async {
|
||||
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
DownloadedMediaCompanion(
|
||||
@@ -96,16 +173,13 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
/// Update artwork paths
|
||||
Future<void> updateArtworkPaths({required String globalKey, String? thumbPath}) async {
|
||||
await (update(
|
||||
downloadedMedia,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(thumbPath: Value(thumbPath)));
|
||||
}
|
||||
|
||||
/// Update download error and increment retry count
|
||||
Future<void> updateDownloadError(String globalKey, String errorMessage) async {
|
||||
// Get current retry count to increment it
|
||||
final existing = await getDownloadedMedia(globalKey);
|
||||
final currentCount = existing?.retryCount ?? 0;
|
||||
|
||||
@@ -114,52 +188,84 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
/// Clear download error and reset retry count (for retry)
|
||||
Future<void> clearDownloadError(String globalKey) async {
|
||||
await (update(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
const DownloadedMediaCompanion(errorMessage: Value(null), retryCount: Value(0)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Remove item from queue
|
||||
Future<void> removeFromQueue(String mediaGlobalKey) async {
|
||||
await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(mediaGlobalKey))).go();
|
||||
}
|
||||
|
||||
/// Get downloaded media item
|
||||
Future<DownloadedMediaItem?> getDownloadedMedia(String globalKey) {
|
||||
return (select(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).getSingleOrNull();
|
||||
}
|
||||
|
||||
/// Delete a download
|
||||
Future<void> deleteDownload(String globalKey) async {
|
||||
await (delete(downloadOwners)..where((t) => t.globalKey.equals(globalKey))).go();
|
||||
await (delete(downloadedMedia)..where((t) => t.globalKey.equals(globalKey))).go();
|
||||
await (delete(downloadQueue)..where((t) => t.mediaGlobalKey.equals(globalKey))).go();
|
||||
}
|
||||
|
||||
/// Get all downloaded episodes for a season
|
||||
Future<List<DownloadedMediaItem>> getEpisodesBySeason(String seasonKey) {
|
||||
return (select(downloadedMedia)..where((t) => t.parentRatingKey.equals(seasonKey))).get();
|
||||
Future<List<DownloadedMediaItem>> getEpisodesBySeason(
|
||||
String seasonKey, {
|
||||
ServerId? serverId,
|
||||
String? clientScopeId,
|
||||
bool filterClientScope = false,
|
||||
}) {
|
||||
return (select(downloadedMedia)..where(
|
||||
(t) =>
|
||||
t.parentRatingKey.equals(seasonKey) &
|
||||
_optionalServerPredicate(t.serverId, serverIdOrNull(serverId)) &
|
||||
_optionalClientScopePredicate(t.clientScopeId, clientScopeId, filterClientScope: filterClientScope),
|
||||
))
|
||||
.get();
|
||||
}
|
||||
|
||||
/// Get all downloaded episodes for a show
|
||||
Future<List<DownloadedMediaItem>> getEpisodesByShow(String showKey) {
|
||||
return (select(downloadedMedia)..where((t) => t.grandparentRatingKey.equals(showKey))).get();
|
||||
Future<List<DownloadedMediaItem>> getEpisodesByShow(
|
||||
String showKey, {
|
||||
ServerId? serverId,
|
||||
String? clientScopeId,
|
||||
bool filterClientScope = false,
|
||||
}) {
|
||||
return (select(downloadedMedia)..where(
|
||||
(t) =>
|
||||
t.grandparentRatingKey.equals(showKey) &
|
||||
_optionalServerPredicate(t.serverId, serverIdOrNull(serverId)) &
|
||||
_optionalClientScopePredicate(t.clientScopeId, clientScopeId, filterClientScope: filterClientScope),
|
||||
))
|
||||
.get();
|
||||
}
|
||||
|
||||
/// Get all downloaded items for a specific server
|
||||
Future<List<DownloadedMediaItem>> getDownloadsByServerId(String serverId) {
|
||||
Future<List<DownloadedMediaItem>> getDownloadsByServerId(ServerId serverId) {
|
||||
return (select(downloadedMedia)..where((t) => t.serverId.equals(serverId))).get();
|
||||
}
|
||||
|
||||
/// Update the background_downloader task ID for a download
|
||||
Expression<bool> _optionalServerPredicate(GeneratedColumn<String> column, ServerId? serverId) {
|
||||
return serverId == null ? const Constant(true) : column.equals(serverId);
|
||||
}
|
||||
|
||||
Expression<bool> _optionalClientScopePredicate(
|
||||
GeneratedColumn<String> column,
|
||||
String? clientScopeId, {
|
||||
required bool filterClientScope,
|
||||
}) {
|
||||
if (!filterClientScope && (clientScopeId == null || clientScopeId.isEmpty)) {
|
||||
return const Constant(true);
|
||||
}
|
||||
if (clientScopeId == null || clientScopeId.isEmpty) {
|
||||
return column.isNull() | column.equals('');
|
||||
}
|
||||
return column.equals(clientScopeId);
|
||||
}
|
||||
|
||||
Future<void> updateBgTaskId(String globalKey, String? taskId) async {
|
||||
await (update(
|
||||
downloadedMedia,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(DownloadedMediaCompanion(bgTaskId: Value(taskId)));
|
||||
}
|
||||
|
||||
/// Get the background_downloader task ID for a download
|
||||
Future<String?> getBgTaskId(String globalKey) async {
|
||||
final item = await getDownloadedMedia(globalKey);
|
||||
return item?.bgTaskId;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
/// Key-value cache table for Plex API responses.
|
||||
/// Key-value cache table for media-server API responses (Plex, Jellyfin).
|
||||
/// Used for offline support - stores raw JSON responses.
|
||||
class ApiCache extends Table {
|
||||
/// Composite key: serverId:endpoint (e.g., "abc123:/library/metadata/12345")
|
||||
/// Composite key: serverId:endpoint (e.g., "abc123:/library/metadata/12345"
|
||||
/// for Plex, "abc123:/Users/.../Items/..." for Jellyfin)
|
||||
TextColumn get cacheKey => text()();
|
||||
|
||||
/// JSON response data
|
||||
TextColumn get data => text()();
|
||||
|
||||
/// Whether this item is pinned for offline access
|
||||
@@ -30,9 +30,19 @@ class DownloadQueue extends Table {
|
||||
}
|
||||
|
||||
@DataClassName('DownloadedMediaItem')
|
||||
@TableIndex(name: 'idx_downloaded_media_status', columns: {#status})
|
||||
@TableIndex(name: 'idx_downloaded_media_server', columns: {#serverId})
|
||||
@TableIndex(name: 'idx_downloaded_media_parent', columns: {#parentRatingKey})
|
||||
@TableIndex(name: 'idx_downloaded_media_grandparent', columns: {#grandparentRatingKey})
|
||||
class DownloadedMedia extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
TextColumn get serverId => text()();
|
||||
// Downloads are intentionally app-wide/shared, keyed by the public
|
||||
// serverId:ratingKey globalKey below. For Jellyfin, clientScopeId records
|
||||
// which scoped client produced the cached metadata/download request; it is
|
||||
// not part of download ownership and must not be used to hide or duplicate
|
||||
// the physical downloaded item per user/profile.
|
||||
TextColumn get clientScopeId => text().nullable()();
|
||||
TextColumn get ratingKey => text()();
|
||||
TextColumn get globalKey => text().unique()();
|
||||
TextColumn get type => text()();
|
||||
@@ -48,20 +58,176 @@ class DownloadedMedia extends Table {
|
||||
TextColumn get errorMessage => text().nullable()();
|
||||
IntColumn get retryCount => integer().withDefault(const Constant(0))();
|
||||
TextColumn get bgTaskId => text().nullable()();
|
||||
IntColumn get mediaIndex => integer().withDefault(const Constant(0))();
|
||||
TextColumn get mediaSourceId => text().nullable()();
|
||||
}
|
||||
|
||||
/// Profile ownership for shared physical downloads.
|
||||
///
|
||||
/// [DownloadedMedia] stores one physical row per public serverId:ratingKey so
|
||||
/// files are deduped across profiles. This table controls which active profile
|
||||
/// can see/use that shared row.
|
||||
@DataClassName('DownloadOwnerItem')
|
||||
@TableIndex(name: 'idx_download_owners_profile', columns: {#profileId})
|
||||
@TableIndex(name: 'idx_download_owners_global_key', columns: {#globalKey})
|
||||
class DownloadOwners extends Table {
|
||||
TextColumn get profileId => text()();
|
||||
TextColumn get globalKey => text()();
|
||||
IntColumn get createdAt => integer()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {profileId, globalKey};
|
||||
}
|
||||
|
||||
/// Persistent sync rules for auto-downloading unwatched episodes.
|
||||
///
|
||||
/// Each rule keeps a rolling window of N unwatched episodes for a show/season,
|
||||
/// or mirrors the current contents of a collection/playlist.
|
||||
/// Rules are owned by the active top-level profile. Downloads remain app-wide
|
||||
/// and shared by public serverId:ratingKey identity, but rule ownership must not
|
||||
/// cross users because Jellyfin permissions and watch state are user-scoped.
|
||||
@DataClassName('SyncRuleItem')
|
||||
@TableIndex(name: 'idx_sync_rules_profile', columns: {#profileId})
|
||||
class SyncRules extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
TextColumn get profileId => text().withDefault(const Constant(''))();
|
||||
TextColumn get serverId => text()();
|
||||
TextColumn get ratingKey => text()();
|
||||
TextColumn get globalKey => text().unique()();
|
||||
TextColumn get targetType => text()(); // 'show', 'season', 'collection', 'playlist'
|
||||
IntColumn get episodeCount => integer()();
|
||||
BoolColumn get enabled => boolean().withDefault(const Constant(true))();
|
||||
IntColumn get createdAt => integer()();
|
||||
IntColumn get lastExecutedAt => integer().nullable()();
|
||||
IntColumn get mediaIndex => integer().withDefault(const Constant(0))();
|
||||
TextColumn get downloadFilter => text().withDefault(const Constant('unwatched'))();
|
||||
}
|
||||
|
||||
/// Persisted media-server connections.
|
||||
///
|
||||
/// One row per "connection" the user has added — a Plex account (with its
|
||||
/// discovered servers and active Home profile) or a single Jellyfin server.
|
||||
/// The [configJson] payload is backend-specific and parsed by the
|
||||
/// [Connection] sealed class.
|
||||
@DataClassName('ConnectionRow')
|
||||
@TableIndex(name: 'idx_connections_kind', columns: {#kind})
|
||||
class Connections extends Table {
|
||||
/// Stable identifier for the connection. For Plex it's a generated UUID
|
||||
/// (one per account); for Jellyfin it's the server's machineId.
|
||||
TextColumn get id => text()();
|
||||
|
||||
/// Backend kind: `'plex'` or `'jellyfin'`.
|
||||
TextColumn get kind => text()();
|
||||
|
||||
/// User-visible label (account email, server name).
|
||||
TextColumn get displayName => text()();
|
||||
|
||||
/// Backend-specific config payload (token, baseUrl, profile id, …).
|
||||
TextColumn get configJson => text()();
|
||||
|
||||
/// Whether this is the default connection used at app launch when only
|
||||
/// one connection is present.
|
||||
BoolColumn get isDefault => boolean().withDefault(const Constant(false))();
|
||||
|
||||
/// Timestamp this connection was added (milliseconds since epoch).
|
||||
IntColumn get createdAt => integer()();
|
||||
|
||||
/// Timestamp of the most-recent successful auth refresh (milliseconds
|
||||
/// since epoch). Null until the first successful auth.
|
||||
IntColumn get lastAuthenticatedAt => integer().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
/// Top-level profiles — see [Profile].
|
||||
///
|
||||
/// A profile is the user-facing identity. Plex Home users auto-surface as
|
||||
/// `kind='plex_home'` rows when their parent Plex account is added; users
|
||||
/// can also create `kind='local'` rows manually. Each profile owns one or
|
||||
/// more connections via [ProfileConnections].
|
||||
@DataClassName('ProfileRow')
|
||||
@TableIndex(name: 'idx_profiles_kind', columns: {#kind})
|
||||
class Profiles extends Table {
|
||||
/// Stable identifier. For Plex Home profiles: `plex-home-{accountId}-{homeUserUuid}`
|
||||
/// (deterministic so re-discovery is idempotent). For locals: `local-{uuid}`.
|
||||
TextColumn get id => text()();
|
||||
|
||||
/// `'local'` | `'plex_home'`.
|
||||
TextColumn get kind => text()();
|
||||
|
||||
TextColumn get displayName => text()();
|
||||
|
||||
/// Plex Home users have a thumb URL; locals fall back to initials/colour.
|
||||
TextColumn get avatarThumbUrl => text().nullable()();
|
||||
|
||||
/// Per-kind config:
|
||||
/// - `local`: `{ "pinHash": "..." }`
|
||||
/// - `plex_home`: `{ "restricted": bool, "admin": bool, "hasPassword": bool, "parentConnectionId": "..." }`
|
||||
TextColumn get configJson => text()();
|
||||
|
||||
IntColumn get sortOrder => integer().withDefault(const Constant(0))();
|
||||
IntColumn get createdAt => integer()();
|
||||
IntColumn get lastUsedAt => integer().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
|
||||
/// Many-to-many join between [Profiles] and [Connections], carrying the
|
||||
/// per-profile user-level token used when the profile is active.
|
||||
///
|
||||
/// For Plex: `userToken` is a Home-user token from `/home/users/{uuid}/switch`,
|
||||
/// `userIdentifier` is the Plex Home user UUID. An empty `userToken` is a
|
||||
/// lazy-fetch sentinel — the binder calls `/switch` on first activation.
|
||||
/// The Dart-side [ProfileConnection] model surfaces empty as `null`; the
|
||||
/// column stays non-nullable here to avoid a schema migration.
|
||||
///
|
||||
/// For Jellyfin: `userToken` mirrors the Connection's accessToken (one user
|
||||
/// per Jellyfin connection) and `userIdentifier` is the Jellyfin user id.
|
||||
@DataClassName('ProfileConnectionRow')
|
||||
@TableIndex(name: 'idx_profile_connections_connection_id', columns: {#connectionId})
|
||||
@TableIndex(name: 'idx_profile_connections_profile_id', columns: {#profileId})
|
||||
class ProfileConnections extends Table {
|
||||
// No FK on profile_id: Plex Home profiles are virtual (built by
|
||||
// Profile.virtualPlexHome from PlexHomeService's live cache, never
|
||||
// persisted in `profiles`), so an FK here would reject every join row
|
||||
// they need. The two profile-delete sites clean up join rows manually
|
||||
// via ProfileConnectionRegistry.removeAllForProfile before calling
|
||||
// ProfileRegistry.remove.
|
||||
TextColumn get profileId => text()();
|
||||
TextColumn get connectionId => text().references(Connections, #id, onDelete: KeyAction.cascade)();
|
||||
TextColumn get userToken => text().withDefault(const Constant(''))();
|
||||
TextColumn get userIdentifier => text()();
|
||||
BoolColumn get isDefault => boolean().withDefault(const Constant(false))();
|
||||
IntColumn get tokenAcquiredAt => integer().nullable()();
|
||||
IntColumn get lastUsedAt => integer().nullable()();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {profileId, connectionId};
|
||||
}
|
||||
|
||||
/// Queue for offline watch progress and manual watch actions.
|
||||
///
|
||||
/// Stores watch progress updates and manual watch/unwatch actions
|
||||
/// that need to be synced to the Plex server when back online.
|
||||
/// that need to be synced to the originating media server when back online.
|
||||
@DataClassName('OfflineWatchProgressItem')
|
||||
@TableIndex(name: 'idx_offline_watch_progress_server', columns: {#serverId})
|
||||
@TableIndex(name: 'idx_offline_watch_progress_profile', columns: {#profileId})
|
||||
class OfflineWatchProgress extends Table {
|
||||
/// Auto-incrementing primary key
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
|
||||
/// Active Plezy profile that owns this queued action.
|
||||
TextColumn get profileId => text().nullable()();
|
||||
|
||||
/// Server ID this media belongs to
|
||||
TextColumn get serverId => text()();
|
||||
|
||||
/// Optional user-scoped client/cache id for backends where [serverId] is
|
||||
/// shared by multiple users on the same server.
|
||||
TextColumn get clientScopeId => text().nullable()();
|
||||
|
||||
/// Rating key of the media item
|
||||
TextColumn get ratingKey => text()();
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart';
|
||||
|
||||
/// Sealed base for backend-agnostic media-server exceptions. Both Plex and
|
||||
/// Jellyfin auth/HTTP layers throw subtypes from this hierarchy so consumers
|
||||
/// can catch with one filter and match exhaustively when they care which
|
||||
/// failure mode it is.
|
||||
sealed class MediaServerException implements Exception {
|
||||
final String message;
|
||||
const MediaServerException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType: $message';
|
||||
}
|
||||
|
||||
/// The supplied base URL is unreachable, returns the wrong shape, or doesn't
|
||||
/// look like the expected backend at all. Surfaces in onboarding probes
|
||||
/// (Jellyfin `/System/Info/Public`, Plex resource discovery).
|
||||
class MediaServerUrlException extends MediaServerException {
|
||||
const MediaServerUrlException(super.message);
|
||||
}
|
||||
|
||||
/// Authentication failed — bad password, expired token, disabled user,
|
||||
/// rate-limit. [statusCode] is the HTTP status when the failure was a 4xx
|
||||
/// response; null for transport-layer auth signals (e.g. token rejected
|
||||
/// during refresh).
|
||||
class MediaServerAuthException extends MediaServerException {
|
||||
final int? statusCode;
|
||||
const MediaServerAuthException(super.message, {this.statusCode});
|
||||
}
|
||||
|
||||
/// Auth polling reached a terminal server-side expiry/rejection state before
|
||||
/// the user completed the external sign-in flow.
|
||||
class MediaServerPinExpiredException extends MediaServerAuthException {
|
||||
const MediaServerPinExpiredException() : super('PIN expired before sign-in');
|
||||
}
|
||||
|
||||
/// HTTP transport / non-2xx errors. Carries the status code (when known),
|
||||
/// the parsed response body, and the originating URI so callers can log
|
||||
/// useful diagnostics. Both Plex and Jellyfin route their HTTP failures
|
||||
/// through this type — it's the canonical backend-agnostic transport
|
||||
/// exception.
|
||||
enum MediaServerHttpErrorType { connectionTimeout, receiveTimeout, connectionError, cancelled, unknown }
|
||||
|
||||
class MediaServerHttpException extends MediaServerException {
|
||||
final MediaServerHttpErrorType type;
|
||||
final int? statusCode;
|
||||
final dynamic responseData;
|
||||
final Uri? requestUri;
|
||||
|
||||
MediaServerHttpException({required this.type, String? message, this.statusCode, this.responseData, this.requestUri})
|
||||
: super(message ?? '');
|
||||
|
||||
/// Map a caught exception to a [MediaServerHttpException].
|
||||
factory MediaServerHttpException.from(Object error, {Uri? uri}) {
|
||||
return switch (error) {
|
||||
MediaServerHttpException() => error,
|
||||
RequestAbortedException(:final message, uri: final errorUri) => MediaServerHttpException(
|
||||
type: MediaServerHttpErrorType.cancelled,
|
||||
message: message,
|
||||
requestUri: errorUri ?? uri,
|
||||
),
|
||||
TimeoutException(:final message) => MediaServerHttpException(
|
||||
type: MediaServerHttpErrorType.connectionTimeout,
|
||||
message: message,
|
||||
requestUri: uri,
|
||||
),
|
||||
SocketException(:final message) => MediaServerHttpException(
|
||||
type: MediaServerHttpErrorType.connectionError,
|
||||
message: message,
|
||||
requestUri: uri,
|
||||
),
|
||||
HttpException(:final message) => MediaServerHttpException(
|
||||
type: MediaServerHttpErrorType.connectionError,
|
||||
message: message,
|
||||
requestUri: uri,
|
||||
),
|
||||
ClientException(:final message, uri: final errorUri) => MediaServerHttpException(
|
||||
type: MediaServerHttpErrorType.connectionError,
|
||||
message: message,
|
||||
requestUri: errorUri ?? uri,
|
||||
),
|
||||
_ => MediaServerHttpException(type: MediaServerHttpErrorType.unknown, message: error.toString(), requestUri: uri),
|
||||
};
|
||||
}
|
||||
|
||||
/// Whether the error looks transient (network/timeout) and worth retrying.
|
||||
bool get isTransient =>
|
||||
type == MediaServerHttpErrorType.connectionTimeout ||
|
||||
type == MediaServerHttpErrorType.connectionError ||
|
||||
type == MediaServerHttpErrorType.receiveTimeout;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final parts = <String>[type.name];
|
||||
if (statusCode != null) parts.add('HTTP $statusCode');
|
||||
if (message.isNotEmpty) parts.add(message);
|
||||
final uri = requestUri;
|
||||
if (uri != null) parts.add('${uri.host}${uri.path}');
|
||||
return 'MediaServerHttpException(${parts.join(': ')})';
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,29 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Extension on KeyEvent for common event type checks.
|
||||
extension KeyEventActionable on KeyEvent {
|
||||
/// Whether this event should trigger an action (KeyDownEvent or KeyRepeatEvent).
|
||||
/// Use this to filter out KeyUpEvents early in key handlers.
|
||||
bool get isActionable => this is KeyDownEvent || this is KeyRepeatEvent;
|
||||
bool get isPhysicalKeyboardEvent => deviceType == ui.KeyEventDeviceType.keyboard;
|
||||
// Only true keyboard submit keys belong here. LogicalKeyboardKey.select is
|
||||
// a TV-remote / dpad-center key (Android DPAD_CENTER, tvOS UIPressTypeSelect)
|
||||
// — USB keyboards never emit it. The custom Flutter tvOS engine reports its
|
||||
// synthesized Siri Remote presses with deviceType=keyboard, so classifying
|
||||
// select-with-keyboard-deviceType as a "keyboard enter" would route center
|
||||
// dpad through TextField submit and skip the TV virtual keyboard.
|
||||
bool get isPhysicalKeyboardEnter =>
|
||||
deviceType == ui.KeyEventDeviceType.keyboard &&
|
||||
(logicalKey == LogicalKeyboardKey.enter || logicalKey == LogicalKeyboardKey.numpadEnter);
|
||||
|
||||
bool get isTvSelectEvent {
|
||||
// Dpad-center / gamepad-A are TV-remote-only — always treat as TV select,
|
||||
// regardless of the deviceType claim from the engine.
|
||||
if (logicalKey == LogicalKeyboardKey.select || logicalKey == LogicalKeyboardKey.gameButtonA) return true;
|
||||
if (isPhysicalKeyboardEvent) return false;
|
||||
return logicalKey == LogicalKeyboardKey.enter || logicalKey == LogicalKeyboardKey.numpadEnter;
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared sets for keyboard key categories.
|
||||
final _dpadDirectionKeys = {
|
||||
LogicalKeyboardKey.arrowUp,
|
||||
LogicalKeyboardKey.arrowDown,
|
||||
@@ -31,35 +47,18 @@ final _backKeys = {
|
||||
|
||||
final _contextMenuKeys = {LogicalKeyboardKey.contextMenu, LogicalKeyboardKey.gameButtonX};
|
||||
|
||||
/// Extension methods for checking D-pad related keys.
|
||||
extension DpadKeyExtension on LogicalKeyboardKey {
|
||||
/// Whether this key is a D-pad directional key.
|
||||
bool get isDpadDirection => _dpadDirectionKeys.contains(this);
|
||||
|
||||
/// Whether this key is a select/activate key.
|
||||
bool get isSelectKey => _selectKeys.contains(this);
|
||||
|
||||
/// Whether this key is a back/cancel key.
|
||||
bool get isBackKey => _backKeys.contains(this);
|
||||
|
||||
/// Whether this key is a context menu key.
|
||||
bool get isContextMenuKey => _contextMenuKeys.contains(this);
|
||||
|
||||
/// Whether this key is a navigation key (dpad, select, back, context menu, tab).
|
||||
/// Use this to distinguish navigation keys from typing/volume/media keys.
|
||||
bool get isNavigationKey =>
|
||||
isDpadDirection || isSelectKey || isBackKey || isContextMenuKey || this == LogicalKeyboardKey.tab;
|
||||
|
||||
/// Whether this key moves focus left.
|
||||
bool get isLeftKey => this == LogicalKeyboardKey.arrowLeft;
|
||||
|
||||
/// Whether this key moves focus right.
|
||||
bool get isRightKey => this == LogicalKeyboardKey.arrowRight;
|
||||
|
||||
/// Whether this key moves focus up.
|
||||
bool get isUpKey => this == LogicalKeyboardKey.arrowUp;
|
||||
|
||||
/// Whether this key moves focus down.
|
||||
bool get isDownKey => this == LogicalKeyboardKey.arrowDown;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'focus_theme.dart';
|
||||
|
||||
/// Renders the focus glow for a focused card in the root [Overlay] so it paints
|
||||
/// ABOVE sibling cards on all four sides.
|
||||
///
|
||||
/// The glow is an outward blur ([FocusTheme.focusGlowShadows]). When drawn
|
||||
/// in-tree behind a packed rail/grid it is occluded by later-painted neighbours
|
||||
/// on the trailing edges and only escapes on the leading (left) edge — producing
|
||||
/// a one-sided halo (issue #1231). Lifting it into an [OverlayPortal] that
|
||||
/// follows the card via [LayerLink] makes it render above every sibling, so the
|
||||
/// glow is symmetric on all sides. The crisp focus border stays in-card; only
|
||||
/// the glow moves to the overlay.
|
||||
///
|
||||
/// Only mounts the overlay/leader while the card is focused (or fading out), so
|
||||
/// there is at most one [LeaderLayer] on screen regardless of how many cards a
|
||||
/// grid builds.
|
||||
class FocusGlowOverlay extends StatefulWidget {
|
||||
const FocusGlowOverlay({
|
||||
super.key,
|
||||
required this.isFocused,
|
||||
required this.borderRadius,
|
||||
required this.color,
|
||||
required this.child,
|
||||
this.glowSize,
|
||||
});
|
||||
|
||||
/// Whether the wrapped card currently shows focus. Drives the glow.
|
||||
final bool isFocused;
|
||||
|
||||
/// Border radius of the card, used for the glow's rounded rect.
|
||||
final double borderRadius;
|
||||
|
||||
/// Glow colour, resolved from the card's theme at the call site (the overlay
|
||||
/// builds in the root Overlay's context, which may not carry a nested theme).
|
||||
final Color color;
|
||||
|
||||
/// Explicit card size. When null, falls back to [LayerLink.leaderSize] (one
|
||||
/// frame late on first show, hidden under the opacity-0 fade-in).
|
||||
final Size? glowSize;
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<FocusGlowOverlay> createState() => _FocusGlowOverlayState();
|
||||
}
|
||||
|
||||
class _FocusGlowOverlayState extends State<FocusGlowOverlay> {
|
||||
final OverlayPortalController _controller = OverlayPortalController();
|
||||
final LayerLink _link = LayerLink();
|
||||
|
||||
/// Drives the [AnimatedOpacity] target. Set false on focus loss so the glow
|
||||
/// fades out before the portal is hidden in [_handleFadeEnd].
|
||||
bool _visible = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.isFocused) {
|
||||
_visible = true;
|
||||
_controller.show();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(FocusGlowOverlay oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.isFocused == oldWidget.isFocused) return;
|
||||
if (widget.isFocused) {
|
||||
_controller.show();
|
||||
// Start hidden, then fade in next frame.
|
||||
_visible = false;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && widget.isFocused) setState(() => _visible = true);
|
||||
});
|
||||
} else {
|
||||
// Fade out; _handleFadeEnd hides the portal once opacity reaches 0.
|
||||
setState(() => _visible = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleFadeEnd() {
|
||||
if (!_visible && mounted && _controller.isShowing) {
|
||||
_controller.hide();
|
||||
setState(() {}); // drop the OverlayPortal/leader from the tree
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Gate the LeaderLayer to the focused card only: when not focused and not
|
||||
// mid-fade, return the bare child (no OverlayPortal, no leader).
|
||||
if (!widget.isFocused && !_controller.isShowing) {
|
||||
return widget.child;
|
||||
}
|
||||
|
||||
final duration = FocusTheme.getAnimationDuration(context);
|
||||
|
||||
return OverlayPortal(
|
||||
controller: _controller,
|
||||
overlayChildBuilder: (overlayContext) {
|
||||
final size = widget.glowSize ?? _link.leaderSize;
|
||||
final extent = FocusTheme.focusGlowExtent;
|
||||
|
||||
return CompositedTransformFollower(
|
||||
link: _link,
|
||||
targetAnchor: Alignment.topLeft,
|
||||
followerAnchor: Alignment.topLeft,
|
||||
offset: Offset(-extent, -extent),
|
||||
child: IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
opacity: _visible ? 1.0 : 0.0,
|
||||
duration: duration,
|
||||
curve: Curves.easeOutCubic,
|
||||
onEnd: _handleFadeEnd,
|
||||
child: size == null
|
||||
? const SizedBox.shrink()
|
||||
: CustomPaint(
|
||||
size: Size(size.width + extent * 2, size.height + extent * 2),
|
||||
painter: _FocusGlowPainter(
|
||||
rect: Offset(extent, extent) & size,
|
||||
borderRadius: widget.borderRadius,
|
||||
shadows: FocusTheme.focusGlowShadows(widget.color),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: CompositedTransformTarget(link: _link, child: widget.child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Paints [shadows] around [rect] with the rect interior clipped out, so only
|
||||
/// the outer glow shows — matching the original look where the opaque card hid
|
||||
/// the inner part of the shadow.
|
||||
class _FocusGlowPainter extends CustomPainter {
|
||||
const _FocusGlowPainter({required this.rect, required this.borderRadius, required this.shadows});
|
||||
|
||||
final Rect rect;
|
||||
final double borderRadius;
|
||||
final List<BoxShadow> shadows;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final rrect = RRect.fromRectAndRadius(rect, Radius.circular(borderRadius));
|
||||
final clip = Path.combine(PathOperation.difference, Path()..addRect(Offset.zero & size), Path()..addRRect(rrect));
|
||||
canvas.save();
|
||||
canvas.clipPath(clip);
|
||||
for (final shadow in shadows) {
|
||||
canvas.drawRRect(rrect.shift(shadow.offset).inflate(shadow.spreadRadius), shadow.toPaint());
|
||||
}
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_FocusGlowPainter oldDelegate) {
|
||||
return oldDelegate.rect != rect ||
|
||||
oldDelegate.borderRadius != borderRadius ||
|
||||
!listEquals(oldDelegate.shadows, shadows);
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,7 @@ class FocusMemoryTracker {
|
||||
final String _debugLabelPrefix;
|
||||
String? _lastFocusedKey;
|
||||
|
||||
FocusMemoryTracker({VoidCallback? onFocusChanged, String debugLabelPrefix = 'focus'})
|
||||
: _onFocusChanged = onFocusChanged,
|
||||
_debugLabelPrefix = debugLabelPrefix;
|
||||
FocusMemoryTracker({this._onFocusChanged, this._debugLabelPrefix = 'focus'});
|
||||
|
||||
/// Get or create a focus node for the given key
|
||||
FocusNode get(String key, {String? debugLabel}) {
|
||||
|
||||
@@ -1,44 +1,64 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../theme/mono_tokens.dart';
|
||||
|
||||
/// Focus styling constants for D-pad navigation.
|
||||
class FocusTheme {
|
||||
FocusTheme._();
|
||||
|
||||
/// Scale factor when an item is focused.
|
||||
static const double focusScale = 1.02;
|
||||
|
||||
/// Border width for the focus indicator.
|
||||
static const double fullCardFocusScale = 1.03;
|
||||
static const double focusBorderWidth = 2.5;
|
||||
|
||||
/// Default border radius (matches MonoTokens.radiusSm).
|
||||
static const double defaultBorderRadius = 8.0;
|
||||
static const double focusGlowInnerBlurRadius = 18;
|
||||
static const double focusGlowOuterBlurRadius = 34;
|
||||
static const double focusGlowSpreadRadius = 1.5;
|
||||
|
||||
/// Get the focus border color from the theme.
|
||||
static Color getFocusBorderColor(BuildContext context) {
|
||||
return Theme.of(context).colorScheme.primary;
|
||||
}
|
||||
|
||||
/// Get the animation duration from MonoTokens.
|
||||
static Duration getAnimationDuration(BuildContext context) {
|
||||
return Theme.of(context).extension<MonoTokens>()?.fast ?? const Duration(milliseconds: 150);
|
||||
}
|
||||
|
||||
/// Build the focus border decoration.
|
||||
static BoxDecoration focusDecoration(
|
||||
BuildContext context, {
|
||||
required bool isFocused,
|
||||
double borderRadius = defaultBorderRadius,
|
||||
double borderStrokeAlign = BorderSide.strokeAlignInside,
|
||||
Color? color,
|
||||
}) {
|
||||
final focusColor = color ?? getFocusBorderColor(context);
|
||||
|
||||
return BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
border: Border.all(color: isFocused ? focusColor : Colors.transparent, width: focusBorderWidth),
|
||||
border: Border.all(
|
||||
color: isFocused ? focusColor : Colors.transparent,
|
||||
width: focusBorderWidth,
|
||||
strokeAlign: borderStrokeAlign,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The focus glow as a list of [BoxShadow]s.
|
||||
///
|
||||
/// Rendered by [FocusGlowOverlay] in the root overlay so the glow paints
|
||||
/// above sibling cards on all four sides (an in-tree background shadow is
|
||||
/// occluded by later-painted neighbours, which produced the one-sided halo).
|
||||
static List<BoxShadow> focusGlowShadows(Color color) {
|
||||
return [
|
||||
BoxShadow(
|
||||
color: color.withValues(alpha: 0.34),
|
||||
blurRadius: focusGlowInnerBlurRadius,
|
||||
spreadRadius: focusGlowSpreadRadius,
|
||||
),
|
||||
BoxShadow(color: color.withValues(alpha: 0.2), blurRadius: focusGlowOuterBlurRadius),
|
||||
];
|
||||
}
|
||||
|
||||
/// How far the focus glow visibly reaches beyond the card edge. Used to size
|
||||
/// the overlay paint area so the blur is not clipped.
|
||||
static double get focusGlowExtent => focusGlowOuterBlurRadius * 2 + focusGlowSpreadRadius;
|
||||
|
||||
/// Build focus decoration with background color instead of border.
|
||||
/// Useful for video controls where it should match the native hover style.
|
||||
static BoxDecoration focusBackgroundDecoration({required bool isFocused, double borderRadius = defaultBorderRadius}) {
|
||||
|
||||