Compare commits
788
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61c7453a7a | ||
|
|
395798f28e | ||
|
|
3f49bcabf8 | ||
|
|
9ecf8db90f | ||
|
|
3ae7aa554b | ||
|
|
3509f4b989 | ||
|
|
8a46df2850 | ||
|
|
56ad48824b | ||
|
|
4fe4f7b1d7 | ||
|
|
86c8011b72 | ||
|
|
6f9edd3e93 | ||
|
|
9555937873 | ||
|
|
66549e3a67 | ||
|
|
7f0cad339c | ||
|
|
7c515bf8fa | ||
|
|
f30e2c621a | ||
|
|
dc5a92a2e0 | ||
|
|
d55b875855 | ||
|
|
944a8d89f5 | ||
|
|
834895486b | ||
|
|
4c8272d5b1 | ||
|
|
5a25c1f9cc | ||
|
|
97cb98526e | ||
|
|
c90bc1629a | ||
|
|
88ffe0806d | ||
|
|
0a6865fa18 | ||
|
|
27b994422c | ||
|
|
daab4f1e24 | ||
|
|
f13f5af6e2 | ||
|
|
1bf7aac75b | ||
|
|
53288116fe | ||
|
|
644c782edd | ||
|
|
27acbaf435 | ||
|
|
0ef71e6489 | ||
|
|
69ceea9466 | ||
|
|
f02924b9a5 | ||
|
|
f3795d49eb | ||
|
|
0eee9f688d | ||
|
|
1165998dae | ||
|
|
726dfc6507 | ||
|
|
41ffaa7f2b | ||
|
|
8aa836d106 | ||
|
|
a183c17c3b | ||
|
|
ae331b217c | ||
|
|
8d0fe73ced | ||
|
|
126f5e3aa6 | ||
|
|
19542e57f4 | ||
|
|
3b019c8fe2 | ||
|
|
9a0e96114f | ||
|
|
82d6c5d555 | ||
|
|
53535e1678 | ||
|
|
31b3689740 | ||
|
|
9cd486f5a2 | ||
|
|
1be982f43d | ||
|
|
314fec5383 | ||
|
|
fd136f55f9 | ||
|
|
e362c0985f | ||
|
|
fa4d2a4991 | ||
|
|
d5f7c5d7ac | ||
|
|
841d336cb8 | ||
|
|
ea7dfe6e1e | ||
|
|
0ed0ebf22a | ||
|
|
7e00de3ae9 | ||
|
|
ad3af474e9 | ||
|
|
088501513a | ||
|
|
c64e6fc519 | ||
|
|
5f77da93d1 | ||
|
|
6b87b6551e | ||
|
|
e3cdc2039e | ||
|
|
f695f7b192 | ||
|
|
c45113fefe | ||
|
|
b202c62641 | ||
|
|
db593e1255 | ||
|
|
41c6389cd4 | ||
|
|
bae6055123 | ||
|
|
3515eaf9b4 | ||
|
|
214eeb8aec | ||
|
|
41a2e996e1 | ||
|
|
8e1904ddee | ||
|
|
c48cbf7059 | ||
|
|
de4ed3cd2e | ||
|
|
468d680484 | ||
|
|
78eedd21d3 | ||
|
|
e7f97cc090 | ||
|
|
01d7d523aa | ||
|
|
ef310459e5 | ||
|
|
0c58b2dc55 | ||
|
|
fcda012468 | ||
|
|
a893e1859c | ||
|
|
35f7a12d7c | ||
|
|
100d7729df | ||
|
|
7677d1594c | ||
|
|
15b54e2ec6 | ||
|
|
6c14049e95 | ||
|
|
a56b9a3dfb | ||
|
|
9e8cfbc7dd | ||
|
|
7e05686a7c | ||
|
|
5ee3120a97 | ||
|
|
60cc983471 | ||
|
|
1fea9ef6e3 | ||
|
|
8ef977d890 | ||
|
|
ecc55d9b36 | ||
|
|
f5661c766e | ||
|
|
829d3745a1 | ||
|
|
71735354b9 | ||
|
|
13179f08cd | ||
|
|
eb3ed45af1 | ||
|
|
9429a76acc | ||
|
|
4eaf4423a1 | ||
|
|
c68ffe9ed0 | ||
|
|
83f4e2a263 | ||
|
|
7416327d4b | ||
|
|
316a69a1de | ||
|
|
352b88109b | ||
|
|
61344f7862 | ||
|
|
04d8070fd4 | ||
|
|
4307c49cd2 | ||
|
|
fcaa81bf3c | ||
|
|
ef183437d4 | ||
|
|
7c6eaac2d0 | ||
|
|
3b1e71b3fa | ||
|
|
2a25f21e27 | ||
|
|
e251273322 | ||
|
|
1b3c74550f | ||
|
|
c9543d4af0 | ||
|
|
12b826faff | ||
|
|
516bd69c19 | ||
|
|
005a56db03 | ||
|
|
1ae58f676b | ||
|
|
6710c87892 | ||
|
|
96fd47a160 | ||
|
|
c1cfb9609e | ||
|
|
cd6716df47 | ||
|
|
5c221468d6 | ||
|
|
77da82b648 | ||
|
|
39ddfd2bd9 | ||
|
|
0717009ade | ||
|
|
f8f366a513 | ||
|
|
4af77f4696 | ||
|
|
73f4a3e055 | ||
|
|
0959cd3040 | ||
|
|
9a6f48a4cb | ||
|
|
fb27621c75 | ||
|
|
2b3853a882 | ||
|
|
0643787fbe | ||
|
|
b7d5922b0a | ||
|
|
c7b9eec087 | ||
|
|
db7bc17c49 | ||
|
|
d1cec00b51 | ||
|
|
102ef46d00 | ||
|
|
d6b24c3e07 | ||
|
|
4cc9a35b2e | ||
|
|
40833e65ec | ||
|
|
6c03094ef1 | ||
|
|
269bb7a322 | ||
|
|
54273ab09c | ||
|
|
ef0524485a | ||
|
|
071b632c93 | ||
|
|
62330413ee | ||
|
|
aa66f9a15d | ||
|
|
7ed2049a01 | ||
|
|
a961a5a997 | ||
|
|
ad7ef112fc | ||
|
|
0c6dab01b3 | ||
|
|
3c2a353d32 | ||
|
|
54a002f9b7 | ||
|
|
fb45ff44f3 | ||
|
|
9f2e050797 | ||
|
|
09656fa4d3 | ||
|
|
b41fb4fe75 | ||
|
|
f8bfecf57d | ||
|
|
43a8fe020d | ||
|
|
e0bf66eea8 | ||
|
|
658da37b48 | ||
|
|
1d1c301f61 | ||
|
|
d98e85614a | ||
|
|
5fb167898e | ||
|
|
0283c3bfff | ||
|
|
e32fcc2190 | ||
|
|
f8cb550be7 | ||
|
|
0707d4d9b1 | ||
|
|
46dfc7f992 | ||
|
|
a1b6a89714 | ||
|
|
70cd7e8c67 | ||
|
|
5b90c4bca0 | ||
|
|
6df8aba097 | ||
|
|
a244d249bd | ||
|
|
ac9a5914a2 | ||
|
|
b7cd4e5e9c | ||
|
|
ff602f1456 | ||
|
|
517c3f57ce | ||
|
|
d253450e9d | ||
|
|
fa901be328 | ||
|
|
74cae73844 | ||
|
|
0bcd00bcb6 | ||
|
|
54a4b8e414 | ||
|
|
87c0547090 | ||
|
|
9471e30585 | ||
|
|
ea4bd21977 | ||
|
|
3168f6327f | ||
|
|
139cf83507 | ||
|
|
ddb7520ce8 | ||
|
|
abf1027b77 | ||
|
|
9d812fa3cc | ||
|
|
8c88385977 | ||
|
|
30c978719b | ||
|
|
12eb9b71b6 | ||
|
|
0cb19a4e30 | ||
|
|
991aee6c90 | ||
|
|
cbb0a01c5f | ||
|
|
d86e820a16 | ||
|
|
1bdd76d95a | ||
|
|
0a3e9d9e89 | ||
|
|
a0013323f2 | ||
|
|
d5f3e581df | ||
|
|
ec249d2ed4 | ||
|
|
d6ad6a9506 | ||
|
|
7400a10c59 | ||
|
|
dfeffad044 | ||
|
|
b0c2827eb4 | ||
|
|
5267aa35ee | ||
|
|
40792e2779 | ||
|
|
822657972d | ||
|
|
ae71b7bc64 | ||
|
|
e15bbed554 | ||
|
|
65e63c206e | ||
|
|
f104b5ffae | ||
|
|
e4db04fa62 | ||
|
|
e6e7d8cdfd | ||
|
|
c4e9fa1650 | ||
|
|
8210b23d31 | ||
|
|
bd83c10b9b | ||
|
|
e03edad824 | ||
|
|
26d14f7cb0 | ||
|
|
b9f828ae88 | ||
|
|
098a472670 | ||
|
|
780a1ea180 | ||
|
|
917f6a2839 | ||
|
|
2101f1d7f0 | ||
|
|
a561777456 | ||
|
|
b4575c9780 | ||
|
|
0ef1e773bb | ||
|
|
cd88f73f05 | ||
|
|
d1c4ae83eb | ||
|
|
858952929b | ||
|
|
b676ef6c56 | ||
|
|
a9493f4f0e | ||
|
|
fe8d405a56 | ||
|
|
754c54761a | ||
|
|
7ecadffdb1 | ||
|
|
6506f36843 | ||
|
|
97f7508067 | ||
|
|
a481fc1000 | ||
|
|
b7078448ea | ||
|
|
359a6bf02a | ||
|
|
a5449a1430 | ||
|
|
6182c34448 | ||
|
|
699e73bcd5 | ||
|
|
352aa04d3e | ||
|
|
c81a1f9a97 | ||
|
|
34be331024 | ||
|
|
ceae98b75f | ||
|
|
5328865639 | ||
|
|
f78089edf8 | ||
|
|
c0d8951677 | ||
|
|
2e830d3f97 | ||
|
|
649eaa4cce | ||
|
|
80344a630f | ||
|
|
2a128777f1 | ||
|
|
baf3c41a44 | ||
|
|
f7f4e3a992 | ||
|
|
6890cd8690 | ||
|
|
c84b8b4792 | ||
|
|
3d36ecf094 | ||
|
|
eaa60cc2b5 | ||
|
|
de5234b59f | ||
|
|
69cf5fb8a1 | ||
|
|
a0f153e5ab | ||
|
|
0135318ded | ||
|
|
f4ec6ccf20 | ||
|
|
5f8d263d63 | ||
|
|
242cc8616c | ||
|
|
2ebc1e250d | ||
|
|
5876a602c3 | ||
|
|
79dabaf125 | ||
|
|
65d3d8e471 | ||
|
|
aa230983d5 | ||
|
|
1b9ef3b3b6 | ||
|
|
7ed342b345 | ||
|
|
29c029f04a | ||
|
|
777780926c | ||
|
|
f08fceaee0 | ||
|
|
0ddd0a6c25 | ||
|
|
8a8b4f3a61 | ||
|
|
cdec9d1514 | ||
|
|
9da047de70 | ||
|
|
18835d92dd | ||
|
|
967c25b11d | ||
|
|
877e8880cf | ||
|
|
ac9c236b6b | ||
|
|
df7fa08e40 | ||
|
|
c58013b818 | ||
|
|
b925705173 | ||
|
|
7a0e838017 | ||
|
|
bc55aea701 | ||
|
|
dca51a752a | ||
|
|
0db6eb6f2e | ||
|
|
d1a67828b6 | ||
|
|
048fb77c8c | ||
|
|
a63369c247 | ||
|
|
6d94880c2f | ||
|
|
9c6fcbc841 | ||
|
|
a809cbbcef | ||
|
|
e4b0680158 | ||
|
|
64db84c3e2 | ||
|
|
4a81246986 | ||
|
|
a23c7f215c | ||
|
|
5af3a926d7 | ||
|
|
2679745941 | ||
|
|
4443b76145 | ||
|
|
3a982c2a7a | ||
|
|
e07172ade4 | ||
|
|
c86d40f8dd | ||
|
|
d86e328169 | ||
|
|
02a032d287 | ||
|
|
03651d6c73 | ||
|
|
9b6c12d086 | ||
|
|
c5c7dd614f | ||
|
|
f87d6e5a52 | ||
|
|
c885c0c6bc | ||
|
|
3bf72d7237 | ||
|
|
d07d849fe4 | ||
|
|
e6ffc54c56 | ||
|
|
b5d8ff5332 | ||
|
|
7509077d63 | ||
|
|
cbabdf2151 | ||
|
|
1acc83572e | ||
|
|
b46107cb58 | ||
|
|
1261852514 | ||
|
|
04daf89a20 | ||
|
|
f94b8c6586 | ||
|
|
b4943f8c50 | ||
|
|
2aa084287d | ||
|
|
dcdbbc8483 | ||
|
|
4e6ea5b4c5 | ||
|
|
ede80d225f | ||
|
|
397de2698d | ||
|
|
632d2ec16b | ||
|
|
2c13438d71 | ||
|
|
fe5d6d8d96 | ||
|
|
e2ad28ca32 | ||
|
|
ed183a855e | ||
|
|
5867809560 | ||
|
|
a4a2942546 | ||
|
|
71d644c4ae | ||
|
|
574b8d524f | ||
|
|
94852e49cb | ||
|
|
4a3295b7fd | ||
|
|
ef75887504 | ||
|
|
49d4b433a7 | ||
|
|
5bb8b1b9f2 | ||
|
|
04c0714881 | ||
|
|
5f7925f350 | ||
|
|
226be37f85 | ||
|
|
32c4810c21 | ||
|
|
28bc4a5df8 | ||
|
|
73be8ab1c8 | ||
|
|
4de38f4b17 | ||
|
|
adf732a1a1 | ||
|
|
214a7c13dc | ||
|
|
fcdef343eb | ||
|
|
a4d40486c1 | ||
|
|
338a53a036 | ||
|
|
4da489d4dc | ||
|
|
db18ee4b34 | ||
|
|
764345f021 | ||
|
|
7d6a747aa7 | ||
|
|
422db75b5b | ||
|
|
05a631415e | ||
|
|
fff559da06 | ||
|
|
c87a9060f9 | ||
|
|
ee3c75ba9e | ||
|
|
90c4fe5458 | ||
|
|
97901cba87 | ||
|
|
7f81a0d4fd | ||
|
|
173117602a | ||
|
|
d9130cd932 | ||
|
|
be072365fc | ||
|
|
61f7d33fdf | ||
|
|
3766b2c2d3 | ||
|
|
0dfe013928 | ||
|
|
25c7893a8e | ||
|
|
28ec9cacb2 | ||
|
|
aaf27a7f9e | ||
|
|
0fb0c7fb4c | ||
|
|
f97ab03111 | ||
|
|
3981fac0db | ||
|
|
4a43ae2771 | ||
|
|
c4184c6148 | ||
|
|
87eaafe3c4 | ||
|
|
aee48f6956 | ||
|
|
3d5a1ec966 | ||
|
|
2e4ba352f4 | ||
|
|
31e8c2e1e0 | ||
|
|
96510f8aac | ||
|
|
6e83aeda88 | ||
|
|
7b1c150e18 | ||
|
|
cd8212fb53 | ||
|
|
11f7fd766d | ||
|
|
d80a1ed15a | ||
|
|
f1422feff1 | ||
|
|
d7c0af9ad0 | ||
|
|
87aea49ab6 | ||
|
|
c8ca8a7875 | ||
|
|
d9b365b012 | ||
|
|
05e70ab56d | ||
|
|
f9abcd44fe | ||
|
|
3cad91736b | ||
|
|
632d66f6ac | ||
|
|
ed843b4b89 | ||
|
|
27596bb919 | ||
|
|
6b7aac3372 | ||
|
|
6af4289289 | ||
|
|
eb8796fa26 | ||
|
|
8fe66f6e36 | ||
|
|
837fdf4031 | ||
|
|
dd09e421bf | ||
|
|
50261398bf | ||
|
|
7632d4bea7 | ||
|
|
30de8be2cd | ||
|
|
536e8e3d2a | ||
|
|
3f9c25fe5e | ||
|
|
fd4291aafb | ||
|
|
8fa91fe444 | ||
|
|
7972743a2f | ||
|
|
6d95e35448 | ||
|
|
86abf3e9da | ||
|
|
0e3c592205 | ||
|
|
5f49dddb4d | ||
|
|
074f92d144 | ||
|
|
6274facc3c | ||
|
|
b08b3b846a | ||
|
|
1059658515 | ||
|
|
d193633a5d | ||
|
|
2b7142bdcd | ||
|
|
44be03d39c | ||
|
|
f9123eb40f | ||
|
|
2025e1f9fd | ||
|
|
6e8320f782 | ||
|
|
bfcd11538e | ||
|
|
72d37f2362 | ||
|
|
e8a99c1108 | ||
|
|
48568fd412 | ||
|
|
320bde7f33 | ||
|
|
868a1469a3 | ||
|
|
43ab5d3246 | ||
|
|
c38f24aee9 | ||
|
|
a5041cb3db | ||
|
|
7143bb380f | ||
|
|
d5df426ab6 | ||
|
|
1d2dea214c | ||
|
|
e07f54921c | ||
|
|
ba112d7aa6 | ||
|
|
28e6e349b3 | ||
|
|
2d30686857 | ||
|
|
ea86ce5a4b | ||
|
|
4cf94683ae | ||
|
|
6c96830033 | ||
|
|
ec1473af5e | ||
|
|
675886673e | ||
|
|
19806d9b54 | ||
|
|
79b75483bd | ||
|
|
78090d8bd6 | ||
|
|
2fe0fdb975 | ||
|
|
930534ee31 | ||
|
|
3b611a1e18 | ||
|
|
120749e442 | ||
|
|
d333ac2382 | ||
|
|
c3ca6e4c49 | ||
|
|
ad6ed04901 | ||
|
|
25e5ea042f | ||
|
|
4462b44e9b | ||
|
|
7df969cb90 | ||
|
|
719f317b5d | ||
|
|
c32806d987 | ||
|
|
86637012dc | ||
|
|
bd7f37a3f6 | ||
|
|
d5c575cbfe | ||
|
|
f29030c3a0 | ||
|
|
f04691d321 | ||
|
|
dbe804f8db | ||
|
|
37e4153602 | ||
|
|
b0b14bcd18 | ||
|
|
7b917462c7 | ||
|
|
791433bceb | ||
|
|
e90835c234 | ||
|
|
8affbb7af6 | ||
|
|
21c4ab430e | ||
|
|
c9dc15b1d8 | ||
|
|
b4967ca075 | ||
|
|
cd1978ee1c | ||
|
|
a5e422d284 | ||
|
|
5a1d378b42 | ||
|
|
19abd7831f | ||
|
|
0d78020fbf | ||
|
|
7e1d5ad4c4 | ||
|
|
94f2daa22e | ||
|
|
075e4aeb32 | ||
|
|
52d70dd97d | ||
|
|
d9b03160f0 | ||
|
|
b11c63f557 | ||
|
|
a75cf5fd35 | ||
|
|
6f39a286d3 | ||
|
|
66b80a33ba | ||
|
|
84b9eac09b | ||
|
|
39788ab64a | ||
|
|
4cd289b4b8 | ||
|
|
d49bd20fa0 | ||
|
|
618251b8eb | ||
|
|
9437956b83 | ||
|
|
f602925896 | ||
|
|
1aecb9d2d2 | ||
|
|
4a5731976a | ||
|
|
8198ea9522 | ||
|
|
e069d30555 | ||
|
|
1aa06ffe90 | ||
|
|
1b29cb3ef7 | ||
|
|
ba22dca9e5 | ||
|
|
b5a9f09f12 | ||
|
|
8bf5dbb379 | ||
|
|
5fc599439e | ||
|
|
27723e6f29 | ||
|
|
a4c444435f | ||
|
|
6292685b5d | ||
|
|
7195702242 | ||
|
|
b459c5150c | ||
|
|
dba292cbfb | ||
|
|
01a5d11ef5 | ||
|
|
5e2bd4efa8 | ||
|
|
b23ba4c839 | ||
|
|
06bd9d775d | ||
|
|
d6487e24c9 | ||
|
|
c404589382 | ||
|
|
f1689a7794 | ||
|
|
a861a4110f | ||
|
|
e7809b3160 | ||
|
|
edd0618e64 | ||
|
|
849e62df7e | ||
|
|
e1c0df3bd7 | ||
|
|
349b5b0513 | ||
|
|
3ad049cb28 | ||
|
|
656cf1158b | ||
|
|
f3cb508649 | ||
|
|
c39b5a5cf1 | ||
|
|
9ff3099a9d | ||
|
|
c24a2aade0 | ||
|
|
8f862d1ca0 | ||
|
|
b56608f94f | ||
|
|
2876e1bf78 | ||
|
|
ebd850b0d5 | ||
|
|
f3f962a7c9 | ||
|
|
49ae1fdc34 | ||
|
|
dc769bda41 | ||
|
|
75ba6b0627 | ||
|
|
1e40d21bd8 | ||
|
|
3c43874342 | ||
|
|
4f422f1485 | ||
|
|
85d9b6ebab | ||
|
|
90662e2173 | ||
|
|
24980aff95 | ||
|
|
804261605b | ||
|
|
8a65c78999 | ||
|
|
979db254d1 | ||
|
|
18ebe77806 | ||
|
|
0de856e929 | ||
|
|
a6d82b4637 | ||
|
|
acfb79c857 | ||
|
|
dcfe0f8308 | ||
|
|
23a423fe85 | ||
|
|
b2cb874b73 | ||
|
|
f671f367ea | ||
|
|
665603b314 | ||
|
|
0d7dd093c4 | ||
|
|
c849a213f0 | ||
|
|
49ff0854c0 | ||
|
|
5047057521 | ||
|
|
e28e2b9371 | ||
|
|
bb92b6083b | ||
|
|
acdae74587 | ||
|
|
5e4449247b | ||
|
|
c2fe142293 | ||
|
|
431b0f9b0c | ||
|
|
959e6e9398 | ||
|
|
18e47d2327 | ||
|
|
b2e5e02204 | ||
|
|
e6a488a4e2 | ||
|
|
75d0164a8b | ||
|
|
05251edaf3 | ||
|
|
892dfee381 | ||
|
|
7f1eb3a818 | ||
|
|
bd0a0c5d6b | ||
|
|
71b05fd4b2 | ||
|
|
587609d7f6 | ||
|
|
cceba28d0a | ||
|
|
03c623e8c7 | ||
|
|
2b34f4a9d7 | ||
|
|
ad3cc6b986 | ||
|
|
133db721a2 | ||
|
|
a03d30fa45 | ||
|
|
62abd3c30a | ||
|
|
97ba02f45e | ||
|
|
0f1bf5e011 | ||
|
|
d816d82a38 | ||
|
|
c89c88647e | ||
|
|
3599b0b0e1 | ||
|
|
1adb89c6b3 | ||
|
|
7ec1c2ebdc | ||
|
|
7ec062deaf | ||
|
|
a5c7d5b52a | ||
|
|
e54bbd11f1 | ||
|
|
38afdab2e4 | ||
|
|
f214c50c15 | ||
|
|
1fc081ed6b | ||
|
|
cbaed8cdfb | ||
|
|
eac0e26696 | ||
|
|
77af810d1f | ||
|
|
1292d7ac34 | ||
|
|
e75ab94eb9 | ||
|
|
804bea8e9a | ||
|
|
0502dd7ba0 | ||
|
|
3221a0cb1a | ||
|
|
e7efa458e5 | ||
|
|
9b48f26463 | ||
|
|
903b8eced8 | ||
|
|
3753967caf | ||
|
|
516925cf50 | ||
|
|
cd9498abb8 | ||
|
|
18642cae15 | ||
|
|
92063bd58f | ||
|
|
726281ed44 | ||
|
|
e02daa89b8 | ||
|
|
7de7802368 | ||
|
|
d44a8994ce | ||
|
|
42d9168941 | ||
|
|
38616770e5 | ||
|
|
bf7558f80d | ||
|
|
381e82d61a | ||
|
|
9cebcb97c5 | ||
|
|
eef73e6447 | ||
|
|
e8df18e7b2 | ||
|
|
54512b8f93 | ||
|
|
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 |
@@ -0,0 +1,7 @@
|
||||
**
|
||||
!.maestro/
|
||||
!.maestro/jellyfin-demo/
|
||||
!.maestro/jellyfin-demo/**
|
||||
!scripts/
|
||||
!scripts/maestro_fixtures.py
|
||||
!scripts/maestro_real_jellyfin.py
|
||||
@@ -1,3 +1,17 @@
|
||||
*.sh text eol=lf
|
||||
*.Dockerfile text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
*.bat text eol=lf
|
||||
|
||||
# Byte-exact generated Dart checked by scripts/check_codegen.py.
|
||||
lib/data/ducet_order.dart text eol=lf
|
||||
lib/data/hid_key_labels.dart text eol=lf
|
||||
lib/data/iso_639_data.dart text eol=lf
|
||||
lib/**/*.g.dart text eol=lf
|
||||
lib/**/*.freezed.dart text eol=lf
|
||||
|
||||
# Byte-exact wakelock inputs hashed in packages/wakelock_plus/provenance.json.
|
||||
packages/wakelock_plus/pigeons/messages.dart text eol=lf
|
||||
packages/wakelock_plus/android/src/main/kotlin/dev/fluttercommunity/plus/wakelock/WakelockPlusMessages.g.kt text eol=lf
|
||||
packages/wakelock_plus/ios/wakelock_plus/Sources/wakelock_plus/include/wakelock_plus/messages.g.h text eol=lf
|
||||
packages/wakelock_plus/ios/wakelock_plus/Sources/wakelock_plus/messages.g.m text eol=lf
|
||||
|
||||
@@ -82,6 +82,8 @@ body:
|
||||
options:
|
||||
- Jellyfin
|
||||
- Plex
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: device
|
||||
@@ -89,6 +91,8 @@ body:
|
||||
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
|
||||
@@ -57,17 +59,20 @@ body:
|
||||
- tvOS
|
||||
- Linux
|
||||
- All platforms
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: media-server
|
||||
attributes:
|
||||
label: Media Server
|
||||
description: Is this for Jellyfin, Plex, or both?
|
||||
description: Which media server(s) is this for?
|
||||
multiple: true
|
||||
options:
|
||||
- Jellyfin
|
||||
- Plex
|
||||
- Both
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Set up Flutter from git
|
||||
description: >-
|
||||
Clone the pinned Flutter SDK from its release tag and put it on PATH, for
|
||||
runners without a published archive. Flutter ships no windows-arm64 SDK, so
|
||||
subosito/flutter-action cannot resolve the release for arm64 (no arm64 entry
|
||||
in the stable manifest) and `channel: master` would clone master HEAD, whose
|
||||
engine is not the patched revision install-patched-engine.ps1 asserts
|
||||
(4c525dac). This file is the only place that pin lives: the tag is fetched so
|
||||
the SDK reports its own version, then verified against the immutable commit
|
||||
and against the version Flutter itself reports, so a moved tag fails the job
|
||||
instead of quietly changing SDKs.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Clone Flutter from its immutable commit
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "3.44.0"
|
||||
$expectedCommit = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
|
||||
$root = "$env:RUNNER_TEMP\flutter"
|
||||
git init $root
|
||||
git -C $root remote add origin https://github.com/flutter/flutter.git
|
||||
git -C $root fetch --depth 1 origin "refs/tags/${version}:refs/tags/${version}"
|
||||
git -C $root checkout --detach "refs/tags/$version"
|
||||
$actualCommit = git -C $root rev-parse HEAD
|
||||
if ($LASTEXITCODE -ne 0 -or $actualCommit -ne $expectedCommit) {
|
||||
throw "Flutter $version resolved to $actualCommit, expected $expectedCommit"
|
||||
}
|
||||
"$root\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||
& "$root\bin\flutter.bat" --version
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Unable to bootstrap the Flutter SDK"
|
||||
}
|
||||
$versionOutput = & "$root\bin\flutter.bat" --version --machine
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Unable to resolve the Flutter SDK version"
|
||||
}
|
||||
$versionJson = $versionOutput -join "`n"
|
||||
if ([string]::IsNullOrWhiteSpace($versionJson)) {
|
||||
throw "Flutter did not report machine-readable version JSON"
|
||||
}
|
||||
$reportedVersion = ($versionJson | ConvertFrom-Json).frameworkVersion
|
||||
if ($reportedVersion -ne $version) {
|
||||
throw "Flutter reported version $reportedVersion, expected $version"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: monthly
|
||||
+440
-387
File diff suppressed because it is too large
Load Diff
+454
-48
@@ -4,11 +4,16 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# Keep untrusted code on the read-only pull_request event. Never use pull_request_target here.
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
# Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release.
|
||||
FLUTTER_VERSION: "3.44.0"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Code Analysis
|
||||
@@ -17,18 +22,20 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
@@ -39,44 +46,31 @@ jobs:
|
||||
|
||||
flutter pub get
|
||||
|
||||
- name: Install wakelock_plus development dependencies
|
||||
working-directory: packages/wakelock_plus
|
||||
run: flutter pub get --enforce-lockfile --no-example
|
||||
|
||||
- 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
|
||||
run: scripts/codegen.sh --check
|
||||
|
||||
- name: Verify translation hygiene
|
||||
run: python3 scripts/clean_translations.py --check --strict
|
||||
|
||||
- name: Verify workflow and script guards
|
||||
run: bash scripts/ci_guard_checks.sh
|
||||
|
||||
- name: Verify formatting
|
||||
run: |
|
||||
# Find all Dart files excluding generated files
|
||||
find lib $([ -d test ] && echo test) -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f 2>/dev/null | while IFS= read -r file; do
|
||||
files_found=true
|
||||
break
|
||||
done
|
||||
if [ "$files_found" != "true" ]; then
|
||||
echo "No Dart files found to format"
|
||||
exit 0
|
||||
fi
|
||||
find lib $([ -d test ] && echo test) -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f 2>/dev/null -print0 | xargs -0 dart format --output=none --set-exit-if-changed
|
||||
- name: Analyze code
|
||||
run: |
|
||||
# Run flutter analyze
|
||||
# Fails on errors or warnings only (info messages are allowed)
|
||||
flutter analyze 2>&1 | tee analyze_output.txt || true
|
||||
paths=(lib)
|
||||
[ ! -d test ] || paths+=(test)
|
||||
find "${paths[@]}" -name "*.dart" ! -name "*.g.dart" ! -name "*.freezed.dart" -type f -print0 |
|
||||
xargs -0 -r dart format --output=none --set-exit-if-changed
|
||||
|
||||
# Check output for errors or warnings
|
||||
if grep -q "error •" analyze_output.txt; then
|
||||
echo "❌ Analysis failed with errors"
|
||||
exit 1
|
||||
elif grep -q "warning •" analyze_output.txt; then
|
||||
echo "⚠️ Analysis completed with warnings"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ Analysis passed!"
|
||||
exit 0
|
||||
fi
|
||||
- name: Verify icon consistency
|
||||
run: dart run scripts/check_icon_consistency.dart
|
||||
|
||||
- name: Analyze code
|
||||
run: dart run scripts/check_analyzer.dart
|
||||
|
||||
- name: Check for unused code
|
||||
run: |
|
||||
@@ -109,18 +103,20 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
@@ -134,11 +130,100 @@ jobs:
|
||||
- name: Run tests
|
||||
run: |
|
||||
if [ -d "test" ] && [ "$(find test -name '*_test.dart' | wc -l)" -gt 0 ]; then
|
||||
flutter test
|
||||
scripts/run_tests.sh
|
||||
else
|
||||
echo "No tests found, skipping test execution"
|
||||
fi
|
||||
|
||||
- name: Install wakelock_plus test dependencies
|
||||
working-directory: packages/wakelock_plus
|
||||
run: flutter pub get --enforce-lockfile
|
||||
|
||||
- name: Run wakelock_plus VM tests
|
||||
working-directory: packages/wakelock_plus
|
||||
run: flutter test test/wakelock_plus_linux_plugin_test.dart
|
||||
|
||||
- name: Run wakelock_plus Chrome tests
|
||||
working-directory: packages/wakelock_plus
|
||||
run: flutter test --platform chrome --dart-define=WEB_PLUGIN_TESTS=true test/wakelock_plus_web_plugin_test.dart
|
||||
|
||||
# The vendored atomic-write patch has to keep the upstream contract, not
|
||||
# just the new behaviour. Upstream's own suites are the check for that.
|
||||
- name: Run the vendored desktop preference store tests
|
||||
run: |
|
||||
for pkg in shared_preferences_linux shared_preferences_windows; do
|
||||
(cd "packages/$pkg" && flutter pub get --enforce-lockfile && flutter test)
|
||||
done
|
||||
|
||||
android-test:
|
||||
name: Android JVM and Native Tests
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "21"
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Configure Android local properties
|
||||
run: printf 'flutter.sdk=%s\nsdk.dir=%s\n' "$FLUTTER_ROOT" "$ANDROID_HOME" > android/local.properties
|
||||
|
||||
- name: Configure Android host native tests
|
||||
run: |
|
||||
cmake -S android/app/src/test/cpp -B build/android-host-tests \
|
||||
-DCMAKE_BUILD_TYPE=Debug
|
||||
|
||||
- name: Build Android host native tests
|
||||
run: cmake --build build/android-host-tests --parallel 2
|
||||
|
||||
- name: Run Android host native tests
|
||||
run: |
|
||||
ctest --test-dir build/android-host-tests \
|
||||
--output-on-failure --no-tests=error
|
||||
|
||||
- name: Run Android JVM unit tests
|
||||
working-directory: android
|
||||
run: ./gradlew :app:testDebugUnitTest :saf_util:testDebugUnitTest :libass:testDebugUnitTest -x :app:compileFlutterBuildDebug --continue
|
||||
|
||||
- name: Check Android API compatibility
|
||||
working-directory: android
|
||||
run: ./gradlew :app:lintDebug
|
||||
|
||||
native-format:
|
||||
name: Native Formatting
|
||||
runs-on: ubuntu-latest
|
||||
@@ -146,10 +231,12 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
@@ -157,6 +244,283 @@ jobs:
|
||||
- name: Verify native formatting
|
||||
run: scripts/format_native.sh --check
|
||||
|
||||
- name: Verify Linux native acquisition integrity
|
||||
run: bash linux/packaging/build-libmpv_test.sh
|
||||
|
||||
linux-native-test:
|
||||
name: Linux native reliability (${{ matrix.sanitizer }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- sanitizer: address
|
||||
lifecycle_sanitizers: ON
|
||||
- sanitizer: thread
|
||||
lifecycle_sanitizers: OFF
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Install Linux native test dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev \
|
||||
libstdc++-12-dev libmpv-dev libepoxy-dev libcurl4-openssl-dev libevdev-dev
|
||||
|
||||
- name: Prepare Flutter Linux configuration
|
||||
run: |
|
||||
flutter pub get --enforce-lockfile --no-example
|
||||
flutter build linux --debug --config-only --no-pub
|
||||
|
||||
- name: Configure Linux native reliability tests
|
||||
run: |
|
||||
cmake -S linux -B build/linux-native-${{ matrix.sanitizer }} -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Debug \
|
||||
-DPLEZY_BUILD_MPV_PLAYER_LIFECYCLE_TESTS=ON \
|
||||
-DPLEZY_MPV_LIFECYCLE_SANITIZERS=${{ matrix.lifecycle_sanitizers }} \
|
||||
-DPLEZY_BUILD_MPV_RELIABILITY_TESTS=ON \
|
||||
-DPLEZY_MPV_RELIABILITY_SANITIZER=${{ matrix.sanitizer }}
|
||||
|
||||
- name: Build Linux native reliability tests
|
||||
run: |
|
||||
cmake --build build/linux-native-${{ matrix.sanitizer }} --parallel 2 --target \
|
||||
mpv_player_lifecycle_test \
|
||||
mpv_property_result_contract_test \
|
||||
mpv_gpu_bootstrap_test
|
||||
|
||||
- name: Run Linux native reliability tests
|
||||
run: |
|
||||
ctest --test-dir build/linux-native-${{ matrix.sanitizer }} \
|
||||
--output-on-failure --no-tests=error
|
||||
|
||||
apple-native-test:
|
||||
name: Apple native reliability (${{ matrix.platform }})
|
||||
runs-on: macos-26
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: iOS
|
||||
project_directory: ios
|
||||
workspace: ios/Runner.xcworkspace
|
||||
simulator_runtime: iOS
|
||||
simulator_platform: iOS
|
||||
static_destination: ""
|
||||
- platform: macOS
|
||||
project_directory: macos
|
||||
workspace: macos/Runner.xcworkspace
|
||||
simulator_runtime: ""
|
||||
simulator_platform: ""
|
||||
static_destination: platform=macOS
|
||||
- platform: tvOS
|
||||
project_directory: tvos
|
||||
workspace: tvos/Runner.xcworkspace
|
||||
simulator_runtime: tvOS
|
||||
simulator_platform: tvOS
|
||||
static_destination: ""
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Install locked Dart dependencies
|
||||
run: flutter pub get --enforce-lockfile --no-example
|
||||
|
||||
- name: Record committed CocoaPods lockfile
|
||||
if: matrix.platform != 'tvOS'
|
||||
env:
|
||||
PODFILE_LOCK: ${{ matrix.project_directory }}/Podfile.lock
|
||||
run: |
|
||||
test -f "$PODFILE_LOCK"
|
||||
shasum -a 256 "$PODFILE_LOCK" > "$RUNNER_TEMP/plezy-podfile-lock.sha256"
|
||||
|
||||
- name: Prepare iOS Flutter build settings
|
||||
if: matrix.platform == 'iOS'
|
||||
run: flutter build ios --config-only --simulator --debug --no-pub
|
||||
|
||||
- name: Prepare macOS Flutter build settings
|
||||
if: matrix.platform == 'macOS'
|
||||
run: flutter build macos --config-only --debug --no-pub
|
||||
|
||||
- name: Prepare tvOS Flutter engine
|
||||
if: matrix.platform == 'tvOS'
|
||||
run: tvos/scripts/fetch_engine.sh
|
||||
|
||||
- name: Verify Flutter configuration preserved CocoaPods lockfile
|
||||
if: matrix.platform != 'tvOS'
|
||||
run: shasum -a 256 --check "$RUNNER_TEMP/plezy-podfile-lock.sha256"
|
||||
|
||||
- name: Install locked CocoaPods dependencies
|
||||
if: matrix.platform != 'tvOS'
|
||||
working-directory: ${{ matrix.project_directory }}
|
||||
run: pod install --deployment
|
||||
|
||||
- name: Install tvOS CocoaPods dependencies
|
||||
if: matrix.platform == 'tvOS'
|
||||
run: tvos/scripts/pod_install.sh
|
||||
|
||||
- name: Verify tvOS project wiring
|
||||
if: matrix.platform == 'tvOS'
|
||||
run: ruby tvos/scripts/test_wire_mpv.rb
|
||||
|
||||
- name: Select Apple test destination
|
||||
env:
|
||||
SIMULATOR_RUNTIME: ${{ matrix.simulator_runtime }}
|
||||
SIMULATOR_PLATFORM: ${{ matrix.simulator_platform }}
|
||||
STATIC_DESTINATION: ${{ matrix.static_destination }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
destination = os.environ["STATIC_DESTINATION"]
|
||||
if not destination:
|
||||
runtime_name = os.environ["SIMULATOR_RUNTIME"]
|
||||
payload = json.loads(
|
||||
subprocess.check_output(
|
||||
["xcrun", "simctl", "list", "devices", "available", "-j"],
|
||||
text=True,
|
||||
)
|
||||
)
|
||||
devices = [
|
||||
device
|
||||
for runtime, candidates in payload["devices"].items()
|
||||
if f".{runtime_name}-" in runtime
|
||||
for device in candidates
|
||||
if device.get("isAvailable", False)
|
||||
]
|
||||
if not devices:
|
||||
raise SystemExit(f"no available {runtime_name} simulator")
|
||||
destination = (
|
||||
f"platform={os.environ['SIMULATOR_PLATFORM']} Simulator,"
|
||||
f"id={devices[0]['udid']}"
|
||||
)
|
||||
with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as output:
|
||||
output.write(f"APPLE_TEST_DESTINATION={destination}\n")
|
||||
PY
|
||||
|
||||
- name: Run Apple native reliability tests
|
||||
run: |
|
||||
xcodebuild test \
|
||||
-workspace "${{ matrix.workspace }}" \
|
||||
-scheme Runner \
|
||||
-configuration Debug \
|
||||
-destination "$APPLE_TEST_DESTINATION" \
|
||||
-disableAutomaticPackageResolution \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
COMPILER_INDEX_STORE_ENABLE=NO
|
||||
|
||||
windows-native-test:
|
||||
name: Windows native reliability (${{ matrix.arch }})
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
runner: windows-latest
|
||||
flutter_setup: action
|
||||
- arch: arm64
|
||||
runner: windows-11-arm
|
||||
flutter_setup: git
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install 7-Zip
|
||||
if: matrix.arch == 'arm64'
|
||||
shell: pwsh
|
||||
run: choco install 7zip -y
|
||||
|
||||
- name: Setup Flutter
|
||||
if: matrix.flutter_setup == 'action'
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Set up Flutter from its pinned commit
|
||||
if: matrix.flutter_setup == 'git'
|
||||
uses: ./.github/actions/setup-flutter-git
|
||||
|
||||
- name: Install locked Dart dependencies
|
||||
shell: pwsh
|
||||
run: flutter pub get --enforce-lockfile --no-example
|
||||
|
||||
# Windows replacement semantics (`MoveFileExW` with
|
||||
# MOVEFILE_REPLACE_EXISTING) cannot be proven on the POSIX runners, and a
|
||||
# memory file system proves nothing about either. The vendored
|
||||
# shared_preferences_windows store write is the fix for #1732, so exercise
|
||||
# it here, on a real NTFS volume, against the real backend.
|
||||
- name: Run the vendored Windows preference store tests
|
||||
shell: pwsh
|
||||
run: flutter test test/services/prefs_store_atomic_write_windows_test.dart
|
||||
|
||||
- name: Install patched Flutter engine
|
||||
shell: pwsh
|
||||
run: |
|
||||
flutter precache --windows
|
||||
.\windows\tool\install-patched-engine.ps1
|
||||
|
||||
- name: Prepare Flutter Windows configuration
|
||||
shell: pwsh
|
||||
run: flutter build windows --debug --config-only --no-pub
|
||||
|
||||
- name: Configure Windows native reliability tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
$buildDir = "build/windows/${{ matrix.arch }}"
|
||||
cmake -S windows -B $buildDir `
|
||||
-DPLEZY_BUILD_MPV_PROPERTY_CONTRACT_TESTS=ON `
|
||||
-DPLEZY_BUILD_DISPLAY_RECOVERY_TESTS=ON
|
||||
|
||||
- name: Build Windows native reliability tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
$buildDir = "build/windows/${{ matrix.arch }}"
|
||||
cmake --build $buildDir --config Debug --parallel 2 --target `
|
||||
mpv_property_result_contract_test `
|
||||
mpv_player_property_contract_test `
|
||||
display_mode_manager_test
|
||||
|
||||
- name: Run Windows native reliability tests
|
||||
shell: pwsh
|
||||
run: |
|
||||
$buildDir = "build/windows/${{ matrix.arch }}"
|
||||
ctest --test-dir "$buildDir/runner" -C Debug --output-on-failure --no-tests=error
|
||||
|
||||
dependency-check:
|
||||
name: Dependency Validation
|
||||
runs-on: ubuntu-latest
|
||||
@@ -164,18 +528,20 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.pub-cache
|
||||
@@ -186,3 +552,43 @@ jobs:
|
||||
flutter clean
|
||||
flutter pub get
|
||||
flutter pub outdated
|
||||
|
||||
server:
|
||||
name: Server checks
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: server/go.mod
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Run server checks
|
||||
run: scripts/ci_server_checks.sh
|
||||
|
||||
|
||||
website:
|
||||
name: Website checks
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: "1.3.14"
|
||||
|
||||
- name: Run website checks
|
||||
run: scripts/ci_website_checks.sh
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
name: E2E - Maestro
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
# Keep untrusted code on the read-only pull_request event. Never use pull_request_target here.
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: maestro-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
android-maestro:
|
||||
name: Android Maestro suites
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 150
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
FLUTTER_VERSION: "3.44.0"
|
||||
MAESTRO_VERSION: "2.6.1"
|
||||
MAESTRO_SHA256: "3440825f514f537c6a96bcf5de995780c2a4a7f83a43208fdc95d4f1fecfad3b"
|
||||
MAESTRO_CLI_NO_ANALYTICS: "1"
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Enable KVM
|
||||
run: |
|
||||
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
|
||||
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
|
||||
sudo udevadm control --reload-rules
|
||||
sudo udevadm trigger --name-match=kvm
|
||||
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: stable
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
id: pub-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
|
||||
|
||||
- name: Cache Gradle
|
||||
id: gradle-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-e2e-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-e2e-
|
||||
${{ runner.os }}-gradle-
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build debug APK
|
||||
run: flutter build apk --debug --dart-define=PLEZY_MAESTRO_E2E=true
|
||||
|
||||
- name: Cache Maestro CLI
|
||||
id: maestro-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.maestro/bin
|
||||
~/.maestro/lib
|
||||
key: ${{ runner.os }}-maestro-${{ env.MAESTRO_VERSION }}
|
||||
|
||||
- name: Install verified Maestro CLI
|
||||
if: steps.maestro-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
archive="$RUNNER_TEMP/maestro.zip"
|
||||
staging="$RUNNER_TEMP/maestro-install"
|
||||
curl --fail --location --retry 3 \
|
||||
--output "$archive" \
|
||||
"https://github.com/mobile-dev-inc/maestro/releases/download/cli-${MAESTRO_VERSION}/maestro.zip"
|
||||
echo "${MAESTRO_SHA256} ${archive}" | sha256sum --check -
|
||||
rm -rf "$staging" "$HOME/.maestro/bin" "$HOME/.maestro/lib"
|
||||
mkdir -p "$staging" "$HOME/.maestro"
|
||||
unzip -q "$archive" -d "$staging"
|
||||
mv "$staging/maestro/bin" "$HOME/.maestro/bin"
|
||||
mv "$staging/maestro/lib" "$HOME/.maestro/lib"
|
||||
|
||||
- name: Add Maestro to PATH
|
||||
run: |
|
||||
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
|
||||
"$HOME/.maestro/bin/maestro" --version
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
|
||||
|
||||
- name: Build deterministic Jellyfin image for pull request
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
file: .maestro/jellyfin-demo/Dockerfile
|
||||
load: true
|
||||
tags: plezy-jellyfin-demo:local
|
||||
cache-from: type=gha,scope=maestro-jellyfin
|
||||
|
||||
- name: Build and cache deterministic Jellyfin image
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .
|
||||
file: .maestro/jellyfin-demo/Dockerfile
|
||||
load: true
|
||||
tags: plezy-jellyfin-demo:local
|
||||
cache-from: type=gha,scope=maestro-jellyfin
|
||||
cache-to: type=gha,mode=max,scope=maestro-jellyfin
|
||||
|
||||
- name: Cache Android 15 AVD
|
||||
id: api35-avd-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.android/avd/maestro-api35.avd
|
||||
~/.android/avd/maestro-api35.ini
|
||||
key: ${{ runner.os }}-avd-v1-api35-x86_64-pixel_6
|
||||
|
||||
- name: Generate Android 15 AVD snapshot
|
||||
if: steps.api35-avd-cache.outputs.cache-hit != 'true'
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
|
||||
with:
|
||||
api-level: 35
|
||||
arch: x86_64
|
||||
profile: pixel_6
|
||||
avd-name: maestro-api35
|
||||
force-avd-creation: false
|
||||
disable-animations: false
|
||||
emulator-options: >-
|
||||
-no-window -gpu swiftshader_indirect -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: echo "Generated Android 15 AVD snapshot."
|
||||
|
||||
- name: Cache Android 9 AVD
|
||||
id: api28-avd-cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.android/avd/maestro-api28.avd
|
||||
~/.android/avd/maestro-api28.ini
|
||||
key: ${{ runner.os }}-avd-v1-api28-x86-pixel_2-playstore
|
||||
|
||||
- name: Generate Android 9 AVD snapshot
|
||||
if: steps.api28-avd-cache.outputs.cache-hit != 'true'
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
|
||||
with:
|
||||
api-level: 28
|
||||
target: google_apis_playstore
|
||||
arch: x86
|
||||
profile: pixel_2
|
||||
avd-name: maestro-api28
|
||||
force-avd-creation: false
|
||||
disable-animations: false
|
||||
emulator-options: >-
|
||||
-no-window -gpu swiftshader_indirect -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: echo "Generated Android 9 AVD snapshot."
|
||||
|
||||
- name: Run Android 15 instrumentation
|
||||
id: api35-instrumentation
|
||||
continue-on-error: true
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
|
||||
with:
|
||||
api-level: 35
|
||||
arch: x86_64
|
||||
profile: pixel_6
|
||||
avd-name: maestro-api35-instrumentation
|
||||
force-avd-creation: false
|
||||
disable-animations: true
|
||||
emulator-options: >-
|
||||
-no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: python3 scripts/run_maestro_ci.py android-15-instrumentation
|
||||
|
||||
- name: Run Android R8 reachability
|
||||
id: api35-r8
|
||||
continue-on-error: true
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
|
||||
with:
|
||||
api-level: 35
|
||||
arch: x86_64
|
||||
profile: pixel_6
|
||||
avd-name: maestro-api35-instrumentation
|
||||
force-avd-creation: false
|
||||
disable-animations: true
|
||||
emulator-options: >-
|
||||
-no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: python3 scripts/run_maestro_ci.py android-r8-reachability
|
||||
|
||||
- name: Run Android 15 suites
|
||||
id: api35
|
||||
continue-on-error: true
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
|
||||
env:
|
||||
MAESTRO_SKIP_BUILD: "1"
|
||||
MAESTRO_SKIP_JELLYFIN_BUILD: "1"
|
||||
MAESTRO_DEVICE_ID: emulator-5554
|
||||
with:
|
||||
api-level: 35
|
||||
arch: x86_64
|
||||
profile: pixel_6
|
||||
avd-name: maestro-api35
|
||||
force-avd-creation: false
|
||||
disable-animations: true
|
||||
emulator-options: >-
|
||||
-no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: python3 scripts/run_maestro_ci.py android-15
|
||||
|
||||
- name: Run Android 9 legacy playback
|
||||
id: api28
|
||||
continue-on-error: true
|
||||
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
|
||||
env:
|
||||
MAESTRO_SKIP_BUILD: "1"
|
||||
MAESTRO_SKIP_JELLYFIN_BUILD: "1"
|
||||
MAESTRO_DEVICE_ID: emulator-5554
|
||||
with:
|
||||
api-level: 28
|
||||
target: google_apis_playstore
|
||||
arch: x86
|
||||
profile: pixel_2
|
||||
avd-name: maestro-api28
|
||||
force-avd-creation: false
|
||||
disable-animations: true
|
||||
emulator-options: >-
|
||||
-no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio
|
||||
-no-boot-anim -camera-back none
|
||||
script: python3 scripts/run_maestro_ci.py android-9
|
||||
# Fork PRs may restore trusted caches, but only trusted events publish cache entries.
|
||||
- name: Save Pub dependency cache
|
||||
if: github.event_name != 'pull_request' && steps.pub-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: ${{ steps.pub-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Save Gradle cache
|
||||
if: github.event_name != 'pull_request' && steps.gradle-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ steps.gradle-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Save Maestro CLI cache
|
||||
if: github.event_name != 'pull_request' && steps.maestro-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.maestro/bin
|
||||
~/.maestro/lib
|
||||
key: ${{ steps.maestro-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Save Android 15 AVD cache
|
||||
if: github.event_name != 'pull_request' && steps.api35-avd-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.android/avd/maestro-api35.avd
|
||||
~/.android/avd/maestro-api35.ini
|
||||
key: ${{ steps.api35-avd-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Save Android 9 AVD cache
|
||||
if: github.event_name != 'pull_request' && steps.api28-avd-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: |
|
||||
~/.android/avd/maestro-api28.avd
|
||||
~/.android/avd/maestro-api28.ini
|
||||
key: ${{ steps.api28-avd-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Upload Maestro diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: maestro-android-diagnostics
|
||||
path: |
|
||||
build/maestro
|
||||
build/maestro-offline
|
||||
build/maestro-real-jellyfin
|
||||
build/maestro-media
|
||||
build/maestro-tv
|
||||
build/maestro-recovery
|
||||
build/maestro-legacy
|
||||
~/.maestro/tests
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
- name: Report suite failures
|
||||
if: ${{ always() && (steps.api35-instrumentation.outcome != 'success' || steps.api35-r8.outcome != 'success' || steps.api35.outcome != 'success' || steps.api28.outcome != 'success') }}
|
||||
run: |
|
||||
echo "Android 15 instrumentation outcome: ${{ steps.api35-instrumentation.outcome }}"
|
||||
echo "Android R8 reachability outcome: ${{ steps.api35-r8.outcome }}"
|
||||
echo "Android 15 outcome: ${{ steps.api35.outcome }}"
|
||||
echo "Android 9 outcome: ${{ steps.api28.outcome }}"
|
||||
exit 1
|
||||
@@ -17,9 +17,17 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Require the protected main branch
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then
|
||||
echo "Release automation may only run from refs/heads/main." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate version format
|
||||
@@ -32,10 +40,8 @@ jobs:
|
||||
- name: Extract current build number
|
||||
id: get_build
|
||||
run: |
|
||||
# Extract current build number from pubspec.yaml
|
||||
CURRENT_VERSION=$(grep '^version:' pubspec.yaml | sed 's/version: //')
|
||||
CURRENT_BUILD=$(echo "$CURRENT_VERSION" | cut -d'+' -f2)
|
||||
NEW_BUILD=$((CURRENT_BUILD + 1))
|
||||
CURRENT_BUILD=$(python3 scripts/pubspec_version.py --build-number pubspec.yaml)
|
||||
NEW_BUILD=$((10#$CURRENT_BUILD + 1))
|
||||
|
||||
echo "current_build=$CURRENT_BUILD" >> $GITHUB_OUTPUT
|
||||
echo "new_build=$NEW_BUILD" >> $GITHUB_OUTPUT
|
||||
@@ -71,7 +77,7 @@ jobs:
|
||||
|
||||
- name: Trigger build workflow
|
||||
run: |
|
||||
curl -L \
|
||||
curl --fail-with-body -L \
|
||||
-X POST \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
|
||||
|
||||
@@ -4,14 +4,71 @@ on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Published release tag to update packages for
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
resolve-release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
tag: ${{ steps.release.outputs.tag }}
|
||||
steps:
|
||||
- name: Require the default branch for manual runs
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" != "refs/heads/${{ github.event.repository.default_branch }}" ]]; then
|
||||
echo "Manual package updates may only run from the default branch." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Resolve published release tag
|
||||
id: release
|
||||
env:
|
||||
EVENT_TAG: ${{ github.event.release.tag_name }}
|
||||
INPUT_TAG: ${{ inputs.release_tag }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$GITHUB_EVENT_NAME" = "release" ]; then
|
||||
REQUESTED_TAG="$EVENT_TAG"
|
||||
else
|
||||
REQUESTED_TAG="$INPUT_TAG"
|
||||
fi
|
||||
|
||||
if [ -z "$REQUESTED_TAG" ]; then
|
||||
echo "A release tag is required." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RELEASE=$(gh release view "$REQUESTED_TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--json tagName,isDraft,publishedAt)
|
||||
RESOLVED_TAG=$(jq -r '.tagName' <<< "$RELEASE")
|
||||
IS_DRAFT=$(jq -r '.isDraft' <<< "$RELEASE")
|
||||
PUBLISHED_AT=$(jq -r '.publishedAt // empty' <<< "$RELEASE")
|
||||
|
||||
if [ "$RESOLVED_TAG" != "$REQUESTED_TAG" ] || \
|
||||
[ "$IS_DRAFT" != "false" ] || [ -z "$PUBLISHED_AT" ]; then
|
||||
echo "Tag '$REQUESTED_TAG' is not an exact published release tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "tag=$RESOLVED_TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
update-homebrew:
|
||||
needs: resolve-release
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
fetch-depth: 0
|
||||
@@ -19,16 +76,14 @@ jobs:
|
||||
- name: Download and hash release
|
||||
id: release
|
||||
run: |
|
||||
VERSION="${{ github.event.release.tag_name || github.ref_name }}"
|
||||
curl -L -o plezy-macos.dmg \
|
||||
"https://github.com/edde746/plezy/releases/download/$VERSION/plezy-macos.dmg"
|
||||
curl --fail-with-body -L -o plezy-macos.dmg \
|
||||
"https://github.com/edde746/plezy/releases/download/$RELEASE_TAG/plezy-macos.dmg"
|
||||
SHA256=$(shasum -a 256 plezy-macos.dmg | cut -d' ' -f1)
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "sha256=$SHA256" >> $GITHUB_OUTPUT
|
||||
echo "sha256=$SHA256" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Update cask
|
||||
run: |
|
||||
sed -i '' "s/version \".*\"/version \"${{ steps.release.outputs.version }}\"/" Casks/plezy.rb
|
||||
sed -i '' "s/version \".*\"/version \"$RELEASE_TAG\"/" Casks/plezy.rb
|
||||
sed -i '' "s/sha256 \".*\"/sha256 \"${{ steps.release.outputs.sha256 }}\"/" Casks/plezy.rb
|
||||
|
||||
- name: Commit
|
||||
@@ -38,41 +93,47 @@ jobs:
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add Casks/plezy.rb
|
||||
if ! git diff --staged --quiet; then
|
||||
git commit -m "chore: update cask to ${{ steps.release.outputs.version }}"
|
||||
git commit -m "chore: update cask to $RELEASE_TAG"
|
||||
git push origin "HEAD:$BRANCH"
|
||||
else
|
||||
echo "No cask changes to commit."
|
||||
fi
|
||||
|
||||
update-winget:
|
||||
needs: resolve-release
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: vedantmgoyal9/winget-releaser@v2
|
||||
- uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2
|
||||
with:
|
||||
identifier: edde746.Plezy
|
||||
installers-regex: 'plezy-windows-installer\.exe$'
|
||||
release-tag: ${{ needs.resolve-release.outputs.tag }}
|
||||
token: ${{ secrets.WINGET_TOKEN }}
|
||||
|
||||
update-appcast-branch:
|
||||
needs: resolve-release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.resolve-release.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Download appcast.xml from release
|
||||
run: |
|
||||
gh release download "${{ github.event.release.tag_name }}" \
|
||||
gh release download "$RELEASE_TAG" \
|
||||
--pattern "appcast.xml"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Push to appcast branch
|
||||
run: |
|
||||
VERSION="${{ github.event.release.tag_name }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
BLOB=$(git hash-object -w appcast.xml)
|
||||
TREE=$(printf "100644 blob %s\tappcast.xml\n" "$BLOB" | git mktree)
|
||||
COMMIT=$(git commit-tree "$TREE" -m "Update appcast for $VERSION")
|
||||
COMMIT=$(git commit-tree "$TREE" -m "Update appcast for $RELEASE_TAG")
|
||||
git push origin "$COMMIT:refs/heads/appcast" --force
|
||||
|
||||
+7
-1
@@ -34,6 +34,11 @@ migrate_working_dir/
|
||||
/build/
|
||||
/debug-info/
|
||||
|
||||
# Windows Store packaging output (windows/build-msix.ps1)
|
||||
/staging-msix/
|
||||
/plezy-windows.msixbundle
|
||||
/AppxManifest.*.xml
|
||||
|
||||
# tvOS: build artifacts and generated assets (regenerated by xcode_appletv.sh)
|
||||
tvos/build/
|
||||
tvos/Pods/
|
||||
@@ -70,4 +75,5 @@ duplication-report/
|
||||
**/fastlane/report.xml
|
||||
maestro/**/*.png
|
||||
|
||||
server/deploy.sh
|
||||
server/deploy.sh
|
||||
server/Caddyfile
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
flows:
|
||||
- "flows/*.yaml"
|
||||
testOutputDir: "build/maestro"
|
||||
executionOrder:
|
||||
continueOnFailure: false
|
||||
flowsOrder:
|
||||
- Fresh install authentication choices
|
||||
- Jellyfin onboarding reaches Home
|
||||
- Browse library and open media details
|
||||
- Search media and open a result
|
||||
- Start and exit video playback
|
||||
- Open empty downloads state
|
||||
- Manage profiles and open settings
|
||||
- Logout returns to authentication
|
||||
- Download and play a movie from Downloads
|
||||
@@ -0,0 +1,43 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Fresh install authentication choices
|
||||
tags:
|
||||
- e2e
|
||||
- auth
|
||||
---
|
||||
- retry:
|
||||
maxRetries: 1
|
||||
commands:
|
||||
- launchApp:
|
||||
clearState: true
|
||||
permissions:
|
||||
all: allow
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Sign in with Plex|Wait).*"
|
||||
timeout: 30000
|
||||
- tapOn:
|
||||
text: "Wait"
|
||||
optional: true
|
||||
- extendedWaitUntil:
|
||||
visible: "Sign in with Plex"
|
||||
timeout: 30000
|
||||
- assertVisible: "Show QR Code"
|
||||
- assertVisible: "Connect to Jellyfin"
|
||||
- tapOn: "Connect to Jellyfin"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add Jellyfin server"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s)Server URLs.*"
|
||||
- assertVisible: "Find server"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- tapOn: "Back"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- hideKeyboard:
|
||||
optional: true
|
||||
- back
|
||||
- assertVisible: "Sign in with Plex"
|
||||
@@ -0,0 +1,13 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Jellyfin onboarding reaches Home
|
||||
tags:
|
||||
- e2e
|
||||
- onboarding
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- assertVisible: "(?s)^Home.*"
|
||||
- assertVisible: "(?s)^Recently Added.*"
|
||||
- assertVisible: "(?s).*(?:Alpha Archive|Bravo Beacon|Charlie Circuit|Delta Drive|Echo Engine|Foxtrot Frame|Gamma Garden|Hotel Horizon|India Index|Juliet Junction|Kilo Key|Lima Loop|Mike Matrix|November Node|Oscar Orbit|Papa Pipeline|Quebec Queue|Romeo Relay|Sierra Signal|Tango Track|Uniform Update|Victor View|Whiskey Widget|Xray XML|Yankee Yield|Zulu Zone).*"
|
||||
- assertVisible: "(?s)^Libraries.*"
|
||||
- assertVisible: "(?s)^Search.*"
|
||||
- assertVisible: "(?s)^Downloads.*"
|
||||
@@ -0,0 +1,33 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Browse library and open media details
|
||||
tags:
|
||||
- e2e
|
||||
- library
|
||||
---
|
||||
- runFlow: ../subflows/ensure_onboarded.yaml
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- tapOn: "Browse"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Maestro Movies.*"
|
||||
timeout: 15000
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Alpha Archive.*"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s).*Alpha Archive.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
- assertVisible: "A deterministic title for TV alphabet focus coverage."
|
||||
- assertVisible: "Play"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- tapOn:
|
||||
point: "6%, 9%"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- back
|
||||
- assertVisible: "(?s).*Maestro Movies.*"
|
||||
@@ -0,0 +1,33 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Search media and open a result
|
||||
tags:
|
||||
- e2e
|
||||
- search
|
||||
---
|
||||
- runFlow: ../subflows/ensure_onboarded.yaml
|
||||
- tapOn: "(?s)^Search.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Search movies, shows, music..."
|
||||
timeout: 10000
|
||||
- tapOn: "Search movies, shows, music..."
|
||||
- inputText: "Maestro"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- pressKey: ENTER
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- hideKeyboard
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Maestro Movie.*"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s).*Maestro Movie.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
- assertVisible: "A deterministic movie used to verify Plezy's end-to-end flows."
|
||||
- back
|
||||
- assertVisible: "(?s)^Maestro Movie.*"
|
||||
@@ -0,0 +1,57 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Start and exit video playback
|
||||
tags:
|
||||
- e2e
|
||||
- playback
|
||||
---
|
||||
- runFlow: ../subflows/ensure_onboarded.yaml
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- tapOn: "Browse"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Alpha Archive.*"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s).*Alpha Archive.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Play"
|
||||
timeout: 15000
|
||||
- tapOn:
|
||||
text: "Play"
|
||||
waitToSettleTimeoutMs: 1000
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- extendedWaitUntil:
|
||||
visible: "Video timeline"
|
||||
timeout: 20000
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Alpha Archive.*\\n0min\\n0:(?:0[1-9]|[1-5][0-9])\\n-.*"
|
||||
timeout: 10000
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 20000
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- tapOn:
|
||||
point: "90%, 5%"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Pause"
|
||||
timeout: 10000
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,11 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Open empty downloads state
|
||||
tags:
|
||||
- e2e
|
||||
- downloads
|
||||
---
|
||||
- runFlow: ../subflows/ensure_onboarded.yaml
|
||||
- tapOn: "(?s)^Downloads.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "No downloads"
|
||||
timeout: 15000
|
||||
@@ -0,0 +1,87 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Manage profiles and open settings
|
||||
tags:
|
||||
- e2e
|
||||
- profiles
|
||||
- settings
|
||||
---
|
||||
- runFlow: ../subflows/ensure_onboarded.yaml
|
||||
- tapOn: "(?s)^M(?:.*Profiles.*)?$"
|
||||
- tapOn:
|
||||
text: "Profiles"
|
||||
above:
|
||||
text: "Settings"
|
||||
- extendedWaitUntil:
|
||||
visible: "Switch Profile"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s).*Maestro.*"
|
||||
- tapOn: "Add Plezy profile"
|
||||
- extendedWaitUntil:
|
||||
visible: "New profile"
|
||||
timeout: 10000
|
||||
- tapOn: "e.g. Guests, Kids, Family Room"
|
||||
- inputText: "E2E Guest"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- pressKey: ENTER
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- hideKeyboard
|
||||
- tapOn: "Continue"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add to E2E Guest"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s)^Sign in with Plex.*"
|
||||
- assertVisible: "(?s)^Connect to Jellyfin.*"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- tapOn: "Back"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- back
|
||||
- assertVisible: "Switch Profile"
|
||||
- assertVisible: "(?s).*E2E Guest.*"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- tapOn: "Back"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Home.*"
|
||||
timeout: 10000
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- repeat:
|
||||
times: 2
|
||||
while:
|
||||
notVisible: "Settings"
|
||||
commands:
|
||||
- tapOn: "(?s)^M(?:.*Profiles.*)?$"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- tapOn:
|
||||
text: "Settings"
|
||||
below:
|
||||
text: "Profiles"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Appearance.*"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s)^Video Playback.*"
|
||||
- assertVisible: "(?s)^Connections.*"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
file: ../subflows/settings_deep_checks.yaml
|
||||
@@ -0,0 +1,23 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Logout returns to authentication
|
||||
tags:
|
||||
- e2e
|
||||
- auth
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- tapOn: "(?s)^M(?:.*Profiles.*)?$"
|
||||
- tapOn:
|
||||
text: "Log out"
|
||||
below:
|
||||
text: "Settings"
|
||||
- extendedWaitUntil:
|
||||
visible: "Are you sure you want to log out?"
|
||||
timeout: 10000
|
||||
- tapOn:
|
||||
text: "Log out"
|
||||
below:
|
||||
text: "Are you sure you want to log out?"
|
||||
- extendedWaitUntil:
|
||||
visible: "Sign in with Plex"
|
||||
timeout: 20000
|
||||
- assertVisible: "Connect to Jellyfin"
|
||||
@@ -0,0 +1,77 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Download and play a movie from Downloads
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- downloads
|
||||
- playback
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- tapOn: "Browse"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Alpha Archive.*"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s).*Alpha Archive.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
- tapOn: "Download"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- tapOn:
|
||||
point: "6%, 9%"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^Downloads.*"
|
||||
- tapOn:
|
||||
text: "Movies"
|
||||
waitToSettleTimeoutMs: 3000
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Alpha Archive.*"
|
||||
timeout: 60000
|
||||
- runScript:
|
||||
file: ../scripts/set_jellyfin_offline.js
|
||||
env:
|
||||
JELLYFIN_CONTROL_URL: ${JELLYFIN_CONTROL_URL}
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- tapOn: "(?s).*Alpha Archive.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
- tapOn:
|
||||
text: "Play"
|
||||
waitToSettleTimeoutMs: 1000
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 30000
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- tapOn:
|
||||
point: "90%, 5%"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Pause"
|
||||
timeout: 10000
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,56 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
ARG JELLYFIN_IMAGE=jellyfin/jellyfin:10.11.11@sha256:aefb67e6a7ff1debdd154a78a7bbb780fd0c873d8639210a7f6a2016ad2b35db
|
||||
|
||||
FROM ${JELLYFIN_IMAGE} AS seed
|
||||
|
||||
USER root
|
||||
RUN apt-get update \
|
||||
&& apt-get install --yes --no-install-recommends ca-certificates python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /opt/plezy-demo
|
||||
COPY scripts/maestro_fixtures.py scripts/maestro_real_jellyfin.py scripts/prepare_maestro_media.py ./
|
||||
COPY .maestro/jellyfin-demo/seed.sh ./seed.sh
|
||||
|
||||
ARG PLEZY_DEMO_MEDIA_BASE_URL=https://demo-files.plezy.app/media-samples/
|
||||
ARG PLEZY_DEMO_MEDIA_REVISION=2026-07-14
|
||||
ARG PLEZY_DEMO_MEDIA_DURATION=300
|
||||
RUN test -n "${PLEZY_DEMO_MEDIA_REVISION}" \
|
||||
&& python3 maestro_real_jellyfin.py download-codecs \
|
||||
--base-url "${PLEZY_DEMO_MEDIA_BASE_URL}" \
|
||||
--output-dir /tmp/plezy-codecs \
|
||||
&& PATH="/usr/lib/jellyfin-ffmpeg:${PATH}" python3 prepare_maestro_media.py \
|
||||
/tmp/plezy-codecs /tmp/plezy-codecs-prepared \
|
||||
--duration "${PLEZY_DEMO_MEDIA_DURATION}" \
|
||||
--extend av1_opus_ass_srt.mkv \
|
||||
--extend h264_eac3_multisub.mkv \
|
||||
--extend hevc10_flac_ass.mkv \
|
||||
&& python3 maestro_real_jellyfin.py prepare \
|
||||
--output-dir /media \
|
||||
--codec-source-dir /tmp/plezy-codecs-prepared \
|
||||
--include-codecs \
|
||||
&& rm -rf /tmp/plezy-codecs /tmp/plezy-codecs-prepared __pycache__ \
|
||||
&& chmod -R a=rX /media
|
||||
|
||||
RUN ./seed.sh
|
||||
|
||||
FROM ${JELLYFIN_IMAGE}
|
||||
|
||||
LABEL org.opencontainers.image.title="Plezy Jellyfin demo server" \
|
||||
org.opencontainers.image.description="Ready-to-run Jellyfin server with Plezy's deterministic codec demo catalog" \
|
||||
org.opencontainers.image.source="https://github.com/edde746/plezy"
|
||||
|
||||
COPY --from=seed /media /media
|
||||
COPY --from=seed /opt/plezy-demo/seed-config /opt/plezy-demo/seed-config
|
||||
COPY .maestro/jellyfin-demo/entrypoint.sh /usr/local/bin/plezy-demo-entrypoint
|
||||
|
||||
RUN chmod 0755 /usr/local/bin/plezy-demo-entrypoint \
|
||||
&& chmod -R a=rX /media /opt/plezy-demo/seed-config
|
||||
|
||||
ENV TZ=UTC \
|
||||
JELLYFIN_PublishedServerUrl=http://localhost:8096
|
||||
|
||||
EXPOSE 8096
|
||||
STOPSIGNAL SIGTERM
|
||||
ENTRYPOINT ["/usr/local/bin/plezy-demo-entrypoint"]
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SEED_CONFIG=/opt/plezy-demo/seed-config
|
||||
SEED_MARKER=.plezy-demo-seed
|
||||
|
||||
if [ ! -f "/config/${SEED_MARKER}" ]; then
|
||||
existing="$(find /config -mindepth 1 -maxdepth 1 -print -quit)"
|
||||
if [ -n "${existing}" ]; then
|
||||
echo "Refusing to overwrite a non-demo Jellyfin configuration in /config." >&2
|
||||
echo "Start this image with a new or empty /config volume." >&2
|
||||
exit 1
|
||||
fi
|
||||
cp -a "${SEED_CONFIG}/." /config/
|
||||
fi
|
||||
|
||||
exec /jellyfin/jellyfin "$@"
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
SEED_ROOT=/opt/plezy-demo
|
||||
SEED_CONFIG="${SEED_ROOT}/seed-config"
|
||||
SEED_CACHE="${SEED_ROOT}/seed-cache"
|
||||
JELLYFIN_PID=""
|
||||
|
||||
stop_jellyfin() {
|
||||
if [ -n "${JELLYFIN_PID}" ] && kill -0 "${JELLYFIN_PID}" 2>/dev/null; then
|
||||
kill -TERM "${JELLYFIN_PID}"
|
||||
wait "${JELLYFIN_PID}" || true
|
||||
fi
|
||||
}
|
||||
|
||||
on_exit() {
|
||||
exit_status=$?
|
||||
if [ "${exit_status}" -ne 0 ] && [ -f "${SEED_ROOT}/seed-jellyfin.log" ]; then
|
||||
cat "${SEED_ROOT}/seed-jellyfin.log" >&2
|
||||
fi
|
||||
stop_jellyfin
|
||||
}
|
||||
trap on_exit EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
mkdir -p "${SEED_CONFIG}/config" "${SEED_CONFIG}/log" "${SEED_CACHE}"
|
||||
JELLYFIN_DATA_DIR="${SEED_CONFIG}" \
|
||||
JELLYFIN_CONFIG_DIR="${SEED_CONFIG}/config" \
|
||||
JELLYFIN_LOG_DIR="${SEED_CONFIG}/log" \
|
||||
JELLYFIN_CACHE_DIR="${SEED_CACHE}" \
|
||||
XDG_CACHE_HOME="${SEED_CACHE}" \
|
||||
JELLYFIN_PublishedServerUrl="http://127.0.0.1:8096" \
|
||||
/jellyfin/jellyfin >"${SEED_ROOT}/seed-jellyfin.log" 2>&1 &
|
||||
JELLYFIN_PID=$!
|
||||
|
||||
python3 "${SEED_ROOT}/maestro_real_jellyfin.py" bootstrap \
|
||||
--url http://127.0.0.1:8096 \
|
||||
--timeout 180 \
|
||||
--include-codecs
|
||||
|
||||
stop_jellyfin
|
||||
JELLYFIN_PID=""
|
||||
rm -rf "${SEED_CONFIG}/log" "${SEED_CACHE}" "${SEED_ROOT}/seed-jellyfin.log"
|
||||
printf '%s\n' 'Plezy Jellyfin demo seed v1' >"${SEED_CONFIG}/.plezy-demo-seed"
|
||||
trap - EXIT INT TERM
|
||||
@@ -0,0 +1,12 @@
|
||||
flows:
|
||||
- "*.yaml"
|
||||
testOutputDir: "build/maestro-media"
|
||||
executionOrder:
|
||||
continueOnFailure: false
|
||||
flowsOrder:
|
||||
- Codec sample - UHD Dolby Vision TrueHD and PGS
|
||||
- Codec sample - Web Dolby Vision EAC3 Atmos and SRT
|
||||
- Codec sample - AV1 Opus ASS and SRT
|
||||
- Codec sample - H264 High10 DTS-HD and ASS
|
||||
- Codec sample - H264 EAC3 multilingual SRT
|
||||
- Codec sample - HEVC10 FLAC and ASS
|
||||
@@ -0,0 +1,28 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Codec sample - UHD Dolby Vision TrueHD and PGS
|
||||
tags:
|
||||
- e2e
|
||||
- media
|
||||
- dolby-vision
|
||||
---
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec DV UHD TrueHD PGS"
|
||||
SAMPLE_OVERVIEW: "Dolby Vision profile 8 with an HDR fallback, TrueHD Atmos, AC-3, and PGS subtitles."
|
||||
- runFlow:
|
||||
file: ../subflows/switch_codec_tracks.yaml
|
||||
env:
|
||||
AUDIO_TRACK: "(?is)^English\\s+[^,]*(?:AC3|TrueHD|Surround|5\\.1|7\\.1)[^,]*$"
|
||||
SUBTITLE_TRACK: "(?is)^Spanish\\s+[^,]*(?:PGS|APPLICATION/PGS)[^,]*$"
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,28 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Codec sample - Web Dolby Vision EAC3 Atmos and SRT
|
||||
tags:
|
||||
- e2e
|
||||
- media
|
||||
- dolby-vision
|
||||
---
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec DV Web EAC3 SRT"
|
||||
SAMPLE_OVERVIEW: "Dolby Vision profile 8 with an HDR fallback, E-AC-3 Atmos, and multilingual SRT subtitles."
|
||||
- runFlow:
|
||||
file: ../subflows/switch_codec_tracks.yaml
|
||||
env:
|
||||
AUDIO_TRACK: "(?is)^German\\s+[^,]*(?:E-AC3|Dolby Digital Plus|Surround|5\\.1)[^,]*$"
|
||||
SUBTITLE_TRACK: "(?is)^German\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$"
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,28 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Codec sample - AV1 Opus ASS and SRT
|
||||
tags:
|
||||
- e2e
|
||||
- media
|
||||
- av1
|
||||
---
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec AV1 Opus ASS"
|
||||
SAMPLE_OVERVIEW: "AV1 video with multilingual Opus audio, styled ASS subtitles, SRT subtitles, and embedded fonts."
|
||||
- runFlow:
|
||||
file: ../subflows/switch_codec_tracks.yaml
|
||||
env:
|
||||
AUDIO_TRACK: "(?is)^English\\s+[^,]*(?:Opus|Stereo)[^,]*$"
|
||||
SUBTITLE_TRACK: "(?is)^English\\s+[^,]*(?:ASS|TEXT/X-SSA)[^,]*$"
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,28 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Codec sample - H264 High10 DTS-HD and ASS
|
||||
tags:
|
||||
- e2e
|
||||
- media
|
||||
- h264-high10
|
||||
---
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec H264 High10 DTS-HD ASS"
|
||||
SAMPLE_OVERVIEW: "H.264 High 10 video with dual DTS-HD MA audio, styled ASS subtitles, and embedded fonts."
|
||||
- runFlow:
|
||||
file: ../subflows/switch_codec_tracks.yaml
|
||||
env:
|
||||
AUDIO_TRACK: "(?is)^Japanese\\s+[^,]*(?:DTS-HD MA|DTS|Surround|5\\.1)[^,]*$"
|
||||
SUBTITLE_TRACK: "(?is)^English\\s+[^,]*(?:ASS|TEXT/X-SSA)[^,]*$"
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,29 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Codec sample - H264 EAC3 multilingual SRT
|
||||
tags:
|
||||
- e2e
|
||||
- media
|
||||
- subtitles
|
||||
---
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec H264 EAC3 Multisub"
|
||||
SAMPLE_OVERVIEW: "H.264 video with three E-AC-3 audio tracks and a broad multilingual SRT subtitle set."
|
||||
- runFlow:
|
||||
file: ../subflows/switch_codec_tracks_deep.yaml
|
||||
env:
|
||||
AUDIO_TRACK: "(?is)^English\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$"
|
||||
SUBTITLE_TRACK: "(?is)^English\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$"
|
||||
SHEET_MARKER: "(?is)^Japanese\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$"
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,28 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Codec sample - HEVC10 FLAC and ASS
|
||||
tags:
|
||||
- e2e
|
||||
- media
|
||||
- hevc
|
||||
---
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec HEVC10 FLAC ASS"
|
||||
SAMPLE_OVERVIEW: "HEVC Main 10 video with dual FLAC 5.1 audio, styled ASS subtitles, and embedded fonts."
|
||||
- runFlow:
|
||||
file: ../subflows/switch_codec_tracks.yaml
|
||||
env:
|
||||
AUDIO_TRACK: "(?is)^Japanese\\s+[^,]*(?:FLAC|Surround|5\\.1)[^,]*$"
|
||||
SUBTITLE_TRACK: "(?is)^English\\s+[^,]*(?:ASS|TEXT/X-SSA)[^,]*$"
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,38 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Real Jellyfin imports and opens codec media
|
||||
tags:
|
||||
- e2e
|
||||
- real-jellyfin
|
||||
- media
|
||||
---
|
||||
- runFlow: ../subflows/ensure_onboarded.yaml
|
||||
- tapOn: "(?s)^Search.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Search movies, shows, music..."
|
||||
timeout: 10000
|
||||
- tapOn: "Search movies, shows, music..."
|
||||
- inputText: "Codec DV UHD"
|
||||
- hideKeyboard
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Codec DV UHD TrueHD PGS.*"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s).*Codec DV UHD TrueHD PGS.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
- assertVisible: "Dolby Vision profile 8 with an HDR fallback, TrueHD Atmos, AC-3, and PGS subtitles."
|
||||
- tapOn:
|
||||
text: "Play"
|
||||
waitToSettleTimeoutMs: 1000
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Overview"
|
||||
timeout: 15000
|
||||
- back
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,54 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Jellyfin music browsing survives grouping reloads
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- real-jellyfin
|
||||
- music
|
||||
- regression
|
||||
---
|
||||
- runFlow: ../subflows/ensure_onboarded.yaml
|
||||
- assertVisible: "(?s)^Latest Albums in Maestro Music.*"
|
||||
- assertVisible: "(?s)^Regression Album.*"
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
- tapOn: "Maestro Movies"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Music"
|
||||
timeout: 10000
|
||||
- tapOn: "Maestro Music"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Regression Album.*"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s)^Latest Albums in Maestro Music.*"
|
||||
- tapOn: "Browse"
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Retry"
|
||||
commands:
|
||||
- tapOn: "Retry"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Artist"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "(?i).*connection timeout.*"
|
||||
- tapOn: "Library options"
|
||||
- tapOn: "(?s)^Grouping.*Artists$"
|
||||
- tapOn: "Albums"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Regression Album.*"
|
||||
timeout: 15000
|
||||
- tapOn: "Library options"
|
||||
- tapOn: "(?s)^Grouping.*Albums$"
|
||||
- tapOn: "Tracks"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Resilient Track.*"
|
||||
timeout: 15000
|
||||
- tapOn: "Library options"
|
||||
- tapOn: "(?s)^Grouping.*Tracks$"
|
||||
- tapOn: "Folders"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Artist"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "(?i).*connection timeout.*"
|
||||
@@ -0,0 +1,66 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Profile switching isolates Jellyfin content and libraries
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- profiles
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- assertVisible: "(?s).*Zulu Zone.*"
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "Guest Movies"
|
||||
- tapOn: "(?s)^Home.*"
|
||||
- runFlow: ../subflows/create_guest_jellyfin_profile.yaml
|
||||
- tapOn: "(?s)^E\nE2E Guest\n.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Guest Galaxy.*"
|
||||
timeout: 30000
|
||||
- assertNotVisible: "(?s).*Zulu Zone.*"
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Guest Movies"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "Maestro Movies"
|
||||
- tapOn: "(?s)^Home.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Guest Galaxy.*"
|
||||
timeout: 15000
|
||||
- tapOn: "E"
|
||||
- tapOn:
|
||||
text: "Profiles"
|
||||
above:
|
||||
text: "Settings"
|
||||
- extendedWaitUntil:
|
||||
visible: "Switch Profile"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^M\nMaestro\n.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Zulu Zone.*"
|
||||
timeout: 30000
|
||||
- assertNotVisible: "(?s).*Guest Galaxy.*"
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "Guest Movies"
|
||||
- tapOn: "(?s)^Home.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Zulu Zone.*"
|
||||
timeout: 15000
|
||||
- tapOn: "M"
|
||||
- tapOn:
|
||||
text: "Profiles"
|
||||
above:
|
||||
text: "Settings"
|
||||
- extendedWaitUntil:
|
||||
visible: "Switch Profile"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^E\nE2E Guest\n.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Guest Galaxy.*"
|
||||
timeout: 30000
|
||||
- assertNotVisible: "(?s).*Zulu Zone.*"
|
||||
@@ -0,0 +1,51 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Removing a Jellyfin profile connection leaves no orphaned session
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- profiles
|
||||
- teardown
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- runFlow: ../subflows/create_guest_jellyfin_profile.yaml
|
||||
- tapOn:
|
||||
text: "Manage"
|
||||
index: 1
|
||||
- tapOn: "Manage"
|
||||
- extendedWaitUntil:
|
||||
visible: "Profile name"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s)^E2E Guest.*"
|
||||
- assertVisible: "(?s)^Maestro Jellyfin.*Default.*$"
|
||||
- tapOn: "Manage"
|
||||
- extendedWaitUntil:
|
||||
visible: "Remove"
|
||||
timeout: 10000
|
||||
- tapOn: "Remove"
|
||||
- extendedWaitUntil:
|
||||
visible: "^Remove connection\\?$"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s).*E2E Guest's access to Maestro Jellyfin.*"
|
||||
- tapOn: "Remove"
|
||||
- extendedWaitUntil:
|
||||
notVisible: "(?s)^Maestro Jellyfin.*"
|
||||
timeout: 15000
|
||||
- assertVisible: "Delete profile"
|
||||
- retry:
|
||||
maxRetries: 2
|
||||
commands:
|
||||
- tapOn: "Delete profile"
|
||||
- extendedWaitUntil:
|
||||
visible: "^Delete profile\\?$"
|
||||
timeout: 10000
|
||||
- tapOn: "^Delete$"
|
||||
- extendedWaitUntil:
|
||||
visible: "Switch Profile"
|
||||
timeout: 30000
|
||||
- assertNotVisible: "(?s).*E2E Guest.*"
|
||||
- assertVisible: "(?s)^M\nMaestro\nActive.*"
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Recently Added in Maestro Movies.*"
|
||||
timeout: 30000
|
||||
@@ -0,0 +1,77 @@
|
||||
appId: com.edde746.plezy
|
||||
name: TV alphabet rail retains focus until returning to the media grid
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- tv
|
||||
- focus
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin_tv.yaml
|
||||
- pressKey: "Remote Dpad Left"
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "Recommended"
|
||||
timeout: 15000
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- pressKey: "Remote Dpad Up"
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- pressKey: "Remote Dpad Up"
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- pressKey: "Remote Dpad Right"
|
||||
# The grid row must be matchable before the sort sheet opens, otherwise the
|
||||
# occlusion assertion below would pass for the wrong reason.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Alpha Archive.*"
|
||||
timeout: 15000
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "Title"
|
||||
timeout: 10000
|
||||
# The host renders the grid, the barrier, and the sheet in one Stack and never
|
||||
# unmounts the grid, so before 8e1904dd wrapped the barrier in BlockSemantics
|
||||
# an occluded row still read as visible to accessibility and to Maestro.
|
||||
- assertNotVisible: "(?s).*Alpha Archive.*"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- pressKey: "Remote Dpad Left"
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Sort By"
|
||||
timeout: 10000
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Alpha Archive.*"
|
||||
timeout: 15000
|
||||
- repeat:
|
||||
times: 8
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- repeat:
|
||||
times: 13
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Mike Matrix.*"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "(?s).*Alpha Archive.*"
|
||||
- repeat:
|
||||
times: 13
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Zulu Zone.*"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "(?s).*Mike Matrix.*"
|
||||
- pressKey: "Remote Dpad Left"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "Play"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s).*Zulu Zone.*"
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
visible: "Browse"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s).*Zulu Zone.*"
|
||||
@@ -0,0 +1,73 @@
|
||||
appId: com.edde746.plezy
|
||||
name: TV hardware media and back keys follow player chrome state
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- tv
|
||||
- playback
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin_tv.yaml
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "(?s)^(Play|Resume).*$"
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 30000
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Pause"
|
||||
timeout: 10000
|
||||
# Hardware transport keys drive playback without raising the chrome (#1676):
|
||||
# the player answers with a centred disc (pause) or a side readout (seek), never the
|
||||
# full chrome, so subtitles stay readable.
|
||||
# Pause first — it also freezes the clip, so the seek assertions below cannot
|
||||
# race the end of a short fixture item.
|
||||
- pressKey: "Remote Media Play Pause"
|
||||
- extendedWaitUntil:
|
||||
visible: "Paused"
|
||||
timeout: 10000
|
||||
- assertNotVisible: "(?s)^Play$"
|
||||
# D-pad seeking reports through the skip badge, never the scrub bar, and
|
||||
# consecutive presses in one direction stack into a running total.
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Seek forward 10 seconds$"
|
||||
timeout: 10000
|
||||
- assertNotVisible: "(?s)^Play$"
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Seek forward 20 seconds$"
|
||||
timeout: 10000
|
||||
- pressKey: "Remote Media Play Pause"
|
||||
- extendedWaitUntil:
|
||||
visible: "Playing"
|
||||
timeout: 10000
|
||||
- assertNotVisible: "(?s)^Pause$"
|
||||
# Select stays the deliberate way to bring the chrome back.
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(Play|Pause)$"
|
||||
timeout: 10000
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
notVisible: "(?s)^Pause$"
|
||||
timeout: 10000
|
||||
- assertNotVisible: "Overview"
|
||||
- pressKey: "back"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Overview"
|
||||
commands:
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "tv_browse_rail_selection"
|
||||
timeout: 15000
|
||||
@@ -0,0 +1,50 @@
|
||||
appId: com.edde746.plezy
|
||||
name: TV Back dismisses Next Episode without leaving playback
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- tv
|
||||
- playback
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin_tv.yaml
|
||||
- tapOn:
|
||||
point: "6%,22%"
|
||||
- tapOn: "Search"
|
||||
- extendedWaitUntil:
|
||||
visible: "Search movies, shows, music..."
|
||||
timeout: 10000
|
||||
- tapOn: "Search movies, shows, music..."
|
||||
- inputText: "Maestro Show"
|
||||
- tapOn:
|
||||
point: "87%,90%"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Maestro Show, TV show, (?:watched|unwatched)$"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s)^Maestro Show, TV show, (?:watched|unwatched)$"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- tapOn: "(?s)^Play S1E1$"
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 30000
|
||||
- pressKey: "Remote Media Fast Forward"
|
||||
- pressKey: "Remote Media Fast Forward"
|
||||
- extendedWaitUntil:
|
||||
visible: "Next Episode"
|
||||
timeout: 20000
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- tapOn: "Cancel"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- pressKey: "back"
|
||||
- assertNotVisible: "Next Episode"
|
||||
- assertNotVisible: "Overview"
|
||||
- pressKey: "Remote Dpad Up"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Maestro Episode 1.*"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,36 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Playback recovers after a transient stream failure
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- playback
|
||||
- recovery
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
# Every seeded alphabet title carries the same dateadded, so which of them the
|
||||
# "Recently Added" rail returns is not stable across scans. This flow only
|
||||
# needs some playable movie, so accept any of them rather than pinning one and
|
||||
# failing whenever the tie-break lands elsewhere.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*(?:Alpha Archive|Bravo Beacon|Charlie Circuit|Delta Drive|Echo Engine|Foxtrot Frame|Gamma Garden|Hotel Horizon|India Index|Juliet Junction|Kilo Key|Lima Loop|Mike Matrix|November Node|Oscar Orbit|Papa Pipeline|Quebec Queue|Romeo Relay|Sierra Signal|Tango Track|Uniform Update|Victor View|Whiskey Widget|Xray XML|Yankee Yield|Zulu Zone).*, movie, .*"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s).*(?:Alpha Archive|Bravo Beacon|Charlie Circuit|Delta Drive|Echo Engine|Foxtrot Frame|Gamma Garden|Hotel Horizon|India Index|Juliet Junction|Kilo Key|Lima Loop|Mike Matrix|November Node|Oscar Orbit|Papa Pipeline|Quebec Queue|Romeo Relay|Sierra Signal|Tango Track|Uniform Update|Victor View|Whiskey Widget|Xray XML|Yankee Yield|Zulu Zone).*, movie, .*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Play"
|
||||
timeout: 15000
|
||||
- tapOn:
|
||||
text: "Play"
|
||||
waitToSettleTimeoutMs: 1000
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 45000
|
||||
- assertNotVisible: "Overview"
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Pause"
|
||||
timeout: 10000
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
@@ -0,0 +1,94 @@
|
||||
appId: com.edde746.plezy
|
||||
name: System back dismisses a hosted sheet without leaving Settings
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- settings
|
||||
- sheets
|
||||
---
|
||||
# Guards 8e1904dd and c48cbf70 together.
|
||||
#
|
||||
# On a phone, Settings is a pushed route. Before c48cbf70 it carried no
|
||||
# OverlaySheetHost, so Manage Libraries fell back to showModalBottomSheet and a
|
||||
# single Back tore down the sheet *and* Settings. Before 8e1904dd the host
|
||||
# answered the platform pop with a TV-only dedup marker that could already be
|
||||
# spent, stranding the sheet open with no way out. Either defect fails the
|
||||
# "Manage Libraries is still there after one Back" assertion below.
|
||||
#
|
||||
# The same commit blocks semantics behind the barrier, so the occlusion
|
||||
# assertions pin that an open sheet actually hides the rows underneath.
|
||||
# Pristine state on purpose. The TV regressions in the same CI group leave
|
||||
# "Force TV mode" enabled, and this flow asserts the handheld Settings route,
|
||||
# so it must not inherit whichever layout the previous flow left behind.
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- runFlow: ../subflows/open_settings.yaml
|
||||
- assertVisible: "(?s)^Services.*"
|
||||
- assertVisible: "(?s)^Video Playback.*"
|
||||
- tapOn: "(?s)^Manage Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
# The barrier swallows every pointer event, so the rows behind it must leave
|
||||
# the semantics tree too. Without BlockSemantics an occluded row still reads as
|
||||
# visible and a tap on it lands on the barrier instead. "Manage Libraries" is
|
||||
# excluded on purpose: it is also the sheet's own title.
|
||||
- assertNotVisible: "(?s)^Services.*"
|
||||
- assertNotVisible: "(?s)^Video Playback.*"
|
||||
- assertNotVisible: "(?s)^Appearance.*"
|
||||
- assertNotVisible: "(?s)^Connections.*"
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- assertNotVisible: "Maestro Movies"
|
||||
# One Back closed only the sheet: Settings is still the current route.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Manage Libraries.*"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s)^Services.*"
|
||||
- assertVisible: "(?s)^Video Playback.*"
|
||||
- assertNotVisible: "Discover"
|
||||
# Reopening proves the host did not strand the one-shot dedup marker: a second
|
||||
# dismissal has to work exactly like the first.
|
||||
- tapOn: "(?s)^Manage Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
# The per-library menu is a nested page of the same sheet now that the host
|
||||
# exists, so Back returns to the library list instead of tearing the sheet down.
|
||||
- tapOn: "Library options"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Refresh Metadata.*"
|
||||
timeout: 15000
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 10000
|
||||
- assertNotVisible: "(?s).*Refresh Metadata.*"
|
||||
# Back at the library list, one more Back leaves the sheet entirely.
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- assertNotVisible: "Maestro Movies"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Manage Libraries.*"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^Manage Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- assertNotVisible: "Maestro Movies"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Manage Libraries.*"
|
||||
timeout: 10000
|
||||
# With no sheet open the host must let the route pop normally.
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Discover"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "(?s)^Manage Libraries.*"
|
||||
@@ -0,0 +1,112 @@
|
||||
appId: com.edde746.plezy
|
||||
name: An explicit track choice survives the pending automatic pass
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- playback
|
||||
- subtitles
|
||||
---
|
||||
# Guards 468d6804. When a source advertises tracks the native list has not
|
||||
# produced yet, TrackManager arms an automatic selection pass with a 5s first
|
||||
# attempt and a 25s deadline. A user pick made inside that window used to only
|
||||
# persist the preference, leaving the pass armed, so the choice snapped back to
|
||||
# the server/profile preferred track up to ~30s later. The pick now retires the
|
||||
# pending pass, and the wait below is long enough to outlive both timers.
|
||||
#
|
||||
# Fixture: Codec H264 EAC3 Multisub advertises three E-AC-3 audio streams
|
||||
# (Hindi and Japanese flagged default, English not) and 36 SRT subtitle
|
||||
# streams, so both picks below are genuine changes away from the automatic one.
|
||||
# Pristine state on purpose, for two reasons: the TV regressions in the same
|
||||
# CI group leave "Force TV mode" enabled, and a remembered track selection
|
||||
# from an earlier run would pre-select the very rows this flow picks, making
|
||||
# every assertion below vacuous.
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
env:
|
||||
SAMPLE_TITLE: "Codec H264 EAC3 Multisub"
|
||||
SAMPLE_OVERVIEW: "H.264 video with three E-AC-3 audio tracks and a broad multilingual SRT subtitle set."
|
||||
- assertVisible: "(?is)^English\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$"
|
||||
- tapOn:
|
||||
text: "(?is)^English\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^.*Audio & Subtitles$"
|
||||
timeout: 30000
|
||||
- tapOn: "(?s)^.*Audio & Subtitles$"
|
||||
# German is the only German subtitle in the set and is never the automatic
|
||||
# pick, so a revert to the preferred language or to Off is unambiguous.
|
||||
- repeat:
|
||||
times: 12
|
||||
while:
|
||||
notVisible: "(?is)^German\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$"
|
||||
commands:
|
||||
- swipe:
|
||||
start: 70%, 82%
|
||||
end: 70%, 68%
|
||||
duration: 300
|
||||
- tapOn:
|
||||
text: "(?is)^German\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^.*Audio & Subtitles$"
|
||||
timeout: 30000
|
||||
# The pending pass is wall-clock driven and a paused frame settles instantly,
|
||||
# so playback has to run for the wait below to consume real time.
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Play"
|
||||
commands:
|
||||
- tapOn: "Play"
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 20000
|
||||
- repeat:
|
||||
times: 14
|
||||
commands:
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
# Chrome auto-hides after 30s even in Maestro builds, so bring it back.
|
||||
- tapOn:
|
||||
point: 50%, 50%
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^.*Audio & Subtitles$"
|
||||
timeout: 30000
|
||||
# The tracks button announces the live selection, so this catches a revert
|
||||
# without reopening the sheet.
|
||||
- assertVisible: "(?is)^English[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*,.*Audio & Subtitles$"
|
||||
- tapOn: "(?s)^.*Audio & Subtitles$"
|
||||
- assertVisible:
|
||||
text: "(?is)^English\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$"
|
||||
selected: true
|
||||
- assertNotVisible:
|
||||
text: "(?is)^Japanese\\s+[^,]*(?:Dolby Digital Plus|E-AC3|Surround|5\\.1)[^,]*$"
|
||||
selected: true
|
||||
- repeat:
|
||||
times: 12
|
||||
while:
|
||||
notVisible: "(?is)^German\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$"
|
||||
commands:
|
||||
- swipe:
|
||||
start: 70%, 82%
|
||||
end: 70%, 68%
|
||||
duration: 300
|
||||
- assertVisible:
|
||||
text: "(?is)^German\\s+[^,]*(?:SRT|APPLICATION/X-SUBRIP)[^,]*$"
|
||||
selected: true
|
||||
- assertNotVisible:
|
||||
text: "Off"
|
||||
selected: true
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- back
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Overview"
|
||||
commands:
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
@@ -0,0 +1,90 @@
|
||||
appId: com.edde746.plezy
|
||||
name: Language picker offers the new locales and switching one relocalizes the app
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- settings
|
||||
- i18n
|
||||
---
|
||||
# Guards 7677d159 and 100d7729. AppLocale is switched over exhaustively in
|
||||
# appearance_settings_screen.dart, so a missing arm is a compile error, but a
|
||||
# locale whose JSON never reached the generator ships a picker entry that falls
|
||||
# back to English. Selecting Turkish and reading root-level navigation labels
|
||||
# proves the generated locale is wired end to end.
|
||||
#
|
||||
# This flow mutates a persisted pref, and the app language decides every
|
||||
# selector after the tap, so it starts from a cleared install rather than
|
||||
# ensure_onboarded: a run that dies mid-flow must not leave the next flow
|
||||
# hunting for English strings in a Turkish tree.
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- runFlow: ../subflows/open_settings.yaml
|
||||
- tapOn: "(?s)^Appearance.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Theme.*"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s)^Language.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Azərbaycanca"
|
||||
timeout: 10000
|
||||
- assertVisible: "English"
|
||||
- assertVisible: "Қазақша"
|
||||
# The dialog holds 22 endonyms and only the first 16 fit on a phone, so the two
|
||||
# locales that sort last need scrolling into view.
|
||||
- repeat:
|
||||
times: 8
|
||||
while:
|
||||
notVisible: "Türkçe"
|
||||
commands:
|
||||
- swipe:
|
||||
start: 50%, 80%
|
||||
end: 50%, 45%
|
||||
duration: 300
|
||||
- assertVisible: "Türkçe"
|
||||
- assertVisible: "Oʻzbekcha"
|
||||
- tapOn: "Türkçe"
|
||||
# Selecting a language rebuilds the app from the root route, so everything
|
||||
# below runs against the Turkish tree.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Kitaplıklar.*"
|
||||
timeout: 30000
|
||||
- assertVisible: "(?s)^Ana Sayfa.*"
|
||||
- assertVisible: "(?s)^Ara.*"
|
||||
- assertVisible: "(?s)^İndirmeler.*"
|
||||
- assertVisible: "Keşfet"
|
||||
- assertNotVisible: "(?s)^Libraries.*"
|
||||
- assertNotVisible: "Discover"
|
||||
# Walk back through the Turkish tree and restore English. The pref outlives a
|
||||
# plain launchApp, so leaving the device in Turkish would break every later
|
||||
# flow in the suite.
|
||||
- repeat:
|
||||
times: 3
|
||||
while:
|
||||
notVisible: "Ayarlar"
|
||||
commands:
|
||||
- tapOn: "(?s)^M(?:.*Profiller.*)?$"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- tapOn:
|
||||
text: "Ayarlar"
|
||||
below:
|
||||
text: "Profiller"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Görünüm.*"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s)^Servisler.*"
|
||||
- assertVisible: "(?s)^Video Oynatma.*"
|
||||
- tapOn: "(?s)^Görünüm.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Tema.*"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s)^Dil.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "English"
|
||||
timeout: 10000
|
||||
- tapOn: "English"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Libraries.*"
|
||||
timeout: 30000
|
||||
- assertVisible: "Discover"
|
||||
- assertNotVisible: "(?s)^Kitaplıklar.*"
|
||||
@@ -0,0 +1,142 @@
|
||||
appId: com.edde746.plezy
|
||||
name: TV settings stay navigable and dismiss their overlays
|
||||
|
||||
tags:
|
||||
- e2e
|
||||
- regression
|
||||
- tv
|
||||
- settings
|
||||
---
|
||||
# Guards ef310459 and the Apple-only gates touched by 6c14049e, and pins TV
|
||||
# settings navigation generally.
|
||||
#
|
||||
# It deliberately does NOT guard the 15b54e2e density change. That commit took
|
||||
# every settings row from ~80dp to ~61dp, but the rows below the fold stay in
|
||||
# the semantics tree either way, so this flow passes identically on both sides
|
||||
# of it — measured, not assumed. A real guard would need a pixel `height`
|
||||
# assertion, and Maestro has no DPR-relative form, so pinning one here would
|
||||
# only hold for the box it was written on. Row geometry belongs to
|
||||
# test/widgets/setting_tile_test.dart.
|
||||
#
|
||||
# Every step uses D-pad keys, never taps. InputModeTracker flips to pointer
|
||||
# mode on the first touch event and the TV number spinner only builds in
|
||||
# keyboard mode, so a single tapOn anywhere above would silently swap the
|
||||
# spinner for a plain text field.
|
||||
- runFlow: ../subflows/onboard_jellyfin_tv_device.yaml
|
||||
- pressKey: "Remote Dpad Left"
|
||||
- extendedWaitUntil:
|
||||
visible: "Settings"
|
||||
timeout: 15000
|
||||
- repeat:
|
||||
times: 12
|
||||
while:
|
||||
notVisible:
|
||||
text: "Settings"
|
||||
focused: true
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Appearance.*"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s)^Video Playback.*"
|
||||
- assertVisible: "(?s)^Manage Libraries.*"
|
||||
- assertVisible: "(?s)^Services.*"
|
||||
# The Connections card sits below the first card, so reaching it also proves
|
||||
# the list scrolled or fits — not that any particular row height applies.
|
||||
- assertVisible: "(?s)^Add connection.*"
|
||||
- assertVisible: "(?s)^Profiles.*"
|
||||
# Manage Libraries takes the TV dialog path, not the overlay-sheet path, and
|
||||
# one Back has to return to Settings rather than leave it.
|
||||
- repeat:
|
||||
times: 6
|
||||
while:
|
||||
notVisible:
|
||||
text: "(?s)^Manage Libraries.*"
|
||||
focused: true
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
- assertVisible: "Maestro Shows"
|
||||
- assertNotVisible: "(?s)^Services.*"
|
||||
- assertNotVisible: "(?s)^Video Playback.*"
|
||||
- pressKey: "back"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- assertNotVisible: "Maestro Movies"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Services.*"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s)^Manage Libraries.*"
|
||||
# Services keeps every hub row after losing its own density overrides.
|
||||
- repeat:
|
||||
times: 6
|
||||
while:
|
||||
notVisible:
|
||||
text: "(?s)^Services.*"
|
||||
focused: true
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Trakt.*"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s)^MyAnimeList.*"
|
||||
- assertVisible: "(?s)^AniList.*"
|
||||
- assertVisible: "(?s)^Simkl.*"
|
||||
- assertVisible: "(?s)^Seerr.*"
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Manage Libraries.*"
|
||||
timeout: 15000
|
||||
# Video Playback: the Atmos test screen is gated to Apple TV, and the D-pad
|
||||
# numeric dialog must expose the spinner's restored accessibility labels.
|
||||
- repeat:
|
||||
times: 8
|
||||
while:
|
||||
notVisible:
|
||||
text: "(?s)^Video Playback.*"
|
||||
focused: true
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Up"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Player.*"
|
||||
timeout: 15000
|
||||
- assertNotVisible: "(?s)^Atmos Output Test.*"
|
||||
- repeat:
|
||||
times: 30
|
||||
while:
|
||||
notVisible:
|
||||
text: "(?s)^Small Skip Duration.*"
|
||||
focused: true
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Down"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "Decrease"
|
||||
timeout: 15000
|
||||
- assertVisible: "Increase"
|
||||
- assertVisible: "(?s)^Enter duration.*"
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Small Skip Duration.*"
|
||||
timeout: 15000
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Manage Libraries.*"
|
||||
timeout: 15000
|
||||
# Settings is a rail tab on TV rather than a pushed route, so Back hands focus
|
||||
# back to the rail and expands it instead of leaving the screen.
|
||||
- pressKey: "back"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
text: "Settings"
|
||||
focused: true
|
||||
timeout: 20000
|
||||
- assertVisible: "Home"
|
||||
- assertVisible: "Libraries"
|
||||
- assertVisible: "Downloads"
|
||||
@@ -0,0 +1,11 @@
|
||||
const controlUrl = String(JELLYFIN_CONTROL_URL).trim();
|
||||
|
||||
if (controlUrl !== "" && controlUrl !== "undefined" && controlUrl !== "null") {
|
||||
const response = http.post(`${controlUrl}/__maestro/offline`, {
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({enabled: true}),
|
||||
});
|
||||
if (response.status !== 204) {
|
||||
throw new Error(`Could not put Jellyfin fixture offline: HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- tapOn: "M"
|
||||
- tapOn:
|
||||
text: "Profiles"
|
||||
above:
|
||||
text: "Settings"
|
||||
- extendedWaitUntil:
|
||||
visible: "Switch Profile"
|
||||
timeout: 10000
|
||||
- tapOn: "Add Plezy profile"
|
||||
- extendedWaitUntil:
|
||||
visible: "New profile"
|
||||
timeout: 10000
|
||||
- tapOn: "e.g. Guests, Kids, Family Room"
|
||||
- inputText: "E2E Guest"
|
||||
- hideKeyboard
|
||||
- tapOn: "Continue"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add to E2E Guest"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^Connect to Jellyfin.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add Jellyfin server"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)Server URLs.*"
|
||||
- inputText: ${JELLYFIN_URL}
|
||||
- hideKeyboard
|
||||
- tapOn: "Find server"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Jellyfin"
|
||||
timeout: 15000
|
||||
- tapOn: "Username"
|
||||
- inputText: "guest"
|
||||
- tapOn: "Password"
|
||||
- inputText: "guest"
|
||||
- hideKeyboard
|
||||
- tapOn: "Sign in"
|
||||
- extendedWaitUntil:
|
||||
visible: "Switch Profile"
|
||||
timeout: 30000
|
||||
- assertVisible: "(?s)^M\nMaestro\n.*"
|
||||
- assertVisible: "(?s)^E\nE2E Guest\n.*"
|
||||
@@ -0,0 +1,27 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
# Reach a signed-in Home without paying for onboarding that already happened.
|
||||
#
|
||||
# `onboard_jellyfin.yaml` clears app state and retypes the server URL and
|
||||
# credentials, which measures ~59s per flow on a Pixel 7 and dominates every
|
||||
# flow that only needs "signed in, on Home". A plain `launchApp` keeps stored
|
||||
# data and cold-starts straight back to Home, which measures ~16s.
|
||||
#
|
||||
# The guard keeps each flow runnable on its own: when no session is stored --
|
||||
# a fresh install, or a run after the logout flow -- "Discover" is absent and
|
||||
# this falls through to the real onboarding. Flows that must prove onboarding
|
||||
# itself, or that need pristine state, keep calling onboard_jellyfin.yaml.
|
||||
- launchApp
|
||||
# Settle on a known screen first. Branching straight off `launchApp` races the
|
||||
# splash: "Discover" has not rendered yet, the guard reads it as absent, and
|
||||
# every flow pays for a full re-onboarding it did not need.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Discover|Sign in with Plex|Connect to Jellyfin|Wait).*"
|
||||
timeout: 30000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Discover"
|
||||
file: onboard_jellyfin.yaml
|
||||
- extendedWaitUntil:
|
||||
visible: "Discover"
|
||||
timeout: 30000
|
||||
@@ -0,0 +1,50 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- retry:
|
||||
maxRetries: 1
|
||||
commands:
|
||||
- launchApp:
|
||||
clearState: true
|
||||
permissions:
|
||||
all: allow
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Connect to Jellyfin|Wait).*"
|
||||
timeout: 30000
|
||||
# A miss on `tapOn ... optional: true` still runs Maestro's full element
|
||||
# search before giving up, which measured 3s here and 7.8s for "Sign in"
|
||||
# on every onboarding. These taps normally find nothing, so gate them on
|
||||
# a single visibility check instead.
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Wait"
|
||||
commands:
|
||||
- tapOn: "Wait"
|
||||
- extendedWaitUntil:
|
||||
visible: "Connect to Jellyfin"
|
||||
timeout: 30000
|
||||
- tapOn: "Connect to Jellyfin"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add Jellyfin server"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)Server URLs.*"
|
||||
- inputText: ${JELLYFIN_URL}
|
||||
- tapOn: "Find server"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Jellyfin"
|
||||
timeout: 15000
|
||||
- tapOn: "Username"
|
||||
- inputText: "maestro"
|
||||
- tapOn: "Password"
|
||||
- inputText: "maestro"
|
||||
- pressKey: ENTER
|
||||
- retry:
|
||||
maxRetries: 1
|
||||
commands:
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Sign in"
|
||||
commands:
|
||||
- tapOn: "Sign in"
|
||||
- extendedWaitUntil:
|
||||
visible: "Discover"
|
||||
timeout: 30000
|
||||
@@ -0,0 +1,55 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- retry:
|
||||
maxRetries: 1
|
||||
commands:
|
||||
- launchApp:
|
||||
appId: com.edde746.plezy
|
||||
clearState: true
|
||||
permissions:
|
||||
all: allow
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Connect to Jellyfin|Wait).*"
|
||||
timeout: 30000
|
||||
- tapOn:
|
||||
text: "Wait"
|
||||
optional: true
|
||||
- extendedWaitUntil:
|
||||
visible: "Connect to Jellyfin"
|
||||
timeout: 30000
|
||||
- tapOn: "Connect to Jellyfin"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add Jellyfin server"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)Server URLs.*"
|
||||
- inputText: ${JELLYFIN_URL}
|
||||
- tapOn: "Find server"
|
||||
- extendedWaitUntil:
|
||||
visible: "Username"
|
||||
timeout: 15000
|
||||
- tapOn: "Username"
|
||||
- inputText: "maestro"
|
||||
- tapOn: "Password"
|
||||
- inputText: "maestro"
|
||||
- tapOn: "Sign in"
|
||||
- extendedWaitUntil:
|
||||
visible: "Discover"
|
||||
timeout: 30000
|
||||
- tapOn: "M"
|
||||
- tapOn:
|
||||
text: "Settings"
|
||||
below:
|
||||
text: "Profiles"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Appearance.*"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^Appearance.*"
|
||||
- swipe:
|
||||
start: 50%, 85%
|
||||
end: 50%, 25%
|
||||
duration: 500
|
||||
- tapOn: "(?s)^Force TV mode.*"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "tv_browse_rail_selection"
|
||||
timeout: 30000
|
||||
@@ -0,0 +1,57 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
# Onboarding for a device that already reports itself as a TV.
|
||||
#
|
||||
# `onboard_jellyfin_tv.yaml` exists for the phone emulator CI runs on: it
|
||||
# onboards through the handheld layout and then walks Settings to flip
|
||||
# "Force TV mode". A real Android TV box takes the TV layout from the first
|
||||
# frame, so that walk has no handheld chrome to tap and the toggle would only
|
||||
# re-assert what PlatformDetector already reports.
|
||||
- retry:
|
||||
maxRetries: 1
|
||||
commands:
|
||||
- launchApp:
|
||||
clearState: true
|
||||
permissions:
|
||||
all: allow
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Connect to Jellyfin|Wait).*"
|
||||
timeout: 30000
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Wait"
|
||||
commands:
|
||||
- tapOn: "Wait"
|
||||
- extendedWaitUntil:
|
||||
visible: "Connect to Jellyfin"
|
||||
timeout: 30000
|
||||
- tapOn: "Connect to Jellyfin"
|
||||
- extendedWaitUntil:
|
||||
visible: "Add Jellyfin server"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s)Server URLs.*"
|
||||
- inputText: ${JELLYFIN_URL}
|
||||
- tapOn: "Find server"
|
||||
# A TV-sized layout offers Quick Connect first because typing on a remote is
|
||||
# slow. The code path needs a second device to approve, so fall back to the
|
||||
# username form the phone flow already uses.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Username|Cancel)$"
|
||||
timeout: 30000
|
||||
- runFlow:
|
||||
when:
|
||||
notVisible: "Username"
|
||||
commands:
|
||||
- tapOn: "Cancel"
|
||||
- extendedWaitUntil:
|
||||
visible: "Username"
|
||||
timeout: 20000
|
||||
- tapOn: "Username"
|
||||
- inputText: "maestro"
|
||||
- tapOn: "Password"
|
||||
- inputText: "maestro"
|
||||
- tapOn: "Sign in"
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "tv_browse_rail_selection"
|
||||
timeout: 45000
|
||||
@@ -0,0 +1,48 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- runFlow: ensure_onboarded.yaml
|
||||
- tapOn: "(?s)^Search.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Search movies, shows, music..."
|
||||
timeout: 10000
|
||||
- tapOn: "Search movies, shows, music..."
|
||||
- inputText: "${SAMPLE_TITLE}"
|
||||
- runFlow:
|
||||
when:
|
||||
platform: iOS
|
||||
commands:
|
||||
- pressKey: ENTER
|
||||
- runFlow:
|
||||
when:
|
||||
platform: Android
|
||||
commands:
|
||||
- hideKeyboard
|
||||
# A card announces its watch state three ways, and the codec samples keep a
|
||||
# resume position on the server once any earlier flow has played them, so the
|
||||
# partial form has to be accepted for this subflow to be re-runnable against a
|
||||
# fixture container that is not thrown away between suites.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^${SAMPLE_TITLE}, movie, (?:watched|unwatched|[0-9]+ percent watched)$"
|
||||
timeout: 15000
|
||||
- tapOn: "(?s)^${SAMPLE_TITLE}, movie, (?:watched|unwatched|[0-9]+ percent watched)$"
|
||||
- extendedWaitUntil:
|
||||
visible: "Overview"
|
||||
timeout: 15000
|
||||
- assertVisible: "${SAMPLE_OVERVIEW}"
|
||||
- tapOn:
|
||||
text: "Play"
|
||||
waitToSettleTimeoutMs: 1000
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Overview"
|
||||
timeout: 15000
|
||||
- extendedWaitUntil:
|
||||
visible: "Pause"
|
||||
timeout: 30000
|
||||
- tapOn: "Pause"
|
||||
- extendedWaitUntil:
|
||||
visible: "Play"
|
||||
timeout: 5000
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^.*Audio & Subtitles$"
|
||||
timeout: 30000
|
||||
- tapOn: "(?s)^.*Audio & Subtitles$"
|
||||
@@ -0,0 +1,27 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
# Reach Settings from a signed-in Home on a phone layout.
|
||||
#
|
||||
# The profile menu is itself an overlay sheet on the MainScreen host, so a tap
|
||||
# issued while the previous route is still settling opens nothing. The guarded
|
||||
# repeat re-opens the menu instead of failing the flow, matching the pattern
|
||||
# already used by flows/07_profiles_settings.yaml.
|
||||
- extendedWaitUntil:
|
||||
visible: "Discover"
|
||||
timeout: 30000
|
||||
- repeat:
|
||||
times: 3
|
||||
while:
|
||||
notVisible: "Settings"
|
||||
commands:
|
||||
- tapOn: "(?s)^M(?:.*Profiles.*)?$"
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- tapOn:
|
||||
text: "Settings"
|
||||
below:
|
||||
text: "Profiles"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Appearance.*"
|
||||
timeout: 15000
|
||||
- assertVisible: "(?s)^Manage Libraries.*"
|
||||
@@ -0,0 +1,60 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- tapOn: "(?s)^Appearance.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Theme.*"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s).*Library Density.*"
|
||||
- back
|
||||
- assertVisible: "(?s)^Appearance.*"
|
||||
- tapOn: "(?s)^Video Playback.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Player.*"
|
||||
timeout: 10000
|
||||
- repeat:
|
||||
times: 4
|
||||
while:
|
||||
notVisible: "(?s)^Subtitles & Configuration.*Subtitle Styling.*"
|
||||
commands:
|
||||
- swipe:
|
||||
start: 50%, 80%
|
||||
end: 50%, 65%
|
||||
duration: 800
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 1500
|
||||
- assertVisible: "(?s)^Subtitles & Configuration.*Subtitle Styling.*"
|
||||
- repeat:
|
||||
times: 4
|
||||
while:
|
||||
notVisible: "(?s)^Seek & Timing.*"
|
||||
commands:
|
||||
- swipe:
|
||||
start: 50%, 80%
|
||||
end: 50%, 65%
|
||||
duration: 800
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 1500
|
||||
- assertVisible: "(?s)^Seek & Timing.*"
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Manage Libraries.*"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^Manage Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 10000
|
||||
- back
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Services.*"
|
||||
timeout: 10000
|
||||
- tapOn: "(?s)^Services.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Services"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s)^Trakt.*"
|
||||
- assertVisible: "(?s)^MyAnimeList.*"
|
||||
- assertVisible: "(?s)^AniList.*"
|
||||
- assertVisible: "(?s)^Simkl.*"
|
||||
- assertVisible: "(?s)^Seerr.*"
|
||||
- back
|
||||
- assertVisible: "(?s)^Services.*"
|
||||
@@ -0,0 +1,23 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- assertVisible: "${AUDIO_TRACK}"
|
||||
- tapOn:
|
||||
text: "${AUDIO_TRACK}"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^.*Audio & Subtitles$"
|
||||
timeout: 30000
|
||||
- tapOn: "(?s)^.*Audio & Subtitles$"
|
||||
- assertVisible: "${SUBTITLE_TRACK}"
|
||||
- tapOn:
|
||||
text: "${SUBTITLE_TRACK}"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^.*Audio & Subtitles$"
|
||||
timeout: 30000
|
||||
- tapOn: "(?s)^.*Audio & Subtitles$"
|
||||
- assertVisible:
|
||||
text: "${AUDIO_TRACK}"
|
||||
selected: true
|
||||
- assertVisible:
|
||||
text: "${SUBTITLE_TRACK}"
|
||||
selected: true
|
||||
- back
|
||||
@@ -0,0 +1,41 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
- assertVisible: "${AUDIO_TRACK}"
|
||||
- tapOn:
|
||||
text: "${AUDIO_TRACK}"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^.*Audio & Subtitles$"
|
||||
timeout: 30000
|
||||
- tapOn: "(?s)^.*Audio & Subtitles$"
|
||||
- repeat:
|
||||
times: 12
|
||||
while:
|
||||
notVisible: "${SUBTITLE_TRACK}"
|
||||
commands:
|
||||
- swipe:
|
||||
start: 70%, 82%
|
||||
end: 70%, 68%
|
||||
duration: 300
|
||||
- assertVisible: "${SUBTITLE_TRACK}"
|
||||
- tapOn:
|
||||
text: "${SUBTITLE_TRACK}"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^.*Audio & Subtitles$"
|
||||
timeout: 30000
|
||||
- tapOn: "(?s)^.*Audio & Subtitles$"
|
||||
- assertVisible:
|
||||
text: "${AUDIO_TRACK}"
|
||||
selected: true
|
||||
- repeat:
|
||||
times: 12
|
||||
while:
|
||||
notVisible: "${SUBTITLE_TRACK}"
|
||||
commands:
|
||||
- swipe:
|
||||
start: 70%, 82%
|
||||
end: 70%, 68%
|
||||
duration: 300
|
||||
- assertVisible:
|
||||
text: "${SUBTITLE_TRACK}"
|
||||
selected: true
|
||||
- back
|
||||
+77
-4
@@ -4,7 +4,7 @@
|
||||
|
||||
1. Fork and clone the repository
|
||||
2. Run `flutter pub get` to install dependencies
|
||||
3. Run `dart run build_runner build` to generate code
|
||||
3. Run `scripts/codegen.sh` to generate translations and Dart model code
|
||||
4. Start developing!
|
||||
|
||||
## Development
|
||||
@@ -13,7 +13,7 @@
|
||||
- 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
|
||||
- Run `scripts/run_tests.sh` to run the test suite (same as `flutter test`, but scaled to your core count)
|
||||
- Test your changes thoroughly
|
||||
|
||||
### Code Quality Checks
|
||||
@@ -30,11 +30,84 @@ The project includes automated CI checks that run on all pull requests:
|
||||
- Run locally: `flutter analyze`
|
||||
- Note: CI excludes generated files from analysis (configured in `analysis_options.yaml`)
|
||||
|
||||
3. **Tests**: Runs unit and widget tests (when available)
|
||||
- Run locally: `flutter test`
|
||||
3. **Generated Code**: Ensures generated translations and model files are current
|
||||
- Run locally: `scripts/codegen.sh --check`
|
||||
|
||||
4. **Tests**: Runs unit and widget tests (when available)
|
||||
- Run locally: `scripts/run_tests.sh`
|
||||
- This is `flutter test` with `-j` set to the core count. The default is half your cores, which
|
||||
leaves most of the machine idle because the suite is dominated by per-file compilation.
|
||||
Arguments are forwarded, so `scripts/run_tests.sh test/widgets/some_test.dart` works.
|
||||
|
||||
All these checks must pass before your changes can be merged.
|
||||
|
||||
### Maestro end-to-end tests
|
||||
|
||||
Android E2E tests use [Maestro](https://maestro.mobile.dev/) against a disposable, pre-seeded Jellyfin container.
|
||||
|
||||
Prerequisites: Java 17, Flutter and Android SDK/platform tools, a running Android emulator, Docker, and the
|
||||
[Maestro CLI](https://docs.maestro.dev/getting-started/installing-maestro).
|
||||
|
||||
Run the suites from the repository root (`py -3` can replace `python3` on Windows):
|
||||
|
||||
```bash
|
||||
python3 scripts/run_maestro.py basic # Basic user flows
|
||||
python3 scripts/run_maestro.py catalog # Catalog and music flows
|
||||
python3 scripts/run_maestro.py media # Codec playback and track selection
|
||||
```
|
||||
|
||||
Run one flow with `--flow`:
|
||||
|
||||
```bash
|
||||
python3 scripts/run_maestro.py basic --flow .maestro/flows/04_search.yaml
|
||||
```
|
||||
|
||||
Use `--skip-build` to reuse the debug APK and `--skip-jellyfin-build` to reuse the Jellyfin image. Set
|
||||
`--device <adb-serial>` when multiple devices are connected; physical devices also require `--adb-reverse`.
|
||||
|
||||
Top-level flows live in `.maestro/flows/`, shared setup in `.maestro/subflows/`, and focused regressions in
|
||||
`.maestro/regression_flows/`. Automatic PR groups are declared in `scripts/run_maestro_ci.py::GROUPS`. Every top-level
|
||||
regression flow must be registered either there or in `DESTRUCTIVE_MANUAL_TARGETS`; reusable subflows are not
|
||||
independent tests. A manual-only classification must state why the flow cannot run automatically and must not be
|
||||
described as CI coverage. `.github/workflows/e2e.yml` runs only automatic targets and uploads diagnostics on failure.
|
||||
|
||||
The profile-isolation and profile-teardown regressions create and remove profile connections, so they are a destructive
|
||||
manual target rather than automatic PR coverage. Run them only against the pre-seeded Jellyfin fixture and a disposable
|
||||
emulator, using the required opt-in:
|
||||
|
||||
```bash
|
||||
python3 scripts/run_maestro_ci.py profile-regressions --disposable-emulator
|
||||
```
|
||||
|
||||
The target refuses to start without `--disposable-emulator`. Each profile flow writes to its own Jellyfin log and
|
||||
diagnostics directory under `build/maestro-profile-regressions/`.
|
||||
|
||||
### Production container image updates
|
||||
|
||||
Production images in `server/Dockerfile` and `server/docker-compose.yml` use a readable version or source-revision tag
|
||||
plus an authoritative multi-platform index digest. The adjacent `Platforms` declaration records the supported
|
||||
`linux/amd64` and `linux/arm64` variants. Never replace these references with a mutable tag or a single-platform child
|
||||
manifest.
|
||||
|
||||
Update a production image only through a reviewed change:
|
||||
|
||||
1. For the Bugs service, first record the running container's image ID, repository digest, platform, and OCI source
|
||||
revision without printing its environment. Prefer that reviewed running identity; selecting anything else is a
|
||||
service upgrade, not a routine pin refresh.
|
||||
2. Review the upstream source revision and changelog, provenance, vulnerability results, and manifest contents. Resolve
|
||||
the readable tag and digest-qualified reference independently and confirm they identify the same OCI index in two
|
||||
clean caches. The index must contain both declared platforms; provenance/attestation descriptors do not count as
|
||||
runnable platforms.
|
||||
3. Change the readable tag, full `sha256` index digest, and adjacent platform declaration together. Include the old and
|
||||
new identities, manifest/platform evidence, review findings, smoke results, and rollback notes in the change.
|
||||
4. Before changing the Bugs digest, exercise it with non-production configuration and a disposable volume. Review
|
||||
migrations, take a restorable `bugs_data` backup, then validate a cloned volume. A forward-only migration rolls back
|
||||
with the prior digest and pre-change backup, not by changing the image reference alone.
|
||||
5. Run `python3 scripts/check_container_image_pins.py`, `python3 scripts/test_check_container_image_pins.py`, and
|
||||
`(cd server && go test ./...)`. Inspect the rendered Compose configuration and rebuilt images locally without
|
||||
exposing configuration values. Do not publish or deploy from a review checkout, and never fall back to `latest` when
|
||||
a digest is unavailable.
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
This project uses `slang` for internationalization with JSON files.
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
cask "plezy" do
|
||||
version "2.2.0"
|
||||
sha256 "9bfd7397d27bb2dbfd0f4d3e47d6fb73d6a1135f9f181ec8c48767c95964f1e9"
|
||||
version "2.11.0"
|
||||
sha256 "72a6a74d2f8f4ac76d83d005ff28957b3abcc9d80d7d575710df031d12c1c8cf"
|
||||
|
||||
url "https://github.com/edde746/plezy/releases/download/#{version}/plezy-macos.dmg"
|
||||
name "Plezy"
|
||||
|
||||
@@ -19,13 +19,13 @@ A modern client for Plex and Jellyfin on desktop, mobile, and TV. Built with Flu
|
||||
|
||||
## Download
|
||||
|
||||
<a href='https://apps.apple.com/us/app/id6754315964'><img height='60' alt='Download on the App Store' src='./assets/app-store-badge.png'/></a>
|
||||
<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://apps.apple.com/app/apple-store/id6754315964?pt=128238902&ct=GitHub&mt=8'><img height='60' alt='Download on the App Store' src='./assets/app-store-badge.png'/></a>
|
||||
<a href='https://play.google.com/store/apps/details?id=com.edde746.plezy&referrer=utm_source%3Dgithub%26utm_campaign%3Dreadme_badge'><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>
|
||||
<a href='https://get.microsoft.com/installer/download/9n5r1s1t68h7?referrer=appbadge&cid=github'><img height='60' alt='Get it from Microsoft' src='./assets/microsoft-badge.png'/></a>
|
||||
|
||||
| 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) |
|
||||
@@ -50,36 +50,61 @@ Package managers:
|
||||
## Features
|
||||
|
||||
### <img src="assets/readme_icons/browse.svg" height="20" alt="" align="center" /> Browse & Discover
|
||||
- Libraries, collections, and playlists
|
||||
- Libraries, collections, and playlists — video and audio
|
||||
- Discover hub — Continue Watching, Next Up, trending, and recommendations
|
||||
- Cross-server search
|
||||
- Cross-server search across every connected Plex and Jellyfin server
|
||||
- Filtering, sorting, and alphabetical jump navigation
|
||||
- Folder browsing and folder playback — home-video libraries open in folder view
|
||||
- Resolution, HDR/Dolby Vision, and audio-format badges on cards and detail pages
|
||||
- Favorites and unwatched library filters[^jf]
|
||||
- Extras — trailers, deleted scenes, behind-the-scenes
|
||||
|
||||
### <img src="assets/readme_icons/explore.svg" height="20" alt="" align="center" /> Explore & Requests
|
||||
- Explore tab — watchlist, trending, popular, and recommendation rows from Plex Discover[^plex], Trakt, MyAnimeList, AniList, Simkl, and Seerr[^connect]
|
||||
- Search any connected catalog source
|
||||
- Catalog titles matched back to your own libraries by external ID
|
||||
- Seerr — request movies and shows with per-season, 4K, and advanced destination options, and see request status inline
|
||||
- Watchlist sync — add and remove titles on Plex, Trakt, MyAnimeList, AniList, and Simkl from anywhere in the app
|
||||
|
||||
### <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[^1]
|
||||
- HDR and Dolby Vision[^hdr]
|
||||
- Direct play, or transcode presets from 240p/320 kbps to 1080p/20 Mbps
|
||||
- Multi-version switching with per-version file details
|
||||
- Full ASS/SSA subtitles with customizable styling
|
||||
- Online subtitle search & download[^2]
|
||||
- Audio & subtitle choices remembered per title
|
||||
- Online subtitle search & download[^plex]
|
||||
- Audio & subtitle choices remembered per title, or follow the server's per-episode selections
|
||||
- Progress sync and resume
|
||||
- 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.)
|
||||
- Playback speed from 0.25x to 8x, audio sync offset, sleep timer (fixed durations or end of video)
|
||||
- Video zoom 50-200% with pinch, presets, and hotkeys
|
||||
- Audio passthrough[^pass], stereo downmix with center-channel boost, and loudness normalization
|
||||
- File Info sheet — every version, file, and stream the server reports
|
||||
- Ambient lighting and GLSL shader presets[^mpv]
|
||||
- Picture-in-Picture[^pip]
|
||||
- Refresh-rate matching[^rrm]
|
||||
- External player launch (VLC, MX Player, etc.) with progress sync back[^android]
|
||||
|
||||
### <img src="assets/readme_icons/music.svg" height="20" alt="" align="center" /> Music
|
||||
- Music libraries — artist, album, and track browsing with square artwork
|
||||
- Album and artist screens with play, shuffle, and Instant Mix
|
||||
- Gapless playback with a full play queue — reorder, remove, play next, add to queue
|
||||
- Now Playing with synced lyrics[^lyrics], persistent mini-player, and sleep timer
|
||||
- Background playback with lock-screen, media-key, and notification controls[^bgaudio]
|
||||
- Offline playback of downloaded albums and tracks
|
||||
- Streaming quality presets — Original, 320, 192, or 128 kbps
|
||||
|
||||
### <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]
|
||||
- Live TV channel browsing, tuning, and favorites
|
||||
- EPG guide with What's On and per-show schedules
|
||||
- DVR recording rules, scheduled recordings, and a rememberable recording target library[^plex]
|
||||
- Multi-server Live TV support where available
|
||||
|
||||
### <img src="assets/readme_icons/downloads.svg" height="20" alt="" align="center" /> Downloads & Offline
|
||||
- Download media for offline viewing
|
||||
- Download movies, shows, and music for offline playback[^dl]
|
||||
- Background queue with pause / resume
|
||||
- Sync rules for automatic downloads
|
||||
- Sync rules for automatic downloads, with per-show "Include Specials"
|
||||
- Offline browsing with watch state sync-back on reconnect
|
||||
|
||||
### <img src="assets/readme_icons/watch-together.svg" height="20" alt="" align="center" /> Watch Together
|
||||
@@ -87,30 +112,42 @@ Package managers:
|
||||
- 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
|
||||
- Discord Rich Presence[^desktop]
|
||||
- Trakt, MyAnimeList, AniList, and Simkl — ratings, watched sync, and real-time scrobbling[^rt]
|
||||
- Plezy Remote — control desktop and TV from mobile
|
||||
- Watch Next row[^6]
|
||||
- Watch Next row and tvOS Top Shelf[^shelf]
|
||||
|
||||
### <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]
|
||||
- Multiple servers at once — Plex and Jellyfin side by side
|
||||
- Profiles with per-profile downloads, watch state, and settings; Plex Home switching with PIN
|
||||
- Jellyfin local-server discovery, multiple URLs per server, and Quick Connect sign-in
|
||||
- TV layout options — corner spotlight backdrop, full-card artwork, and Force TV mode on desktop
|
||||
- Customizable keyboard shortcuts[^desktop]
|
||||
- Metadata and artwork editing
|
||||
- Settings import/export
|
||||
- Localized in English plus 14 translations
|
||||
- Localized in English plus 21 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.
|
||||
[^jf]: Jellyfin only.
|
||||
[^plex]: Plex only.
|
||||
[^connect]: Requires connecting the service under Settings > Services.
|
||||
[^hdr]: In-app HDR toggle on Windows, macOS, iOS, and tvOS. Dolby Vision on Android and Apple TV.
|
||||
[^pass]: Desktop, Android TV, and Apple TV.
|
||||
[^mpv]: Requires the mpv player backend — unavailable on iOS and tvOS, and Android defaults to ExoPlayer.
|
||||
[^pip]: Android, iOS, and macOS — not on Android TV or Apple TV.
|
||||
[^rrm]: Windows, Android, and tvOS.
|
||||
[^android]: Progress sync on Android.
|
||||
[^lyrics]: Where your server provides lyrics.
|
||||
[^bgaudio]: tvOS pauses music when the app is backgrounded.
|
||||
[^dl]: Not available on tvOS.
|
||||
[^desktop]: Desktop only.
|
||||
[^rt]: Real-time scrobbling on Trakt and Simkl; MyAnimeList and AniList update on completion.
|
||||
[^shelf]: Android TV / Fire TV and tvOS.
|
||||
|
||||
## Building from Source
|
||||
|
||||
### Prerequisites
|
||||
- Flutter SDK 3.38.4+
|
||||
- Flutter SDK 3.44.0+
|
||||
- A Plex account or Jellyfin server with user credentials
|
||||
|
||||
### Setup
|
||||
@@ -149,6 +186,12 @@ To install the same pre-commit checks locally:
|
||||
scripts/setup_hooks.sh
|
||||
```
|
||||
|
||||
End-to-end tests (Android emulator plus a Dockerized Jellyfin fixture):
|
||||
|
||||
```bash
|
||||
python3 scripts/run_maestro.py basic
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for development workflow, formatting, tests, and translation guidelines.
|
||||
|
||||
@@ -4,8 +4,6 @@ analyzer:
|
||||
exclude:
|
||||
- "**/*.g.dart"
|
||||
- "**/*.freezed.dart"
|
||||
plugins:
|
||||
- dart_code_linter
|
||||
|
||||
linter:
|
||||
rules:
|
||||
@@ -17,7 +15,14 @@ linter:
|
||||
prefer_final_in_for_each: true
|
||||
avoid_print: true
|
||||
|
||||
# Do not register DCL as an analyzer plugin: its analyzer 10 integration crashes
|
||||
# on Linux. CI invokes the supported check-unused commands explicitly.
|
||||
dart_code_linter:
|
||||
rules-exclude:
|
||||
- "test/**"
|
||||
metrics-exclude:
|
||||
- "test/**"
|
||||
|
||||
extends:
|
||||
- package:dart_code_linter/presets/recommended.yaml
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
+409
-63
@@ -1,5 +1,55 @@
|
||||
import java.io.FileInputStream
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
import java.security.MessageDigest
|
||||
import java.util.Properties
|
||||
import java.util.UUID
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
fun verifySha256(file: File, expected: String, identity: String) {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().buffered().use { input ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val count = input.read(buffer)
|
||||
if (count < 0) break
|
||||
digest.update(buffer, 0, count)
|
||||
}
|
||||
}
|
||||
val actual = digest.digest().joinToString("") {
|
||||
(it.toInt() and 0xff).toString(16).padStart(2, '0')
|
||||
}
|
||||
if (actual != expected) {
|
||||
throw GradleException("SHA-256 mismatch for $identity: expected $expected, got $actual")
|
||||
}
|
||||
}
|
||||
|
||||
fun promoteDirectory(staging: File, destination: File) {
|
||||
val backup = File(destination.parentFile, "${destination.name}.backup-${UUID.randomUUID()}")
|
||||
val hadDestination = destination.exists()
|
||||
try {
|
||||
if (hadDestination) {
|
||||
Files.move(destination.toPath(), backup.toPath(), StandardCopyOption.ATOMIC_MOVE)
|
||||
}
|
||||
try {
|
||||
Files.move(staging.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE)
|
||||
} catch (promotionFailure: Exception) {
|
||||
if (hadDestination && backup.exists()) {
|
||||
try {
|
||||
Files.move(backup.toPath(), destination.toPath(), StandardCopyOption.ATOMIC_MOVE)
|
||||
} catch (restoreFailure: Exception) {
|
||||
promotionFailure.addSuppressed(restoreFailure)
|
||||
}
|
||||
}
|
||||
throw promotionFailure
|
||||
}
|
||||
if (hadDestination && backup.exists() && !backup.deleteRecursively()) {
|
||||
throw GradleException("Failed to remove obsolete native artifact backup at ${backup.absolutePath}")
|
||||
}
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
@@ -9,77 +59,274 @@ plugins {
|
||||
}
|
||||
|
||||
val mpvVersion = "v1.0.7"
|
||||
val mpvSha256 = "d55d440e587b2a9ffb91874d93069460a987be05fe72af8394849983f0df2d7a"
|
||||
val mpvDir = layout.buildDirectory.dir("libmpv").get().asFile
|
||||
val mpvAar = "libmpv-release.aar"
|
||||
val mpvUrl = "https://github.com/edde746/libmpv-android/releases/download/$mpvVersion/$mpvAar"
|
||||
|
||||
val downloadLibmpv by tasks.registering {
|
||||
val stamp = File(mpvDir, ".version")
|
||||
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == mpvVersion }
|
||||
val media3Version = "1.10.1"
|
||||
val mpvFfmpegVersion = "8.0.1"
|
||||
val mpvFfmpegSourceSha256 = "05ee0b03119b45c0bdb4df654b96802e909e0a752f72e4fe3794f487229e5a41"
|
||||
val mpvFfmpegSourceUrl = "https://ffmpeg.org/releases/ffmpeg-$mpvFfmpegVersion.tar.xz"
|
||||
val mpvFfmpegDevelopmentDir = File(mpvDir, "ffmpeg-development")
|
||||
|
||||
val downloadLibmpv = tasks.register("downloadLibmpv") {
|
||||
val aar = File(mpvDir, mpvAar)
|
||||
val manifest = File(mpvDir, ".manifest")
|
||||
inputs.property("version", mpvVersion)
|
||||
inputs.property("sourceUrl", mpvUrl)
|
||||
inputs.property("sha256", mpvSha256)
|
||||
outputs.files(aar, manifest)
|
||||
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)
|
||||
mpvDir.parentFile.mkdirs()
|
||||
val staging = File(mpvDir.parentFile, "${mpvDir.name}.staging-${UUID.randomUUID()}")
|
||||
try {
|
||||
staging.mkdirs()
|
||||
val stagedAar = File(staging, mpvAar)
|
||||
try {
|
||||
providers.exec {
|
||||
commandLine("curl", "-sfL", mpvUrl, "-o", stagedAar.absolutePath)
|
||||
}.result.get().assertNormalExitValue()
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to download $mpvAar $mpvVersion", error)
|
||||
}
|
||||
verifySha256(stagedAar, mpvSha256, "$mpvAar $mpvVersion")
|
||||
File(staging, ".manifest").writeText("version=$mpvVersion\nsha256=$mpvSha256\n")
|
||||
promoteDirectory(staging, mpvDir)
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
// Extract libc++_shared.so from the libmpv AAR so the app source set can package
|
||||
// it with top merge priority (see packaging { jniLibs } and sourceSets below).
|
||||
val extractMpvLibcxx = tasks.register("extractMpvLibcxx") {
|
||||
dependsOn(downloadLibmpv)
|
||||
val aar = File(mpvDir, mpvAar)
|
||||
val outDir = File(mpvDir, "libcxx")
|
||||
inputs.file(aar)
|
||||
outputs.dir(outDir)
|
||||
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) }
|
||||
outDir.deleteRecursively() // drop stale ABIs from a previous AAR version
|
||||
outDir.mkdirs()
|
||||
providers.exec {
|
||||
commandLine(
|
||||
"unzip",
|
||||
"-q",
|
||||
"-o",
|
||||
aar.absolutePath,
|
||||
"jni/*/libc++_shared.so",
|
||||
"-d",
|
||||
outDir.absolutePath
|
||||
)
|
||||
}.result.get().assertNormalExitValue()
|
||||
}
|
||||
}
|
||||
|
||||
// Build the Media3 JNI adapter against the same shared FFmpeg libraries that
|
||||
// libmpv packages. Headers are pinned to libmpv's FFmpeg version and remain
|
||||
// build-only; the APK contains one FFmpeg implementation for both players.
|
||||
val prepareMpvFfmpegDevelopment = tasks.register("prepareMpvFfmpegDevelopment") {
|
||||
dependsOn(downloadLibmpv)
|
||||
val aar = File(mpvDir, mpvAar)
|
||||
val manifest = File(mpvFfmpegDevelopmentDir, ".manifest")
|
||||
val abis = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
|
||||
val libraries = listOf("avcodec", "avutil", "swresample")
|
||||
inputs.file(aar)
|
||||
inputs.property("ffmpegVersion", mpvFfmpegVersion)
|
||||
inputs.property("sourceUrl", mpvFfmpegSourceUrl)
|
||||
inputs.property("sourceSha256", mpvFfmpegSourceSha256)
|
||||
outputs.files(
|
||||
abis.flatMap { abi ->
|
||||
libraries.map { library -> File(mpvFfmpegDevelopmentDir, "native/$abi/lib$library.so") }
|
||||
}
|
||||
)
|
||||
outputs.files(
|
||||
File(mpvFfmpegDevelopmentDir, "include/libavcodec/avcodec.h"),
|
||||
File(mpvFfmpegDevelopmentDir, "include/libavutil/avconfig.h"),
|
||||
File(mpvFfmpegDevelopmentDir, "include/libswresample/swresample.h"),
|
||||
manifest
|
||||
)
|
||||
doLast {
|
||||
val staging = File(
|
||||
mpvFfmpegDevelopmentDir.parentFile,
|
||||
"${mpvFfmpegDevelopmentDir.name}.staging-${UUID.randomUUID()}"
|
||||
)
|
||||
try {
|
||||
val sourceArchive = File(staging, "ffmpeg-$mpvFfmpegVersion.tar.xz")
|
||||
val includeDir = File(staging, "include")
|
||||
val nativeDir = File(staging, "native")
|
||||
staging.mkdirs()
|
||||
try {
|
||||
providers.exec {
|
||||
commandLine("curl", "-sfL", mpvFfmpegSourceUrl, "-o", sourceArchive.absolutePath)
|
||||
}.result.get().assertNormalExitValue()
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to download FFmpeg $mpvFfmpegVersion headers", error)
|
||||
}
|
||||
verifySha256(sourceArchive, mpvFfmpegSourceSha256, "FFmpeg $mpvFfmpegVersion source")
|
||||
|
||||
val extractedSource = File(staging, "source").apply { mkdirs() }
|
||||
try {
|
||||
providers.exec {
|
||||
commandLine(
|
||||
"tar",
|
||||
"-xJf",
|
||||
sourceArchive.absolutePath,
|
||||
"--strip-components=1",
|
||||
"-C",
|
||||
extractedSource.absolutePath
|
||||
)
|
||||
}.result.get().assertNormalExitValue()
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to extract FFmpeg $mpvFfmpegVersion headers", error)
|
||||
}
|
||||
listOf("libavcodec", "libavutil", "libswresample").forEach { library ->
|
||||
project.copy {
|
||||
from(File(extractedSource, library)) {
|
||||
include("*.h")
|
||||
}
|
||||
into(File(includeDir, library))
|
||||
}
|
||||
}
|
||||
File(includeDir, "libavutil/avconfig.h").writeText(
|
||||
"""
|
||||
|/* Generated for Plezy's little-endian Android ABIs. */
|
||||
|#ifndef AVUTIL_AVCONFIG_H
|
||||
|#define AVUTIL_AVCONFIG_H
|
||||
|#define AV_HAVE_BIGENDIAN 0
|
||||
|#define AV_HAVE_FAST_UNALIGNED 0
|
||||
|#endif /* AVUTIL_AVCONFIG_H */
|
||||
|
|
||||
""".trimMargin()
|
||||
)
|
||||
|
||||
project.copy {
|
||||
from(zipTree(aar)) {
|
||||
include(
|
||||
"jni/*/libavcodec.so",
|
||||
"jni/*/libavutil.so",
|
||||
"jni/*/libswresample.so"
|
||||
)
|
||||
eachFile {
|
||||
path = path.removePrefix("jni/")
|
||||
}
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
into(nativeDir)
|
||||
}
|
||||
|
||||
val missing = abis.flatMap { abi ->
|
||||
libraries.map { library -> File(nativeDir, "$abi/lib$library.so") }
|
||||
}.filterNot(File::isFile)
|
||||
if (missing.isNotEmpty()) {
|
||||
throw GradleException(
|
||||
"libmpv $mpvVersion is missing FFmpeg libraries: ${missing.joinToString { it.relativeTo(staging).path }}"
|
||||
)
|
||||
}
|
||||
File(staging, ".manifest").writeText(
|
||||
"mpv=$mpvVersion\nffmpeg=$mpvFfmpegVersion\nsourceSha256=$mpvFfmpegSourceSha256\n"
|
||||
)
|
||||
sourceArchive.delete()
|
||||
extractedSource.deleteRecursively()
|
||||
promoteDirectory(staging, mpvFfmpegDevelopmentDir)
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
}
|
||||
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"
|
||||
val doviArtifacts = mapOf(
|
||||
"arm64-v8a" to Pair(
|
||||
"aarch64-linux-android",
|
||||
"9d2983fc86f2f9e6da54c3c84ba8ea3a528690619f312ff4620198071b84e9ae"
|
||||
),
|
||||
"armeabi-v7a" to Pair(
|
||||
"armv7-linux-androideabi",
|
||||
"ed6fec8bf744e41c661b97f5fc4bf1197ebe9b09a140cbde369728e790ee3a68"
|
||||
),
|
||||
"x86" to Pair(
|
||||
"i686-linux-android",
|
||||
"50f0a5606e617dff8976b9e7930a23272f4804882a35a6f0f2b2f2d3f8ed7135"
|
||||
),
|
||||
"x86_64" to Pair(
|
||||
"x86_64-linux-android",
|
||||
"eba59678f89b792f5c6f802962e237542fe8328f6aa03a0a90ee77353dac3194"
|
||||
)
|
||||
)
|
||||
val doviBaseUrl = "https://github.com/edde746/libdovi-builds/releases/download/v$doviVersion"
|
||||
|
||||
val downloadLibdovi by tasks.registering {
|
||||
val stamp = File(doviDir, ".version")
|
||||
outputs.upToDateWhen { stamp.exists() && stamp.readText().trim() == doviVersion }
|
||||
val downloadLibdovi = tasks.register("downloadLibdovi") {
|
||||
val manifest = File(doviDir, ".manifest")
|
||||
inputs.property("version", doviVersion)
|
||||
inputs.property("baseUrl", doviBaseUrl)
|
||||
doviArtifacts.forEach { (abi, artifact) ->
|
||||
inputs.property("$abi.triple", artifact.first)
|
||||
inputs.property("$abi.sha256", artifact.second)
|
||||
inputs.property("$abi.sourceUrl", "$doviBaseUrl/libdovi-${artifact.first}.tar.gz")
|
||||
}
|
||||
outputs.files(doviArtifacts.keys.map { abi -> File(doviDir, "$abi/lib/libdovi.a") } + manifest)
|
||||
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()
|
||||
doviDir.parentFile.mkdirs()
|
||||
val staging = File(doviDir.parentFile, "${doviDir.name}.staging-${UUID.randomUUID()}")
|
||||
try {
|
||||
staging.mkdirs()
|
||||
val downloads = File(staging, ".downloads").apply { mkdirs() }
|
||||
doviArtifacts.forEach { (abi, artifact) ->
|
||||
val (triple, expectedSha256) = artifact
|
||||
val archiveName = "libdovi-$triple.tar.gz"
|
||||
val archive = File(downloads, archiveName)
|
||||
val sourceUrl = "$doviBaseUrl/$archiveName"
|
||||
try {
|
||||
providers.exec {
|
||||
commandLine("curl", "-sfL", sourceUrl, "-o", archive.absolutePath)
|
||||
}.result.get().assertNormalExitValue()
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to download $archiveName v$doviVersion", error)
|
||||
}
|
||||
verifySha256(archive, expectedSha256, "$archiveName v$doviVersion")
|
||||
|
||||
val outDir = File(staging, "$abi/lib").apply { mkdirs() }
|
||||
try {
|
||||
providers.exec {
|
||||
commandLine("tar", "-xzf", archive.absolutePath, "-C", outDir.absolutePath)
|
||||
}.result.get().assertNormalExitValue()
|
||||
} catch (error: Exception) {
|
||||
throw GradleException("Failed to extract $archiveName", error)
|
||||
}
|
||||
if (!File(outDir, "libdovi.a").isFile) {
|
||||
throw GradleException("$archiveName did not contain the expected libdovi.a")
|
||||
}
|
||||
}
|
||||
if (!downloads.deleteRecursively()) {
|
||||
throw GradleException("Failed to clean staged libdovi archives")
|
||||
}
|
||||
val manifestText = buildString {
|
||||
append("version=$doviVersion\n")
|
||||
doviArtifacts.forEach { (abi, artifact) ->
|
||||
append("$abi=${artifact.first},${artifact.second}\n")
|
||||
}
|
||||
}
|
||||
File(staging, ".manifest").writeText(manifestText)
|
||||
promoteDirectory(staging, doviDir)
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
}
|
||||
stamp.writeText(doviVersion)
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.edde746.plezy"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
buildToolsVersion = "36.1.0"
|
||||
ndkVersion = "29.0.14206865"
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
@@ -90,12 +337,14 @@ android {
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
arguments += listOf(
|
||||
"-DDOVI_ENABLE_LIBDOVI=ON",
|
||||
"-DDOVI_LIBDOVI_PREBUILT_ROOT=${doviDir.absolutePath}"
|
||||
"-DDOVI_LIBDOVI_PREBUILT_ROOT=${doviDir.absolutePath}",
|
||||
"-DMPV_FFMPEG_ROOT=${mpvFfmpegDevelopmentDir.absolutePath}"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -111,6 +360,7 @@ android {
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path = file("src/main/cpp/CMakeLists.txt")
|
||||
version = "4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,51 +391,147 @@ android {
|
||||
debugSymbolLevel = "FULL"
|
||||
}
|
||||
}
|
||||
|
||||
// Instrumentation target that runs R8 (see testBuildType below).
|
||||
//
|
||||
// R8 only ever ran on `release`, so every gate in this repository exercised code
|
||||
// the shipped APK does not contain: reflective lookups, JNI callbacks and native
|
||||
// library loading can all break under shrinking while every debug check passes.
|
||||
// #1703 shipped that way — DefaultRenderersFactory's Class.forName for the bundled
|
||||
// FFmpeg audio renderer failed in release builds only.
|
||||
//
|
||||
// Inherits release's minification and keep rules (the Flutter plugin has already
|
||||
// installed them by the time this block runs) but stays debuggable and debug-signed,
|
||||
// so it is an ordinary test artifact and never a publishable one. Debuggable also
|
||||
// makes the Flutter plugin treat it as debug mode, so it uses debug Dart artifacts.
|
||||
create("minified") {
|
||||
initWith(getByName("release"))
|
||||
isDebuggable = true
|
||||
// Resource shrinking is orthogonal to the reachability this variant guards and
|
||||
// would only slow the instrumentation build down.
|
||||
isShrinkResources = false
|
||||
testProguardFiles("proguard-test-rules.pro")
|
||||
proguardFile("proguard-instrumentation-rules.pro")
|
||||
// Release has no signing config unless key.properties exists, which would leave
|
||||
// this variant unsigned and uninstallable in CI.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
// Plugin subprojects only publish debug and release variants.
|
||||
matchingFallbacks += listOf("debug", "release")
|
||||
ndk {
|
||||
debugSymbolLevel = "NONE"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Instrumentation normally runs against `debug`; the R8 reachability gate opts into the
|
||||
// minified variant with -Pplezy.testBuildType=minified. Only one build type can host
|
||||
// androidTest, and the existing playback suites need media3 builder APIs the app itself
|
||||
// never calls — which R8 legitimately shrinks — so they stay on debug.
|
||||
testBuildType = (findProperty("plezy.testBuildType") as String?) ?: "debug"
|
||||
|
||||
packaging {
|
||||
jniLibs {
|
||||
// Resolve conflict between libass-android and libmpv native libraries
|
||||
// pickFirst only suppresses the duplicate libc++ merge error; the
|
||||
// sourceSets rule below makes libmpv's newer runtime win for
|
||||
// std::from_chars<float>, while older native consumers remain ABI-compatible.
|
||||
pickFirsts.add("lib/*/libc++_shared.so")
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
// PROJECT-scope jniLibs merge ahead of subprojects/AARs, so dependency
|
||||
// order cannot accidentally select the older libc++ copy.
|
||||
jniLibs.srcDir(File(mpvDir, "libcxx/jni"))
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
// Enforce the app-owned minSdk boundary without auditing upstream AndroidX.
|
||||
checkDependencies = false
|
||||
checkOnly += setOf("NewApi")
|
||||
}
|
||||
}
|
||||
|
||||
// BackgroundWorkDiagnostics routes users to background_downloader's private
|
||||
// notification channel. Fail the build if an upstream ref changes that ID.
|
||||
val verifyBackgroundDownloaderNotificationChannel = tasks.register("verifyBackgroundDownloaderNotificationChannel") {
|
||||
val expectedChannelId = "background_downloader"
|
||||
val downloaderProject = rootProject.findProject(":background_downloader")
|
||||
val notificationsSource = downloaderProject?.projectDir?.resolve(
|
||||
"src/main/kotlin/com/bbflight/background_downloader/Notifications.kt"
|
||||
)
|
||||
notificationsSource?.let(inputs::file)
|
||||
doLast {
|
||||
if (notificationsSource == null) {
|
||||
logger.lifecycle("Skipping downloader channel verification: Flutter plugin project is not configured")
|
||||
return@doLast
|
||||
}
|
||||
val actualChannelId = Regex(
|
||||
"""private const val notificationChannelId\s*=\s*"([^"]+)""""
|
||||
).find(notificationsSource.readText())?.groupValues?.get(1)
|
||||
?: throw GradleException("Could not locate background_downloader's notification channel ID")
|
||||
if (actualChannelId != expectedChannelId) {
|
||||
throw GradleException(
|
||||
"background_downloader channel ID changed from $expectedChannelId to $actualChannelId; " +
|
||||
"update BackgroundWorkDiagnostics and its tests"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
tasks.named("preBuild").configure {
|
||||
dependsOn(verifyBackgroundDownloaderNotificationChannel)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_17)
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
// Download libdovi before any CMake/native build task
|
||||
tasks.matching { it.name.contains("CMake") || it.name.contains("externalNative") }.configureEach {
|
||||
dependsOn(downloadLibdovi)
|
||||
dependsOn(downloadLibdovi, prepareMpvFfmpegDevelopment)
|
||||
}
|
||||
|
||||
// Download libmpv and libass AARs before compilation
|
||||
tasks.matching { it.name.startsWith("pre") && it.name.endsWith("Build") }.configureEach {
|
||||
dependsOn(downloadLibmpv)
|
||||
dependsOn(downloadLibass)
|
||||
dependsOn(downloadLibmpv, extractMpvLibcxx, prepareMpvFfmpegDevelopment)
|
||||
}
|
||||
// Gradle snapshots jniLibs source dirs before task execution; this keeps the
|
||||
// extracted libmpv libc++ directory present during input discovery.
|
||||
tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }.configureEach {
|
||||
dependsOn(extractMpvLibcxx)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(files(File(mpvDir, mpvAar)))
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
|
||||
|
||||
// Android TV Watch Next integration
|
||||
implementation("androidx.tvprovider:tvprovider:1.0.0")
|
||||
implementation("androidx.tvprovider:tvprovider:1.1.0")
|
||||
|
||||
// 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")
|
||||
implementation("androidx.media3:media3-decoder:$media3Version")
|
||||
implementation("androidx.media3:media3-exoplayer:$media3Version")
|
||||
implementation("androidx.media3:media3-exoplayer-hls:$media3Version")
|
||||
implementation("androidx.media3:media3-ui:$media3Version")
|
||||
implementation("androidx.media3:media3-common:$media3Version")
|
||||
|
||||
// Cronet for HTTP/2 multiplexing + better connection management
|
||||
implementation("androidx.media3:media3-datasource-cronet:1.9.2")
|
||||
implementation("androidx.media3:media3-datasource-cronet:$media3Version")
|
||||
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")
|
||||
// Keeping libass in-project lets its static core share the app's native
|
||||
// packaging rules.
|
||||
implementation(project(":libass"))
|
||||
|
||||
// libass-android for ASS/SSA subtitle rendering
|
||||
assAars.forEach { implementation(files(File(assDir, it))) }
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
// Real android.util.* implementations for tests exercising media3 classes
|
||||
// (MatroskaExtractor uses SparseArray, which is a no-op stub on plain JVM)
|
||||
testImplementation("org.robolectric:robolectric:4.16.1")
|
||||
androidTestImplementation("androidx.test:runner:1.7.0")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.3.0")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Applied only to the `minified` variant, which exists to run R8 over the app under test.
|
||||
#
|
||||
# The instrumentation runner is loaded through the tested app's class loader, and its
|
||||
# supertypes resolve from the app APK. Shrinking androidx.test there leaves the harness
|
||||
# with an AndroidJUnitRunner it cannot link, and the run dies with ClassNotFoundException
|
||||
# before a single test starts — reported as `tests="0"`, which is easy to mistake for a
|
||||
# passing gate. No shipped build includes this file.
|
||||
-keep class androidx.test.** { *; }
|
||||
-dontwarn androidx.test.**
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
# Flutter turns minification on for every release build (FlutterPlugin sets
|
||||
# releaseBuildType.isMinifyEnabled), and appends this file when it exists. Anything the
|
||||
# app reaches only by name — reflection or JNI — therefore needs an explicit keep here.
|
||||
|
||||
# The bundled Media3 FFmpeg audio decoder (ALAC, DTS, DTS-HD, TrueHD, ...).
|
||||
#
|
||||
# DefaultRenderersFactory instantiates FfmpegAudioRenderer through Class.forName and no
|
||||
# app code references it, so R8 shrinks the class away; media3's own consumer rules only
|
||||
# -keepclassmembers its constructor, which neither keeps the class nor pins its name.
|
||||
# ffmpeg_jni.cc separately resolves FfmpegAudioDecoder and its growOutputBuffer callback
|
||||
# by name in JNI_OnLoad, and returns JNI_ERR when either is missing, which fails the whole
|
||||
# System.loadLibrary("ffmpegJNI") call.
|
||||
#
|
||||
# Without these keeps a release build silently loses every codec this decoder adds:
|
||||
# TrueHD/DTS-HD land on MediaCodecAudioRenderer, which has no decoder for them, and
|
||||
# playback bails to the mpv fallback and loses ExoPlayer's Dolby Vision handling (#1703).
|
||||
-keep class androidx.media3.decoder.ffmpeg.** { *; }
|
||||
|
||||
# growOutputBuffer's JNI descriptor names this type, so it may not be renamed either.
|
||||
-keep class androidx.media3.decoder.SimpleDecoderOutputBuffer { *; }
|
||||
@@ -0,0 +1,11 @@
|
||||
# Shrinker rules for the instrumentation APK of the `minified` variant.
|
||||
#
|
||||
# That variant exists to run R8 over the app under test, not over the harness. AGP
|
||||
# applies the app's rules to the test APK too, which deletes the instrumentation runner
|
||||
# and every test class the runner resolves by name — the run then dies with
|
||||
# ClassNotFoundException before a single test starts. The harness is never shipped, so
|
||||
# it has nothing to gain from shrinking.
|
||||
-dontshrink
|
||||
-dontoptimize
|
||||
# The runner resolves the instrumentation class and every -e class filter by name.
|
||||
-dontobfuscate
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+160
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Plezy contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package androidx.media3.decoder.ffmpeg;
|
||||
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
import android.os.Handler;
|
||||
import android.os.HandlerThread;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.media3.common.PlaybackException;
|
||||
import androidx.media3.common.Player;
|
||||
import androidx.media3.datasource.DefaultDataSource;
|
||||
import androidx.media3.exoplayer.ExoPlayer;
|
||||
import androidx.media3.exoplayer.Renderer;
|
||||
import androidx.media3.exoplayer.RenderersFactory;
|
||||
import androidx.media3.exoplayer.audio.DefaultAudioSink;
|
||||
import androidx.media3.exoplayer.source.MediaSource;
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource;
|
||||
import androidx.media3.extractor.DefaultExtractorsFactory;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class PlezyFfmpegPlaybackTest {
|
||||
private static final String[] FIXTURES = {
|
||||
"ffmpeg/stereo.flac",
|
||||
"ffmpeg/surround_5_1.flac",
|
||||
"ffmpeg/surround_7_1.flac",
|
||||
"ffmpeg/planar_5_1.m4a",
|
||||
"ffmpeg/surround_5_1_eac3.mka",
|
||||
"ffmpeg/surround_5_1_dts.mka",
|
||||
"ffmpeg/surround_5_1_truehd.mka"
|
||||
};
|
||||
|
||||
@Test
|
||||
public void sharedDecoderUsesLibmpvFfmpegAndPlaysAllFixtures() throws Exception {
|
||||
assertTrue("FFmpeg JNI library is unavailable", FfmpegLibrary.isAvailable());
|
||||
String version = FfmpegLibrary.getVersion();
|
||||
assertTrue("Expected libmpv's FFmpeg 8, got " + version, version != null && version.startsWith("Lavc62."));
|
||||
for (String fixture : FIXTURES) {
|
||||
playToEnd(fixture);
|
||||
}
|
||||
}
|
||||
|
||||
private static void playToEnd(String fixture) throws Exception {
|
||||
Context instrumentationContext = InstrumentationRegistry.getInstrumentation().getContext();
|
||||
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
File fixtureFile = copyFixture(instrumentationContext, context, fixture);
|
||||
HandlerThread playbackThread = new HandlerThread("ffmpeg-test-player");
|
||||
playbackThread.start();
|
||||
Handler handler = new Handler(playbackThread.getLooper());
|
||||
CountDownLatch completed = new CountDownLatch(1);
|
||||
AtomicReference<ExoPlayer> playerReference = new AtomicReference<>();
|
||||
AtomicReference<Throwable> errorReference = new AtomicReference<>();
|
||||
|
||||
handler.post(
|
||||
() -> {
|
||||
try {
|
||||
RenderersFactory renderersFactory =
|
||||
(eventHandler,
|
||||
videoRendererEventListener,
|
||||
audioRendererEventListener,
|
||||
textRendererOutput,
|
||||
metadataRendererOutput) ->
|
||||
new Renderer[] {
|
||||
new FfmpegAudioRenderer(
|
||||
eventHandler, audioRendererEventListener, new DefaultAudioSink.Builder().build())
|
||||
};
|
||||
ExoPlayer player =
|
||||
new ExoPlayer.Builder(context, renderersFactory)
|
||||
.setLooper(playbackThread.getLooper())
|
||||
.build();
|
||||
playerReference.set(player);
|
||||
player.addListener(
|
||||
new Player.Listener() {
|
||||
@Override
|
||||
public void onPlayerError(PlaybackException error) {
|
||||
errorReference.set(error);
|
||||
completed.countDown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlaybackStateChanged(@Player.State int playbackState) {
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
completed.countDown();
|
||||
}
|
||||
}
|
||||
});
|
||||
MediaSource source =
|
||||
new ProgressiveMediaSource.Factory(
|
||||
new DefaultDataSource.Factory(context), new DefaultExtractorsFactory())
|
||||
.createMediaSource(MediaItem.fromUri(Uri.fromFile(fixtureFile)));
|
||||
player.setMediaSource(source);
|
||||
player.prepare();
|
||||
player.play();
|
||||
} catch (Throwable error) {
|
||||
errorReference.set(error);
|
||||
completed.countDown();
|
||||
}
|
||||
});
|
||||
|
||||
boolean finished = completed.await(20, TimeUnit.SECONDS);
|
||||
CountDownLatch released = new CountDownLatch(1);
|
||||
handler.post(
|
||||
() -> {
|
||||
@Nullable ExoPlayer player = playerReference.get();
|
||||
if (player != null) player.release();
|
||||
playbackThread.quitSafely();
|
||||
released.countDown();
|
||||
});
|
||||
boolean teardownFinished = released.await(5, TimeUnit.SECONDS);
|
||||
playbackThread.join(5000);
|
||||
boolean fixtureDeleted = fixtureFile.delete();
|
||||
|
||||
assertTrue("Player teardown timed out for " + fixture, teardownFinished);
|
||||
assertTrue("Playback timed out for " + fixture, finished);
|
||||
assertNull("Playback failed for " + fixture, errorReference.get());
|
||||
assertTrue("Fixture cleanup failed for " + fixture, fixtureDeleted);
|
||||
}
|
||||
|
||||
private static File copyFixture(
|
||||
Context instrumentationContext, Context targetContext, String fixture) throws Exception {
|
||||
File output = File.createTempFile("ffmpeg-fixture-", null, targetContext.getCacheDir());
|
||||
try (InputStream input = instrumentationContext.getAssets().open(fixture);
|
||||
OutputStream sink = new FileOutputStream(output)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int count;
|
||||
while ((count = input.read(buffer)) != -1) {
|
||||
sink.write(buffer, 0, count);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package androidx.media3.decoder.ffmpeg
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.audio.AudioRendererEventListener
|
||||
import androidx.media3.exoplayer.video.VideoRendererEventListener
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.edde746.plezy.exoplayer.PlezyRenderersFactory
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Asserts the bundled FFmpeg audio decoder is reachable the way production reaches it.
|
||||
*
|
||||
* Every path here is name-based, so R8 can sever it while the code still compiles and
|
||||
* every debug check passes. #1703 shipped exactly that: the shrinker dropped
|
||||
* FfmpegAudioRenderer and FfmpegAudioDecoder.growOutputBuffer, TrueHD and DTS-HD lost
|
||||
* their only decoder, and 4K Dolby Vision files bailed to the mpv fallback.
|
||||
*
|
||||
* Run this against the `minified` build type (`-Pplezy.testBuildType=minified`); on an
|
||||
* unminified variant it can only ever pass. Deliberately touches no ExoPlayer builder
|
||||
* API, so no keep rule beyond the ones under test has to exist for it to run.
|
||||
*
|
||||
* Emptying `proguard-rules.pro` was verified to fail
|
||||
* [nativeLibraryLoadsAndReportsTheCodecsOnlyItCanDecode]; the renderer-list assertion
|
||||
* kept passing, because something else in this variant still retains that class. Treat
|
||||
* the JNI assertion as the load-bearing one, and `scripts/check_shrinker_rules.py` as the
|
||||
* guard for the renderer's own keep.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class FfmpegDecoderReachabilityTest {
|
||||
|
||||
@Test
|
||||
fun productionRendererListIncludesTheFfmpegAudioRenderer() {
|
||||
// Goes through the app's own factory rather than repeating media3's Class.forName:
|
||||
// the instrumentation APK shares a class loader with the app, so a direct reflective
|
||||
// lookup can resolve a copy the harness carries even when the app APK lost its own.
|
||||
// DefaultRenderersFactory swallows ClassNotFoundException as "built without the
|
||||
// extension", so a shrunk renderer leaves no trace but missing codecs.
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val factory = PlezyRenderersFactory(context)
|
||||
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
val names = factory.createRenderers(
|
||||
handler,
|
||||
object : VideoRendererEventListener {},
|
||||
object : AudioRendererEventListener {},
|
||||
{ },
|
||||
{ }
|
||||
).map { it.name }
|
||||
|
||||
assertTrue("FfmpegAudioRenderer missing from $names", names.contains("FfmpegAudioRenderer"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nativeLibraryLoadsAndReportsTheCodecsOnlyItCanDecode() {
|
||||
// isAvailable() covers the whole JNI handshake: the shared library loads, JNI_OnLoad
|
||||
// resolves FfmpegAudioDecoder by name, and GetMethodID finds growOutputBuffer with a
|
||||
// descriptor naming SimpleDecoderOutputBuffer. Any of those renamed or shrunk away
|
||||
// makes this false.
|
||||
assertTrue("FFmpeg JNI library is unavailable", FfmpegLibrary.isAvailable())
|
||||
|
||||
// The formats MediaCodec has no decoder for on the affected devices.
|
||||
assertTrue("no truehd decoder", FfmpegLibrary.supportsFormat(MimeTypes.AUDIO_TRUEHD))
|
||||
assertTrue("no dts-hd decoder", FfmpegLibrary.supportsFormat(MimeTypes.AUDIO_DTS_HD))
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.audio.ChannelMixingMatrix
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.ProgressiveMediaSource
|
||||
import androidx.media3.extractor.DefaultExtractorsFactory
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import java.io.File
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PlezyAudioModePlaybackTest {
|
||||
private enum class AudioMode {
|
||||
PASSTHROUGH_ALLOWED,
|
||||
FORCE_DECODED,
|
||||
DOWNMIX_NORMALIZED,
|
||||
DOWNMIX_UNNORMALIZED,
|
||||
NORMALIZATION
|
||||
}
|
||||
|
||||
@Test
|
||||
fun appAudioPipelinePlaysAcrossOutputModes() {
|
||||
playToEnd("ffmpeg/surround_5_1_dts.mka", AudioMode.PASSTHROUGH_ALLOWED)
|
||||
playToEnd("ffmpeg/surround_5_1_truehd.mka", AudioMode.FORCE_DECODED)
|
||||
playToEnd("ffmpeg/surround_5_1.flac", AudioMode.DOWNMIX_NORMALIZED)
|
||||
playToEnd("ffmpeg/surround_7_1.flac", AudioMode.DOWNMIX_UNNORMALIZED)
|
||||
playToEnd("ffmpeg/surround_5_1_eac3.mka", AudioMode.NORMALIZATION)
|
||||
}
|
||||
|
||||
private fun playToEnd(fixture: String, mode: AudioMode) {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
val fixtureFile = copyFixture(instrumentation.context, context, fixture)
|
||||
val playbackThread = HandlerThread("plezy-audio-mode-test").apply { start() }
|
||||
val handler = Handler(playbackThread.looper)
|
||||
val completed = CountDownLatch(1)
|
||||
val playerReference = AtomicReference<ExoPlayer>()
|
||||
val errorReference = AtomicReference<Throwable>()
|
||||
val outputPolicyConsulted = AtomicBoolean(false)
|
||||
val normalizationAttachAttempted = AtomicBoolean(false)
|
||||
val normalization = AudioNormalizationEffect { _, _, _ -> }
|
||||
val downmixActive = AtomicBoolean(false)
|
||||
|
||||
handler.post {
|
||||
try {
|
||||
val factory = PlezyRenderersFactory(context).apply {
|
||||
setEnableDecoderFallback(true)
|
||||
setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
|
||||
shouldBlockDirectAudioOutput = { format ->
|
||||
val encoded = format.sampleMimeType != null && format.sampleMimeType != MimeTypes.AUDIO_RAW
|
||||
if (encoded) outputPolicyConsulted.set(true)
|
||||
encoded && mode != AudioMode.PASSTHROUGH_ALLOWED
|
||||
}
|
||||
if (mode == AudioMode.DOWNMIX_NORMALIZED || mode == AudioMode.DOWNMIX_UNNORMALIZED) {
|
||||
val normalize = mode == AudioMode.DOWNMIX_NORMALIZED
|
||||
for (channelCount in DownmixMatrices.MIN_DOWNMIX_INPUT_CHANNELS..DownmixMatrices.MAX_DOWNMIX_INPUT_CHANNELS) {
|
||||
val coefficients = DownmixMatrices.stereoCoefficients(channelCount, centerBoostDb = 6, normalize = normalize)!!
|
||||
channelMixProcessor.putChannelMixingMatrix(ChannelMixingMatrix(channelCount, 2, coefficients))
|
||||
}
|
||||
}
|
||||
}
|
||||
val player = ExoPlayer.Builder(context, factory)
|
||||
.setLooper(playbackThread.looper)
|
||||
.build()
|
||||
playerReference.set(player)
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onAudioSessionIdChanged(audioSessionId: Int) {
|
||||
if (mode == AudioMode.NORMALIZATION) {
|
||||
normalizationAttachAttempted.set(true)
|
||||
normalization.attach(audioSessionId, channelCount = 6)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
errorReference.set(error)
|
||||
completed.countDown()
|
||||
}
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
downmixActive.set(factory.channelMixProcessor.isActive)
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
})
|
||||
val source = ProgressiveMediaSource.Factory(
|
||||
DefaultDataSource.Factory(context),
|
||||
DefaultExtractorsFactory()
|
||||
).createMediaSource(MediaItem.fromUri(Uri.fromFile(fixtureFile)))
|
||||
player.setMediaSource(source)
|
||||
player.prepare()
|
||||
player.play()
|
||||
} catch (error: Throwable) {
|
||||
errorReference.set(error)
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
val finished = completed.await(20, TimeUnit.SECONDS)
|
||||
val released = CountDownLatch(1)
|
||||
handler.post {
|
||||
playerReference.get()?.release()
|
||||
normalization.release()
|
||||
playbackThread.quitSafely()
|
||||
released.countDown()
|
||||
}
|
||||
val teardownFinished = released.await(5, TimeUnit.SECONDS)
|
||||
playbackThread.join(5_000)
|
||||
val fixtureDeleted = fixtureFile.delete()
|
||||
|
||||
assertTrue("Player teardown timed out for $mode / $fixture", teardownFinished)
|
||||
assertTrue("Playback timed out for $mode / $fixture", finished)
|
||||
assertNull("Playback failed for $mode / $fixture", errorReference.get())
|
||||
assertTrue("Audio output policy was not consulted for $mode / $fixture", outputPolicyConsulted.get())
|
||||
if (mode == AudioMode.DOWNMIX_NORMALIZED || mode == AudioMode.DOWNMIX_UNNORMALIZED) {
|
||||
assertTrue("Downmix processor was inactive for $mode / $fixture", downmixActive.get())
|
||||
}
|
||||
if (mode == AudioMode.NORMALIZATION) {
|
||||
assertTrue("Normalization did not receive an audio session", normalizationAttachAttempted.get())
|
||||
}
|
||||
assertTrue("Fixture cleanup failed for $mode / $fixture", fixtureDeleted)
|
||||
}
|
||||
|
||||
private fun copyFixture(instrumentationContext: Context, targetContext: Context, fixture: String): File {
|
||||
val output = File.createTempFile("audio-mode-fixture-", null, targetContext.cacheDir)
|
||||
instrumentationContext.assets.open(fixture).use { input ->
|
||||
output.outputStream().use(input::copyTo)
|
||||
}
|
||||
return output
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
<!-- Internet access permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||
|
||||
|
||||
<!-- PIP and media session foreground service permissions -->
|
||||
@@ -27,6 +28,7 @@
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
<!-- Android TV Watch Next integration -->
|
||||
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<!-- Touchscreen not required for TV -->
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
|
||||
@@ -40,6 +42,7 @@
|
||||
android:banner="@drawable/tv_banner"
|
||||
android:appCategory="video"
|
||||
android:largeHeap="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
@@ -48,7 +51,7 @@
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:supportsPictureInPicture="true"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|navigation|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
@@ -59,6 +62,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"/>
|
||||
@@ -83,6 +89,21 @@
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_provider_paths" />
|
||||
</provider>
|
||||
<!-- Launcher shelf artwork. Not exported: readers reach it only through the
|
||||
per-URI read grants issued to CATEGORY_HOME packages by WatchNextProvider. -->
|
||||
<provider
|
||||
android:name=".watchnext.SystemShelfArtworkProvider"
|
||||
android:authorities="com.edde746.plezy.systemshelf.artwork"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true" />
|
||||
<receiver
|
||||
android:name=".watchnext.SystemShelfUpdateReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
@@ -113,5 +134,10 @@
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:mimeType="video/*" />
|
||||
</intent>
|
||||
<!-- Required to identify HOME launchers as shelf artwork consumers. -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.HOME" />
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
cmake_minimum_required(VERSION 3.22.1)
|
||||
project(dovi_bridge)
|
||||
project(plezy_native)
|
||||
|
||||
option(DOVI_ENABLE_LIBDOVI "Link real libdovi" ON)
|
||||
set(DOVI_LIBDOVI_PREBUILT_ROOT "" CACHE PATH "Path to prebuilt libdovi")
|
||||
set(MPV_FFMPEG_ROOT "" CACHE PATH "Path to libmpv's FFmpeg headers and shared libraries")
|
||||
|
||||
add_library(dovi_bridge SHARED dovi_bridge.cpp)
|
||||
|
||||
@@ -17,3 +18,22 @@ else()
|
||||
target_compile_definitions(dovi_bridge PRIVATE DOVI_REAL_LINKED=0)
|
||||
target_link_libraries(dovi_bridge log)
|
||||
endif()
|
||||
|
||||
foreach(ffmpeg_library avcodec avutil swresample)
|
||||
add_library(${ffmpeg_library} SHARED IMPORTED)
|
||||
set_target_properties(${ffmpeg_library} PROPERTIES
|
||||
IMPORTED_LOCATION "${MPV_FFMPEG_ROOT}/native/${ANDROID_ABI}/lib${ffmpeg_library}.so")
|
||||
endforeach()
|
||||
|
||||
add_library(ffmpegJNI SHARED
|
||||
media3_ffmpeg_decoder/ffmpeg_jni.cc)
|
||||
target_compile_features(ffmpegJNI PRIVATE cxx_std_17)
|
||||
target_include_directories(ffmpegJNI PRIVATE
|
||||
"${MPV_FFMPEG_ROOT}/include"
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/media3_ffmpeg_decoder")
|
||||
target_link_libraries(ffmpegJNI
|
||||
avcodec
|
||||
avutil
|
||||
swresample
|
||||
android
|
||||
log)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#include <jni.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <new>
|
||||
#include <vector>
|
||||
|
||||
@@ -18,6 +19,7 @@ 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;
|
||||
static constexpr int MAX_ERROR_LOG_LENGTH = 256;
|
||||
|
||||
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,
|
||||
@@ -62,60 +64,47 @@ extern "C" JNIEXPORT jint JNICALL Java_com_edde746_plezy_exoplayer_DoviBridge_na
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
// Try dovi_parse_unspec62_nalu first (handles escaped NALs), fallback to dovi_parse_rpu
|
||||
// The input is a complete (possibly escaped) HEVC UNSPEC62 NAL. Parsing it as
|
||||
// a raw RPU after a framed-parser error can reinterpret malformed/truncated
|
||||
// NAL bytes as valid metadata.
|
||||
const auto rpu_len = static_cast<size_t>(payload_length);
|
||||
DoviRpuOpaque* rpu = dovi_parse_unspec62_nalu(scratch.data(), rpu_len);
|
||||
using RpuPtr = std::unique_ptr<DoviRpuOpaque, decltype(&dovi_rpu_free)>;
|
||||
RpuPtr rpu(dovi_parse_unspec62_nalu(scratch.data(), rpu_len), dovi_rpu_free);
|
||||
|
||||
if (rpu == nullptr) {
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
const char* err = dovi_rpu_get_error(rpu);
|
||||
const char* err = dovi_rpu_get_error(rpu.get());
|
||||
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) {
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
err = dovi_rpu_get_error(rpu);
|
||||
if (err != nullptr) {
|
||||
LOGW("RPU parse failed: %s", err);
|
||||
dovi_rpu_free(rpu);
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
LOGW("RPU NAL parse failed: %.*s", MAX_ERROR_LOG_LENGTH, err);
|
||||
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));
|
||||
// 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.get(), 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);
|
||||
err = dovi_rpu_get_error(rpu.get());
|
||||
LOGW("RPU conversion failed (mode %d): %.*s", mode, MAX_ERROR_LOG_LENGTH, err ? err : "unknown");
|
||||
return CONVERT_FAILED;
|
||||
}
|
||||
|
||||
// Write back as UNSPEC62 NAL
|
||||
const DoviData* out = dovi_write_unspec62_nalu(rpu);
|
||||
using DoviDataPtr = std::unique_ptr<const DoviData, decltype(&dovi_data_free)>;
|
||||
DoviDataPtr out(dovi_write_unspec62_nalu(rpu.get()), dovi_data_free);
|
||||
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);
|
||||
err = dovi_rpu_get_error(rpu.get());
|
||||
LOGW("RPU write failed: %.*s", MAX_ERROR_LOG_LENGTH, err ? err : "unknown");
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -124,9 +113,6 @@ extern "C" JNIEXPORT jint JNICALL Java_com_edde746_plezy_exoplayer_DoviBridge_na
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Plezy contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
#ifndef PLEZY_FFMPEG_AUDIO_BUFFER_H_
|
||||
#define PLEZY_FFMPEG_AUDIO_BUFFER_H_
|
||||
|
||||
#include <limits.h>
|
||||
#include <stdint.h>
|
||||
|
||||
namespace plezy {
|
||||
namespace ffmpeg {
|
||||
|
||||
inline bool CheckedAudioByteCount(int sample_count, int channel_count, int bytes_per_sample, int* byte_count) {
|
||||
if (sample_count < 0 || channel_count <= 0 || bytes_per_sample <= 0 || byte_count == nullptr) {
|
||||
return false;
|
||||
}
|
||||
const int64_t size = static_cast<int64_t>(sample_count) * channel_count * bytes_per_sample;
|
||||
if (size > INT_MAX) {
|
||||
return false;
|
||||
}
|
||||
*byte_count = static_cast<int>(size);
|
||||
return true;
|
||||
}
|
||||
|
||||
inline bool CheckedAddByteCount(int current_size, int additional_size, int* total_size) {
|
||||
if (current_size < 0 || additional_size < 0 || total_size == nullptr || current_size > INT_MAX - additional_size) {
|
||||
return false;
|
||||
}
|
||||
*total_size = current_size + additional_size;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace ffmpeg
|
||||
} // namespace plezy
|
||||
|
||||
#endif // PLEZY_FFMPEG_AUDIO_BUFFER_H_
|
||||
@@ -0,0 +1,508 @@
|
||||
/*
|
||||
* Copyright (C) 2016 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
#include <android/log.h>
|
||||
#include <jni.h>
|
||||
|
||||
#include "ffmpeg_audio_buffer.h"
|
||||
|
||||
extern "C" {
|
||||
#ifdef __cplusplus
|
||||
#define __STDC_CONSTANT_MACROS
|
||||
#ifdef _STDINT_H
|
||||
#undef _STDINT_H
|
||||
#endif
|
||||
#include <stdint.h>
|
||||
#endif
|
||||
#include <libavcodec/avcodec.h>
|
||||
#include <libavutil/channel_layout.h>
|
||||
#include <libavutil/error.h>
|
||||
#include <libavutil/opt.h>
|
||||
#include <libswresample/swresample.h>
|
||||
}
|
||||
|
||||
#define LOG_TAG "ffmpeg_jni"
|
||||
#define LOGE(...) ((void)__android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__))
|
||||
#define LOGD(...) ((void)__android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__))
|
||||
|
||||
// clang-format 18 and 21 disagree on escaped-newline alignment in these JNI macros.
|
||||
// clang-format off
|
||||
#define LIBRARY_FUNC(RETURN_TYPE, NAME, ...) \
|
||||
extern "C" { \
|
||||
JNIEXPORT RETURN_TYPE \
|
||||
Java_androidx_media3_decoder_ffmpeg_FfmpegLibrary_##NAME(JNIEnv* env, jobject thiz, ##__VA_ARGS__); \
|
||||
} \
|
||||
JNIEXPORT RETURN_TYPE Java_androidx_media3_decoder_ffmpeg_FfmpegLibrary_##NAME( \
|
||||
JNIEnv* env, jobject thiz, ##__VA_ARGS__)
|
||||
|
||||
#define AUDIO_DECODER_FUNC(RETURN_TYPE, NAME, ...) \
|
||||
extern "C" { \
|
||||
JNIEXPORT RETURN_TYPE \
|
||||
Java_androidx_media3_decoder_ffmpeg_FfmpegAudioDecoder_##NAME(JNIEnv* env, jobject thiz, ##__VA_ARGS__); \
|
||||
} \
|
||||
JNIEXPORT RETURN_TYPE Java_androidx_media3_decoder_ffmpeg_FfmpegAudioDecoder_##NAME( \
|
||||
JNIEnv* env, jobject thiz, ##__VA_ARGS__)
|
||||
// clang-format on
|
||||
|
||||
#define ERROR_STRING_BUFFER_LENGTH 256
|
||||
|
||||
// Output format corresponding to AudioFormat.ENCODING_PCM_16BIT.
|
||||
static const AVSampleFormat OUTPUT_FORMAT_PCM_16BIT = AV_SAMPLE_FMT_S16;
|
||||
// Output format corresponding to AudioFormat.ENCODING_PCM_FLOAT.
|
||||
static const AVSampleFormat OUTPUT_FORMAT_PCM_FLOAT = AV_SAMPLE_FMT_FLT;
|
||||
|
||||
// LINT.IfChange
|
||||
static const int AUDIO_DECODER_ERROR_INVALID_DATA = -1;
|
||||
static const int AUDIO_DECODER_ERROR_OTHER = -2;
|
||||
// LINT.ThenChange(../java/androidx/media3/decoder/ffmpeg/FfmpegAudioDecoder.java)
|
||||
|
||||
namespace {
|
||||
struct ResampleState {
|
||||
SwrContext* context;
|
||||
AVChannelLayout input_channel_layout;
|
||||
AVSampleFormat input_sample_format;
|
||||
AVSampleFormat output_sample_format;
|
||||
int sample_rate;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
static bool resampleConfigurationMatches(
|
||||
const ResampleState* state, const AVCodecContext* context, const AVFrame* frame);
|
||||
static int configureResampler(ResampleState* state, const AVCodecContext* context, const AVFrame* frame);
|
||||
static void releaseResampleState(ResampleState* state);
|
||||
|
||||
static jmethodID growOutputBufferMethod;
|
||||
|
||||
/**
|
||||
* Returns the AVCodec with the specified name, or NULL if it is not available.
|
||||
*/
|
||||
static const AVCodec* getCodecByName(JNIEnv* env, jstring codecName);
|
||||
|
||||
/**
|
||||
* Allocates and opens a new AVCodecContext for the specified codec, passing the
|
||||
* provided extraData as initialization data for the decoder if it is non-NULL.
|
||||
* Returns the created context.
|
||||
*/
|
||||
static AVCodecContext* createContext(
|
||||
JNIEnv* env, const AVCodec* codec, jbyteArray extraData, jboolean outputFloat, jint rawSampleRate,
|
||||
jint rawChannelCount);
|
||||
|
||||
namespace {
|
||||
struct GrowOutputBufferCallback {
|
||||
uint8_t* operator()(int requiredSize) const;
|
||||
|
||||
JNIEnv* env;
|
||||
jobject thiz;
|
||||
jobject decoderOutputBuffer;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
/**
|
||||
* Decodes the packet into the output buffer, returning the number of bytes
|
||||
* written, or a negative AUDIO_DECODER_ERROR constant value in the case of an
|
||||
* error.
|
||||
*/
|
||||
static int decodePacket(
|
||||
AVCodecContext* context, AVPacket* packet, uint8_t* outputBuffer, int outputSize,
|
||||
GrowOutputBufferCallback growBuffer);
|
||||
|
||||
/**
|
||||
* Transforms ffmpeg AVERROR into a negative AUDIO_DECODER_ERROR constant value.
|
||||
*/
|
||||
static int transformError(int errorNumber);
|
||||
|
||||
/**
|
||||
* Outputs a log message describing the avcodec error number.
|
||||
*/
|
||||
static void logError(const char* functionName, int errorNumber);
|
||||
|
||||
/**
|
||||
* Releases the specified context.
|
||||
*/
|
||||
static void releaseContext(AVCodecContext* context);
|
||||
|
||||
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
|
||||
JNIEnv* env;
|
||||
if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
|
||||
LOGE("JNI_OnLoad: GetEnv failed");
|
||||
return -1;
|
||||
}
|
||||
jclass clazz = env->FindClass("androidx/media3/decoder/ffmpeg/FfmpegAudioDecoder");
|
||||
if (!clazz) {
|
||||
LOGE("JNI_OnLoad: FindClass failed");
|
||||
return -1;
|
||||
}
|
||||
growOutputBufferMethod = env->GetMethodID(
|
||||
clazz, "growOutputBuffer",
|
||||
"(Landroidx/media3/decoder/"
|
||||
"SimpleDecoderOutputBuffer;I)Ljava/nio/ByteBuffer;");
|
||||
if (!growOutputBufferMethod) {
|
||||
LOGE("JNI_OnLoad: GetMethodID failed");
|
||||
return -1;
|
||||
}
|
||||
return JNI_VERSION_1_6;
|
||||
}
|
||||
|
||||
LIBRARY_FUNC(jstring, ffmpegGetVersion) { return env->NewStringUTF(LIBAVCODEC_IDENT); }
|
||||
|
||||
LIBRARY_FUNC(jint, ffmpegGetInputBufferPaddingSize) { return (jint)AV_INPUT_BUFFER_PADDING_SIZE; }
|
||||
|
||||
LIBRARY_FUNC(jboolean, ffmpegHasDecoder, jstring codecName) { return getCodecByName(env, codecName) != nullptr; }
|
||||
|
||||
AUDIO_DECODER_FUNC(
|
||||
jlong, ffmpegInitialize, jstring codecName, jbyteArray extraData, jboolean outputFloat, jint rawSampleRate,
|
||||
jint rawChannelCount) {
|
||||
const AVCodec* codec = getCodecByName(env, codecName);
|
||||
if (!codec) {
|
||||
LOGE("Codec not found.");
|
||||
return 0L;
|
||||
}
|
||||
return (jlong)createContext(env, codec, extraData, outputFloat, rawSampleRate, rawChannelCount);
|
||||
}
|
||||
|
||||
AUDIO_DECODER_FUNC(
|
||||
jint, ffmpegDecode, jlong context, jobject inputData, jint inputSize, jobject decoderOutputBuffer,
|
||||
jobject outputData, jint outputSize) {
|
||||
if (!context) {
|
||||
LOGE("Context must be non-NULL.");
|
||||
return -1;
|
||||
}
|
||||
if (!inputData || !decoderOutputBuffer || !outputData) {
|
||||
LOGE("Input and output buffers must be non-NULL.");
|
||||
return -1;
|
||||
}
|
||||
if (inputSize < 0) {
|
||||
LOGE("Invalid input buffer size: %d.", inputSize);
|
||||
return -1;
|
||||
}
|
||||
if (outputSize < 0) {
|
||||
LOGE("Invalid output buffer length: %d", outputSize);
|
||||
return -1;
|
||||
}
|
||||
const jlong inputCapacity = env->GetDirectBufferCapacity(inputData);
|
||||
const jlong outputCapacity = env->GetDirectBufferCapacity(outputData);
|
||||
if (inputCapacity < inputSize || outputCapacity < outputSize) {
|
||||
LOGE("Buffer size exceeds direct buffer capacity.");
|
||||
return -1;
|
||||
}
|
||||
uint8_t* inputBuffer = (uint8_t*)env->GetDirectBufferAddress(inputData);
|
||||
uint8_t* outputBuffer = (uint8_t*)env->GetDirectBufferAddress(outputData);
|
||||
AVPacket* packet = av_packet_alloc();
|
||||
if (!packet) {
|
||||
LOGE("Failed to allocate packet.");
|
||||
return -1;
|
||||
}
|
||||
packet->data = inputBuffer;
|
||||
packet->size = inputSize;
|
||||
const int ret = decodePacket(
|
||||
(AVCodecContext*)context, packet, outputBuffer, outputSize,
|
||||
GrowOutputBufferCallback{env, thiz, decoderOutputBuffer});
|
||||
av_packet_free(&packet);
|
||||
return ret;
|
||||
}
|
||||
|
||||
uint8_t* GrowOutputBufferCallback::operator()(int requiredSize) const {
|
||||
jobject newOutputData = env->CallObjectMethod(thiz, growOutputBufferMethod, decoderOutputBuffer, requiredSize);
|
||||
if (env->ExceptionCheck()) {
|
||||
LOGE("growOutputBuffer() failed");
|
||||
env->ExceptionDescribe();
|
||||
return nullptr;
|
||||
}
|
||||
if (env->GetDirectBufferCapacity(newOutputData) < requiredSize) {
|
||||
LOGE("growOutputBuffer() returned an undersized or non-direct buffer.");
|
||||
return nullptr;
|
||||
}
|
||||
return static_cast<uint8_t*>(env->GetDirectBufferAddress(newOutputData));
|
||||
}
|
||||
|
||||
AUDIO_DECODER_FUNC(jint, ffmpegGetChannelCount, jlong context) {
|
||||
if (!context) {
|
||||
LOGE("Context must be non-NULL.");
|
||||
return -1;
|
||||
}
|
||||
return ((AVCodecContext*)context)->ch_layout.nb_channels;
|
||||
}
|
||||
|
||||
AUDIO_DECODER_FUNC(jint, ffmpegGetSampleRate, jlong context) {
|
||||
if (!context) {
|
||||
LOGE("Context must be non-NULL.");
|
||||
return -1;
|
||||
}
|
||||
return ((AVCodecContext*)context)->sample_rate;
|
||||
}
|
||||
|
||||
AUDIO_DECODER_FUNC(jlong, ffmpegReset, jlong jContext, jbyteArray extraData) {
|
||||
AVCodecContext* context = (AVCodecContext*)jContext;
|
||||
if (!context) {
|
||||
LOGE("Tried to reset without a context.");
|
||||
return 0L;
|
||||
}
|
||||
|
||||
AVCodecID codecId = context->codec_id;
|
||||
if (codecId == AV_CODEC_ID_TRUEHD) {
|
||||
jboolean outputFloat = (jboolean)(context->request_sample_fmt == OUTPUT_FORMAT_PCM_FLOAT);
|
||||
// Release and recreate the context if the codec is TrueHD.
|
||||
// TODO: Figure out why flushing doesn't work for this codec.
|
||||
releaseContext(context);
|
||||
const AVCodec* codec = avcodec_find_decoder(codecId);
|
||||
if (!codec) {
|
||||
LOGE("Unexpected error finding codec %d.", codecId);
|
||||
return 0L;
|
||||
}
|
||||
return (jlong)createContext(
|
||||
env, codec, extraData, outputFloat,
|
||||
/* rawSampleRate= */ -1,
|
||||
/* rawChannelCount= */ -1);
|
||||
}
|
||||
|
||||
avcodec_flush_buffers(context);
|
||||
return (jlong)context;
|
||||
}
|
||||
|
||||
AUDIO_DECODER_FUNC(void, ffmpegRelease, jlong context) {
|
||||
if (context) {
|
||||
releaseContext((AVCodecContext*)context);
|
||||
}
|
||||
}
|
||||
|
||||
static const AVCodec* getCodecByName(JNIEnv* env, jstring codecName) {
|
||||
if (!codecName) {
|
||||
return nullptr;
|
||||
}
|
||||
const char* codecNameChars = env->GetStringUTFChars(codecName, nullptr);
|
||||
const AVCodec* codec = avcodec_find_decoder_by_name(codecNameChars);
|
||||
env->ReleaseStringUTFChars(codecName, codecNameChars);
|
||||
return codec;
|
||||
}
|
||||
|
||||
static AVCodecContext* createContext(
|
||||
JNIEnv* env, const AVCodec* codec, jbyteArray extraData, jboolean outputFloat, jint rawSampleRate,
|
||||
jint rawChannelCount) {
|
||||
AVCodecContext* context = avcodec_alloc_context3(codec);
|
||||
if (!context) {
|
||||
LOGE("Failed to allocate context.");
|
||||
return nullptr;
|
||||
}
|
||||
context->request_sample_fmt = outputFloat ? OUTPUT_FORMAT_PCM_FLOAT : OUTPUT_FORMAT_PCM_16BIT;
|
||||
if (extraData) {
|
||||
jsize size = env->GetArrayLength(extraData);
|
||||
if (size > INT_MAX - AV_INPUT_BUFFER_PADDING_SIZE) {
|
||||
LOGE("Extradata is too large.");
|
||||
releaseContext(context);
|
||||
return nullptr;
|
||||
}
|
||||
context->extradata_size = size;
|
||||
context->extradata = (uint8_t*)av_mallocz(static_cast<size_t>(size) + AV_INPUT_BUFFER_PADDING_SIZE);
|
||||
if (!context->extradata) {
|
||||
LOGE("Failed to allocate extradata.");
|
||||
releaseContext(context);
|
||||
return nullptr;
|
||||
}
|
||||
env->GetByteArrayRegion(extraData, 0, size, (jbyte*)context->extradata);
|
||||
}
|
||||
if (context->codec_id == AV_CODEC_ID_PCM_MULAW || context->codec_id == AV_CODEC_ID_PCM_ALAW) {
|
||||
context->sample_rate = rawSampleRate;
|
||||
av_channel_layout_default(&context->ch_layout, rawChannelCount);
|
||||
}
|
||||
context->err_recognition = AV_EF_IGNORE_ERR;
|
||||
int result = avcodec_open2(context, codec, nullptr);
|
||||
if (result < 0) {
|
||||
logError("avcodec_open2", result);
|
||||
releaseContext(context);
|
||||
return nullptr;
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
static bool resampleConfigurationMatches(
|
||||
const ResampleState* state, const AVCodecContext* context, const AVFrame* frame) {
|
||||
return state && state->context && state->input_sample_format == static_cast<AVSampleFormat>(frame->format) &&
|
||||
state->output_sample_format == context->request_sample_fmt && state->sample_rate == frame->sample_rate &&
|
||||
av_channel_layout_compare(&state->input_channel_layout, &frame->ch_layout) == 0;
|
||||
}
|
||||
|
||||
static int configureResampler(ResampleState* state, const AVCodecContext* context, const AVFrame* frame) {
|
||||
SwrContext* nextContext = nullptr;
|
||||
const AVSampleFormat inputSampleFormat = static_cast<AVSampleFormat>(frame->format);
|
||||
int result = swr_alloc_set_opts2(
|
||||
&nextContext, // ps
|
||||
&frame->ch_layout, // out_ch_layout
|
||||
context->request_sample_fmt, // out_sample_fmt
|
||||
frame->sample_rate, // out_sample_rate
|
||||
&frame->ch_layout, // in_ch_layout
|
||||
inputSampleFormat, // in_sample_fmt
|
||||
frame->sample_rate, // in_sample_rate
|
||||
0, // log_offset
|
||||
nullptr // log_ctx
|
||||
);
|
||||
if (result < 0) {
|
||||
logError("swr_alloc_set_opts2", result);
|
||||
return result;
|
||||
}
|
||||
result = swr_init(nextContext);
|
||||
if (result < 0) {
|
||||
logError("swr_init", result);
|
||||
swr_free(&nextContext);
|
||||
return result;
|
||||
}
|
||||
|
||||
AVChannelLayout nextInputChannelLayout = {};
|
||||
result = av_channel_layout_copy(&nextInputChannelLayout, &frame->ch_layout);
|
||||
if (result < 0) {
|
||||
logError("av_channel_layout_copy", result);
|
||||
swr_free(&nextContext);
|
||||
return result;
|
||||
}
|
||||
|
||||
swr_free(&state->context);
|
||||
av_channel_layout_uninit(&state->input_channel_layout);
|
||||
state->context = nextContext;
|
||||
state->input_channel_layout = nextInputChannelLayout;
|
||||
state->input_sample_format = inputSampleFormat;
|
||||
state->output_sample_format = context->request_sample_fmt;
|
||||
state->sample_rate = frame->sample_rate;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void releaseResampleState(ResampleState* state) {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
swr_free(&state->context);
|
||||
av_channel_layout_uninit(&state->input_channel_layout);
|
||||
av_free(state);
|
||||
}
|
||||
|
||||
static int decodePacket(
|
||||
AVCodecContext* context, AVPacket* packet, uint8_t* outputBuffer, int outputSize,
|
||||
GrowOutputBufferCallback growBuffer) {
|
||||
int result = avcodec_send_packet(context, packet);
|
||||
if (result) {
|
||||
logError("avcodec_send_packet", result);
|
||||
return transformError(result);
|
||||
}
|
||||
|
||||
int outSize = 0;
|
||||
while (true) {
|
||||
AVFrame* frame = av_frame_alloc();
|
||||
if (!frame) {
|
||||
LOGE("Failed to allocate output frame.");
|
||||
return AUDIO_DECODER_ERROR_INVALID_DATA;
|
||||
}
|
||||
result = avcodec_receive_frame(context, frame);
|
||||
if (result) {
|
||||
av_frame_free(&frame);
|
||||
if (result == AVERROR(EAGAIN)) {
|
||||
break;
|
||||
}
|
||||
logError("avcodec_receive_frame", result);
|
||||
return transformError(result);
|
||||
}
|
||||
|
||||
const AVSampleFormat sampleFormat = static_cast<AVSampleFormat>(frame->format);
|
||||
const int channelCount = frame->ch_layout.nb_channels;
|
||||
const int sampleRate = frame->sample_rate;
|
||||
const int sampleCount = frame->nb_samples;
|
||||
if (sampleFormat == AV_SAMPLE_FMT_NONE || channelCount <= 0 || sampleRate <= 0 || sampleCount < 0 ||
|
||||
!frame->extended_data || !av_channel_layout_check(&frame->ch_layout)) {
|
||||
LOGE("Decoder returned an invalid audio frame.");
|
||||
av_frame_free(&frame);
|
||||
return AUDIO_DECODER_ERROR_INVALID_DATA;
|
||||
}
|
||||
|
||||
ResampleState* resampleState = static_cast<ResampleState*>(context->opaque);
|
||||
if (!resampleState) {
|
||||
resampleState = static_cast<ResampleState*>(av_mallocz(sizeof(ResampleState)));
|
||||
if (!resampleState) {
|
||||
LOGE("Failed to allocate resampler state.");
|
||||
av_frame_free(&frame);
|
||||
return AUDIO_DECODER_ERROR_OTHER;
|
||||
}
|
||||
context->opaque = resampleState;
|
||||
}
|
||||
if (!resampleConfigurationMatches(resampleState, context, frame)) {
|
||||
result = configureResampler(resampleState, context, frame);
|
||||
if (result < 0) {
|
||||
av_frame_free(&frame);
|
||||
return transformError(result);
|
||||
}
|
||||
}
|
||||
|
||||
const int bytesPerSample = av_get_bytes_per_sample(context->request_sample_fmt);
|
||||
const int outputSampleCapacity = swr_get_out_samples(resampleState->context, sampleCount);
|
||||
int outputByteCapacity;
|
||||
int requiredOutputSize;
|
||||
if (!plezy::ffmpeg::CheckedAudioByteCount(
|
||||
outputSampleCapacity, channelCount, bytesPerSample, &outputByteCapacity) ||
|
||||
!plezy::ffmpeg::CheckedAddByteCount(outSize, outputByteCapacity, &requiredOutputSize)) {
|
||||
LOGE("Decoded audio output size is invalid or too large.");
|
||||
av_frame_free(&frame);
|
||||
return AUDIO_DECODER_ERROR_INVALID_DATA;
|
||||
}
|
||||
if (requiredOutputSize > outputSize) {
|
||||
LOGD(
|
||||
"Output buffer size (%d) too small for output data (%d), "
|
||||
"reallocating buffer.",
|
||||
outputSize, requiredOutputSize);
|
||||
outputSize = requiredOutputSize;
|
||||
outputBuffer = growBuffer(outputSize);
|
||||
if (!outputBuffer) {
|
||||
LOGE("Failed to reallocate output buffer.");
|
||||
av_frame_free(&frame);
|
||||
return AUDIO_DECODER_ERROR_OTHER;
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t* frameOutput = outputBuffer + outSize;
|
||||
uint8_t* outputPlanes[] = {frameOutput};
|
||||
result = swr_convert(
|
||||
resampleState->context, outputPlanes, outputSampleCapacity, (const uint8_t**)frame->extended_data, sampleCount);
|
||||
av_frame_free(&frame);
|
||||
if (result < 0) {
|
||||
logError("swr_convert", result);
|
||||
return AUDIO_DECODER_ERROR_INVALID_DATA;
|
||||
}
|
||||
|
||||
int writtenByteCount;
|
||||
int nextOutSize;
|
||||
if (!plezy::ffmpeg::CheckedAudioByteCount(result, channelCount, bytesPerSample, &writtenByteCount) ||
|
||||
writtenByteCount > outputByteCapacity ||
|
||||
!plezy::ffmpeg::CheckedAddByteCount(outSize, writtenByteCount, &nextOutSize)) {
|
||||
LOGE("Resampler returned an invalid output sample count.");
|
||||
return AUDIO_DECODER_ERROR_INVALID_DATA;
|
||||
}
|
||||
outSize = nextOutSize;
|
||||
}
|
||||
return outSize;
|
||||
}
|
||||
|
||||
static int transformError(int errorNumber) {
|
||||
return errorNumber == AVERROR_INVALIDDATA ? AUDIO_DECODER_ERROR_INVALID_DATA : AUDIO_DECODER_ERROR_OTHER;
|
||||
}
|
||||
|
||||
static void logError(const char* functionName, int errorNumber) {
|
||||
char buffer[ERROR_STRING_BUFFER_LENGTH];
|
||||
av_strerror(errorNumber, buffer, sizeof(buffer));
|
||||
LOGE("Error in %s: %s", functionName, buffer);
|
||||
}
|
||||
|
||||
static void releaseContext(AVCodecContext* context) {
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
releaseResampleState(static_cast<ResampleState*>(context->opaque));
|
||||
context->opaque = nullptr;
|
||||
avcodec_free_context(&context);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
* Copyright (C) 2016 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package androidx.media3.decoder.ffmpeg;
|
||||
|
||||
import static com.google.common.base.Preconditions.checkNotNull;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.Format;
|
||||
import androidx.media3.common.MimeTypes;
|
||||
import androidx.media3.common.util.ParsableByteArray;
|
||||
import androidx.media3.common.util.Util;
|
||||
import androidx.media3.decoder.DecoderInputBuffer;
|
||||
import androidx.media3.decoder.SimpleDecoder;
|
||||
import androidx.media3.decoder.SimpleDecoderOutputBuffer;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.List;
|
||||
|
||||
/** Media3 audio decoder backed by the FFmpeg libraries packaged by libmpv. */
|
||||
final class FfmpegAudioDecoder
|
||||
extends SimpleDecoder<DecoderInputBuffer, SimpleDecoderOutputBuffer, FfmpegDecoderException> {
|
||||
|
||||
private static final int INITIAL_OUTPUT_BUFFER_SIZE_16BIT = 65535;
|
||||
private static final int INITIAL_OUTPUT_BUFFER_SIZE_32BIT = INITIAL_OUTPUT_BUFFER_SIZE_16BIT * 2;
|
||||
private static final int AUDIO_DECODER_ERROR_INVALID_DATA = -1;
|
||||
private static final int AUDIO_DECODER_ERROR_OTHER = -2;
|
||||
|
||||
private static final byte[] FLAC_STREAM_MARKER = {'f', 'L', 'a', 'C'};
|
||||
private static final int FLAC_METADATA_TYPE_STREAM_INFO = 0;
|
||||
private static final int FLAC_METADATA_BLOCK_HEADER_SIZE = 4;
|
||||
private static final int FLAC_STREAM_INFO_DATA_SIZE = 34;
|
||||
|
||||
private final String codecName;
|
||||
@Nullable private final byte[] extraData;
|
||||
private final @C.PcmEncoding int encoding;
|
||||
private int outputBufferSize;
|
||||
|
||||
private long nativeContext;
|
||||
private boolean hasOutputFormat;
|
||||
private volatile int channelCount;
|
||||
private volatile int sampleRate;
|
||||
|
||||
FfmpegAudioDecoder(
|
||||
Format format,
|
||||
int numInputBuffers,
|
||||
int numOutputBuffers,
|
||||
int initialInputBufferSize,
|
||||
boolean outputFloat)
|
||||
throws FfmpegDecoderException {
|
||||
super(new DecoderInputBuffer[numInputBuffers], new SimpleDecoderOutputBuffer[numOutputBuffers]);
|
||||
if (!FfmpegLibrary.isAvailable()) {
|
||||
throw new FfmpegDecoderException("Failed to load decoder native libraries.");
|
||||
}
|
||||
String mimeType = checkNotNull(format.sampleMimeType);
|
||||
codecName = checkNotNull(FfmpegLibrary.getCodecName(mimeType));
|
||||
extraData = getExtraData(mimeType, format.initializationData);
|
||||
encoding = outputFloat ? C.ENCODING_PCM_FLOAT : C.ENCODING_PCM_16BIT;
|
||||
outputBufferSize =
|
||||
outputFloat ? INITIAL_OUTPUT_BUFFER_SIZE_32BIT : INITIAL_OUTPUT_BUFFER_SIZE_16BIT;
|
||||
nativeContext =
|
||||
ffmpegInitialize(codecName, extraData, outputFloat, format.sampleRate, format.channelCount);
|
||||
if (nativeContext == 0) {
|
||||
throw new FfmpegDecoderException("Initialization failed.");
|
||||
}
|
||||
setInitialInputBufferSize(initialInputBufferSize);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "ffmpeg" + FfmpegLibrary.getVersion() + "-" + codecName;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected DecoderInputBuffer createInputBuffer() {
|
||||
return new DecoderInputBuffer(
|
||||
DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_DIRECT,
|
||||
FfmpegLibrary.getInputBufferPaddingSize());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected SimpleDecoderOutputBuffer createOutputBuffer() {
|
||||
return new SimpleDecoderOutputBuffer(this::releaseOutputBuffer);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected FfmpegDecoderException createUnexpectedDecodeException(Throwable error) {
|
||||
return new FfmpegDecoderException("Unexpected decode error", error);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
protected FfmpegDecoderException decode(
|
||||
DecoderInputBuffer inputBuffer, SimpleDecoderOutputBuffer outputBuffer, boolean reset) {
|
||||
if (reset) {
|
||||
nativeContext = ffmpegReset(nativeContext, extraData);
|
||||
if (nativeContext == 0) {
|
||||
return new FfmpegDecoderException("Error resetting (see logcat).");
|
||||
}
|
||||
}
|
||||
ByteBuffer inputData = Util.castNonNull(inputBuffer.data);
|
||||
int inputSize = inputData.limit();
|
||||
ByteBuffer outputData = outputBuffer.init(inputBuffer.timeUs, outputBufferSize);
|
||||
int result =
|
||||
ffmpegDecode(
|
||||
nativeContext, inputData, inputSize, outputBuffer, outputData, outputBufferSize);
|
||||
if (result == AUDIO_DECODER_ERROR_OTHER) {
|
||||
return new FfmpegDecoderException("Error decoding (see logcat).");
|
||||
} else if (result == AUDIO_DECODER_ERROR_INVALID_DATA) {
|
||||
outputBuffer.shouldBeSkipped = true;
|
||||
return null;
|
||||
} else if (result == 0) {
|
||||
outputBuffer.shouldBeSkipped = true;
|
||||
return null;
|
||||
}
|
||||
if (!hasOutputFormat) {
|
||||
channelCount = ffmpegGetChannelCount(nativeContext);
|
||||
sampleRate = ffmpegGetSampleRate(nativeContext);
|
||||
if (sampleRate == 0 && "alac".equals(codecName)) {
|
||||
checkNotNull(extraData);
|
||||
ParsableByteArray parsableExtraData = new ParsableByteArray(extraData);
|
||||
parsableExtraData.setPosition(extraData.length - 4);
|
||||
sampleRate = parsableExtraData.readUnsignedIntToInt();
|
||||
}
|
||||
hasOutputFormat = true;
|
||||
}
|
||||
outputData = checkNotNull(outputBuffer.data);
|
||||
outputData.position(0);
|
||||
outputData.limit(result);
|
||||
return null;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private ByteBuffer growOutputBuffer(SimpleDecoderOutputBuffer outputBuffer, int requiredSize) {
|
||||
outputBufferSize = requiredSize;
|
||||
return outputBuffer.grow(requiredSize);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void release() {
|
||||
super.release();
|
||||
ffmpegRelease(nativeContext);
|
||||
nativeContext = 0;
|
||||
}
|
||||
|
||||
int getChannelCount() {
|
||||
return channelCount;
|
||||
}
|
||||
|
||||
int getSampleRate() {
|
||||
return sampleRate;
|
||||
}
|
||||
|
||||
@C.PcmEncoding
|
||||
int getEncoding() {
|
||||
return encoding;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static byte[] getExtraData(String mimeType, List<byte[]> initializationData) {
|
||||
switch (mimeType) {
|
||||
case MimeTypes.AUDIO_AAC:
|
||||
case MimeTypes.AUDIO_OPUS:
|
||||
return initializationData.get(0);
|
||||
case MimeTypes.AUDIO_ALAC:
|
||||
return getAlacExtraData(initializationData);
|
||||
case MimeTypes.AUDIO_VORBIS:
|
||||
return getVorbisExtraData(initializationData);
|
||||
case MimeTypes.AUDIO_FLAC:
|
||||
return getFlacExtraData(initializationData);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] getAlacExtraData(List<byte[]> initializationData) {
|
||||
byte[] magicCookie = initializationData.get(0);
|
||||
int alacAtomLength = 12 + magicCookie.length;
|
||||
ByteBuffer alacAtom = ByteBuffer.allocate(alacAtomLength);
|
||||
alacAtom.putInt(alacAtomLength);
|
||||
alacAtom.putInt(0x616c6163);
|
||||
alacAtom.putInt(0);
|
||||
alacAtom.put(magicCookie, 0, magicCookie.length);
|
||||
return alacAtom.array();
|
||||
}
|
||||
|
||||
private static byte[] getVorbisExtraData(List<byte[]> initializationData) {
|
||||
byte[] header0 = initializationData.get(0);
|
||||
byte[] header1 = initializationData.get(1);
|
||||
byte[] extraData = new byte[header0.length + header1.length + 6];
|
||||
extraData[0] = (byte) (header0.length >> 8);
|
||||
extraData[1] = (byte) (header0.length & 0xFF);
|
||||
System.arraycopy(header0, 0, extraData, 2, header0.length);
|
||||
extraData[header0.length + 2] = 0;
|
||||
extraData[header0.length + 3] = 0;
|
||||
extraData[header0.length + 4] = (byte) (header1.length >> 8);
|
||||
extraData[header0.length + 5] = (byte) (header1.length & 0xFF);
|
||||
System.arraycopy(header1, 0, extraData, header0.length + 6, header1.length);
|
||||
return extraData;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static byte[] getFlacExtraData(List<byte[]> initializationData) {
|
||||
for (int i = 0; i < initializationData.size(); i++) {
|
||||
@Nullable byte[] streamInfo = extractFlacStreamInfo(initializationData.get(i));
|
||||
if (streamInfo != null) {
|
||||
return streamInfo;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static byte[] extractFlacStreamInfo(byte[] data) {
|
||||
int offset = 0;
|
||||
if (arrayStartsWith(data, FLAC_STREAM_MARKER)) {
|
||||
offset = FLAC_STREAM_MARKER.length;
|
||||
}
|
||||
|
||||
if (data.length - offset == FLAC_STREAM_INFO_DATA_SIZE) {
|
||||
byte[] streamInfo = new byte[FLAC_STREAM_INFO_DATA_SIZE];
|
||||
System.arraycopy(data, offset, streamInfo, 0, FLAC_STREAM_INFO_DATA_SIZE);
|
||||
return streamInfo;
|
||||
}
|
||||
|
||||
if (data.length >= offset + FLAC_METADATA_BLOCK_HEADER_SIZE) {
|
||||
int type = data[offset] & 0x7F;
|
||||
int length =
|
||||
((data[offset + 1] & 0xFF) << 16)
|
||||
| ((data[offset + 2] & 0xFF) << 8)
|
||||
| (data[offset + 3] & 0xFF);
|
||||
if (type == FLAC_METADATA_TYPE_STREAM_INFO
|
||||
&& length == FLAC_STREAM_INFO_DATA_SIZE
|
||||
&& data.length >= offset + FLAC_METADATA_BLOCK_HEADER_SIZE + FLAC_STREAM_INFO_DATA_SIZE) {
|
||||
byte[] streamInfo = new byte[FLAC_STREAM_INFO_DATA_SIZE];
|
||||
System.arraycopy(
|
||||
data,
|
||||
offset + FLAC_METADATA_BLOCK_HEADER_SIZE,
|
||||
streamInfo,
|
||||
0,
|
||||
FLAC_STREAM_INFO_DATA_SIZE);
|
||||
return streamInfo;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean arrayStartsWith(byte[] data, byte[] prefix) {
|
||||
if (data.length < prefix.length) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < prefix.length; i++) {
|
||||
if (data[i] != prefix[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private native long ffmpegInitialize(
|
||||
String codecName,
|
||||
@Nullable byte[] extraData,
|
||||
boolean outputFloat,
|
||||
int rawSampleRate,
|
||||
int rawChannelCount);
|
||||
|
||||
private native int ffmpegDecode(
|
||||
long context,
|
||||
ByteBuffer inputData,
|
||||
int inputSize,
|
||||
SimpleDecoderOutputBuffer decoderOutputBuffer,
|
||||
ByteBuffer outputData,
|
||||
int outputSize);
|
||||
|
||||
private native int ffmpegGetChannelCount(long context);
|
||||
|
||||
private native int ffmpegGetSampleRate(long context);
|
||||
|
||||
private native long ffmpegReset(long context, @Nullable byte[] extraData);
|
||||
|
||||
private native void ffmpegRelease(long context);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright (C) 2016 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package androidx.media3.decoder.ffmpeg;
|
||||
|
||||
import static androidx.media3.exoplayer.audio.AudioSink.SINK_FORMAT_SUPPORTED_DIRECTLY;
|
||||
import static androidx.media3.exoplayer.audio.AudioSink.SINK_FORMAT_SUPPORTED_WITH_TRANSCODING;
|
||||
import static androidx.media3.exoplayer.audio.AudioSink.SINK_FORMAT_UNSUPPORTED;
|
||||
import static com.google.common.base.Preconditions.checkNotNull;
|
||||
|
||||
import android.os.Handler;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.Format;
|
||||
import androidx.media3.common.MimeTypes;
|
||||
import androidx.media3.common.util.TraceUtil;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.media3.common.util.Util;
|
||||
import androidx.media3.decoder.CryptoConfig;
|
||||
import androidx.media3.exoplayer.audio.AudioRendererEventListener;
|
||||
import androidx.media3.exoplayer.audio.AudioSink;
|
||||
import androidx.media3.exoplayer.audio.AudioSink.SinkFormatSupport;
|
||||
import androidx.media3.exoplayer.audio.DecoderAudioRenderer;
|
||||
|
||||
/** Decodes and renders audio using the FFmpeg libraries shared with libmpv. */
|
||||
@UnstableApi
|
||||
public final class FfmpegAudioRenderer extends DecoderAudioRenderer<FfmpegAudioDecoder> {
|
||||
|
||||
private static final String TAG = "FfmpegAudioRenderer";
|
||||
private static final int NUM_BUFFERS = 16;
|
||||
private static final int DEFAULT_INPUT_BUFFER_SIZE = 960 * 6;
|
||||
|
||||
public FfmpegAudioRenderer(
|
||||
@Nullable Handler eventHandler,
|
||||
@Nullable AudioRendererEventListener eventListener,
|
||||
AudioSink audioSink) {
|
||||
super(eventHandler, eventListener, audioSink);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return TAG;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @C.FormatSupport int supportsFormatInternal(Format format) {
|
||||
String mimeType = checkNotNull(format.sampleMimeType);
|
||||
if (!FfmpegLibrary.isAvailable() || !MimeTypes.isAudio(mimeType)) {
|
||||
return C.FORMAT_UNSUPPORTED_TYPE;
|
||||
} else if (!FfmpegLibrary.supportsFormat(mimeType)
|
||||
|| (!sinkSupportsFormat(format, C.ENCODING_PCM_16BIT)
|
||||
&& !sinkSupportsFormat(format, C.ENCODING_PCM_FLOAT))) {
|
||||
return C.FORMAT_UNSUPPORTED_SUBTYPE;
|
||||
} else if (format.cryptoType != C.CRYPTO_TYPE_NONE) {
|
||||
return C.FORMAT_UNSUPPORTED_DRM;
|
||||
} else {
|
||||
return C.FORMAT_HANDLED;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public @AdaptiveSupport int supportsMixedMimeTypeAdaptation() {
|
||||
return ADAPTIVE_NOT_SEAMLESS;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected FfmpegAudioDecoder createDecoder(Format format, @Nullable CryptoConfig cryptoConfig)
|
||||
throws FfmpegDecoderException {
|
||||
TraceUtil.beginSection("createFfmpegAudioDecoder");
|
||||
int initialInputBufferSize =
|
||||
format.maxInputSize != Format.NO_VALUE ? format.maxInputSize : DEFAULT_INPUT_BUFFER_SIZE;
|
||||
FfmpegAudioDecoder decoder =
|
||||
new FfmpegAudioDecoder(
|
||||
format, NUM_BUFFERS, NUM_BUFFERS, initialInputBufferSize, shouldOutputFloat(format));
|
||||
TraceUtil.endSection();
|
||||
return decoder;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Format getOutputFormat(FfmpegAudioDecoder decoder) {
|
||||
checkNotNull(decoder);
|
||||
return new Format.Builder()
|
||||
.setSampleMimeType(MimeTypes.AUDIO_RAW)
|
||||
.setChannelCount(decoder.getChannelCount())
|
||||
.setSampleRate(decoder.getSampleRate())
|
||||
.setPcmEncoding(decoder.getEncoding())
|
||||
.build();
|
||||
}
|
||||
|
||||
private boolean sinkSupportsFormat(Format inputFormat, @C.PcmEncoding int pcmEncoding) {
|
||||
return sinkSupportsFormat(
|
||||
Util.getPcmFormat(pcmEncoding, inputFormat.channelCount, inputFormat.sampleRate));
|
||||
}
|
||||
|
||||
private boolean shouldOutputFloat(Format inputFormat) {
|
||||
if (!sinkSupportsFormat(inputFormat, C.ENCODING_PCM_16BIT)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@SinkFormatSupport
|
||||
int formatSupport =
|
||||
getSinkFormatSupport(
|
||||
Util.getPcmFormat(
|
||||
C.ENCODING_PCM_FLOAT, inputFormat.channelCount, inputFormat.sampleRate));
|
||||
switch (formatSupport) {
|
||||
case SINK_FORMAT_SUPPORTED_DIRECTLY:
|
||||
return !MimeTypes.AUDIO_AC3.equals(inputFormat.sampleMimeType);
|
||||
case SINK_FORMAT_UNSUPPORTED:
|
||||
case SINK_FORMAT_SUPPORTED_WITH_TRANSCODING:
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (C) 2016 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package androidx.media3.decoder.ffmpeg;
|
||||
|
||||
import androidx.media3.decoder.DecoderException;
|
||||
|
||||
/** Thrown when an FFmpeg decoder error occurs. */
|
||||
final class FfmpegDecoderException extends DecoderException {
|
||||
|
||||
FfmpegDecoderException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
FfmpegDecoderException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright (C) 2016 The Android Open Source Project
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package androidx.media3.decoder.ffmpeg;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.media3.common.C;
|
||||
import androidx.media3.common.MimeTypes;
|
||||
import androidx.media3.common.util.LibraryLoader;
|
||||
import androidx.media3.common.util.Log;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
|
||||
/** Configures and queries the FFmpeg libraries shared with libmpv. */
|
||||
@UnstableApi
|
||||
final class FfmpegLibrary {
|
||||
|
||||
private static final String TAG = "FfmpegLibrary";
|
||||
private static final LibraryLoader LOADER =
|
||||
new LibraryLoader("ffmpegJNI") {
|
||||
@Override
|
||||
protected void loadLibrary(String name) {
|
||||
System.loadLibrary(name);
|
||||
}
|
||||
};
|
||||
|
||||
@Nullable private static String version;
|
||||
private static int inputBufferPaddingSize = C.LENGTH_UNSET;
|
||||
|
||||
private FfmpegLibrary() {}
|
||||
|
||||
|
||||
/** Returns whether the JNI adapter and libmpv's FFmpeg libraries can be loaded. */
|
||||
static boolean isAvailable() {
|
||||
return LOADER.isAvailable();
|
||||
}
|
||||
|
||||
/** Returns the linked FFmpeg version. The native libraries must be available. */
|
||||
static String getVersion() {
|
||||
String cachedVersion = version;
|
||||
if (cachedVersion == null) {
|
||||
cachedVersion = ffmpegGetVersion();
|
||||
version = cachedVersion;
|
||||
}
|
||||
return cachedVersion;
|
||||
}
|
||||
|
||||
/** Returns the required FFmpeg input-buffer padding. The native libraries must be available. */
|
||||
static int getInputBufferPaddingSize() {
|
||||
if (inputBufferPaddingSize == C.LENGTH_UNSET) {
|
||||
inputBufferPaddingSize = ffmpegGetInputBufferPaddingSize();
|
||||
}
|
||||
return inputBufferPaddingSize;
|
||||
}
|
||||
|
||||
/** Returns whether the linked FFmpeg build supports the MIME type. */
|
||||
static boolean supportsFormat(String mimeType) {
|
||||
@Nullable String codecName = getCodecName(mimeType);
|
||||
if (codecName == null) {
|
||||
return false;
|
||||
}
|
||||
if (!ffmpegHasDecoder(codecName)) {
|
||||
Log.w(TAG, "No " + codecName + " decoder available in libmpv's FFmpeg build.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Returns the FFmpeg decoder name for a supported MIME type. */
|
||||
@Nullable
|
||||
static String getCodecName(String mimeType) {
|
||||
switch (mimeType) {
|
||||
case MimeTypes.AUDIO_AAC:
|
||||
return "aac";
|
||||
case MimeTypes.AUDIO_MPEG:
|
||||
case MimeTypes.AUDIO_MPEG_L1:
|
||||
case MimeTypes.AUDIO_MPEG_L2:
|
||||
return "mp3";
|
||||
case MimeTypes.AUDIO_AC3:
|
||||
return "ac3";
|
||||
case MimeTypes.AUDIO_E_AC3:
|
||||
case MimeTypes.AUDIO_E_AC3_JOC:
|
||||
return "eac3";
|
||||
case MimeTypes.AUDIO_TRUEHD:
|
||||
return "truehd";
|
||||
case MimeTypes.AUDIO_DTS:
|
||||
case MimeTypes.AUDIO_DTS_HD:
|
||||
return "dca";
|
||||
case MimeTypes.AUDIO_VORBIS:
|
||||
return "vorbis";
|
||||
case MimeTypes.AUDIO_OPUS:
|
||||
return "opus";
|
||||
case MimeTypes.AUDIO_AMR_NB:
|
||||
return "amrnb";
|
||||
case MimeTypes.AUDIO_AMR_WB:
|
||||
return "amrwb";
|
||||
case MimeTypes.AUDIO_FLAC:
|
||||
return "flac";
|
||||
case MimeTypes.AUDIO_ALAC:
|
||||
return "alac";
|
||||
case MimeTypes.AUDIO_MLAW:
|
||||
return "pcm_mulaw";
|
||||
case MimeTypes.AUDIO_ALAW:
|
||||
return "pcm_alaw";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static native String ffmpegGetVersion();
|
||||
|
||||
private static native int ffmpegGetInputBufferPaddingSize();
|
||||
|
||||
private static native boolean ffmpegHasDecoder(String codecName);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package com.edde746.plezy
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
internal data class HistoricalExitRecord(
|
||||
val reason: Int,
|
||||
val status: Int,
|
||||
val importance: Int,
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
internal object AndroidStartupPhases {
|
||||
const val NATIVE_ON_CREATE = "native_on_create"
|
||||
private val allowed = setOf(
|
||||
NATIVE_ON_CREATE,
|
||||
"dart_main",
|
||||
"runApp",
|
||||
"first_frame",
|
||||
"database_open_started",
|
||||
"database_ready",
|
||||
"credentials_loaded",
|
||||
"binding_started",
|
||||
"binding_settled",
|
||||
"main_screen"
|
||||
)
|
||||
|
||||
fun sanitize(raw: String?): String? = raw?.takeIf(allowed::contains)
|
||||
}
|
||||
|
||||
internal class StartupPhaseStore(
|
||||
readPhase: () -> String?,
|
||||
private val persistPhase: (String) -> Boolean
|
||||
) {
|
||||
val previousPhase: String? = AndroidStartupPhases.sanitize(readPhase())
|
||||
|
||||
@Synchronized
|
||||
fun mark(raw: String?): Boolean {
|
||||
val phase = AndroidStartupPhases.sanitize(raw) ?: return false
|
||||
return persistPhase(phase)
|
||||
}
|
||||
}
|
||||
|
||||
internal data class RuntimeDiagnosticSnapshot(
|
||||
val codecContext: String? = null,
|
||||
val channelCount: Int? = null,
|
||||
val sampleRate: Int? = null,
|
||||
val selectedDecoder: String? = null,
|
||||
val passthroughEnabled: Boolean? = null,
|
||||
val downmixEnabled: Boolean? = null,
|
||||
val normalizationEnabled: Boolean? = null,
|
||||
val uiState: String? = null
|
||||
)
|
||||
|
||||
internal object AndroidRuntimeDiagnostics {
|
||||
const val UI_STARTUP = "startup"
|
||||
const val UI_AUTHENTICATION = "authentication"
|
||||
const val UI_MAIN_SCREEN = "main_screen"
|
||||
const val UI_PLAYER = "player"
|
||||
const val UI_PLAYER_DISPOSED = "player_disposed"
|
||||
|
||||
private const val PREFERENCES_NAME = "plezy_runtime_diagnostics"
|
||||
private const val KEY_CODEC_CONTEXT = "codec_context"
|
||||
private const val KEY_CHANNEL_COUNT = "channel_count"
|
||||
private const val KEY_SAMPLE_RATE = "sample_rate"
|
||||
private const val KEY_SELECTED_DECODER = "selected_decoder"
|
||||
private const val KEY_PASSTHROUGH_ENABLED = "passthrough_enabled"
|
||||
private const val KEY_DOWNMIX_ENABLED = "downmix_enabled"
|
||||
private const val KEY_NORMALIZATION_ENABLED = "normalization_enabled"
|
||||
private const val KEY_UI_STATE = "ui_state"
|
||||
private val allowedCodecContexts = setOf(
|
||||
"audio:aac",
|
||||
"audio:ac3",
|
||||
"audio:eac3",
|
||||
"audio:dts",
|
||||
"audio:truehd",
|
||||
"audio:flac",
|
||||
"audio:pcm",
|
||||
"audio:other",
|
||||
"video:dolby_vision",
|
||||
"video:hevc",
|
||||
"video:avc",
|
||||
"video:other"
|
||||
)
|
||||
private val allowedUiStates = setOf(
|
||||
UI_STARTUP,
|
||||
UI_AUTHENTICATION,
|
||||
UI_MAIN_SCREEN,
|
||||
UI_PLAYER,
|
||||
UI_PLAYER_DISPOSED
|
||||
)
|
||||
private val decoderNamePattern = Regex("[A-Za-z0-9_.:-]{1,96}")
|
||||
private val executor by lazy {
|
||||
Executors.newSingleThreadExecutor { task ->
|
||||
Thread(task, "plezy-runtime-diagnostics").apply { isDaemon = true }
|
||||
}
|
||||
}
|
||||
|
||||
fun codecContextForMime(mimeType: String?): String? {
|
||||
val normalized = mimeType?.lowercase() ?: return null
|
||||
return when (normalized) {
|
||||
"audio/mp4a-latm" -> "audio:aac"
|
||||
"audio/ac3" -> "audio:ac3"
|
||||
"audio/eac3", "audio/eac3-joc" -> "audio:eac3"
|
||||
"audio/vnd.dts", "audio/vnd.dts.hd" -> "audio:dts"
|
||||
"audio/true-hd" -> "audio:truehd"
|
||||
"audio/flac" -> "audio:flac"
|
||||
"audio/raw" -> "audio:pcm"
|
||||
"video/dolby-vision" -> "video:dolby_vision"
|
||||
"video/hevc" -> "video:hevc"
|
||||
"video/avc" -> "video:avc"
|
||||
else -> when {
|
||||
normalized.startsWith("audio/") -> "audio:other"
|
||||
normalized.startsWith("video/") -> "video:other"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sanitizeDecoderName(raw: String?): String? {
|
||||
if (raw == null) return null
|
||||
return raw.takeIf(decoderNamePattern::matches) ?: "unknown"
|
||||
}
|
||||
|
||||
fun sanitizeUiState(raw: String?): String? = raw?.takeIf(allowedUiStates::contains)
|
||||
fun sanitize(snapshot: RuntimeDiagnosticSnapshot): RuntimeDiagnosticSnapshot = RuntimeDiagnosticSnapshot(
|
||||
codecContext = snapshot.codecContext?.takeIf(allowedCodecContexts::contains),
|
||||
channelCount = snapshot.channelCount?.takeIf { it in 1..32 },
|
||||
sampleRate = snapshot.sampleRate?.takeIf { it in 1..768_000 },
|
||||
selectedDecoder = sanitizeDecoderName(snapshot.selectedDecoder),
|
||||
passthroughEnabled = snapshot.passthroughEnabled,
|
||||
downmixEnabled = snapshot.downmixEnabled,
|
||||
normalizationEnabled = snapshot.normalizationEnabled,
|
||||
uiState = sanitizeUiState(snapshot.uiState)
|
||||
)
|
||||
|
||||
fun update(
|
||||
context: Context,
|
||||
codecContext: String? = null,
|
||||
channelCount: Int? = null,
|
||||
sampleRate: Int? = null,
|
||||
selectedDecoder: String? = null,
|
||||
passthroughEnabled: Boolean? = null,
|
||||
downmixEnabled: Boolean? = null,
|
||||
normalizationEnabled: Boolean? = null,
|
||||
uiState: String? = null
|
||||
) {
|
||||
val safeCodecContext = codecContext?.takeIf(allowedCodecContexts::contains)
|
||||
val safeChannelCount = channelCount?.takeIf { it in 1..32 }
|
||||
val safeSampleRate = sampleRate?.takeIf { it in 1..768_000 }
|
||||
val safeDecoder = sanitizeDecoderName(selectedDecoder)
|
||||
val safeUiState = sanitizeUiState(uiState)
|
||||
val applicationContext = context.applicationContext
|
||||
executor.execute {
|
||||
runCatching {
|
||||
applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE).edit().apply {
|
||||
safeCodecContext?.let { putString(KEY_CODEC_CONTEXT, it) }
|
||||
safeChannelCount?.let { putInt(KEY_CHANNEL_COUNT, it) }
|
||||
safeSampleRate?.let { putInt(KEY_SAMPLE_RATE, it) }
|
||||
safeDecoder?.let { putString(KEY_SELECTED_DECODER, it) }
|
||||
passthroughEnabled?.let { putBoolean(KEY_PASSTHROUGH_ENABLED, it) }
|
||||
downmixEnabled?.let { putBoolean(KEY_DOWNMIX_ENABLED, it) }
|
||||
normalizationEnabled?.let { putBoolean(KEY_NORMALIZATION_ENABLED, it) }
|
||||
safeUiState?.let { putString(KEY_UI_STATE, it) }
|
||||
}.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearPlayback(context: Context) {
|
||||
val applicationContext = context.applicationContext
|
||||
executor.execute {
|
||||
runCatching {
|
||||
applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE).edit()
|
||||
.remove(KEY_CODEC_CONTEXT)
|
||||
.remove(KEY_CHANNEL_COUNT)
|
||||
.remove(KEY_SAMPLE_RATE)
|
||||
.remove(KEY_SELECTED_DECODER)
|
||||
.remove(KEY_PASSTHROUGH_ENABLED)
|
||||
.remove(KEY_DOWNMIX_ENABLED)
|
||||
.remove(KEY_NORMALIZATION_ENABLED)
|
||||
.putString(KEY_UI_STATE, UI_PLAYER_DISPOSED)
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun read(context: Context): RuntimeDiagnosticSnapshot {
|
||||
val preferences = context.applicationContext.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||
return RuntimeDiagnosticSnapshot(
|
||||
codecContext = runCatching { preferences.getString(KEY_CODEC_CONTEXT, null) }
|
||||
.getOrNull()
|
||||
?.takeIf(allowedCodecContexts::contains),
|
||||
channelCount = runCatching { preferences.getInt(KEY_CHANNEL_COUNT, -1) }.getOrNull()?.takeIf { it in 1..32 },
|
||||
sampleRate = runCatching { preferences.getInt(KEY_SAMPLE_RATE, -1) }.getOrNull()?.takeIf { it in 1..768_000 },
|
||||
selectedDecoder = sanitizeDecoderName(runCatching { preferences.getString(KEY_SELECTED_DECODER, null) }.getOrNull()),
|
||||
passthroughEnabled = readBoolean(preferences, KEY_PASSTHROUGH_ENABLED),
|
||||
downmixEnabled = readBoolean(preferences, KEY_DOWNMIX_ENABLED),
|
||||
normalizationEnabled = readBoolean(preferences, KEY_NORMALIZATION_ENABLED),
|
||||
uiState = sanitizeUiState(runCatching { preferences.getString(KEY_UI_STATE, null) }.getOrNull())
|
||||
)
|
||||
}
|
||||
|
||||
private fun readBoolean(preferences: SharedPreferences, key: String): Boolean? {
|
||||
if (!preferences.contains(key)) return null
|
||||
return runCatching { preferences.getBoolean(key, false) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
internal data class PreviousExitReport(
|
||||
val reason: String,
|
||||
val status: Int,
|
||||
val importance: Int,
|
||||
val timestamp: Long,
|
||||
val deviceModel: String,
|
||||
val apiLevel: Int,
|
||||
val abi: String,
|
||||
val lowRam: Boolean,
|
||||
val startupPhase: String?,
|
||||
val runtime: RuntimeDiagnosticSnapshot,
|
||||
val dedupeKey: String
|
||||
) {
|
||||
fun toMap(): Map<String, Any> = buildMap {
|
||||
put("reason", reason)
|
||||
put("status", status)
|
||||
put("importance", importance)
|
||||
put("timestamp", timestamp)
|
||||
put("deviceModel", deviceModel)
|
||||
put("apiLevel", apiLevel)
|
||||
put("abi", abi)
|
||||
put("lowRam", lowRam)
|
||||
startupPhase?.let { put("startupPhase", it) }
|
||||
runtime.codecContext?.let { put("codecContext", it) }
|
||||
runtime.channelCount?.let { put("channelCount", it) }
|
||||
runtime.sampleRate?.let { put("sampleRate", it) }
|
||||
runtime.selectedDecoder?.let { put("selectedDecoder", it) }
|
||||
runtime.passthroughEnabled?.let { put("passthroughEnabled", it) }
|
||||
runtime.downmixEnabled?.let { put("downmixEnabled", it) }
|
||||
runtime.normalizationEnabled?.let { put("normalizationEnabled", it) }
|
||||
runtime.uiState?.let { put("uiState", it) }
|
||||
}
|
||||
}
|
||||
|
||||
internal object AndroidExitReportMapper {
|
||||
private const val REASON_LOW_MEMORY = 3
|
||||
private const val REASON_CRASH = 4
|
||||
private const val REASON_CRASH_NATIVE = 5
|
||||
private const val REASON_ANR = 6
|
||||
private const val REASON_USER_REQUESTED = 10
|
||||
private const val REASON_USER_STOPPED = 11
|
||||
private const val MAX_DEVICE_MODEL_LENGTH = 80
|
||||
private val supportedAbis = setOf("arm64-v8a", "armeabi-v7a", "x86_64", "x86")
|
||||
|
||||
fun map(
|
||||
record: HistoricalExitRecord,
|
||||
deviceModel: String,
|
||||
apiLevel: Int,
|
||||
abi: String,
|
||||
lowRam: Boolean,
|
||||
startupPhase: String? = null,
|
||||
runtime: RuntimeDiagnosticSnapshot = RuntimeDiagnosticSnapshot()
|
||||
): PreviousExitReport {
|
||||
val dedupeKey = sha256(
|
||||
listOf(
|
||||
record.timestamp.toString(),
|
||||
record.reason.toString(),
|
||||
record.status.toString(),
|
||||
record.importance.toString()
|
||||
)
|
||||
)
|
||||
return PreviousExitReport(
|
||||
reason = mapReason(record.reason),
|
||||
status = record.status,
|
||||
importance = record.importance,
|
||||
timestamp = record.timestamp,
|
||||
deviceModel = sanitizeDeviceModel(deviceModel),
|
||||
apiLevel = apiLevel,
|
||||
abi = abi.takeIf(supportedAbis::contains) ?: "unknown",
|
||||
lowRam = lowRam,
|
||||
startupPhase = AndroidStartupPhases.sanitize(startupPhase),
|
||||
runtime = AndroidRuntimeDiagnostics.sanitize(runtime),
|
||||
dedupeKey = dedupeKey
|
||||
)
|
||||
}
|
||||
|
||||
fun mapReason(reason: Int): String = when (reason) {
|
||||
REASON_CRASH -> "crash"
|
||||
REASON_CRASH_NATIVE -> "native_crash"
|
||||
REASON_ANR -> "anr"
|
||||
REASON_LOW_MEMORY -> "low_memory"
|
||||
REASON_USER_REQUESTED, REASON_USER_STOPPED -> "user_requested"
|
||||
else -> "other"
|
||||
}
|
||||
|
||||
fun sanitizeDeviceModel(raw: String): String {
|
||||
val sanitized = buildString(raw.length.coerceAtMost(MAX_DEVICE_MODEL_LENGTH)) {
|
||||
var pendingSpace = false
|
||||
raw.forEach { character ->
|
||||
if (length >= MAX_DEVICE_MODEL_LENGTH) return@forEach
|
||||
if (character.isWhitespace() || Character.isISOControl(character)) {
|
||||
pendingSpace = isNotEmpty()
|
||||
} else {
|
||||
if (pendingSpace && length < MAX_DEVICE_MODEL_LENGTH) append(' ')
|
||||
if (length < MAX_DEVICE_MODEL_LENGTH) append(character)
|
||||
pendingSpace = false
|
||||
}
|
||||
}
|
||||
}.trim()
|
||||
return sanitized.ifEmpty { "unknown" }
|
||||
}
|
||||
|
||||
private fun sha256(fields: List<String>): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
fields.forEach { field ->
|
||||
digest.update(field.length.toString().toByteArray(Charsets.UTF_8))
|
||||
digest.update(':'.code.toByte())
|
||||
digest.update(field.toByteArray(Charsets.UTF_8))
|
||||
digest.update(';'.code.toByte())
|
||||
}
|
||||
return digest.digest().joinToString("") { byte ->
|
||||
(byte.toInt() and 0xff).toString(16).padStart(2, '0')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class PreviousExitReportStore(
|
||||
private val readDedupeKey: () -> String?,
|
||||
private val persistDedupeKey: (String) -> Boolean
|
||||
) {
|
||||
@Synchronized
|
||||
fun takeIfNew(report: PreviousExitReport): Map<String, Any>? {
|
||||
if (readDedupeKey() == report.dedupeKey) return null
|
||||
if (!persistDedupeKey(report.dedupeKey)) return null
|
||||
return report.toMap()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package com.edde746.plezy
|
||||
|
||||
import android.app.ActivityManager
|
||||
import android.app.NotificationManager
|
||||
import android.app.usage.UsageStatsManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
|
||||
/**
|
||||
* Raw per-signal snapshot of everything the OS is willing to tell us about
|
||||
* whether our own background work will be allowed to run.
|
||||
*
|
||||
* Every field is nullable: a null means "this API level cannot answer", not
|
||||
* "unrestricted". [BackgroundWorkClassifier] treats unknown as OK, because a
|
||||
* warning we cannot substantiate is worse than no warning at all.
|
||||
*/
|
||||
internal data class BackgroundWorkSignals(
|
||||
val sdkInt: Int? = null,
|
||||
val backgroundRestricted: Boolean? = null,
|
||||
val standbyBucket: Int? = null,
|
||||
val notificationsEnabled: Boolean? = null,
|
||||
val downloadChannelBlocked: Boolean? = null,
|
||||
val dataSaverRestricted: Boolean? = null,
|
||||
val ignoringBatteryOptimizations: Boolean? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Turns [BackgroundWorkSignals] into a verdict plus ordered reasons.
|
||||
*
|
||||
* Deliberately pure and free of `Build.VERSION` reads so the whole decision
|
||||
* table is exercisable from plain JVM unit tests.
|
||||
*/
|
||||
internal object BackgroundWorkClassifier {
|
||||
const val VERDICT_OK = "ok"
|
||||
const val VERDICT_DEGRADED = "degraded"
|
||||
const val VERDICT_BLOCKED = "blocked"
|
||||
|
||||
const val REASON_BACKGROUND_RESTRICTED = "background_restricted"
|
||||
const val REASON_STANDBY_RESTRICTED = "standby_restricted"
|
||||
const val REASON_DOWNLOAD_CHANNEL_BLOCKED = "download_channel_blocked"
|
||||
const val REASON_NOTIFICATIONS_DISABLED = "notifications_disabled"
|
||||
const val REASON_DATA_SAVER = "data_saver"
|
||||
|
||||
/**
|
||||
* `STANDBY_BUCKET_RESTRICTED` (API 30) and `STANDBY_BUCKET_NEVER`. Declared
|
||||
* literally so the classifier stays compilable and testable below those API
|
||||
* levels. `RARE` (40) is intentionally absent — it is reached by ordinary
|
||||
* disuse and says nothing about a user-imposed restriction, so warning on it
|
||||
* would fire on healthy devices.
|
||||
*/
|
||||
const val BUCKET_RESTRICTED = 45
|
||||
const val BUCKET_NEVER = 50
|
||||
|
||||
private val blockingReasons = setOf(
|
||||
REASON_BACKGROUND_RESTRICTED,
|
||||
REASON_STANDBY_RESTRICTED
|
||||
)
|
||||
|
||||
/** Ordered most- to least-actionable; the UI leads with the first entry. */
|
||||
fun reasons(signals: BackgroundWorkSignals): List<String> = buildList {
|
||||
if (signals.backgroundRestricted == true) add(REASON_BACKGROUND_RESTRICTED)
|
||||
if (signals.standbyBucket != null && isRestrictedBucket(signals.standbyBucket)) add(REASON_STANDBY_RESTRICTED)
|
||||
// App-wide denial is more fundamental than a muted channel and routes to
|
||||
// a screen that also exists before the channel has been created.
|
||||
if (signals.notificationsEnabled == false) {
|
||||
add(REASON_NOTIFICATIONS_DISABLED)
|
||||
} else if (signals.downloadChannelBlocked == true) {
|
||||
add(REASON_DOWNLOAD_CHANNEL_BLOCKED)
|
||||
}
|
||||
if (signals.dataSaverRestricted == true) add(REASON_DATA_SAVER)
|
||||
}
|
||||
|
||||
fun verdict(signals: BackgroundWorkSignals, reasons: List<String> = reasons(signals)): String = when {
|
||||
reasons.any(blockingReasons::contains) -> VERDICT_BLOCKED
|
||||
signals.sdkInt != null &&
|
||||
signals.sdkInt >= Build.VERSION_CODES.TIRAMISU &&
|
||||
signals.notificationsEnabled == false -> VERDICT_BLOCKED
|
||||
reasons.isNotEmpty() -> VERDICT_DEGRADED
|
||||
else -> VERDICT_OK
|
||||
}
|
||||
|
||||
fun isRestrictedBucket(bucket: Int): Boolean = bucket == BUCKET_RESTRICTED || bucket == BUCKET_NEVER
|
||||
|
||||
fun toMap(signals: BackgroundWorkSignals): Map<String, Any?> {
|
||||
val reasons = reasons(signals)
|
||||
return mapOf(
|
||||
"verdict" to verdict(signals, reasons),
|
||||
"reasons" to reasons,
|
||||
"sdkInt" to signals.sdkInt,
|
||||
"backgroundRestricted" to signals.backgroundRestricted,
|
||||
"standbyBucket" to signals.standbyBucket,
|
||||
"notificationsEnabled" to signals.notificationsEnabled,
|
||||
"downloadChannelBlocked" to signals.downloadChannelBlocked,
|
||||
"dataSaverRestricted" to signals.dataSaverRestricted,
|
||||
"ignoringBatteryOptimizations" to signals.ignoringBatteryOptimizations
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Settings screen a remedy button can send the user to. */
|
||||
internal enum class BackgroundSettingsTarget(val id: String) {
|
||||
APP_DETAILS("app_details"),
|
||||
APP_NOTIFICATIONS("app_notifications"),
|
||||
NOTIFICATION_CHANNEL("notification_channel");
|
||||
|
||||
companion object {
|
||||
fun fromId(raw: String?): BackgroundSettingsTarget? = entries.firstOrNull { it.id == raw }
|
||||
}
|
||||
}
|
||||
|
||||
/** Declarative intent description, kept Intent-free so it unit-tests on the JVM. */
|
||||
internal data class SettingsIntentSpec(
|
||||
val action: String,
|
||||
val data: String? = null,
|
||||
val stringExtras: Map<String, String> = emptyMap()
|
||||
)
|
||||
|
||||
/**
|
||||
* Ordered candidate intents per target, each falling back to the app details
|
||||
* page — the one Settings screen that exists on every Android build and that
|
||||
* reaches Battery → Unrestricted on One UI, Pixel, and AOSP alike.
|
||||
*
|
||||
* Samsung's own "Background usage limits" screen lives in an unexported
|
||||
* `com.samsung.android.lool` activity that moves between One UI releases, so
|
||||
* it is deliberately not targeted; the user gets written directions instead.
|
||||
*/
|
||||
internal object BackgroundSettingsIntents {
|
||||
fun specsFor(
|
||||
target: BackgroundSettingsTarget,
|
||||
packageName: String,
|
||||
sdkInt: Int
|
||||
): List<SettingsIntentSpec> {
|
||||
val appDetails = SettingsIntentSpec(
|
||||
action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
data = "package:$packageName"
|
||||
)
|
||||
if (sdkInt < Build.VERSION_CODES.O) return listOf(appDetails)
|
||||
return when (target) {
|
||||
BackgroundSettingsTarget.APP_DETAILS -> listOf(appDetails)
|
||||
BackgroundSettingsTarget.APP_NOTIFICATIONS -> listOf(
|
||||
SettingsIntentSpec(
|
||||
action = Settings.ACTION_APP_NOTIFICATION_SETTINGS,
|
||||
stringExtras = mapOf(Settings.EXTRA_APP_PACKAGE to packageName)
|
||||
),
|
||||
appDetails
|
||||
)
|
||||
BackgroundSettingsTarget.NOTIFICATION_CHANNEL -> listOf(
|
||||
SettingsIntentSpec(
|
||||
action = Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS,
|
||||
stringExtras = mapOf(
|
||||
Settings.EXTRA_APP_PACKAGE to packageName,
|
||||
Settings.EXTRA_CHANNEL_ID to BackgroundWorkDiagnostics.DOWNLOAD_NOTIFICATION_CHANNEL_ID
|
||||
)
|
||||
),
|
||||
SettingsIntentSpec(
|
||||
action = Settings.ACTION_APP_NOTIFICATION_SETTINGS,
|
||||
stringExtras = mapOf(Settings.EXTRA_APP_PACKAGE to packageName)
|
||||
),
|
||||
appDetails
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the OS-visible reasons background downloads may be blocked.
|
||||
*
|
||||
* Every read is for our own package and needs no permission. Notably absent is
|
||||
* `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`: it is a Play-policy-restricted
|
||||
* permission whose qualifying use cases do not include media downloading.
|
||||
* Whitelist state is reported for support diagnostics only.
|
||||
*/
|
||||
internal object BackgroundWorkDiagnostics {
|
||||
/** `background_downloader`'s channel id (`Notifications.kt`, `nId`). */
|
||||
const val DOWNLOAD_NOTIFICATION_CHANNEL_ID = "background_downloader"
|
||||
|
||||
fun read(context: Context): BackgroundWorkSignals = BackgroundWorkSignals(
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
backgroundRestricted = readBackgroundRestricted(context),
|
||||
standbyBucket = readStandbyBucket(context),
|
||||
notificationsEnabled = readNotificationsEnabled(context),
|
||||
downloadChannelBlocked = readDownloadChannelBlocked(context),
|
||||
dataSaverRestricted = readDataSaverRestricted(context),
|
||||
ignoringBatteryOptimizations = readIgnoringBatteryOptimizations(context)
|
||||
)
|
||||
|
||||
private fun readBackgroundRestricted(context: Context): Boolean? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return null
|
||||
return runCatching {
|
||||
(context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).isBackgroundRestricted
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun readStandbyBucket(context: Context): Int? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) return null
|
||||
return runCatching {
|
||||
(context.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager).appStandbyBucket
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun readNotificationsEnabled(context: Context): Boolean? = runCatching {
|
||||
(context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).areNotificationsEnabled()
|
||||
}.getOrNull()
|
||||
|
||||
/**
|
||||
* Null until the plugin has created its channel (first download), which is
|
||||
* correct — there is nothing to warn about before then.
|
||||
*/
|
||||
private fun readDownloadChannelBlocked(context: Context): Boolean? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return null
|
||||
return runCatching {
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
val channel = manager.getNotificationChannel(DOWNLOAD_NOTIFICATION_CHANNEL_ID) ?: return null
|
||||
channel.importance == NotificationManager.IMPORTANCE_NONE
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun readDataSaverRestricted(context: Context): Boolean? {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return null
|
||||
return runCatching {
|
||||
val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
connectivity.restrictBackgroundStatus == ConnectivityManager.RESTRICT_BACKGROUND_STATUS_ENABLED
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun readIgnoringBatteryOptimizations(context: Context): Boolean? = runCatching {
|
||||
(context.getSystemService(Context.POWER_SERVICE) as PowerManager)
|
||||
.isIgnoringBatteryOptimizations(context.packageName)
|
||||
}.getOrNull()
|
||||
|
||||
/**
|
||||
* Launches the first candidate Settings screen that resolves. Returns false
|
||||
* when the device has none of them — some TV and Fire OS builds ship without
|
||||
* a battery settings activity entirely.
|
||||
*/
|
||||
fun openSettings(context: Context, target: BackgroundSettingsTarget): Boolean {
|
||||
for (spec in BackgroundSettingsIntents.specsFor(target, context.packageName, Build.VERSION.SDK_INT)) {
|
||||
val launched = runCatching {
|
||||
val intent = Intent(spec.action).apply {
|
||||
spec.data?.let { data = Uri.parse(it) }
|
||||
spec.stringExtras.forEach { (key, value) -> putExtra(key, value) }
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
if (launched) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.edde746.plezy
|
||||
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.TextureView
|
||||
|
||||
internal class DeferredSurfaceTextureListener(
|
||||
private val delegate: TextureView.SurfaceTextureListener,
|
||||
private val handler: Handler = Handler(Looper.getMainLooper()),
|
||||
private val onSurfaceAvailable: () -> Unit = {},
|
||||
private val isCurrentSurface: (SurfaceTexture) -> Boolean
|
||||
) : TextureView.SurfaceTextureListener {
|
||||
|
||||
private var pendingResize: Runnable? = null
|
||||
|
||||
override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
|
||||
delegate.onSurfaceTextureAvailable(surface, width, height)
|
||||
onSurfaceAvailable()
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) {
|
||||
pendingResize?.let(handler::removeCallbacks)
|
||||
pendingResize = Runnable {
|
||||
pendingResize = null
|
||||
if (isCurrentSurface(surface)) {
|
||||
delegate.onSurfaceTextureSizeChanged(surface, width, height)
|
||||
}
|
||||
}
|
||||
handler.postDelayed(pendingResize!!, RESIZE_DELAY_MILLIS)
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {
|
||||
delegate.onSurfaceTextureUpdated(surface)
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
|
||||
pendingResize?.let(handler::removeCallbacks)
|
||||
pendingResize = null
|
||||
return delegate.onSurfaceTextureDestroyed(surface)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val RESIZE_DELAY_MILLIS = 100L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package com.edde746.plezy
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.core.content.FileProvider
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.File
|
||||
|
||||
internal class ExternalPlayerChannel(private val activity: Activity) {
|
||||
companion object {
|
||||
private const val CHANNEL = "com.plezy/external_player"
|
||||
const val REQUEST_CODE = 7461
|
||||
|
||||
private const val API_MX_RETURN_RESULT = "return_result"
|
||||
private const val API_MX_RESULT_ID = "com.mxtech.intent.result.VIEW"
|
||||
private const val API_MX_RESULT_POSITION = "position"
|
||||
private const val API_MX_RESULT_DURATION = "duration"
|
||||
private const val API_MX_RESULT_END_BY = "end_by"
|
||||
private const val API_MX_RESULT_END_BY_PLAYBACK_COMPLETION = "playback_completion"
|
||||
private const val API_MX_TITLE = "title"
|
||||
private const val API_MX_FILENAME = "filename"
|
||||
private const val API_MX_SECURE_URI = "secure_uri"
|
||||
private const val API_VLC_RESULT_POSITION = "extra_position"
|
||||
private const val API_VLC_RESULT_DURATION = "extra_duration"
|
||||
|
||||
private const val API_VIMU_TITLE = "forcename"
|
||||
private const val API_VIMU_SEEK_POSITION = "startfrom"
|
||||
private const val API_VIMU_RESUME = "forceresume"
|
||||
private const val API_VIMU_RESULT_ID = "net.gtvbox.videoplayer.result"
|
||||
private const val API_VIMU_RESULT_ERROR = 4
|
||||
private const val API_VIMU_RESULT_PLAYBACK_COMPLETED = 1
|
||||
|
||||
private val positionExtras = arrayOf(API_MX_RESULT_POSITION, API_VLC_RESULT_POSITION)
|
||||
private val durationExtras = arrayOf(API_MX_RESULT_DURATION, API_VLC_RESULT_DURATION)
|
||||
}
|
||||
|
||||
private var pendingResult: MethodChannel.Result? = null
|
||||
|
||||
fun attach(messenger: BinaryMessenger) {
|
||||
MethodChannel(messenger, CHANNEL).setMethodCallHandler(::onMethodCall)
|
||||
}
|
||||
|
||||
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||
if (requestCode != REQUEST_CODE) return false
|
||||
val result = pendingResult
|
||||
pendingResult = null
|
||||
if (result == null) {
|
||||
android.util.Log.w("ExternalPlayerChannel", "Result received without a pending channel call")
|
||||
} else {
|
||||
result.success(buildResult(resultCode, data))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
pendingResult?.error("ACTIVITY_DESTROYED", "Activity was destroyed while external player was active", null)
|
||||
pendingResult = null
|
||||
}
|
||||
|
||||
internal fun buildResult(resultCode: Int, data: Intent?): Map<String, Any?> {
|
||||
val extras = data?.extras
|
||||
val endPosition = firstNumberExtra(extras, positionExtras)
|
||||
val duration = firstNumberExtra(extras, durationExtras)
|
||||
val action = data?.action
|
||||
val playbackCompleted = when (action) {
|
||||
API_MX_RESULT_ID -> extras?.getString(API_MX_RESULT_END_BY) == API_MX_RESULT_END_BY_PLAYBACK_COMPLETION
|
||||
API_VIMU_RESULT_ID -> resultCode == API_VIMU_RESULT_PLAYBACK_COMPLETED
|
||||
else -> false
|
||||
}
|
||||
val playbackError = action == API_VIMU_RESULT_ID && resultCode == API_VIMU_RESULT_ERROR
|
||||
|
||||
return mapOf(
|
||||
"launched" to true,
|
||||
"resultCode" to resultCode,
|
||||
"resultOk" to (resultCode == Activity.RESULT_OK),
|
||||
"action" to action,
|
||||
"positionMs" to endPosition,
|
||||
"durationMs" to duration,
|
||||
"playbackCompleted" to playbackCompleted,
|
||||
"playbackError" to playbackError
|
||||
)
|
||||
}
|
||||
|
||||
private fun firstNumberExtra(extras: Bundle?, keys: Array<String>): Long? {
|
||||
if (extras == null) return null
|
||||
for (key in keys) {
|
||||
@Suppress("DEPRECATION")
|
||||
val value = extras.get(key)
|
||||
when (value) {
|
||||
is Number -> return value.toLong()
|
||||
is String -> value.toLongOrNull()?.let { return it }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
if (call.method != "openVideo") {
|
||||
result.notImplemented()
|
||||
return
|
||||
}
|
||||
|
||||
val filePath = call.argument<String>("filePath")
|
||||
val packageNames = call.argument<List<Any?>>("packages")
|
||||
?.mapNotNull { (it as? String)?.trim()?.takeIf(String::isNotEmpty) }
|
||||
?: emptyList()
|
||||
val title = call.argument<String>("title")?.trim()?.takeIf(String::isNotEmpty)
|
||||
val startPositionMs = call.argument<Number>("startPositionMs")?.toLong() ?: 0L
|
||||
|
||||
if (filePath == null) {
|
||||
result.error("INVALID_ARGUMENT", "filePath is required", null)
|
||||
return
|
||||
}
|
||||
if (pendingResult != null) {
|
||||
result.error("ALREADY_ACTIVE", "An external player is already active", null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val source = resolveSource(filePath)
|
||||
val targetPackages = if (packageNames.isEmpty()) listOf<String?>(null) else packageNames
|
||||
for (packageName in targetPackages) {
|
||||
try {
|
||||
pendingResult = result
|
||||
activity.startActivityForResult(
|
||||
buildIntent(source, packageName, startPositionMs, title),
|
||||
REQUEST_CODE
|
||||
)
|
||||
return
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
pendingResult = null
|
||||
}
|
||||
}
|
||||
|
||||
val message = if (packageNames.isEmpty()) {
|
||||
"No app found for video"
|
||||
} else {
|
||||
"No app found for packages: ${packageNames.joinToString(", ")}"
|
||||
}
|
||||
result.error("APP_NOT_FOUND", message, null)
|
||||
} catch (error: Exception) {
|
||||
pendingResult = null
|
||||
result.error("LAUNCH_FAILED", error.message ?: error.javaClass.simpleName, null)
|
||||
}
|
||||
}
|
||||
|
||||
private data class Source(val uri: Uri, val grantRead: Boolean, val fileName: String?)
|
||||
|
||||
private fun resolveSource(filePath: String): Source {
|
||||
if (filePath.startsWith("http://") || filePath.startsWith("https://")) {
|
||||
val uri = Uri.parse(filePath)
|
||||
return Source(uri, grantRead = false, fileName = uri.lastPathSegment)
|
||||
}
|
||||
if (filePath.startsWith("content://")) {
|
||||
val uri = Uri.parse(filePath)
|
||||
return Source(uri, grantRead = true, fileName = uri.lastPathSegment)
|
||||
}
|
||||
|
||||
val path = if (filePath.startsWith("file://")) filePath.removePrefix("file://") else filePath
|
||||
val file = File(path)
|
||||
val uri = FileProvider.getUriForFile(activity, "com.edde746.plezy.fileprovider", file)
|
||||
return Source(uri, grantRead = true, fileName = file.name)
|
||||
}
|
||||
|
||||
private fun buildIntent(
|
||||
source: Source,
|
||||
packageName: String?,
|
||||
startPositionMs: Long,
|
||||
title: String?
|
||||
): Intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(source.uri, "video/*")
|
||||
if (source.grantRead) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
packageName?.let(::setPackage)
|
||||
val startPosition = startPositionMs.coerceAtLeast(0).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
|
||||
if (startPosition > 0) {
|
||||
putExtra(API_MX_RESULT_POSITION, startPosition)
|
||||
putExtra(API_VIMU_SEEK_POSITION, startPosition)
|
||||
}
|
||||
putExtra(API_MX_RETURN_RESULT, true)
|
||||
putExtra(API_MX_SECURE_URI, true)
|
||||
putExtra(API_VIMU_RESUME, false)
|
||||
title?.let {
|
||||
putExtra(API_MX_TITLE, it)
|
||||
putExtra(API_VIMU_TITLE, it)
|
||||
}
|
||||
source.fileName?.let { putExtra(API_MX_FILENAME, it) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.edde746.plezy
|
||||
|
||||
internal enum class FlutterRenderer(
|
||||
val diagnosticName: String,
|
||||
val shellArgument: String?
|
||||
) {
|
||||
SKIA("Skia", "--enable-impeller=false"),
|
||||
IMPELLER("Impeller", null)
|
||||
}
|
||||
|
||||
/** Selects the Flutter UI renderer before the engine starts. */
|
||||
internal object FlutterRendererPolicy {
|
||||
private const val ANDROID_12_API = 31
|
||||
|
||||
fun select(
|
||||
isEWaste: Boolean,
|
||||
manufacturer: String,
|
||||
isAndroidTv: Boolean,
|
||||
sdkInt: Int,
|
||||
supportsVulkan11: Boolean,
|
||||
is64Bit: Boolean
|
||||
): FlutterRenderer {
|
||||
if (isEWaste) return FlutterRenderer.SKIA
|
||||
if (manufacturer.equals("NVIDIA", ignoreCase = true)) return FlutterRenderer.SKIA
|
||||
if (manufacturer.equals("Huawei", ignoreCase = true) ||
|
||||
manufacturer.equals("HONOR", ignoreCase = true)
|
||||
) {
|
||||
return FlutterRenderer.SKIA
|
||||
}
|
||||
if (!isAndroidTv) return FlutterRenderer.IMPELLER
|
||||
if (sdkInt < ANDROID_12_API || manufacturer.equals("Amazon", ignoreCase = true) || !supportsVulkan11) {
|
||||
return FlutterRenderer.SKIA
|
||||
}
|
||||
|
||||
// 32-bit Android TV SoCs are the low-memory / low-throughput class. Skia avoids
|
||||
// Impeller/Vulkan's substantially higher raster cost on these devices.
|
||||
if (!is64Bit) return FlutterRenderer.SKIA
|
||||
|
||||
return FlutterRenderer.IMPELLER
|
||||
}
|
||||
}
|
||||
@@ -7,23 +7,23 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.net.Uri
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.Process
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.util.Rational
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.TextureView
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowInsets
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.edde746.plezy.exoplayer.ExoPlayerPlugin
|
||||
import com.edde746.plezy.mpv.MpvAudioPlayerPlugin
|
||||
import com.edde746.plezy.mpv.MpvPlayerPlugin
|
||||
import com.edde746.plezy.shared.DeviceQuirks
|
||||
import com.edde746.plezy.shared.ThemeHelper
|
||||
@@ -35,25 +35,53 @@ import io.flutter.embedding.android.TransparencyMode
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.FlutterShellArgs
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.RejectedExecutionException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "MainActivity"
|
||||
private const val TEXT_INPUT_DIAGNOSTICS_ENABLED = false
|
||||
var usingSkia = false
|
||||
private const val EXIT_DIAGNOSTICS_PREFS = "plezy_exit_diagnostics"
|
||||
private const val LAST_EXIT_DEDUPE_KEY = "last_reported_exit"
|
||||
private const val LAST_STARTUP_PHASE_KEY = "last_startup_phase"
|
||||
private val startupPhaseLock = Any()
|
||||
|
||||
@Volatile private var startupPhaseInitializationAttempted = false
|
||||
|
||||
@Volatile private var startupPhaseStore: StartupPhaseStore? = null
|
||||
|
||||
@Volatile private var previousRuntimeDiagnostics = RuntimeDiagnosticSnapshot()
|
||||
private val exitDiagnosticsExecutor by lazy {
|
||||
Executors.newSingleThreadExecutor { runnable ->
|
||||
Thread(runnable, "plezy-exit-diagnostics").apply { isDaemon = true }
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors DevicePerformance._lowMemThresholdBytes (2252 MiB): nominal
|
||||
// "2GB" devices report totalMem slightly above 2 GiB after carve-outs.
|
||||
private const val LOW_MEM_THRESHOLD_BYTES = 2252L shl 20
|
||||
|
||||
private var selectedFlutterRenderer = FlutterRenderer.IMPELLER
|
||||
}
|
||||
|
||||
private val PIP_CHANNEL = "com.plezy/pip"
|
||||
private val EXTERNAL_PLAYER_CHANNEL = "com.plezy/external_player"
|
||||
private val THEME_CHANNEL = "com.plezy/theme"
|
||||
private val DEVICE_CHANNEL = "com.plezy/device"
|
||||
private val DEVICE_ADJUSTMENT_CHANNEL = "com.plezy/device_adjustment"
|
||||
private val TEXT_INPUT_CHANNEL = "com.plezy/text_input"
|
||||
private val APP_EXIT_CHANNEL = "com.plezy/app_exit"
|
||||
private val APP_FOREGROUND_CHANNEL = "com.plezy/app_foreground"
|
||||
private var watchNextPlugin: WatchNextPlugin? = null
|
||||
private var nativeTextInputFocused = false
|
||||
private var originalWindowBrightness: Float? = null
|
||||
private var flutterTextureView: FlutterTextureView? = null
|
||||
private var flutterSurfaceReconnectPending = false
|
||||
private var activityStarted = false
|
||||
private val externalPlayerChannel = ExternalPlayerChannel(this)
|
||||
private val exitDiagnosticsRequested = AtomicBoolean(false)
|
||||
|
||||
private inline fun logTextInputDiag(message: () -> String) {
|
||||
if (TEXT_INPUT_DIAGNOSTICS_ENABLED) {
|
||||
@@ -68,6 +96,8 @@ class MainActivity : FlutterActivity() {
|
||||
|
||||
private fun isAndroidTvDevice(): Boolean = getAndroidTvDetection()["isTv"] as Boolean
|
||||
|
||||
private fun isPipSupportedDevice(): Boolean = !isAndroidTvDevice() && packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
|
||||
|
||||
private fun isImeVisible(): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return false
|
||||
return window.decorView.rootWindowInsets?.isVisible(WindowInsets.Type.ime()) == true
|
||||
@@ -140,6 +170,7 @@ class MainActivity : FlutterActivity() {
|
||||
val hasFireTvFeature = pm.hasSystemFeature("amazon.hardware.fire_tv")
|
||||
val hasTouchscreen = pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN)
|
||||
val hasFakeTouch = pm.hasSystemFeature(PackageManager.FEATURE_FAKETOUCH)
|
||||
val isAutomotive = pm.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE)
|
||||
|
||||
val reasons = mutableListOf<String>()
|
||||
if (isTelevisionUiMode) reasons.add("ui_mode_television")
|
||||
@@ -149,7 +180,11 @@ class MainActivity : FlutterActivity() {
|
||||
if (!hasTouchscreen) reasons.add("no_touchscreen")
|
||||
|
||||
return mapOf(
|
||||
"isTv" to reasons.isNotEmpty(),
|
||||
// A car is never a TV: rotary-only head units report no touchscreen, and
|
||||
// an OEM image can carry a stray leanback flag. Keep the raw reasons for
|
||||
// diagnostics, but never let them promote a vehicle to the TV experience.
|
||||
"isTv" to (!isAutomotive && reasons.isNotEmpty()),
|
||||
"isAutomotive" to isAutomotive,
|
||||
"reasons" to reasons,
|
||||
"isTelevisionUiMode" to isTelevisionUiMode,
|
||||
"hasTelevisionFeature" to hasTelevisionFeature,
|
||||
@@ -162,7 +197,174 @@ class MainActivity : FlutterActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
/** Hardware capability signals used by Dart to pick the visual-effects tier. */
|
||||
private fun getPerformanceSignals(): Map<String, Any> {
|
||||
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
val memoryInfo = ActivityManager.MemoryInfo()
|
||||
activityManager.getMemoryInfo(memoryInfo)
|
||||
return mapOf(
|
||||
// Actual process bitness: low-end TV boxes often run 32-bit userspace.
|
||||
"is64Bit" to Process.is64Bit(),
|
||||
"isLowRamDevice" to activityManager.isLowRamDevice,
|
||||
"totalMemBytes" to memoryInfo.totalMem
|
||||
)
|
||||
}
|
||||
|
||||
private fun initializeStartupPhaseStore() {
|
||||
var shouldMarkNativeOnCreate = false
|
||||
synchronized(startupPhaseLock) {
|
||||
if (startupPhaseInitializationAttempted) return
|
||||
startupPhaseInitializationAttempted = true
|
||||
try {
|
||||
previousRuntimeDiagnostics = AndroidRuntimeDiagnostics.read(this)
|
||||
val preferences = getSharedPreferences(EXIT_DIAGNOSTICS_PREFS, Context.MODE_PRIVATE)
|
||||
startupPhaseStore = StartupPhaseStore(
|
||||
readPhase = { preferences.getString(LAST_STARTUP_PHASE_KEY, null) },
|
||||
persistPhase = { phase ->
|
||||
preferences.edit().putString(LAST_STARTUP_PHASE_KEY, phase).commit()
|
||||
}
|
||||
)
|
||||
shouldMarkNativeOnCreate = true
|
||||
} catch (_: Throwable) {
|
||||
Log.w(TAG, "Startup phase persistence unavailable")
|
||||
}
|
||||
}
|
||||
if (shouldMarkNativeOnCreate) {
|
||||
queueStartupPhase(AndroidStartupPhases.NATIVE_ON_CREATE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun queueStartupPhase(raw: String?, result: MethodChannel.Result? = null) {
|
||||
val phase = AndroidStartupPhases.sanitize(raw)
|
||||
if (phase == null) {
|
||||
result?.let { completeStartupPhase(it, false) }
|
||||
return
|
||||
}
|
||||
AndroidRuntimeDiagnostics.update(this, uiState = uiStateForStartupPhase(phase))
|
||||
try {
|
||||
exitDiagnosticsExecutor.execute {
|
||||
val persisted = try {
|
||||
startupPhaseStore?.mark(phase) == true
|
||||
} catch (_: Throwable) {
|
||||
Log.w(TAG, "Startup phase update failed")
|
||||
false
|
||||
}
|
||||
result?.let { reply ->
|
||||
runOnUiThread { completeStartupPhase(reply, persisted) }
|
||||
}
|
||||
}
|
||||
} catch (_: Throwable) {
|
||||
Log.w(TAG, "Startup phase update could not start")
|
||||
result?.let { completeStartupPhase(it, false) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun uiStateForStartupPhase(phase: String): String = when (phase) {
|
||||
"credentials_loaded", "binding_started", "binding_settled" -> AndroidRuntimeDiagnostics.UI_AUTHENTICATION
|
||||
"main_screen" -> AndroidRuntimeDiagnostics.UI_MAIN_SCREEN
|
||||
else -> AndroidRuntimeDiagnostics.UI_STARTUP
|
||||
}
|
||||
|
||||
private fun completeStartupPhase(result: MethodChannel.Result, persisted: Boolean) {
|
||||
try {
|
||||
result.success(persisted)
|
||||
} catch (_: Throwable) {
|
||||
Log.w(TAG, "Startup phase reply failed")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePreviousExit(result: MethodChannel.Result) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
completePreviousExit(result, null)
|
||||
return
|
||||
}
|
||||
if (!exitDiagnosticsRequested.compareAndSet(false, true)) {
|
||||
completePreviousExit(result, null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
exitDiagnosticsExecutor.execute {
|
||||
val report = try {
|
||||
readPreviousExit()
|
||||
} catch (_: Throwable) {
|
||||
Log.w(TAG, "Previous exit diagnostics failed")
|
||||
null
|
||||
}
|
||||
runOnUiThread { completePreviousExit(result, report) }
|
||||
}
|
||||
} catch (_: RejectedExecutionException) {
|
||||
completePreviousExit(result, null)
|
||||
} catch (_: Throwable) {
|
||||
Log.w(TAG, "Previous exit diagnostics could not start")
|
||||
completePreviousExit(result, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun completePreviousExit(result: MethodChannel.Result, report: Map<String, Any>?) {
|
||||
try {
|
||||
result.success(report)
|
||||
} catch (_: Throwable) {
|
||||
Log.w(TAG, "Previous exit diagnostics reply failed")
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private fun readPreviousExit(): Map<String, Any>? {
|
||||
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
val exitInfo = activityManager
|
||||
.getHistoricalProcessExitReasons(packageName, 0, 1)
|
||||
.firstOrNull()
|
||||
?: return null
|
||||
val report = AndroidExitReportMapper.map(
|
||||
record = HistoricalExitRecord(
|
||||
reason = exitInfo.reason,
|
||||
status = exitInfo.status,
|
||||
importance = exitInfo.importance,
|
||||
timestamp = exitInfo.timestamp
|
||||
),
|
||||
deviceModel = Build.MODEL,
|
||||
apiLevel = Build.VERSION.SDK_INT,
|
||||
abi = Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown",
|
||||
lowRam = activityManager.isLowRamDevice,
|
||||
startupPhase = startupPhaseStore?.previousPhase,
|
||||
runtime = previousRuntimeDiagnostics
|
||||
)
|
||||
val preferences = getSharedPreferences(EXIT_DIAGNOSTICS_PREFS, Context.MODE_PRIVATE)
|
||||
return PreviousExitReportStore(
|
||||
readDedupeKey = { preferences.getString(LAST_EXIT_DEDUPE_KEY, null) },
|
||||
persistDedupeKey = { key ->
|
||||
preferences.edit().putString(LAST_EXIT_DEDUPE_KEY, key).commit()
|
||||
}
|
||||
).takeIfNew(report)
|
||||
}
|
||||
|
||||
/**
|
||||
* Same triple DevicePerformance uses for the reduced tier on the Dart
|
||||
* side — keep the two in sync. Evaluated here too because engine shell
|
||||
* args must be decided before Dart runs.
|
||||
*/
|
||||
private fun isLowRamClass(): Boolean {
|
||||
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
val memoryInfo = ActivityManager.MemoryInfo()
|
||||
activityManager.getMemoryInfo(memoryInfo)
|
||||
return !Process.is64Bit() || activityManager.isLowRamDevice || memoryInfo.totalMem <= LOW_MEM_THRESHOLD_BYTES
|
||||
}
|
||||
|
||||
/** User-assigned device name (Settings > About > Device name), or null. */
|
||||
private fun getDeviceName(): String? {
|
||||
// The name the user gave the device; also used by Cast/Nearby.
|
||||
val name = Settings.Global.getString(contentResolver, Settings.Global.DEVICE_NAME)
|
||||
if (!name.isNullOrBlank()) return name
|
||||
// Fallback: the Bluetooth name usually mirrors the device name. Reading the
|
||||
// settings string needs no BLUETOOTH permission (unlike BluetoothAdapter).
|
||||
val bt = Settings.Secure.getString(contentResolver, "bluetooth_name")
|
||||
return if (!bt.isNullOrBlank()) bt else null
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// Snapshot the previous process phase before this launch can overwrite it.
|
||||
initializeStartupPhaseStore()
|
||||
// Apply persisted theme color to the window background before anything
|
||||
// else renders. This prevents a white flash between the native splash
|
||||
// screen and Flutter's first frame for non-default themes (e.g. OLED).
|
||||
@@ -250,6 +452,20 @@ class MainActivity : FlutterActivity() {
|
||||
return handled
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (!externalPlayerChannel.onActivityResult(requestCode, resultCode, data)) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
externalPlayerChannel.dispose()
|
||||
activityStarted = false
|
||||
flutterSurfaceReconnectPending = false
|
||||
flutterTextureView = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun handleWatchNextIntent(intent: Intent?) {
|
||||
val contentId = WatchNextPlugin.handleIntent(intent)
|
||||
if (contentId != null) {
|
||||
@@ -260,24 +476,38 @@ class MainActivity : FlutterActivity() {
|
||||
|
||||
override fun getFlutterShellArgs(): FlutterShellArgs {
|
||||
val args = super.getFlutterShellArgs()
|
||||
usingSkia = shouldDisableImpeller()
|
||||
if (usingSkia) args.add("--enable-impeller=false")
|
||||
selectedFlutterRenderer = selectFlutterRenderer()
|
||||
selectedFlutterRenderer.shellArgument?.let { args.add(it) }
|
||||
if (isLowRamClass()) {
|
||||
// Bound the memory pools Dart can't reach: Skia's GPU resource cache
|
||||
// is sized from the surface area (hundreds of MB on a 4K-composited
|
||||
// TV) and the Dart old gen defaults to a large fraction of physical
|
||||
// RAM. Both drive LMK kills on 2GB boxes (#1349).
|
||||
if (selectedFlutterRenderer == FlutterRenderer.SKIA) {
|
||||
args.add("--resource-cache-max-bytes-threshold=50331648")
|
||||
}
|
||||
args.add("--old-gen-heap-size=256")
|
||||
Log.i(
|
||||
TAG,
|
||||
"Low-RAM device: capped engine caches " +
|
||||
"(renderer=${selectedFlutterRenderer.diagnosticName}, oldGen=256MB)"
|
||||
)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
private fun shouldDisableImpeller(): Boolean {
|
||||
// Android TV devices — weaker GPUs, less Impeller testing
|
||||
if (isAndroidTvDevice()) return true
|
||||
if (DeviceQuirks.isEWaste) return true
|
||||
// NVIDIA Tegra (Shield TV)
|
||||
if (Build.MANUFACTURER.equals("NVIDIA", ignoreCase = true)) return true
|
||||
// Huawei/HONOR Kirin SoCs use Mali GPUs
|
||||
if (Build.MANUFACTURER.equals("Huawei", ignoreCase = true) ||
|
||||
Build.MANUFACTURER.equals("HONOR", ignoreCase = true)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
private fun selectFlutterRenderer(): FlutterRenderer {
|
||||
val isAndroidTv = isAndroidTvDevice()
|
||||
val vulkan11 = 0x401000 // FEATURE_VULKAN_HARDWARE_VERSION encodes 1.1.0 as 0x401000
|
||||
return FlutterRendererPolicy.select(
|
||||
isEWaste = DeviceQuirks.isEWaste,
|
||||
manufacturer = Build.MANUFACTURER,
|
||||
isAndroidTv = isAndroidTv,
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
supportsVulkan11 = isAndroidTv &&
|
||||
packageManager.hasSystemFeature(PackageManager.FEATURE_VULKAN_HARDWARE_VERSION, vulkan11),
|
||||
is64Bit = Process.is64Bit()
|
||||
)
|
||||
}
|
||||
|
||||
override fun getRenderMode(): RenderMode {
|
||||
@@ -293,41 +523,49 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
|
||||
override fun onFlutterTextureViewCreated(flutterTextureView: FlutterTextureView) {
|
||||
this.flutterTextureView = flutterTextureView
|
||||
val original = flutterTextureView.surfaceTextureListener ?: return
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
var pendingResize: Runnable? = null
|
||||
var lastWidth = 0
|
||||
var lastHeight = 0
|
||||
flutterTextureView.surfaceTextureListener =
|
||||
DeferredSurfaceTextureListener(
|
||||
delegate = original,
|
||||
onSurfaceAvailable = ::tryReconnectFlutterSurface
|
||||
) { surface ->
|
||||
flutterTextureView.isAvailable && flutterTextureView.surfaceTexture === surface
|
||||
}
|
||||
}
|
||||
|
||||
flutterTextureView.surfaceTextureListener = object : TextureView.SurfaceTextureListener {
|
||||
override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
|
||||
original.onSurfaceTextureAvailable(surface, width, height)
|
||||
override fun onStop() {
|
||||
activityStarted = false
|
||||
if (isAndroidTvDevice()) flutterSurfaceReconnectPending = true
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
activityStarted = true
|
||||
tryReconnectFlutterSurface()
|
||||
}
|
||||
|
||||
private fun tryReconnectFlutterSurface() {
|
||||
if (!activityStarted || !flutterSurfaceReconnectPending) return
|
||||
val textureView = flutterTextureView ?: return
|
||||
textureView.post {
|
||||
if (!activityStarted ||
|
||||
!flutterSurfaceReconnectPending ||
|
||||
textureView !== flutterTextureView ||
|
||||
!textureView.isAttachedToWindow ||
|
||||
!textureView.isAvailable
|
||||
) {
|
||||
return@post
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) {
|
||||
if (width == lastWidth && height == lastHeight) return
|
||||
lastWidth = width
|
||||
lastHeight = height
|
||||
pendingResize?.let { handler.removeCallbacks(it) }
|
||||
pendingResize = Runnable {
|
||||
if (flutterTextureView.isAvailable) {
|
||||
original.onSurfaceTextureSizeChanged(surface, width, height)
|
||||
}
|
||||
}
|
||||
handler.postDelayed(pendingResize!!, 100)
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {
|
||||
original.onSurfaceTextureUpdated(surface)
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
|
||||
pendingResize?.let { handler.removeCallbacks(it) }
|
||||
pendingResize = null
|
||||
lastWidth = 0
|
||||
lastHeight = 0
|
||||
return original.onSurfaceTextureDestroyed(surface)
|
||||
}
|
||||
// Some TV firmware retains an available TextureView while invalidating
|
||||
// its compositor surface during standby. Force Flutter's supported
|
||||
// surface-swap path so rendering resumes without restarting the engine.
|
||||
Log.i(TAG, "Reconnecting Flutter texture surface after Android TV standby")
|
||||
textureView.pause()
|
||||
textureView.resume()
|
||||
flutterSurfaceReconnectPending = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,14 +573,39 @@ class MainActivity : FlutterActivity() {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
flutterEngine.plugins.add(MpvPlayerPlugin())
|
||||
flutterEngine.plugins.add(ExoPlayerPlugin())
|
||||
flutterEngine.plugins.add(MpvAudioPlayerPlugin())
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, DEVICE_CHANNEL).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"getTvDetection" -> result.success(getAndroidTvDetection())
|
||||
"getDeviceName" -> result.success(getDeviceName())
|
||||
"getPerformanceSignals" -> result.success(getPerformanceSignals())
|
||||
"getBackgroundWorkSignals" -> result.success(
|
||||
BackgroundWorkClassifier.toMap(BackgroundWorkDiagnostics.read(this))
|
||||
)
|
||||
"openBackgroundSettings" -> {
|
||||
val target = BackgroundSettingsTarget.fromId(call.arguments as? String)
|
||||
result.success(target != null && BackgroundWorkDiagnostics.openSettings(this, target))
|
||||
}
|
||||
"getPreviousExit" -> handlePreviousExit(result)
|
||||
"setStartupPhase" -> queueStartupPhase(call.arguments as? String, result)
|
||||
"setRuntimeUiState" -> {
|
||||
val uiState = AndroidRuntimeDiagnostics.sanitizeUiState(call.arguments as? String)
|
||||
if (uiState == null) {
|
||||
result.success(false)
|
||||
} else {
|
||||
AndroidRuntimeDiagnostics.update(this, uiState = uiState)
|
||||
result.success(true)
|
||||
}
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, DEVICE_ADJUSTMENT_CHANNEL).setMethodCallHandler { call, result ->
|
||||
handleDeviceAdjustmentCall(call.method, call.arguments, result)
|
||||
}
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, TEXT_INPUT_CHANNEL).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"setNativeTextInputFocused" -> {
|
||||
@@ -369,67 +632,12 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, APP_FOREGROUND_CHANNEL).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"requestForeground" -> result.success(requestForeground())
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
// External player: open local video files with proper content:// URIs
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, EXTERNAL_PLAYER_CHANNEL).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"openVideo" -> {
|
||||
val filePath = call.argument<String>("filePath")
|
||||
val packageName = call.argument<String>("package")
|
||||
|
||||
if (filePath == null) {
|
||||
result.error("INVALID_ARGUMENT", "filePath is required", null)
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
|
||||
try {
|
||||
val uri: Uri
|
||||
val grantRead: Boolean
|
||||
|
||||
if (filePath.startsWith("http://") || filePath.startsWith("https://")) {
|
||||
uri = Uri.parse(filePath)
|
||||
grantRead = false
|
||||
} else if (filePath.startsWith("content://")) {
|
||||
uri = Uri.parse(filePath)
|
||||
grantRead = true
|
||||
} else {
|
||||
val path = if (filePath.startsWith("file://")) filePath.removePrefix("file://") else filePath
|
||||
uri = FileProvider.getUriForFile(this, "com.edde746.plezy.fileprovider", File(path))
|
||||
grantRead = true
|
||||
}
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "video/*")
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
if (grantRead) {
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
if (packageName != null) {
|
||||
setPackage(packageName)
|
||||
}
|
||||
}
|
||||
startActivity(intent)
|
||||
result.success(true)
|
||||
} catch (e: android.content.ActivityNotFoundException) {
|
||||
result.error("APP_NOT_FOUND", "No app found for package: $packageName", null)
|
||||
} catch (e: Exception) {
|
||||
result.error("LAUNCH_FAILED", e.message ?: e.javaClass.simpleName, null)
|
||||
}
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
externalPlayerChannel.attach(flutterEngine.dartExecutor.binaryMessenger)
|
||||
|
||||
// Splash screen theme: persist user's chosen theme for next launch (API 31+)
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, THEME_CHANNEL).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"getRenderer" -> result.success(if (usingSkia) "Skia" else "Impeller")
|
||||
"getRenderer" -> result.success(selectedFlutterRenderer.diagnosticName)
|
||||
"setSplashTheme" -> {
|
||||
val mode = call.argument<String>("mode")
|
||||
|
||||
@@ -461,7 +669,7 @@ class MainActivity : FlutterActivity() {
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, PIP_CHANNEL).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"isSupported" -> {
|
||||
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !isAndroidTvDevice())
|
||||
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && isPipSupportedDevice())
|
||||
}
|
||||
"enter" -> {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
@@ -469,7 +677,7 @@ class MainActivity : FlutterActivity() {
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
|
||||
if (isAndroidTvDevice()) {
|
||||
if (!isPipSupportedDevice()) {
|
||||
result.success(mapOf("success" to false, "errorCode" to "not_supported"))
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
@@ -496,7 +704,7 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
"setAutoPipReady" -> {
|
||||
if (isAndroidTvDevice()) {
|
||||
if (!isPipSupportedDevice()) {
|
||||
autoPipReady = false
|
||||
result.success(true)
|
||||
return@setMethodCallHandler
|
||||
@@ -521,31 +729,92 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestForeground(): Boolean = try {
|
||||
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
|
||||
activityManager.moveTaskToFront(taskId, 0)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to move task to foreground", e)
|
||||
private fun handleDeviceAdjustmentCall(method: String, arguments: Any?, result: MethodChannel.Result) {
|
||||
try {
|
||||
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)
|
||||
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
when (method) {
|
||||
"getBrightness" -> result.success(getScreenBrightnessFraction())
|
||||
"setBrightness" -> {
|
||||
setScreenBrightnessFraction(argumentAsDouble(arguments))
|
||||
result.success(null)
|
||||
}
|
||||
"restoreBrightness" -> {
|
||||
restoreScreenBrightness()
|
||||
result.success(null)
|
||||
}
|
||||
"getMediaVolume" -> result.success(getMediaVolumeFraction())
|
||||
"setMediaVolume" -> {
|
||||
setMediaVolumeFraction(argumentAsDouble(arguments))
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
if (launchIntent != null) {
|
||||
startActivity(launchIntent)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} catch (launchError: Exception) {
|
||||
Log.w(TAG, "Failed to start foreground activity", launchError)
|
||||
false
|
||||
} catch (e: IllegalArgumentException) {
|
||||
result.error("INVALID_ARGUMENT", e.message ?: e.javaClass.simpleName, null)
|
||||
} catch (e: Exception) {
|
||||
result.error("DEVICE_ADJUSTMENT_FAILED", e.message ?: e.javaClass.simpleName, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun argumentAsDouble(arguments: Any?): Double {
|
||||
val value = (arguments as? Number)?.toDouble()
|
||||
?: throw IllegalArgumentException("Expected a numeric value")
|
||||
if (value.isNaN() || value.isInfinite()) {
|
||||
throw IllegalArgumentException("Expected a finite numeric value")
|
||||
}
|
||||
return value.coerceIn(0.0, 1.0)
|
||||
}
|
||||
|
||||
private fun getScreenBrightnessFraction(): Double {
|
||||
val windowBrightness = window.attributes.screenBrightness
|
||||
if (windowBrightness >= 0f) return windowBrightness.coerceIn(0f, 1f).toDouble()
|
||||
|
||||
return try {
|
||||
Settings.System.getInt(contentResolver, Settings.System.SCREEN_BRIGHTNESS).coerceIn(0, 255) / 255.0
|
||||
} catch (e: Settings.SettingNotFoundException) {
|
||||
0.5
|
||||
}
|
||||
}
|
||||
|
||||
private fun setScreenBrightnessFraction(value: Double) {
|
||||
if (originalWindowBrightness == null) originalWindowBrightness = window.attributes.screenBrightness
|
||||
val attributes = window.attributes
|
||||
attributes.screenBrightness = value.coerceIn(0.0, 1.0).toFloat()
|
||||
window.attributes = attributes
|
||||
}
|
||||
|
||||
private fun restoreScreenBrightness() {
|
||||
val attributes = window.attributes
|
||||
attributes.screenBrightness = originalWindowBrightness ?: WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||
window.attributes = attributes
|
||||
originalWindowBrightness = null
|
||||
}
|
||||
|
||||
private fun getMediaVolumeFraction(): Double {
|
||||
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
val minVolume = streamMinVolume(audioManager)
|
||||
if (maxVolume <= minVolume) return 0.0
|
||||
|
||||
val volume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC).coerceIn(minVolume, maxVolume)
|
||||
return (volume - minVolume).toDouble() / (maxVolume - minVolume).toDouble()
|
||||
}
|
||||
|
||||
private fun setMediaVolumeFraction(value: Double) {
|
||||
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
val minVolume = streamMinVolume(audioManager)
|
||||
val target = (minVolume + value.coerceIn(0.0, 1.0) * (maxVolume - minVolume))
|
||||
.roundToInt()
|
||||
.coerceIn(minVolume, maxVolume)
|
||||
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0)
|
||||
}
|
||||
|
||||
private fun streamMinVolume(audioManager: AudioManager): Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
audioManager.getStreamMinVolume(AudioManager.STREAM_MUSIC)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||
flutterEngine?.let { engine ->
|
||||
@@ -559,7 +828,7 @@ class MainActivity : FlutterActivity() {
|
||||
override fun onUserLeaveHint() {
|
||||
super.onUserLeaveHint()
|
||||
// Auto PiP for API 26-30 (API 31+ uses setAutoEnterEnabled)
|
||||
if (!isAndroidTvDevice() &&
|
||||
if (isPipSupportedDevice() &&
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S &&
|
||||
autoPipReady &&
|
||||
@@ -578,6 +847,7 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun isPipPermissionGranted(): Boolean {
|
||||
val appOpsManager = getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
|
||||
return appOpsManager.checkOpNoThrow(
|
||||
@@ -587,6 +857,7 @@ class MainActivity : FlutterActivity() {
|
||||
) == AppOpsManager.MODE_ALLOWED
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
private fun buildPipParams(width: Int, height: Int, autoEnterEnabled: Boolean? = null): PictureInPictureParams {
|
||||
val (w, h) = if (width <= 0 || height <= 0) {
|
||||
Pair(16, 9)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.edde746.plezy
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
|
||||
/**
|
||||
* Native mirror of MainActivity.getAndroidTvDetection(): any TV signal counts,
|
||||
* except that FEATURE_AUTOMOTIVE vetoes the verdict outright. Kept in sync with
|
||||
* the Dart-facing detection so native gating matches PlatformDetector.isTV().
|
||||
*/
|
||||
object TvDetection {
|
||||
fun isTv(context: Context): Boolean {
|
||||
val pm = context.packageManager
|
||||
val uiModeType = context.resources.configuration.uiMode and Configuration.UI_MODE_TYPE_MASK
|
||||
|
||||
// A car is never a TV: rotary-only head units report no touchscreen, and an
|
||||
// OEM image can carry a stray leanback flag.
|
||||
if (pm.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE)) return false
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
return uiModeType == Configuration.UI_MODE_TYPE_TELEVISION ||
|
||||
pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION) ||
|
||||
pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK) ||
|
||||
pm.hasSystemFeature("amazon.hardware.fire_tv") ||
|
||||
!pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.os.Build
|
||||
import android.view.SurfaceControl
|
||||
import android.view.SurfaceView
|
||||
import androidx.annotation.RequiresApi
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Auto-calibrates [ExoPlayerCore]'s subtitle/video layer offset by measuring, per device, how much
|
||||
* later the codec VIDEO plane reaches the display than the GL subtitle OVERLAY plane — the
|
||||
* inter-plane latency (a deep video VPP on TV boxes) that otherwise makes subtitles a frame ahead.
|
||||
*
|
||||
* Both planes are pinned to the same video-frame target (releaseTimeNs). Measuring BOTH with the
|
||||
* same mechanism — [SurfaceTxProbe] + the API-34 previous-release fence — the per-SoC buffer-recycle
|
||||
* bias cancels in the difference, leaving the pure offset:
|
||||
*
|
||||
* offsetFrames = round( (median(videoReleaseVsTarget) − median(overlayReleaseVsTarget)) / frameMs )
|
||||
*
|
||||
* This is a MEDIAN-of-each-plane model, NOT a per-frame paired difference: every sample is
|
||||
* self-paired to its own frame's target, and the per-plane release-vs-target is a steady hardware
|
||||
* constant, so the two medians need not come from the same frames — only from the same (stable)
|
||||
* device. Both medians use a recent-sample window so they stay roughly contemporaneous, and a
|
||||
* confidence gate refuses to converge on an ambiguous (~half-frame) result.
|
||||
*
|
||||
* Video samples arrive every few frames from the metadata listener (codec thread); overlay samples
|
||||
* from the libass GL thread's pre-swap hook (sparse — only when subtitles swap). Once both planes
|
||||
* have enough samples and the result is confident it converges ONCE, applies + persists, and stops
|
||||
* all probing (zero steady-state overhead). Re-runs each play to re-confirm.
|
||||
*
|
||||
* Threads: probeVideo (codec), probeOverlay (libass GL), onResult (probe reader). The lock guards
|
||||
* only counter/sample state and is NEVER held across the binder/JNI `applyTransactionToFrame` call
|
||||
* (so the GL swap path is never stalled by it). The native slot ring is allocated atomically.
|
||||
*/
|
||||
internal class AssLatencyCalibrator(
|
||||
private val videoSurface: SurfaceView,
|
||||
private val overlaySurface: SurfaceView,
|
||||
private val onCalibrated: (Int) -> Unit,
|
||||
private val onDone: () -> Unit,
|
||||
private val log: (String) -> Unit
|
||||
) {
|
||||
private val lock = Any()
|
||||
private val finished = AtomicBoolean(false)
|
||||
private val applyFail = AtomicInteger(0)
|
||||
|
||||
@Volatile private var frameIntervalNs: Long = 0L
|
||||
|
||||
@Volatile private var converged = false
|
||||
|
||||
@Volatile private var stopped = false
|
||||
|
||||
private var videoFrames = 0
|
||||
private var videoAttempts = 0
|
||||
private var overlaySwaps = 0
|
||||
private var overlayAttempts = 0
|
||||
private val videoRel = ArrayDeque<Double>()
|
||||
private val overlayRel = ArrayDeque<Double>()
|
||||
|
||||
fun start() {
|
||||
SurfaceTxProbe.sink = { tag, latch, release, count, state, source, cb ->
|
||||
onResult(tag, latch, release, count, state, source, cb)
|
||||
}
|
||||
log("calibration started (API ${Build.VERSION.SDK_INT})")
|
||||
}
|
||||
|
||||
fun probeVideo(releaseTimeNs: Long, fps: Float) {
|
||||
if (stopped || converged || Build.VERSION.SDK_INT < 34) return
|
||||
if (fps > 1f) frameIntervalNs = (1_000_000_000.0 / fps).toLong()
|
||||
var doAttach = false
|
||||
var giveUp = false
|
||||
synchronized(lock) {
|
||||
videoFrames++
|
||||
if (videoFrames > GIVEUP_FRAMES) {
|
||||
giveUp = true
|
||||
} else if (videoAttempts < VIDEO_ATTEMPT_CAP && videoFrames % VIDEO_EVERY == 0) {
|
||||
videoAttempts++
|
||||
doAttach = true
|
||||
}
|
||||
}
|
||||
if (giveUp) {
|
||||
finishIncomplete()
|
||||
} else if (doAttach) {
|
||||
attach(videoSurface, releaseTimeNs, SurfaceTxProbe.SOURCE_VIDEO)
|
||||
}
|
||||
}
|
||||
|
||||
fun probeOverlay(releaseTimeNs: Long) {
|
||||
if (stopped || converged || Build.VERSION.SDK_INT < 34) return
|
||||
var doAttach = false
|
||||
synchronized(lock) {
|
||||
overlaySwaps++
|
||||
if (overlayAttempts < OVERLAY_ATTEMPT_CAP && overlaySwaps % OVERLAY_EVERY == 0) {
|
||||
overlayAttempts++
|
||||
doAttach = true
|
||||
}
|
||||
}
|
||||
if (doAttach) attach(overlaySurface, releaseTimeNs, SurfaceTxProbe.SOURCE_OVERLAY)
|
||||
}
|
||||
|
||||
/** Never called while holding [lock]: applyTransactionToFrame is a binder call and runs on the
|
||||
* GL swap path. The native slot allocation is atomic, so concurrent callers are safe. */
|
||||
@RequiresApi(34)
|
||||
private fun attach(surface: SurfaceView, releaseTimeNs: Long, source: Int) {
|
||||
try {
|
||||
val tx = SurfaceControl.Transaction()
|
||||
SurfaceTxProbe.nativeAttach(tx, releaseTimeNs, source)
|
||||
surface.applyTransactionToFrame(tx)
|
||||
} catch (t: Throwable) {
|
||||
if (applyFail.incrementAndGet() == 1) {
|
||||
log("applyTransactionToFrame failed: ${t.javaClass.simpleName}: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onResult(
|
||||
tag: Long,
|
||||
latchNs: Long,
|
||||
releaseNs: Long,
|
||||
surfaceCount: Int,
|
||||
fenceState: Int,
|
||||
source: Int,
|
||||
callbackNs: Long
|
||||
) {
|
||||
if (surfaceCount <= 0 || fenceState != FENCE_OK || releaseNs <= 0) return
|
||||
val relMs = (releaseNs - tag) / 1_000_000.0
|
||||
var result: Int? = null
|
||||
synchronized(lock) {
|
||||
if (converged || stopped) return
|
||||
val list = if (source == SurfaceTxProbe.SOURCE_OVERLAY) overlayRel else videoRel
|
||||
val window = if (source == SurfaceTxProbe.SOURCE_OVERLAY) OVERLAY_WINDOW else VIDEO_WINDOW
|
||||
list.addLast(relMs)
|
||||
while (list.size > window) list.removeFirst() // keep a recent window so medians stay fresh
|
||||
result = tryComputeOffset()
|
||||
}
|
||||
result?.let {
|
||||
onCalibrated(it)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryComputeOffset(): Int? {
|
||||
val frameMs = frameIntervalNs / 1_000_000.0
|
||||
if (frameMs <= 0.5) return null
|
||||
if (videoRel.size < VIDEO_MIN || overlayRel.size < OVERLAY_MIN) return null
|
||||
val medV = median(videoRel)
|
||||
val medO = median(overlayRel)
|
||||
val offsetMs = medV - medO
|
||||
val raw = offsetMs / frameMs
|
||||
val frames = raw.roundToInt()
|
||||
// Confidence gate: only trust a measurement that points clearly at an integer frame count.
|
||||
// An ambiguous ~half-frame result (corruption / drift / wrong fps) keeps collecting instead.
|
||||
if (abs(raw - frames) > CONFIDENCE_TOL) return null
|
||||
val clamped = frames.coerceIn(-2, 2)
|
||||
converged = true
|
||||
log(
|
||||
"CALIBRATED offsetFrames=$clamped raw=${"%.2f".format(raw)} " +
|
||||
"(video=${"%.1f".format(medV)}ms − overlay=${"%.1f".format(medO)}ms = ${"%.1f".format(offsetMs)}ms " +
|
||||
"/ frameMs=${"%.2f".format(frameMs)}) samples video=${videoRel.size} overlay=${overlayRel.size} " +
|
||||
"applyFail=${applyFail.get()}"
|
||||
)
|
||||
return clamped
|
||||
}
|
||||
|
||||
private fun finishIncomplete() {
|
||||
if (converged || stopped) return
|
||||
log(
|
||||
"calibration incomplete after $videoFrames frames " +
|
||||
"(video=${videoRel.size} overlay=${overlayRel.size}); keeping seed"
|
||||
)
|
||||
finish()
|
||||
}
|
||||
|
||||
fun stop() = finish()
|
||||
|
||||
private fun finish() {
|
||||
if (!finished.compareAndSet(false, true)) return
|
||||
stopped = true
|
||||
SurfaceTxProbe.sink = null
|
||||
onDone()
|
||||
}
|
||||
|
||||
private fun median(xs: Collection<Double>): Double {
|
||||
val s = xs.sorted()
|
||||
val n = s.size
|
||||
return if (n % 2 == 1) s[n / 2] else (s[n / 2 - 1] + s[n / 2]) / 2.0
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FENCE_OK = 3
|
||||
private const val VIDEO_EVERY = 2
|
||||
private const val OVERLAY_EVERY = 1 // overlay swaps are already sparse
|
||||
private const val VIDEO_MIN = 40
|
||||
private const val OVERLAY_MIN = 12
|
||||
private const val VIDEO_WINDOW = 60 // recent-sample window for the median
|
||||
private const val OVERLAY_WINDOW = 40
|
||||
private const val VIDEO_ATTEMPT_CAP = 200 // ~16s @24fps/2 of video probing, then the window freezes
|
||||
private const val OVERLAY_ATTEMPT_CAP = 60
|
||||
private const val CONFIDENCE_TOL = 0.33 // |raw − round(raw)| must be within ⅓ frame to converge
|
||||
private const val GIVEUP_FRAMES = 1800 // ~75s @24fps without enough confident overlay samples
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.media.audiofx.DynamicsProcessing
|
||||
import android.media.audiofx.LoudnessEnhancer
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
|
||||
/**
|
||||
* Session-bound loudness normalization approximating mpv's
|
||||
* `loudnorm=I=-14:TP=-3:LRA=4` filter (#1289).
|
||||
*
|
||||
* API 28+: DynamicsProcessing with a single full-range compressor band as a
|
||||
* slow AGC plus a limiter for the true-peak ceiling. API 25-27, or when the
|
||||
* device lacks the DynamicsProcessing effect HAL: LoudnessEnhancer (fixed
|
||||
* makeup gain with built-in limiting).
|
||||
*
|
||||
* Effects bind to the audio session, so they survive AudioTrack re-creation
|
||||
* (seeks, format changes) while the session id is stable. They only process
|
||||
* PCM mixer streams — the caller must force decoded PCM output and disable
|
||||
* tunneling while enabled. DynamicsProcessing parameters are per-channel, so
|
||||
* the effect is re-created when the output channel count changes (dialogue
|
||||
* lives in the center channel of 5.1 PCM).
|
||||
*/
|
||||
class AudioNormalizationEffect(private val log: (String, String, String) -> Unit) {
|
||||
|
||||
private companion object {
|
||||
const val EFFECT_PRIORITY = 0
|
||||
|
||||
// DynamicsProcessing — tuned for movie mixes whose dialogue sits around
|
||||
// -27 LUFS: compress above threshold, make up toward I=-14, limit at TP=-3.
|
||||
const val FRAME_DURATION_MS = 10f
|
||||
const val MBC_CUTOFF_HZ = 20_000f
|
||||
const val MBC_ATTACK_MS = 30f
|
||||
const val MBC_RELEASE_MS = 300f
|
||||
const val MBC_RATIO = 4f
|
||||
const val MBC_THRESHOLD_DB = -34f
|
||||
const val MBC_KNEE_DB = 6f
|
||||
const val MBC_NOISE_GATE_DB = -90f // effectively disabled
|
||||
const val MBC_EXPANDER_RATIO = 1f // no downward expansion
|
||||
const val MBC_POST_GAIN_DB = 15f
|
||||
const val LIMITER_ATTACK_MS = 1f
|
||||
const val LIMITER_RELEASE_MS = 60f
|
||||
const val LIMITER_RATIO = 10f
|
||||
const val LIMITER_THRESHOLD_DB = -3f
|
||||
const val DEFAULT_CHANNEL_COUNT = 2
|
||||
const val MAX_CHANNEL_COUNT = 8
|
||||
|
||||
// LoudnessEnhancer fallback: fixed boost, internally limited.
|
||||
const val LOUDNESS_ENHANCER_GAIN_MB = 900 // +9 dB
|
||||
}
|
||||
|
||||
private var dynamicsProcessing: DynamicsProcessing? = null
|
||||
private var loudnessEnhancer: LoudnessEnhancer? = null
|
||||
private var attachedSessionId = 0
|
||||
private var attachedChannelCount = 0
|
||||
|
||||
val isActive: Boolean get() = dynamicsProcessing != null || loudnessEnhancer != null
|
||||
|
||||
/** For stats/QA: which engine is processing. */
|
||||
val describe: String
|
||||
get() = when {
|
||||
dynamicsProcessing != null -> "DynamicsProcessing"
|
||||
loudnessEnhancer != null -> "LoudnessEnhancer"
|
||||
else -> "off"
|
||||
}
|
||||
|
||||
/** Attach to [sessionId]; idempotent for an unchanged (session, channels) pair. */
|
||||
fun attach(sessionId: Int, channelCount: Int?) {
|
||||
val channels = (channelCount ?: DEFAULT_CHANNEL_COUNT).coerceIn(1, MAX_CHANNEL_COUNT)
|
||||
if (sessionId == attachedSessionId && channels == attachedChannelCount && isActive) return
|
||||
release()
|
||||
if (sessionId == 0) return // AUDIO_SESSION_ID_UNSET — retry on onAudioSessionIdChanged
|
||||
attachedSessionId = sessionId
|
||||
attachedChannelCount = channels
|
||||
if (Build.VERSION.SDK_INT >= 28 && tryDynamicsProcessing(sessionId, channels)) return
|
||||
tryLoudnessEnhancer(sessionId)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
dynamicsProcessing?.let { effect ->
|
||||
runCatching { effect.setEnabled(false) }
|
||||
runCatching { effect.release() }
|
||||
}
|
||||
dynamicsProcessing = null
|
||||
loudnessEnhancer?.let { effect ->
|
||||
runCatching { effect.setEnabled(false) }
|
||||
runCatching { effect.release() }
|
||||
}
|
||||
loudnessEnhancer = null
|
||||
attachedSessionId = 0
|
||||
attachedChannelCount = 0
|
||||
}
|
||||
|
||||
@RequiresApi(28)
|
||||
private fun tryDynamicsProcessing(sessionId: Int, channelCount: Int): Boolean = try {
|
||||
val band = DynamicsProcessing.MbcBand(
|
||||
// inUse
|
||||
true,
|
||||
MBC_CUTOFF_HZ,
|
||||
MBC_ATTACK_MS,
|
||||
MBC_RELEASE_MS,
|
||||
MBC_RATIO,
|
||||
MBC_THRESHOLD_DB,
|
||||
MBC_KNEE_DB,
|
||||
MBC_NOISE_GATE_DB,
|
||||
MBC_EXPANDER_RATIO,
|
||||
// preGain
|
||||
0f,
|
||||
MBC_POST_GAIN_DB
|
||||
)
|
||||
val mbc = DynamicsProcessing.Mbc(
|
||||
// inUse
|
||||
true,
|
||||
// enabled
|
||||
true,
|
||||
// bandCount
|
||||
1
|
||||
).apply { setBand(0, band) }
|
||||
val limiter = DynamicsProcessing.Limiter(
|
||||
// inUse
|
||||
true,
|
||||
// enabled
|
||||
true,
|
||||
// linkGroup
|
||||
0,
|
||||
LIMITER_ATTACK_MS,
|
||||
LIMITER_RELEASE_MS,
|
||||
LIMITER_RATIO,
|
||||
LIMITER_THRESHOLD_DB,
|
||||
// postGain
|
||||
0f
|
||||
)
|
||||
val channel = DynamicsProcessing.Channel(
|
||||
// inputGain
|
||||
0f,
|
||||
// preEqInUse
|
||||
false,
|
||||
// preEqBandCount
|
||||
0,
|
||||
// mbcInUse
|
||||
true,
|
||||
// mbcBandCount
|
||||
1,
|
||||
// postEqInUse
|
||||
false,
|
||||
// postEqBandCount
|
||||
0,
|
||||
// limiterInUse
|
||||
true
|
||||
).apply {
|
||||
setMbc(mbc)
|
||||
setLimiter(limiter)
|
||||
}
|
||||
val config = DynamicsProcessing.Config.Builder(
|
||||
DynamicsProcessing.VARIANT_FAVOR_FREQUENCY_RESOLUTION,
|
||||
channelCount,
|
||||
// preEqInUse
|
||||
false,
|
||||
// preEqBandCount
|
||||
0,
|
||||
// mbcInUse
|
||||
true,
|
||||
// mbcBandCount
|
||||
1,
|
||||
// postEqInUse
|
||||
false,
|
||||
// postEqBandCount
|
||||
0,
|
||||
// limiterInUse
|
||||
true
|
||||
)
|
||||
.setPreferredFrameDuration(FRAME_DURATION_MS)
|
||||
.setAllChannelsTo(channel)
|
||||
.build()
|
||||
dynamicsProcessing = DynamicsProcessing(EFFECT_PRIORITY, sessionId, config).apply { setEnabled(true) }
|
||||
log("info", "audio-normalization", "DynamicsProcessing attached (session=$sessionId, channels=$channelCount)")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
log(
|
||||
"warn",
|
||||
"audio-normalization",
|
||||
"DynamicsProcessing unavailable (${e.javaClass.simpleName}: ${e.message}); trying LoudnessEnhancer"
|
||||
)
|
||||
dynamicsProcessing = null
|
||||
false
|
||||
}
|
||||
|
||||
private fun tryLoudnessEnhancer(sessionId: Int) {
|
||||
try {
|
||||
loudnessEnhancer = LoudnessEnhancer(sessionId).apply {
|
||||
setTargetGain(LOUDNESS_ENHANCER_GAIN_MB)
|
||||
setEnabled(true)
|
||||
}
|
||||
log("info", "audio-normalization", "LoudnessEnhancer attached (session=$sessionId, gain=${LOUDNESS_ENHANCER_GAIN_MB}mB)")
|
||||
} catch (e: Exception) {
|
||||
loudnessEnhancer = null
|
||||
attachedSessionId = 0
|
||||
attachedChannelCount = 0
|
||||
log(
|
||||
"warn",
|
||||
"audio-normalization",
|
||||
"No loudness effect available on this device (${e.javaClass.simpleName}: ${e.message})"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.audio.AudioCapabilities
|
||||
|
||||
private const val TAG = "AudioOutputPolicy"
|
||||
|
||||
internal fun isPassthroughAudioMimeType(mimeType: String): Boolean = when (mimeType) {
|
||||
"audio/ac3",
|
||||
"audio/eac3",
|
||||
"audio/eac3-joc",
|
||||
"audio/ac4",
|
||||
"audio/vnd.dts",
|
||||
"audio/vnd.dts.hd",
|
||||
"audio/vnd.dts.uhd",
|
||||
MimeTypes.AUDIO_TRUEHD -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
internal fun shouldBlockDirectOutputForPassthrough(mimeType: String, audioPassthroughEnabled: Boolean): Boolean = !audioPassthroughEnabled && isPassthroughAudioMimeType(mimeType)
|
||||
|
||||
/**
|
||||
* mpv `audio-spdif` codec names and the exact platform encoding a route must
|
||||
* advertise to carry that bitstream.
|
||||
*/
|
||||
private val MPV_SPDIF_CODECS: List<Pair<String, Int>> = listOf(
|
||||
"ac3" to C.ENCODING_AC3,
|
||||
"eac3" to C.ENCODING_E_AC3,
|
||||
"dts" to C.ENCODING_DTS,
|
||||
"dts-hd" to C.ENCODING_DTS_HD,
|
||||
"truehd" to C.ENCODING_DOLBY_TRUEHD
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds an `audio-spdif` value naming only the codecs [supportsEncoding] advertises.
|
||||
*
|
||||
* mpv force-passes through every codec named here and has no decode fallback, so an
|
||||
* unsupported name leaves the file rendering video against a dead audio output (#1703).
|
||||
*
|
||||
* The gate is the exact encoding rather than media3's passthrough probe on purpose.
|
||||
* That probe answers DTS-HD by downgrading to the DTS core (and E-AC3 JOC to E-AC3)
|
||||
* for receivers that decode only the base layer, and it also rejects channel counts
|
||||
* above the route's PCM maximum, which does not apply to an IEC 61937 carrier. mpv
|
||||
* additionally treats `dts,dts-hd` as `dts-hd` alone, so accepting the downgrade would
|
||||
* name DTS-HD MA to a core-only receiver and lose DTS as well.
|
||||
*/
|
||||
internal fun mpvSpdifCodecs(supportsEncoding: (Int) -> Boolean): String = MPV_SPDIF_CODECS
|
||||
.filter { (_, encoding) -> supportsEncoding(encoding) }
|
||||
.joinToString(",") { (codec, _) -> codec }
|
||||
|
||||
/** [mpvSpdifCodecs] resolved against the audio route [context] is currently routed to. */
|
||||
// Deprecated only in favour of an overload that also takes spatializer channel masks, which
|
||||
// do not affect bitstream routing. Same probe ExoPlayerCore's TrueHD decision uses.
|
||||
@Suppress("DEPRECATION")
|
||||
@OptIn(UnstableApi::class)
|
||||
internal fun supportedMpvSpdifCodecs(context: Context): String {
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build()
|
||||
val capabilities = try {
|
||||
AudioCapabilities.getCapabilities(context, audioAttributes, null)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Audio route capabilities unavailable; mpv will decode instead of bitstreaming", error)
|
||||
return ""
|
||||
}
|
||||
return mpvSpdifCodecs(capabilities::supportsEncoding)
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.DataReader
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.ParserException
|
||||
import androidx.media3.common.util.ParsableByteArray
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import java.io.EOFException
|
||||
|
||||
/**
|
||||
* Buffers transformed [TrackOutput] data until metadata identifies the exact sample range.
|
||||
*
|
||||
* Media3 may append multiple samples before reporting metadata, and [TrackOutput.sampleMetadata]
|
||||
* identifies a sample relative to all appended bytes using `size` and `offset`. Before calling
|
||||
* [transformSample], this class compacts only that sample to `inputBuffer[0 until inputLength]`.
|
||||
* Bytes after the sample remain buffered for later metadata; unreferenced bytes before it are
|
||||
* discarded. Extractor decorators must call [resetBufferedData] when a seek abandons pending data.
|
||||
*/
|
||||
abstract class BufferedTransformingTrackOutput(
|
||||
protected val delegate: TrackOutput,
|
||||
initialBufferSize: Int,
|
||||
initialReadBufferSize: Int = initialBufferSize,
|
||||
private val maxBufferedSampleBytes: Int = Int.MAX_VALUE
|
||||
) : TrackOutput {
|
||||
protected var inputBuffer = ByteArray(initialBufferSize)
|
||||
private set
|
||||
|
||||
private var inputLength = 0
|
||||
private var buffering = false
|
||||
private var readBuffer = ByteArray(initialReadBufferSize)
|
||||
private val outputParsable = ParsableByteArray()
|
||||
|
||||
protected abstract val transformEnabled: Boolean
|
||||
protected abstract val transformedBuffer: ByteArray
|
||||
|
||||
/**
|
||||
* Transforms the exact sample in `inputBuffer[0 until inputLength]`.
|
||||
*
|
||||
* Implementations must treat [inputBuffer] as read-only. Returns the number of bytes available
|
||||
* from [transformedBuffer], or a negative value to drop the sample.
|
||||
*/
|
||||
protected abstract fun transformSample(inputLength: Int, flags: Int): Int
|
||||
|
||||
init {
|
||||
require(initialBufferSize > 0)
|
||||
require(initialReadBufferSize > 0)
|
||||
require(maxBufferedSampleBytes >= initialBufferSize)
|
||||
}
|
||||
open override fun format(format: Format) = delegate.format(format)
|
||||
|
||||
override fun sampleData(
|
||||
input: DataReader,
|
||||
length: Int,
|
||||
allowEndOfInput: Boolean,
|
||||
sampleDataPart: Int
|
||||
): Int {
|
||||
if (!transformEnabled) {
|
||||
return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart)
|
||||
}
|
||||
|
||||
buffering = true
|
||||
val remainingCapacity = maxBufferedSampleBytes - inputLength
|
||||
if (length < 0 || remainingCapacity <= 0) throw sampleTooLarge()
|
||||
val requested = minOf(length, remainingCapacity, readBuffer.size)
|
||||
val bytesRead = input.read(readBuffer, 0, requested)
|
||||
if (bytesRead == C.RESULT_END_OF_INPUT && !allowEndOfInput) throw EOFException()
|
||||
if (bytesRead > 0) appendInput(readBuffer, bytesRead)
|
||||
return bytesRead
|
||||
}
|
||||
|
||||
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
|
||||
if (!transformEnabled) {
|
||||
delegate.sampleData(data, length, sampleDataPart)
|
||||
return
|
||||
}
|
||||
|
||||
buffering = true
|
||||
ensureInputCapacity(length)
|
||||
data.readBytes(inputBuffer, inputLength, length)
|
||||
inputLength += length
|
||||
}
|
||||
|
||||
override fun sampleMetadata(
|
||||
timeUs: Long,
|
||||
flags: Int,
|
||||
size: Int,
|
||||
offset: Int,
|
||||
cryptoData: TrackOutput.CryptoData?
|
||||
) {
|
||||
if (!transformEnabled || !buffering) {
|
||||
delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData)
|
||||
return
|
||||
}
|
||||
|
||||
// Offset can describe data for one or more later samples already appended by the extractor.
|
||||
if (size < 0 || offset < 0 || size > inputLength - offset) {
|
||||
resetBufferedData()
|
||||
return
|
||||
}
|
||||
|
||||
val sampleEnd = inputLength - offset
|
||||
val sampleStart = sampleEnd - size
|
||||
if (sampleStart > 0) {
|
||||
System.arraycopy(inputBuffer, sampleStart, inputBuffer, 0, size)
|
||||
}
|
||||
|
||||
try {
|
||||
val transformedLength = transformSample(size, flags)
|
||||
if (transformedLength < 0) return
|
||||
|
||||
outputParsable.reset(transformedBuffer, transformedLength)
|
||||
delegate.sampleData(outputParsable, transformedLength, TrackOutput.SAMPLE_DATA_PART_MAIN)
|
||||
delegate.sampleMetadata(timeUs, flags, transformedLength, 0, cryptoData)
|
||||
} finally {
|
||||
if (offset > 0) {
|
||||
System.arraycopy(inputBuffer, sampleEnd, inputBuffer, 0, offset)
|
||||
}
|
||||
inputLength = offset
|
||||
buffering = offset > 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops sample bytes buffered before an extractor seek while retaining allocated storage. */
|
||||
internal fun resetBufferedData() {
|
||||
inputLength = 0
|
||||
buffering = false
|
||||
}
|
||||
|
||||
private fun appendInput(source: ByteArray, length: Int) {
|
||||
ensureInputCapacity(length)
|
||||
System.arraycopy(source, 0, inputBuffer, inputLength, length)
|
||||
inputLength += length
|
||||
}
|
||||
|
||||
private fun ensureInputCapacity(additionalBytes: Int) {
|
||||
if (additionalBytes < 0 || additionalBytes > maxBufferedSampleBytes - inputLength) {
|
||||
throw sampleTooLarge()
|
||||
}
|
||||
val needed = inputLength + additionalBytes
|
||||
if (inputBuffer.size < needed) {
|
||||
val doubledSize = minOf(maxBufferedSampleBytes.toLong(), inputBuffer.size.toLong() * 2).toInt()
|
||||
inputBuffer = inputBuffer.copyOf(maxOf(needed, doubledSize))
|
||||
}
|
||||
}
|
||||
|
||||
private fun sampleTooLarge(): ParserException = ParserException.createForMalformedContainer(
|
||||
"Buffered sample exceeds the maximum size of $maxBufferedSampleBytes bytes",
|
||||
null
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,63 @@
|
||||
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"
|
||||
|
||||
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")
|
||||
@@ -29,46 +74,269 @@ object DoviBridge {
|
||||
|
||||
fun isAvailable(): Boolean = conversionPathReady
|
||||
|
||||
private fun deviceSupportsDvProfile(profile: Int, minApi: Int = 0): Boolean {
|
||||
private val dolbyVisionDecoders: List<DvDecoderCapability> by lazy {
|
||||
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 }
|
||||
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 DV profile $profile support", e)
|
||||
return false
|
||||
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 {
|
||||
deviceSupportsDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtr)
|
||||
.also { Log.i(TAG, "Device DV Profile 7 support: $it") }
|
||||
deviceAdvertisesDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheDtb)
|
||||
.also { Log.i(TAG, "Device advertises exact DV Profile 7 (DvheDtb): $it") }
|
||||
}
|
||||
|
||||
val deviceSupportsDvProfile8: Boolean by lazy {
|
||||
deviceSupportsDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheSt, minApi = 27)
|
||||
.also { Log.i(TAG, "Device DV Profile 8 support: $it") }
|
||||
deviceAdvertisesDvProfile(MediaCodecInfo.CodecProfileLevel.DolbyVisionProfileDvheSt, minApi = 27)
|
||||
.also { Log.i(TAG, "Device advertises DV Profile 8 (DvheSt): $it") }
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
val display = getCurrentDisplay(context)
|
||||
if (display == null) {
|
||||
Log.i(TAG, "Display Dolby Vision support: false (no active display)")
|
||||
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 getDv7FallbackMode(): DvConversionMode = when {
|
||||
deviceSupportsDvProfile8 -> DvConversionMode.DV81
|
||||
else -> DvConversionMode.HEVC_STRIP
|
||||
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)"
|
||||
}
|
||||
}
|
||||
|
||||
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)})"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun describeDisplayHdrCapabilities(display: Display): String {
|
||||
val hdrCapabilities = getDisplayHdrCapabilities(display)
|
||||
val parts = mutableListOf(
|
||||
"display=${display.displayId}:${display.name}",
|
||||
"activeMode=${describeDisplayMode(display.mode)}"
|
||||
)
|
||||
if (hdrCapabilities == null) {
|
||||
parts += "hdrCapabilities=unavailable"
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
parts += "hdrCapabilities=${describeHdrTypes(hdrCapabilities.supportedHdrTypes)}"
|
||||
parts += "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 getDisplayHdrCapabilities(display: Display): Display.HdrCapabilities? = runCatching {
|
||||
display.hdrCapabilities
|
||||
}.onFailure { Log.w(TAG, "Failed to query display HDR capabilities", it) }.getOrNull()
|
||||
|
||||
private fun getDisplayHdrTypes(display: Display): IntArray {
|
||||
val hdrCapabilities = getDisplayHdrCapabilities(display) ?: return IntArray(0)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
return runCatching { display.mode.supportedHdrTypes }.getOrElse { error ->
|
||||
Log.w(TAG, "Failed to query mode HDR types; falling back to display HDR capabilities", error)
|
||||
@Suppress("DEPRECATION")
|
||||
hdrCapabilities.supportedHdrTypes
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
return 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"
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
+102
-155
@@ -2,18 +2,17 @@ package com.edde746.plezy.exoplayer
|
||||
|
||||
import android.util.Log
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.DataReader
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.util.ParsableByteArray
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
|
||||
/**
|
||||
* TrackOutput wrapper that processes DV Profile 7 HEVC samples based on conversion mode:
|
||||
*
|
||||
* - DV81: Convert RPU NALs via libdovi to Profile 8.1, present as video/dolby-vision
|
||||
* with dvhe.08.XX codec string. Preserves dynamic tone mapping metadata.
|
||||
* - HEVC_STRIP: Strip all DV enhancement layers, present as plain video/hevc.
|
||||
* - DV81: Convert RPU NALs via libdovi mode 2 to Profile 8.1, present as
|
||||
* video/dolby-vision with dvhe.08.XX codec string. Conversion failures drop
|
||||
* that RPU instead of forwarding Profile 7 metadata into a Profile 8.1 stream.
|
||||
* - HEVC_STRIP: Strip DV RPU/EL NALs, present as plain video/hevc.
|
||||
*
|
||||
* Two modes of NAL framing (auto-detected):
|
||||
* - Annex B (MKV path): MatroskaExtractor outputs 00 00 00 01 start codes
|
||||
@@ -22,16 +21,15 @@ import androidx.media3.extractor.TrackOutput
|
||||
* NAL processing:
|
||||
* - Type 62 (UNSPEC62): DV RPU → convert (DV81) or strip (HEVC_STRIP)
|
||||
* - Type 63 (UNSPEC63): DV Enhancement Layer → strip
|
||||
* - nuh_layer_id > 0: Enhancement layer NAL → strip
|
||||
* - All retained NALs: normalize nuh_layer_id to 0
|
||||
* - All other NALs: pass through unchanged, matching Kodi's compatibility path
|
||||
*
|
||||
* All buffers are reused across samples to minimize GC pressure on the hot path.
|
||||
*/
|
||||
class DoviConvertingTrackOutput(
|
||||
private val delegate: TrackOutput,
|
||||
delegate: TrackOutput,
|
||||
private val dvMode: DvConversionMode = DvConversionMode.HEVC_STRIP,
|
||||
private val emitLog: ((String, String, String) -> Unit)? = null
|
||||
) : TrackOutput {
|
||||
) : BufferedTransformingTrackOutput(delegate, INITIAL_BUFFER_SIZE) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "DoviConvertTrack"
|
||||
@@ -47,6 +45,10 @@ class DoviConvertingTrackOutput(
|
||||
private set
|
||||
var strippedNalCount = 0L
|
||||
private set
|
||||
var strippedRpuNalCount = 0L
|
||||
private set
|
||||
var strippedElNalCount = 0L
|
||||
private set
|
||||
var strippedInitNalCount = 0L
|
||||
private set
|
||||
var convertedRpuCount = 0L
|
||||
@@ -61,12 +63,8 @@ class DoviConvertingTrackOutput(
|
||||
get() = if (sampleCount > 0) totalSampleProcessingTimeUs / sampleCount else 0L
|
||||
|
||||
// Reusable buffers — grown as needed, never shrunk
|
||||
private var sampleBuf = ByteArray(INITIAL_BUFFER_SIZE)
|
||||
private var sampleLen = 0
|
||||
private var outputBuf = ByteArray(INITIAL_BUFFER_SIZE)
|
||||
private var outputLen = 0
|
||||
private val outputParsable = ParsableByteArray()
|
||||
private var buffering = false
|
||||
|
||||
// Sample counter for periodic logging
|
||||
private var sampleCount = 0L
|
||||
@@ -75,15 +73,22 @@ class DoviConvertingTrackOutput(
|
||||
private var totalSampleProcessingTimeUs = 0L
|
||||
private var loggedSupplementalWrapper = false
|
||||
private var loggedEncryptedSupplementalPassthrough = false
|
||||
private var outputIsProcessed = false
|
||||
|
||||
override fun format(format: Format) {
|
||||
if (!conversionActive) {
|
||||
val codecs = format.codecs
|
||||
if (codecs != null && codecs.startsWith("dvhe.07")) {
|
||||
if (isDvProfile7Codec(codecs)) {
|
||||
val codecString = codecs ?: ""
|
||||
conversionActive = true
|
||||
logInfo("DV Profile 7 detected ($codecs), mode=$dvMode")
|
||||
logInfo("DV Profile 7 detected ($codecString), mode=$dvMode")
|
||||
when (dvMode) {
|
||||
DvConversionMode.DV81 -> logInfo("DV81: Kodi-style libdovi conversion (convert RPU type 62, drop EL type 63)")
|
||||
DvConversionMode.HEVC_STRIP -> logInfo("HEVC_STRIP: Kodi-compatible strip (drop RPU type 62 and EL type 63 only)")
|
||||
else -> Unit
|
||||
}
|
||||
logInfo(
|
||||
"Original format: mime=${format.sampleMimeType}, codecs=$codecs, " +
|
||||
"Original format: mime=${format.sampleMimeType}, codecs=$codecString, " +
|
||||
"initData=${format.initializationData.size} entries " +
|
||||
"(${format.initializationData.mapIndexed { i, d -> "$i:${d.size}B" }.joinToString()})"
|
||||
)
|
||||
@@ -92,7 +97,7 @@ class DoviConvertingTrackOutput(
|
||||
val newFormat = when (dvMode) {
|
||||
DvConversionMode.DV81 -> {
|
||||
// Parse DV level from codec string: "dvhe.07.06" → 6
|
||||
val level = codecs.split('.').getOrNull(2)?.toIntOrNull() ?: 6
|
||||
val level = codecString.split('.').getOrNull(2)?.toIntOrNull() ?: 6
|
||||
val newCodecs = "dvhe.08.%02d".format(level)
|
||||
val dvConfigRecord = buildDv81ConfigRecord(level)
|
||||
logInfo("DV81: rewriting to $newCodecs, config=${dvConfigRecord.size}B")
|
||||
@@ -125,81 +130,37 @@ class DoviConvertingTrackOutput(
|
||||
delegate.format(format)
|
||||
}
|
||||
|
||||
override fun sampleData(
|
||||
input: DataReader,
|
||||
length: Int,
|
||||
allowEndOfInput: Boolean,
|
||||
sampleDataPart: Int
|
||||
): Int {
|
||||
if (!conversionActive) {
|
||||
return delegate.sampleData(input, length, allowEndOfInput, sampleDataPart)
|
||||
}
|
||||
override val transformEnabled: Boolean
|
||||
get() = conversionActive
|
||||
|
||||
buffering = true
|
||||
ensureSampleCapacity(sampleLen + length)
|
||||
val bytesRead = input.read(sampleBuf, sampleLen, length)
|
||||
if (bytesRead > 0) {
|
||||
sampleLen += bytesRead
|
||||
}
|
||||
return bytesRead
|
||||
}
|
||||
|
||||
override fun sampleData(data: ParsableByteArray, length: Int, sampleDataPart: Int) {
|
||||
if (!conversionActive) {
|
||||
delegate.sampleData(data, length, sampleDataPart)
|
||||
return
|
||||
}
|
||||
|
||||
buffering = true
|
||||
ensureSampleCapacity(sampleLen + length)
|
||||
data.readBytes(sampleBuf, sampleLen, length)
|
||||
sampleLen += length
|
||||
}
|
||||
|
||||
override fun sampleMetadata(
|
||||
timeUs: Long,
|
||||
flags: Int,
|
||||
size: Int,
|
||||
offset: Int,
|
||||
cryptoData: TrackOutput.CryptoData?
|
||||
) {
|
||||
if (!conversionActive || !buffering) {
|
||||
delegate.sampleMetadata(timeUs, flags, size, offset, cryptoData)
|
||||
return
|
||||
}
|
||||
|
||||
buffering = false
|
||||
val srcLen = sampleLen
|
||||
sampleLen = 0
|
||||
override val transformedBuffer: ByteArray
|
||||
get() = if (outputIsProcessed) outputBuf else inputBuffer
|
||||
|
||||
override fun transformSample(inputLength: Int, flags: Int): Int {
|
||||
val processStartNs = System.nanoTime()
|
||||
val success = if ((flags and C.BUFFER_FLAG_ENCRYPTED) != 0) {
|
||||
if ((flags and C.BUFFER_FLAG_ENCRYPTED) != 0) {
|
||||
outputIsProcessed = false
|
||||
if (!loggedEncryptedSupplementalPassthrough && (flags and C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA) != 0) {
|
||||
loggedEncryptedSupplementalPassthrough = true
|
||||
logWarn("Encrypted supplemental sample encountered, passing raw sample")
|
||||
}
|
||||
false
|
||||
} else {
|
||||
try {
|
||||
processSampleData(flags, srcLen)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
logError("NAL processing failed, passing raw sample", e)
|
||||
false
|
||||
return if (inputLength == 0) -1 else inputLength
|
||||
}
|
||||
|
||||
return try {
|
||||
if (!processSampleData(flags, inputLength)) {
|
||||
outputIsProcessed = true
|
||||
-1
|
||||
} else {
|
||||
outputIsProcessed = true
|
||||
recordSampleProcessing((System.nanoTime() - processStartNs) / 1_000L)
|
||||
if (outputLen == 0) -1 else outputLen
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
outputIsProcessed = false
|
||||
logError("NAL processing failed, passing raw sample", e)
|
||||
if (inputLength == 0) -1 else inputLength
|
||||
}
|
||||
val outLen = if (success) outputLen else srcLen
|
||||
val outBuf = if (success) outputBuf else sampleBuf
|
||||
if (success) {
|
||||
recordSampleProcessing((System.nanoTime() - processStartNs) / 1_000L)
|
||||
}
|
||||
|
||||
// Skip empty samples (all NALs were DV layers) — don't confuse the decoder
|
||||
if (outLen == 0) return
|
||||
|
||||
outputParsable.reset(outBuf, outLen)
|
||||
delegate.sampleData(outputParsable, outLen, TrackOutput.SAMPLE_DATA_PART_MAIN)
|
||||
delegate.sampleMetadata(timeUs, flags, outLen, 0, cryptoData)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,23 +170,21 @@ class DoviConvertingTrackOutput(
|
||||
* [4-byte big-endian main sample size][main sample][supplemental data].
|
||||
* Only the main sample contains video NALs and should be rewritten.
|
||||
*/
|
||||
private fun processSampleData(flags: Int, dataLen: Int) {
|
||||
private fun processSampleData(flags: Int, dataLen: Int): Boolean {
|
||||
if ((flags and C.BUFFER_FLAG_HAS_SUPPLEMENTAL_DATA) == 0) {
|
||||
processNalUnits(0, dataLen)
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
if (dataLen < 4) {
|
||||
logWarn("Bad supplemental sample: ${dataLen}B is too small")
|
||||
copyRawSample(dataLen)
|
||||
return
|
||||
logWarn("Bad supplemental sample: ${dataLen}B is too small; dropping sample")
|
||||
return false
|
||||
}
|
||||
|
||||
val mainSampleLen = readInt32BE(sampleBuf, 0)
|
||||
val mainSampleLen = readInt32BE(inputBuffer, 0)
|
||||
if (mainSampleLen < 0 || mainSampleLen > dataLen - 4) {
|
||||
logWarn("Bad supplemental sample: mainLen=$mainSampleLen total=$dataLen")
|
||||
copyRawSample(dataLen)
|
||||
return
|
||||
logWarn("Bad supplemental sample: mainLen=$mainSampleLen total=$dataLen; dropping sample")
|
||||
return false
|
||||
}
|
||||
|
||||
val supplementalLen = dataLen - 4 - mainSampleLen
|
||||
@@ -233,23 +192,20 @@ class DoviConvertingTrackOutput(
|
||||
loggedSupplementalWrapper = true
|
||||
logDebug(
|
||||
"Supplemental wrapper detected: total=${dataLen}B, main=${mainSampleLen}B, " +
|
||||
"supplemental=${supplementalLen}B, innerFirstBytes=${formatBytes(sampleBuf, 4, 8)}"
|
||||
"supplemental=${supplementalLen}B, innerFirstBytes=${formatBytes(inputBuffer, 4, 8)}"
|
||||
)
|
||||
}
|
||||
|
||||
processNalUnits(4, mainSampleLen)
|
||||
|
||||
val processedMainLen = outputLen
|
||||
if (processedMainLen == 0) {
|
||||
outputLen = 0
|
||||
return
|
||||
}
|
||||
if (processedMainLen == 0) return true
|
||||
|
||||
ensureOutputCapacity(4 + processedMainLen + supplementalLen)
|
||||
System.arraycopy(outputBuf, 0, outputBuf, 4, processedMainLen)
|
||||
writeInt32BE(outputBuf, 0, processedMainLen)
|
||||
if (supplementalLen > 0) {
|
||||
System.arraycopy(sampleBuf, 4 + mainSampleLen, outputBuf, 4 + processedMainLen, supplementalLen)
|
||||
System.arraycopy(inputBuffer, 4 + mainSampleLen, outputBuf, 4 + processedMainLen, supplementalLen)
|
||||
}
|
||||
outputLen = 4 + processedMainLen + supplementalLen
|
||||
|
||||
@@ -259,6 +215,7 @@ class DoviConvertingTrackOutput(
|
||||
"supplemental=${supplementalLen}B, total=${dataLen}B -> ${outputLen}B"
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -272,7 +229,7 @@ class DoviConvertingTrackOutput(
|
||||
outputLen = 0
|
||||
if (dataLen < 4) {
|
||||
ensureOutputCapacity(dataLen)
|
||||
System.arraycopy(sampleBuf, dataOffset, outputBuf, 0, dataLen)
|
||||
System.arraycopy(inputBuffer, dataOffset, outputBuf, 0, dataLen)
|
||||
outputLen = dataLen
|
||||
return
|
||||
}
|
||||
@@ -280,22 +237,22 @@ class DoviConvertingTrackOutput(
|
||||
// Auto-detect: Annex B starts with 00 00 00 01 or 00 00 01
|
||||
val isAnnexB = (
|
||||
dataLen >= 4 &&
|
||||
sampleBuf[dataOffset] == 0.toByte() &&
|
||||
sampleBuf[dataOffset + 1] == 0.toByte() &&
|
||||
sampleBuf[dataOffset + 2] == 0.toByte() &&
|
||||
sampleBuf[dataOffset + 3] == 1.toByte()
|
||||
inputBuffer[dataOffset] == 0.toByte() &&
|
||||
inputBuffer[dataOffset + 1] == 0.toByte() &&
|
||||
inputBuffer[dataOffset + 2] == 0.toByte() &&
|
||||
inputBuffer[dataOffset + 3] == 1.toByte()
|
||||
) ||
|
||||
(
|
||||
dataLen >= 3 &&
|
||||
sampleBuf[dataOffset] == 0.toByte() &&
|
||||
sampleBuf[dataOffset + 1] == 0.toByte() &&
|
||||
sampleBuf[dataOffset + 2] == 1.toByte()
|
||||
inputBuffer[dataOffset] == 0.toByte() &&
|
||||
inputBuffer[dataOffset + 1] == 0.toByte() &&
|
||||
inputBuffer[dataOffset + 2] == 1.toByte()
|
||||
)
|
||||
|
||||
if (sampleCount == 0L) {
|
||||
logDebug(
|
||||
"NAL format detected: ${if (isAnnexB) "Annex B" else "length-prefixed"}, " +
|
||||
"first bytes: ${formatBytes(sampleBuf, dataOffset, 8)}"
|
||||
"first bytes: ${formatBytes(inputBuffer, dataOffset, 8)}"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -313,11 +270,11 @@ class DoviConvertingTrackOutput(
|
||||
var scEnd = -1
|
||||
var i = dataOffset
|
||||
while (i < dataEnd - 2) {
|
||||
if (sampleBuf[i] == 0.toByte() && sampleBuf[i + 1] == 0.toByte()) {
|
||||
if (i + 3 < dataEnd && sampleBuf[i + 2] == 0.toByte() && sampleBuf[i + 3] == 1.toByte()) {
|
||||
if (inputBuffer[i] == 0.toByte() && inputBuffer[i + 1] == 0.toByte()) {
|
||||
if (i + 3 < dataEnd && inputBuffer[i + 2] == 0.toByte() && inputBuffer[i + 3] == 1.toByte()) {
|
||||
scEnd = i + 4
|
||||
break
|
||||
} else if (sampleBuf[i + 2] == 1.toByte()) {
|
||||
} else if (inputBuffer[i + 2] == 1.toByte()) {
|
||||
scEnd = i + 3
|
||||
break
|
||||
}
|
||||
@@ -327,7 +284,7 @@ class DoviConvertingTrackOutput(
|
||||
|
||||
if (scEnd < 0) {
|
||||
// No start codes found — pass through
|
||||
System.arraycopy(sampleBuf, dataOffset, outputBuf, 0, dataLen)
|
||||
System.arraycopy(inputBuffer, dataOffset, outputBuf, 0, dataLen)
|
||||
outputLen = dataLen
|
||||
sampleCount++
|
||||
return
|
||||
@@ -340,11 +297,11 @@ class DoviConvertingTrackOutput(
|
||||
var nalEnd = dataEnd
|
||||
i = nalStart
|
||||
while (i < dataEnd - 2) {
|
||||
if (sampleBuf[i] == 0.toByte() && sampleBuf[i + 1] == 0.toByte()) {
|
||||
if (i + 3 < dataEnd && sampleBuf[i + 2] == 0.toByte() && sampleBuf[i + 3] == 1.toByte()) {
|
||||
if (inputBuffer[i] == 0.toByte() && inputBuffer[i + 1] == 0.toByte()) {
|
||||
if (i + 3 < dataEnd && inputBuffer[i + 2] == 0.toByte() && inputBuffer[i + 3] == 1.toByte()) {
|
||||
nalEnd = i
|
||||
break
|
||||
} else if (sampleBuf[i + 2] == 1.toByte()) {
|
||||
} else if (inputBuffer[i + 2] == 1.toByte()) {
|
||||
nalEnd = i
|
||||
break
|
||||
}
|
||||
@@ -359,8 +316,7 @@ class DoviConvertingTrackOutput(
|
||||
ensureOutputCapacity(outputLen + 4 + nalLen)
|
||||
System.arraycopy(ANNEX_B_START_CODE, 0, outputBuf, outputLen, 4)
|
||||
outputLen += 4
|
||||
System.arraycopy(sampleBuf, nalStart, outputBuf, outputLen, nalLen)
|
||||
normalizeLayerId(outputBuf, outputLen)
|
||||
System.arraycopy(inputBuffer, nalStart, outputBuf, outputLen, nalLen)
|
||||
outputLen += nalLen
|
||||
kept++
|
||||
} else if (action == NalAction.CONVERT) {
|
||||
@@ -368,23 +324,22 @@ class DoviConvertingTrackOutput(
|
||||
if (convertedLen >= 0) {
|
||||
System.arraycopy(ANNEX_B_START_CODE, 0, outputBuf, outputLen, 4)
|
||||
outputLen += 4
|
||||
normalizeLayerId(outputBuf, outputLen)
|
||||
outputLen += convertedLen
|
||||
convertedRpuCount++
|
||||
kept++
|
||||
} else {
|
||||
strippedNalCount++
|
||||
recordStrippedNal(action)
|
||||
stripped++
|
||||
}
|
||||
} else {
|
||||
strippedNalCount++
|
||||
recordStrippedNal(action)
|
||||
stripped++
|
||||
}
|
||||
}
|
||||
|
||||
// Advance past the next start code
|
||||
if (nalEnd >= dataEnd) break
|
||||
nalStart = if (nalEnd + 3 < dataEnd && sampleBuf[nalEnd + 2] == 0.toByte() && sampleBuf[nalEnd + 3] == 1.toByte()) {
|
||||
nalStart = if (nalEnd + 3 < dataEnd && inputBuffer[nalEnd + 2] == 0.toByte() && inputBuffer[nalEnd + 3] == 1.toByte()) {
|
||||
nalEnd + 4
|
||||
} else {
|
||||
nalEnd + 3
|
||||
@@ -409,10 +364,10 @@ class DoviConvertingTrackOutput(
|
||||
var stripped = 0
|
||||
|
||||
while (pos + 4 <= dataEnd) {
|
||||
val nalLen = ((sampleBuf[pos].toInt() and 0xFF) shl 24) or
|
||||
((sampleBuf[pos + 1].toInt() and 0xFF) shl 16) or
|
||||
((sampleBuf[pos + 2].toInt() and 0xFF) shl 8) or
|
||||
(sampleBuf[pos + 3].toInt() and 0xFF)
|
||||
val nalLen = ((inputBuffer[pos].toInt() and 0xFF) shl 24) or
|
||||
((inputBuffer[pos + 1].toInt() and 0xFF) shl 16) or
|
||||
((inputBuffer[pos + 2].toInt() and 0xFF) shl 8) or
|
||||
(inputBuffer[pos + 3].toInt() and 0xFF)
|
||||
|
||||
if (nalLen <= 0 || pos + 4 + nalLen > dataEnd) {
|
||||
if (sampleCount < 5) {
|
||||
@@ -427,8 +382,7 @@ class DoviConvertingTrackOutput(
|
||||
ensureOutputCapacity(outputLen + 4 + nalLen)
|
||||
writeInt32BE(outputBuf, outputLen, nalLen)
|
||||
outputLen += 4
|
||||
System.arraycopy(sampleBuf, nalStart, outputBuf, outputLen, nalLen)
|
||||
normalizeLayerId(outputBuf, outputLen)
|
||||
System.arraycopy(inputBuffer, nalStart, outputBuf, outputLen, nalLen)
|
||||
outputLen += nalLen
|
||||
kept++
|
||||
} else if (action == NalAction.CONVERT) {
|
||||
@@ -436,16 +390,15 @@ class DoviConvertingTrackOutput(
|
||||
if (convertedLen >= 0) {
|
||||
writeInt32BE(outputBuf, outputLen, convertedLen)
|
||||
outputLen += 4
|
||||
normalizeLayerId(outputBuf, outputLen)
|
||||
outputLen += convertedLen
|
||||
convertedRpuCount++
|
||||
kept++
|
||||
} else {
|
||||
strippedNalCount++
|
||||
recordStrippedNal(action)
|
||||
stripped++
|
||||
}
|
||||
} else {
|
||||
strippedNalCount++
|
||||
recordStrippedNal(action)
|
||||
stripped++
|
||||
}
|
||||
|
||||
@@ -461,7 +414,17 @@ class DoviConvertingTrackOutput(
|
||||
}
|
||||
}
|
||||
|
||||
private enum class NalAction { KEEP, STRIP, CONVERT }
|
||||
private enum class NalAction { KEEP, STRIP_RPU, STRIP_EL, CONVERT }
|
||||
|
||||
private fun recordStrippedNal(action: NalAction) {
|
||||
strippedNalCount++
|
||||
// CONVERT reaches here only after conversion fails; raw P7 RPUs are not safe in P8.1 output.
|
||||
when (action) {
|
||||
NalAction.STRIP_RPU, NalAction.CONVERT -> strippedRpuNalCount++
|
||||
NalAction.STRIP_EL -> strippedElNalCount++
|
||||
NalAction.KEEP -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun convertRpuIntoOutput(nalStart: Int, nalLen: Int, outputOffset: Int): Int {
|
||||
ensureOutputCapacity(outputOffset + MAX_CONVERTED_RPU_SIZE)
|
||||
@@ -470,7 +433,7 @@ class DoviConvertingTrackOutput(
|
||||
while (true) {
|
||||
val startNs = System.nanoTime()
|
||||
val written = DoviBridge.convertRpuNalu(
|
||||
payload = sampleBuf,
|
||||
payload = inputBuffer,
|
||||
payloadOffset = nalStart,
|
||||
payloadLength = nalLen,
|
||||
output = outputBuf,
|
||||
@@ -565,7 +528,6 @@ class DoviConvertingTrackOutput(
|
||||
System.arraycopy(ANNEX_B_START_CODE, 0, outputBuf, outputLen, 4)
|
||||
outputLen += 4
|
||||
System.arraycopy(data, nalStart, outputBuf, outputLen, nalLen)
|
||||
normalizeLayerId(outputBuf, outputLen)
|
||||
outputLen += nalLen
|
||||
kept++
|
||||
}
|
||||
@@ -615,27 +577,24 @@ class DoviConvertingTrackOutput(
|
||||
}
|
||||
|
||||
/** Classify a NAL at sampleBuf[offset..offset+len) without copying. */
|
||||
private fun processNalInline(offset: Int, len: Int): NalAction = classifyNal(sampleBuf, offset, len, convertRpu = dvMode == DvConversionMode.DV81)
|
||||
private fun processNalInline(offset: Int, len: Int): NalAction = classifyNal(inputBuffer, offset, len, convertRpu = dvMode == DvConversionMode.DV81)
|
||||
|
||||
private fun isDvProfile7Codec(codecs: String?): Boolean {
|
||||
val normalized = codecs?.lowercase() ?: return false
|
||||
return normalized.startsWith("dvhe.07") || normalized.startsWith("dvh1.07")
|
||||
}
|
||||
|
||||
private fun classifyNal(data: ByteArray, offset: Int, len: Int, convertRpu: Boolean): NalAction {
|
||||
if (len < 2) return NalAction.KEEP
|
||||
val nalType = (data[offset].toInt() ushr 1) and 0x3F
|
||||
val nuhLayerId = ((data[offset].toInt() and 1) shl 5) or
|
||||
((data[offset + 1].toInt() ushr 3) and 0x1F)
|
||||
return when {
|
||||
nalType == NAL_TYPE_UNSPEC62 && convertRpu -> NalAction.CONVERT
|
||||
nalType == NAL_TYPE_UNSPEC62 || nalType == NAL_TYPE_UNSPEC63 || nuhLayerId > 0 -> NalAction.STRIP
|
||||
nalType == NAL_TYPE_UNSPEC62 -> NalAction.STRIP_RPU
|
||||
nalType == NAL_TYPE_UNSPEC63 -> NalAction.STRIP_EL
|
||||
else -> NalAction.KEEP
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeLayerId(data: ByteArray, offset: Int) {
|
||||
if (data.size - offset >= 2) {
|
||||
data[offset] = (data[offset].toInt() and 0xFE).toByte()
|
||||
data[offset + 1] = (data[offset + 1].toInt() and 0x07).toByte()
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeInt32BE(buf: ByteArray, offset: Int, value: Int) {
|
||||
buf[offset] = ((value ushr 24) and 0xFF).toByte()
|
||||
buf[offset + 1] = ((value ushr 16) and 0xFF).toByte()
|
||||
@@ -648,24 +607,12 @@ class DoviConvertingTrackOutput(
|
||||
((buf[offset + 2].toInt() and 0xFF) shl 8) or
|
||||
(buf[offset + 3].toInt() and 0xFF)
|
||||
|
||||
private fun copyRawSample(dataLen: Int) {
|
||||
ensureOutputCapacity(dataLen)
|
||||
System.arraycopy(sampleBuf, 0, outputBuf, 0, dataLen)
|
||||
outputLen = dataLen
|
||||
}
|
||||
|
||||
private fun formatBytes(data: ByteArray, offset: Int, length: Int): String {
|
||||
val end = minOf(data.size, offset + length)
|
||||
if (offset >= end) return ""
|
||||
return (offset until end).joinToString(" ") { "%02X".format(data[it]) }
|
||||
}
|
||||
|
||||
private fun ensureSampleCapacity(needed: Int) {
|
||||
if (sampleBuf.size < needed) {
|
||||
sampleBuf = sampleBuf.copyOf(maxOf(needed, sampleBuf.size * 2))
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureOutputCapacity(needed: Int) {
|
||||
if (outputBuf.size < needed) {
|
||||
outputBuf = outputBuf.copyOf(maxOf(needed, outputBuf.size * 2))
|
||||
|
||||
@@ -19,12 +19,19 @@ class DoviExtractorOutputWrapper(
|
||||
private val emitLog: ((String, String, String) -> Unit)?,
|
||||
private val onVideoTrackWrapped: (DoviConvertingTrackOutput) -> Unit
|
||||
) : ExtractorOutput {
|
||||
private val trackOutputs = mutableListOf<DoviConvertingTrackOutput>()
|
||||
|
||||
fun resetTracks() {
|
||||
trackOutputs.forEach { it.resetBufferedData() }
|
||||
}
|
||||
|
||||
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 DoviConvertingTrackOutput(original, dvMode, emitLog).also {
|
||||
trackOutputs.add(it)
|
||||
onVideoTrackWrapped(it)
|
||||
}
|
||||
}
|
||||
return original
|
||||
}
|
||||
@@ -46,16 +53,22 @@ class DoviExtractorWrapper(
|
||||
|
||||
@Volatile var doviTrackOutput: DoviConvertingTrackOutput? = null
|
||||
private set
|
||||
private var outputWrapper: DoviExtractorOutputWrapper? = null
|
||||
|
||||
override fun sniff(input: ExtractorInput): Boolean = delegate.sniff(input)
|
||||
|
||||
override fun init(output: ExtractorOutput) {
|
||||
delegate.init(DoviExtractorOutputWrapper(output, dvMode, emitLog) { doviTrackOutput = it })
|
||||
val wrapper = DoviExtractorOutputWrapper(output, dvMode, emitLog) { doviTrackOutput = it }
|
||||
outputWrapper = wrapper
|
||||
delegate.init(wrapper)
|
||||
}
|
||||
|
||||
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) {
|
||||
outputWrapper?.resetTracks()
|
||||
delegate.seek(position, timeUs)
|
||||
}
|
||||
|
||||
override fun release() = delegate.release()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import kotlin.math.pow
|
||||
|
||||
/**
|
||||
* Stereo downmix coefficient builder (Kodi-style center boost).
|
||||
*
|
||||
* Channel-order assumptions per input count follow the Android channel-mask
|
||||
* bit order that MediaCodec / the FFmpeg extension emit:
|
||||
* 3 = FL FR FC, 4 = FL FR BL BR, 5 = FL FR FC BL BR, 6 = FL FR FC LFE BL BR,
|
||||
* 7 = 6.1 (5.1 + BC), 8 = 7.1 (5.1 + SL SR). LFE is dropped, matching
|
||||
* ffmpeg's default lfe_mix_level=0 (and what the mpv backends produce).
|
||||
*
|
||||
* All coefficients must stay >= 0: ChannelMixingMatrix throws on negatives.
|
||||
*/
|
||||
object DownmixMatrices {
|
||||
const val MIN_DOWNMIX_INPUT_CHANNELS = 3
|
||||
const val MAX_DOWNMIX_INPUT_CHANNELS = 8
|
||||
const val MAX_CENTER_BOOST_DB = 12
|
||||
const val MAX_MIXING_CHANNELS = 12
|
||||
|
||||
const val SURROUND_GAIN = 0.70710678f // -3 dB
|
||||
private const val BACK_CENTER_GAIN = 0.5f // SURROUND_GAIN split across both outputs
|
||||
|
||||
/** Kodi's mechanism: center coefficient = 10^((-3 + boostDb) / 20); boost 0 is the standard -3 dB. */
|
||||
fun centerGain(centerBoostDb: Int): Float = 10f.pow((-3f + centerBoostDb.coerceIn(0, MAX_CENTER_BOOST_DB)) / 20f)
|
||||
|
||||
/**
|
||||
* Row-major stereo coefficients ([inputChannel * 2 + outputChannel]), or null
|
||||
* when [inputChannels] is not downmixed (mono/stereo pass through; >8ch
|
||||
* unsupported, the caller keeps an identity matrix).
|
||||
*
|
||||
* [normalize] scales the matrix so the loudest output sum is <= 1 (cannot
|
||||
* clip); disabled keeps the original level like Kodi's "maintain original
|
||||
* volume" (the 16-bit mix saturates on clip).
|
||||
*/
|
||||
fun stereoCoefficients(inputChannels: Int, centerBoostDb: Int, normalize: Boolean): FloatArray? {
|
||||
val c = centerGain(centerBoostDb)
|
||||
val s = SURROUND_GAIN
|
||||
val rows: List<FloatArray> = when (inputChannels) {
|
||||
3 -> listOf(fl(), fr(), both(c))
|
||||
4 -> listOf(fl(), fr(), left(s), right(s))
|
||||
5 -> listOf(fl(), fr(), both(c), left(s), right(s))
|
||||
6 -> listOf(fl(), fr(), both(c), lfe(), left(s), right(s))
|
||||
7 -> listOf(fl(), fr(), both(c), lfe(), left(s), right(s), both(BACK_CENTER_GAIN))
|
||||
8 -> listOf(fl(), fr(), both(c), lfe(), left(s), right(s), left(s), right(s))
|
||||
else -> return null
|
||||
}
|
||||
val flat = FloatArray(rows.size * 2)
|
||||
rows.forEachIndexed { i, row ->
|
||||
flat[i * 2] = row[0]
|
||||
flat[i * 2 + 1] = row[1]
|
||||
}
|
||||
if (normalize) {
|
||||
var sumL = 0f
|
||||
var sumR = 0f
|
||||
for (i in rows.indices) {
|
||||
sumL += flat[i * 2]
|
||||
sumR += flat[i * 2 + 1]
|
||||
}
|
||||
val peak = maxOf(sumL, sumR, 1f)
|
||||
if (peak > 1f) {
|
||||
for (i in flat.indices) flat[i] /= peak
|
||||
}
|
||||
}
|
||||
return flat
|
||||
}
|
||||
|
||||
fun identityCoefficients(channelCount: Int): FloatArray {
|
||||
require(channelCount in 1..MAX_MIXING_CHANNELS)
|
||||
return FloatArray(channelCount * channelCount).apply {
|
||||
for (channel in 0 until channelCount) {
|
||||
this[channel * channelCount + channel] = 1f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fl() = floatArrayOf(1f, 0f)
|
||||
private fun fr() = floatArrayOf(0f, 1f)
|
||||
private fun left(gain: Float) = floatArrayOf(gain, 0f)
|
||||
private fun right(gain: Float) = floatArrayOf(0f, gain)
|
||||
private fun both(gain: Float) = floatArrayOf(gain, gain)
|
||||
private fun lfe() = floatArrayOf(0f, 0f)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* In-place sanitizer for HEVC Annex B buffers carrying both Dolby Vision and HDR10+
|
||||
* dynamic metadata. Buggy chipsets (Fire TV 4K Max, MediaTek-based Google TV, ...)
|
||||
* crash or black-screen when a native DV codec also receives in-band HDR10+ SEI, so
|
||||
* only the metadata the active decode path consumes may be kept:
|
||||
*
|
||||
* - Native DV codec: strip HDR10+ SEI NALs (types 39/40 with ST 2094-40 payload),
|
||||
* the decoder follows the DV RPU. Port of androidx/media#3085 / Kodi xbmc#24584.
|
||||
* - HEVC fallback for a DV format: strip DV RPU/EL NALs (types 62/63) instead,
|
||||
* leaving HDR10+ for the display.
|
||||
*
|
||||
* Runs on the playback thread against MediaCodec's direct input buffers, where
|
||||
* per-byte ByteBuffer accessor calls are far too slow for high-bitrate UHD remuxes
|
||||
* (#1313). Each sample is therefore staged through a reusable heap array with a
|
||||
* single bulk get, scanned and compacted with array ops, and only the modified tail
|
||||
* is written back. Instances are not thread-safe: use one per renderer.
|
||||
*
|
||||
* Pure JVM (no android/media3 imports) so it stays unit-testable on the host.
|
||||
*/
|
||||
class DvBitstreamSanitizer {
|
||||
|
||||
private companion object {
|
||||
const val NAL_TYPE_PREFIX_SEI = 39
|
||||
const val NAL_TYPE_SUFFIX_SEI = 40
|
||||
const val NAL_TYPE_UNSPEC62 = 62 // DV RPU
|
||||
const val NAL_TYPE_UNSPEC63 = 63 // DV Enhancement Layer
|
||||
|
||||
const val SEI_PAYLOAD_TYPE_ITU_T_T35 = 4
|
||||
|
||||
const val INITIAL_SCRATCH_SIZE = 256 * 1024
|
||||
}
|
||||
|
||||
/** Reusable staging buffer — grown as needed, never shrunk. */
|
||||
private var scratch = ByteArray(INITIAL_SCRATCH_SIZE)
|
||||
|
||||
/**
|
||||
* Scans `[position, limit)` of [data] for Annex B NAL units and removes the selected
|
||||
* metadata NALs by compacting the buffer in place and reducing its limit. The position
|
||||
* is left unchanged. Returns the number of NAL units stripped.
|
||||
*
|
||||
* A sample with no start codes at all is left untouched: it cannot contain the
|
||||
* targeted NALs, and emptying it would only confuse the decoder.
|
||||
*/
|
||||
fun sanitize(data: ByteBuffer, stripHdr10PlusSei: Boolean, stripDvRpu: Boolean): Int {
|
||||
val startPos = data.position()
|
||||
val len = data.limit() - startPos
|
||||
if (len == 0) return 0
|
||||
if (scratch.size < len) scratch = ByteArray(maxOf(len, scratch.size * 2))
|
||||
val buf = scratch
|
||||
data.get(buf, 0, len)
|
||||
data.position(startPos)
|
||||
|
||||
var writeLen = 0
|
||||
var firstModified = -1
|
||||
var strippedCount = 0
|
||||
var nalStartIndex = -1
|
||||
var startCodeLen = 0
|
||||
|
||||
var i = 0
|
||||
while (i <= len) {
|
||||
// Find next start code or end of buffer.
|
||||
val atEnd = i == len
|
||||
var foundStartCode = false
|
||||
var nextStartCodeLen = 0
|
||||
if (!atEnd && i + 2 < len && buf[i].toInt() == 0 && buf[i + 1].toInt() == 0) {
|
||||
if (buf[i + 2].toInt() == 1) {
|
||||
foundStartCode = true
|
||||
nextStartCodeLen = 3
|
||||
} else if (buf[i + 2].toInt() == 0 && i + 3 < len && buf[i + 3].toInt() == 1) {
|
||||
foundStartCode = true
|
||||
nextStartCodeLen = 4
|
||||
}
|
||||
}
|
||||
|
||||
if (foundStartCode || atEnd) {
|
||||
if (nalStartIndex >= 0) {
|
||||
// Complete NAL unit (including its start code) from nalStartIndex to i.
|
||||
val nalDataStart = nalStartIndex + startCodeLen
|
||||
val nalEnd = i
|
||||
var strip = false
|
||||
|
||||
if (nalEnd - nalDataStart >= 2) {
|
||||
// HEVC NAL header: forbidden_zero_bit(1) + nal_unit_type(6) + nuh_layer_id MSB(1).
|
||||
val nalUnitType = (buf[nalDataStart].toInt() and 0x7E) shr 1
|
||||
strip = when (nalUnitType) {
|
||||
NAL_TYPE_UNSPEC62, NAL_TYPE_UNSPEC63 -> stripDvRpu
|
||||
NAL_TYPE_PREFIX_SEI, NAL_TYPE_SUFFIX_SEI ->
|
||||
stripHdr10PlusSei && isHdr10PlusSeiNalUnit(buf, nalDataStart + 2, nalEnd)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
if (strip) {
|
||||
strippedCount++
|
||||
if (firstModified < 0) firstModified = writeLen
|
||||
} else {
|
||||
if (writeLen != nalStartIndex) {
|
||||
// Also reached with zero strips when bytes precede the first start code.
|
||||
if (firstModified < 0) firstModified = writeLen
|
||||
System.arraycopy(buf, nalStartIndex, buf, writeLen, nalEnd - nalStartIndex)
|
||||
}
|
||||
writeLen += nalEnd - nalStartIndex
|
||||
}
|
||||
}
|
||||
nalStartIndex = i
|
||||
startCodeLen = nextStartCodeLen
|
||||
i += if (nextStartCodeLen > 0) nextStartCodeLen else 1
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
if (writeLen == len || firstModified < 0) return 0
|
||||
|
||||
// Bytes before firstModified are byte-identical to the buffer; write back only the tail.
|
||||
data.position(startPos + firstModified)
|
||||
data.put(buf, firstModified, writeLen - firstModified)
|
||||
data.limit(startPos + writeLen)
|
||||
data.position(startPos)
|
||||
return strippedCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the SEI RBSP (starting after the 2-byte HEVC NAL header) begins with an
|
||||
* HDR10+ message: user_data_registered_itu_t_t35 with country code 0xB5 (United States),
|
||||
* provider code 0x003C (Samsung), provider oriented code 0x0001, application identifier 4
|
||||
* (ST 2094-40), application version 0 or 1. Malformed/truncated data returns false so the
|
||||
* NAL is kept.
|
||||
*/
|
||||
private fun isHdr10PlusSeiNalUnit(buf: ByteArray, rbspStart: Int, nalEnd: Int): Boolean {
|
||||
var pos = rbspStart
|
||||
if (pos >= nalEnd) return false
|
||||
|
||||
// SEI payload type: accumulated 0xFF bytes plus the final byte.
|
||||
var payloadType = 0
|
||||
while (pos < nalEnd) {
|
||||
val b = buf[pos++].toInt() and 0xFF
|
||||
payloadType += b
|
||||
if (b != 0xFF) break
|
||||
}
|
||||
|
||||
// SEI payload size, same encoding.
|
||||
var payloadSize = 0
|
||||
while (pos < nalEnd) {
|
||||
val b = buf[pos++].toInt() and 0xFF
|
||||
payloadSize += b
|
||||
if (b != 0xFF) break
|
||||
}
|
||||
|
||||
if (payloadType != SEI_PAYLOAD_TYPE_ITU_T_T35 || payloadSize < 7 || pos + 7 > nalEnd) {
|
||||
return false
|
||||
}
|
||||
|
||||
// The identifier bytes (B5 00 3C 00 01 04 00/01) cannot contain the 0x000003 emulation
|
||||
// prevention pattern, so they can be read without RBSP unescaping.
|
||||
val countryCode = buf[pos].toInt() and 0xFF
|
||||
val providerCode = ((buf[pos + 1].toInt() and 0xFF) shl 8) or (buf[pos + 2].toInt() and 0xFF)
|
||||
val orientedCode = ((buf[pos + 3].toInt() and 0xFF) shl 8) or (buf[pos + 4].toInt() and 0xFF)
|
||||
val appIdentifier = buf[pos + 5].toInt() and 0xFF
|
||||
val appVersion = buf[pos + 6].toInt() and 0xFF
|
||||
|
||||
return countryCode == 0xB5 &&
|
||||
providerCode == 0x003C &&
|
||||
orientedCode == 0x0001 &&
|
||||
appIdentifier == 4 &&
|
||||
(appVersion == 0 || appVersion == 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.edde746.plezy.exoplayer
|
||||
|
||||
import androidx.media3.common.C
|
||||
|
||||
/**
|
||||
* Decision logic for media the player refuses to end (#1673).
|
||||
*
|
||||
* media3 reports `StuckPlayerException.STUCK_PLAYING_NOT_ENDING` when the player
|
||||
* sits in STATE_READY past the declared duration while no renderer ever reports
|
||||
* itself ended. Observed on a tunneled MTK decoder (Fire TV 4K Max): the clock
|
||||
* ran a full minute past the last frame behind a black screen, so nothing ever
|
||||
* completed the item — no Play Next, no auto-play, and a "playing" timeline the
|
||||
* server kept extrapolating past the item duration. The leading hypothesis is
|
||||
* that the tunneled end-of-stream buffer never arrives; what the evidence
|
||||
* establishes is only that the renderers never end.
|
||||
*
|
||||
* The stuck report alone cannot mean "finished": the same condition holds for a
|
||||
* container that under-declares its duration and is legitimately still playing.
|
||||
* The rendered-frame counter separates the two — a finished file has stopped
|
||||
* painting, an under-declared one has not.
|
||||
*
|
||||
* Inputs are deliberately the core's own bookkeeping rather than live player
|
||||
* state: media3 has already stopped the player by the time the report arrives,
|
||||
* so its timeline and track selection may be gone.
|
||||
*/
|
||||
internal object EndOfStreamPolicy {
|
||||
/**
|
||||
* Window media3 waits before reporting a player that is past its duration and
|
||||
* still not ending. media3's own not-ending default is a full minute; this
|
||||
* matches its stuck-*playing* default instead, which [isFinishedFile] can
|
||||
* afford because it also requires the picture to be gone.
|
||||
*/
|
||||
const val STALL_TIMEOUT_MS = 10_000
|
||||
|
||||
/**
|
||||
* How long the rendered-frame counter must sit still before a player stuck
|
||||
* past its duration counts as a finished file rather than a long tail.
|
||||
*/
|
||||
const val FRAME_STALL_MS = 5_000L
|
||||
|
||||
/**
|
||||
* Distance from the end a fallback backend may resume at. MPV opened at or
|
||||
* past the last frame seeks straight into EOF and parks there without ever
|
||||
* reporting it, which is how a failed hand-off turned into a second stall.
|
||||
*/
|
||||
const val FALLBACK_END_GUARD_MS = 1_000L
|
||||
|
||||
/**
|
||||
* Whether a player stuck past its duration has actually reached the end of the
|
||||
* file.
|
||||
*
|
||||
* [hasPlaybackOutput] keeps start-up failures — which the fallback ladder owns
|
||||
* — out of this path. [hasVideoOutput] says whether the rendered-frame counter
|
||||
* means anything for this media: audio-only playback never moves it, so there
|
||||
* the stuck timeout stands alone. [frameStallMs] is the age of the last
|
||||
* counter change.
|
||||
*/
|
||||
fun isFinishedFile(
|
||||
hasPlaybackOutput: Boolean,
|
||||
hasVideoOutput: Boolean,
|
||||
isLive: Boolean,
|
||||
durationMs: Long,
|
||||
positionMs: Long,
|
||||
frameStallMs: Long
|
||||
): Boolean = hasPlaybackOutput &&
|
||||
!isLive &&
|
||||
durationMs != C.TIME_UNSET &&
|
||||
durationMs > 0L &&
|
||||
positionMs >= durationMs &&
|
||||
(!hasVideoOutput || frameStallMs >= FRAME_STALL_MS)
|
||||
|
||||
/** Keep a backend hand-off strictly inside the media, mirroring the seek clamp. */
|
||||
fun fallbackStartPositionMs(positionMs: Long, durationMs: Long, isLive: Boolean): Long {
|
||||
if (isLive || durationMs == C.TIME_UNSET || durationMs <= 0L) return positionMs.coerceAtLeast(0L)
|
||||
return positionMs.coerceIn(0L, (durationMs - FALLBACK_END_GUARD_MS).coerceAtLeast(0L))
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user