mirror of
https://github.com/yattee/yattee.git
synced 2025-04-27 15:30:33 +05:30
Compare commits
740 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
2a597ab3cb | ||
|
4d662115e4 | ||
|
e068257f14 | ||
|
8b809fb0f1 | ||
|
d3e80f500e | ||
|
9343e9d023 | ||
|
e4b25b0f80 | ||
|
09c2fb19a9 | ||
|
043b07274e | ||
|
7f7e12d719 | ||
|
d990c6630e | ||
|
5239b36cfe | ||
|
addc13ebfb | ||
|
2a6f26ec68 | ||
|
2e2f502d97 | ||
|
59afc2f4c7 | ||
|
2f902e74bb | ||
|
500b75da4f | ||
|
3a17cc4dee | ||
|
16897338e6 | ||
|
7c870b8e61 | ||
|
75d9c5c747 | ||
|
9e0f1a72ab | ||
|
7f3b3ac0ab | ||
|
84b70b794b | ||
|
e6bae84162 | ||
|
9efbac3d15 | ||
|
1289f57f60 | ||
|
cc03ab059b | ||
|
17484f65fd | ||
|
65247227e7 | ||
|
625c01aaac | ||
|
7465ff9c5c | ||
|
41de28a698 | ||
|
7baab7a88a | ||
|
43599632b2 | ||
|
e4f413ed2d | ||
|
661b7547c5 | ||
|
d69f410d92 | ||
|
db7abe31ea | ||
|
fff36ece26 | ||
|
8a0e9ae75a | ||
|
d6e5b5ed76 | ||
|
cef1a1caea | ||
|
6c6abe8c84 | ||
|
9732537602 | ||
|
c0deeabaed | ||
|
f29dbcbe36 | ||
|
c7e1a50e56 | ||
|
dd205db15f | ||
|
9ca5d292ec | ||
|
748bc16342 | ||
|
798d2fc67f | ||
|
a5a88f8890 | ||
|
f69ccb6bd6 | ||
|
892b3dea17 | ||
|
9a11e9f9f5 | ||
|
055d5575ba | ||
|
28b6a517b6 | ||
|
b4bcd0c0a0 | ||
|
e62010d5d5 | ||
|
3339e8cb1f | ||
|
4855f9bead | ||
|
a65ed67751 | ||
|
72dcbe4515 | ||
|
7e02b08933 | ||
|
8596ee8811 | ||
|
894439ad5e | ||
|
5dad7a1b47 | ||
|
6d48a825cd | ||
|
ed11e593ff | ||
|
102dfba751 | ||
|
4202b27c03 | ||
|
2f937f74fa | ||
|
34a957b28e | ||
|
0bef798341 | ||
|
28a7b6e981 | ||
|
4663aab3da | ||
|
0de0445805 | ||
|
9cb0325503 | ||
|
5e85fd294c | ||
|
b2421da95d | ||
|
4e4add3c42 | ||
|
2185718d50 | ||
|
b0264aaabe | ||
|
035f3503c4 | ||
|
e3ac11c172 | ||
|
7aed6ac0d9 | ||
|
457c0ce7b3 | ||
|
747baf3edd | ||
|
cd24a0322f | ||
|
d525a22215 | ||
|
322a550666 | ||
|
98fa0b98e5 | ||
|
5313e4ead0 | ||
|
fa7b897e76 | ||
|
9bf3df1a29 | ||
|
34a805b986 | ||
|
36f680be62 | ||
|
a27ab02433 | ||
|
59dd0785b3 | ||
|
d7be915e7e | ||
|
3752f67630 | ||
|
dfe7565138 | ||
|
4d02538cb9 | ||
|
3229528a09 | ||
|
fffc4f4a5f | ||
|
e85bfe5007 | ||
|
b00b733fd5 | ||
|
119c663436 | ||
|
e8fcee23ef | ||
|
d56ef74a99 | ||
|
98f5b1a22b | ||
|
f0b7bd3ab8 | ||
|
2d7a101ce0 | ||
|
b2114174b4 | ||
|
e9f502a486 | ||
|
6978e9437c | ||
|
2026201a5f | ||
|
633af02577 | ||
|
1fd62f04aa | ||
|
e749307a0e | ||
|
d76ec881be | ||
|
72a39a2c75 | ||
|
8a84db5a2d | ||
|
663c37e3d2 | ||
|
ea2b329df2 | ||
|
bd79f56800 | ||
|
9a650b4ac0 | ||
|
13382270d5 | ||
|
24626c2299 | ||
|
18ac577c7f | ||
|
617af2cd20 | ||
|
1b778318dc | ||
|
c9ce574c7a | ||
|
9a1f0d7aaa | ||
|
1cb695848c | ||
|
740a2f85ac | ||
|
1a22ac71be | ||
|
f0d581d512 | ||
|
049a42f2e8 | ||
|
cea2684a29 | ||
|
772e5016c4 | ||
|
ed3d9a7d7c | ||
|
bde9aade11 | ||
|
a194738bb6 | ||
|
45567254f2 | ||
|
a3139ad059 | ||
|
598f17479f | ||
|
e888abfba9 | ||
|
e1e53b2d36 | ||
|
9510d91d61 | ||
|
59da0e71b6 | ||
|
6bdfb7368c | ||
|
7b26fdf400 | ||
|
67b41e36d5 | ||
|
c9c60349df | ||
|
6a70663f06 | ||
|
3d556d836f | ||
|
8feeb33a55 | ||
|
497c3bfc12 | ||
|
b51eadc7a9 | ||
|
7d0c1180c4 | ||
|
0c1fb02d50 | ||
|
6f358fab56 | ||
|
7631e2a8ed | ||
|
3a5f3fdfde | ||
|
e3633bdaf7 | ||
|
e912d910bc | ||
|
5ccb0f90d5 | ||
|
278bc343c2 | ||
|
5dc197664d | ||
|
192550ba7a | ||
|
3369e23e74 | ||
|
4381511c91 | ||
|
af99df9b8a | ||
|
21f21cc944 | ||
|
e1d8bb8125 | ||
|
d948ea6887 | ||
|
66eb8051bf | ||
|
95d3170d31 | ||
|
74b6adb247 | ||
|
a45522f710 | ||
|
0b01adf6eb | ||
|
444f6bcc03 | ||
|
3f871bce2c | ||
|
dc3492fd96 | ||
|
c7365a7dc1 | ||
|
fc9fc194f2 | ||
|
317ac63a3f | ||
|
dc7cee7388 | ||
|
1c5d909201 | ||
|
146da6b9cc | ||
|
6be13451e0 | ||
|
0c41ab6aa2 | ||
|
1b1e95711a | ||
|
13d4a592bc | ||
|
ab493614ba | ||
|
0b57187435 | ||
|
e7928d1016 | ||
|
b3d73aae92 | ||
|
bafcacb9a1 | ||
|
931d373d41 | ||
|
5d1620b0a0 | ||
|
44cfe8e6bc | ||
|
8220bbc3fe | ||
|
f01836010c | ||
|
37ccb27c8e | ||
|
93eb2eb258 | ||
|
ce9db8cd12 | ||
|
d361690d13 | ||
|
ded9699a3a | ||
|
101ddf79d5 | ||
|
d11c5a8c42 | ||
|
b2a84ef01b | ||
|
6c4eb0c840 | ||
|
2b41c73d56 | ||
|
516e305cd3 | ||
|
8921a38504 | ||
|
61d589a9b5 | ||
|
55cbd1fe80 | ||
|
d501cb938c | ||
|
8e97d3f42f | ||
|
64a18678ce | ||
|
522aecfbc1 | ||
|
f5e509c091 | ||
|
08c922f57c | ||
|
0e8436ab40 | ||
|
f3c876acf6 | ||
|
70d821fe5d | ||
|
9a450c9503 | ||
|
7e346bf49c | ||
|
35534bcbb1 | ||
|
94577332a1 | ||
|
56b17b0aa1 | ||
|
5512337984 | ||
|
af75afa912 | ||
|
f32bcae5eb | ||
|
c55161b6b6 | ||
|
3fd99f464a | ||
|
43c8484514 | ||
|
7755b392b7 | ||
|
e0998638b1 | ||
|
511a528eb6 | ||
|
89dfbdb5c7 | ||
|
f88a1d17d6 | ||
|
9e05909659 | ||
|
b966f4509a | ||
|
dc7073dcb5 | ||
|
a258ee3be4 | ||
|
b626f50adc | ||
|
78dc47dc24 | ||
|
46725bf4d9 | ||
|
8697ec8faf | ||
|
8a015d29c3 | ||
|
4097d11b5e | ||
|
5323d53f9e | ||
|
e3e0c4a92f | ||
|
9e5efc1aa6 | ||
|
1ed4c20c3a | ||
|
ced9eb28d7 | ||
|
ea49758ed2 | ||
|
65ec675859 | ||
|
9a650799d3 | ||
|
ddd1f243f7 | ||
|
94f19d55c8 | ||
|
30cdaf88e1 | ||
|
8139bba31e | ||
|
d6cfadab9a | ||
|
5b917ef91d | ||
|
34cb7860b3 | ||
|
934bd65752 | ||
|
e53985534e | ||
|
03e4c6d4e6 | ||
|
335e99cb7b | ||
|
ae9aa6fac7 | ||
|
2f4fb9fc67 | ||
|
f6bea6e045 | ||
|
fa712d8177 | ||
|
03d24fbc42 | ||
|
4fd3a37705 | ||
|
a66857b1fb | ||
|
e44c7f84c8 | ||
|
6b5ecbdd8b | ||
|
15ce82a686 | ||
|
7e3e393c65 | ||
|
108b4de483 | ||
|
7c9810ddf0 | ||
|
96df7fdec5 | ||
|
4fa5a15ad4 | ||
|
c9125644ed | ||
|
4db02b2638 | ||
|
9c5f066e55 | ||
|
c7908d08ae | ||
|
c9fb41c8e8 | ||
|
2e9cceafa5 | ||
|
fa09b2021c | ||
|
90777d91f6 | ||
|
6959778775 | ||
|
0f43efef6f | ||
|
959fb0d1fc | ||
|
81be57904b | ||
|
a42345896d | ||
|
43fc9e20c0 | ||
|
1a1bd1ba5b | ||
|
99aca8e23c | ||
|
ddee3b74f0 | ||
|
b271aed52b | ||
|
1c608c78a1 | ||
|
0ec227ba80 | ||
|
2a93ff52a3 | ||
|
896d46d0cf | ||
|
ad79180530 | ||
|
101f20c538 | ||
|
f28cec79ba | ||
|
a12755ec4b | ||
|
38c4ddbe43 | ||
|
e35f8b7892 | ||
|
c3e4c074d6 | ||
|
6eba2a45c8 | ||
|
1fe8a32fb8 | ||
|
5a0c1bbae3 | ||
|
4ce9dc6729 | ||
|
b783db30b6 | ||
|
7741e531f4 | ||
|
4b21cd48e3 | ||
|
d67e375f16 | ||
|
db2417e455 | ||
|
7f8aa51c78 | ||
|
4038f7fdb9 | ||
|
8a7e4c84b5 | ||
|
eb491a890d | ||
|
c6724472a6 | ||
|
201de81351 | ||
|
ae12eefafc | ||
|
46f89db11a | ||
|
c9d20d28de | ||
|
094461d359 | ||
|
b9649b6356 | ||
|
3d8feda808 | ||
|
d9aa5105fa | ||
|
42110f32da | ||
|
a6c5c3905a | ||
|
7b484e80b8 | ||
|
68f3d5c631 | ||
|
9d291cca28 | ||
|
7c108f18ff | ||
|
1a3012853d | ||
|
5b64290bc5 | ||
|
34989a4f0f | ||
|
4ab60080f6 | ||
|
a3a198a32d | ||
|
b54044cbc5 | ||
|
ebc48fde90 | ||
|
7c50db426f | ||
|
a1bde07ee1 | ||
|
fba01e35a3 | ||
|
16bf83274f | ||
|
d8c8f8084b | ||
|
2590f041c2 | ||
|
790cb5ce1d | ||
|
7b7f877fa5 | ||
|
1d86154012 | ||
|
03fbb4933a | ||
|
bb1d3cd273 | ||
|
fa978dc6d0 | ||
|
3da081b40c | ||
|
0d5a907517 | ||
|
ef7a486fd4 | ||
|
54915dcea1 | ||
|
86b91916a7 | ||
|
4144a29608 | ||
|
c41b635276 | ||
|
c118c77c14 | ||
|
626652c421 | ||
|
fb42b80276 | ||
|
00baf60970 | ||
|
0230106a1e | ||
|
169b9451ed | ||
|
ae65acdd16 | ||
|
b5ac760af2 | ||
|
321eaecd21 | ||
|
0e7d66849d | ||
|
25208c2b5c | ||
|
f3637e2426 | ||
|
dd6106447f | ||
|
d1cf45c6a1 | ||
|
07f3d841b3 | ||
|
b488f86160 | ||
|
e64c3a3c77 | ||
|
576a993faf | ||
|
c77c5a6d21 | ||
|
ae16680fc2 | ||
|
807c0a1e2e | ||
|
96a2119a05 | ||
|
7e940d6304 | ||
|
11402cc2a6 | ||
|
975d8b0ba0 | ||
|
e349898d9e | ||
|
a8802da5a7 | ||
|
19993dfc04 | ||
|
e99dd442e1 | ||
|
d886113f27 | ||
|
ea9b759887 | ||
|
0e784be231 | ||
|
d0ab73eeb2 | ||
|
2193129818 | ||
|
f84c6d319a | ||
|
1f667818db | ||
|
784893048d | ||
|
6ec516dc3d | ||
|
1c7da30caf | ||
|
87337f31a5 | ||
|
cf5262a86e | ||
|
d6be0ffa5b | ||
|
1df8241a01 | ||
|
43e5eae658 | ||
|
71b4560ff8 | ||
|
f6bb2fe5d1 | ||
|
272aafe504 | ||
|
580d782c56 | ||
|
238ddc7ad9 | ||
|
5559e78bc0 | ||
|
6cc38df4e9 | ||
|
7b34c7e72b | ||
|
0dd7943849 | ||
|
6745934a78 | ||
|
76801a34ee | ||
|
4d0318d4b0 | ||
|
9d4446a6ef | ||
|
b74017894c | ||
|
9fef6c0276 | ||
|
fcbeb45d1e | ||
|
66f7286cdc | ||
|
e1e068ba11 | ||
|
524c99dd54 | ||
|
b57ed7055c | ||
|
d84d701b07 | ||
|
bcfd4126b6 | ||
|
97b16cfd04 | ||
|
d5b81ceba1 | ||
|
f3ba61a168 | ||
|
c68aa1d30c | ||
|
d187fc322c | ||
|
e616022278 | ||
|
1b0486df05 | ||
|
e6deb9ef26 | ||
|
0216c17b95 | ||
|
1eff757caf | ||
|
4cfd00b307 | ||
|
8075db3ac8 | ||
|
2cd867e344 | ||
|
b5b2e7f13d | ||
|
cbd7c417d2 | ||
|
ed7a233c9b | ||
|
d75e3e9a61 | ||
|
8b0c9d3d0a | ||
|
371471ad81 | ||
|
d5464186af | ||
|
f4c310846a | ||
|
2413526d70 | ||
|
55f4a4a2a1 | ||
|
5b35c03bc5 | ||
|
93ea943c54 | ||
|
5ae6f321cd | ||
|
2be6f04e37 | ||
|
9826ee4d36 | ||
|
39a109216b | ||
|
05b25b65bc | ||
|
195db01602 | ||
|
292af65ea5 | ||
|
5ee46fe87a | ||
|
179b4358ae | ||
|
5be8a663e0 | ||
|
1d81f710a9 | ||
|
49e051c70d | ||
|
1efaed4541 | ||
|
3e96001511 | ||
|
6e8fb4a6db | ||
|
446ee0ac8e | ||
|
f6a89c7daf | ||
|
ad5dc8a871 | ||
|
afaeb754ca | ||
|
21fd92aea4 | ||
|
02e5749fc9 | ||
|
d44f80bd53 | ||
|
5c87916785 | ||
|
043443fb89 | ||
|
1bc44afde6 | ||
|
d80101d779 | ||
|
39925c390a | ||
|
9c51f24d3f | ||
|
c231546b5c | ||
|
58c43acb2b | ||
|
4d953b0871 | ||
|
101fee9a37 | ||
|
a44bbe4cec | ||
|
3f0eec3c54 | ||
|
ea0f52ebe0 | ||
|
a82bdd2a00 | ||
|
b2b8565635 | ||
|
a061c1c040 | ||
|
0182faceae | ||
|
3b4e594fcf | ||
|
51e5aeec13 | ||
|
06aafc1719 | ||
|
f468fa6340 | ||
|
eb85e3b731 | ||
|
935f5cc75e | ||
|
bdbd23d66b | ||
|
e4796b08b6 | ||
|
19bc9197ec | ||
|
c513753f59 | ||
|
81b5ae1fa9 | ||
|
eddab1980a | ||
|
7ff52294a8 | ||
|
5ae18a5170 | ||
|
ecba91f35d | ||
|
36ecf63b6c | ||
|
0671b6ef9f | ||
|
39f6319043 | ||
|
6a2dc9164e | ||
|
37d3f43596 | ||
|
aef0ba6ffd | ||
|
1ae12cfa21 | ||
|
8475669aab | ||
|
6fec76fcb3 | ||
|
08b2e7ceac | ||
|
60972f0c7b | ||
|
a49db76588 | ||
|
bd0c86060b | ||
|
9849f31e1f | ||
|
00ac222af6 | ||
|
732a8d7385 | ||
|
a009ad7d53 | ||
|
91c7c9fc8e | ||
|
e836f87c88 | ||
|
1e1a23acd0 | ||
|
d8d728a48f | ||
|
067e8a79bf | ||
|
576f40360d | ||
|
c679a52903 | ||
|
82f109290d | ||
|
765006b185 | ||
|
86cddb06e4 | ||
|
012b5156b7 | ||
|
d6c04540e9 | ||
|
3144b52b55 | ||
|
b04385ceae | ||
|
721a97dc41 | ||
|
4ca8adc4dd | ||
|
d361ef01d4 | ||
|
0d9c27319d | ||
|
600b8d198b | ||
|
d65224320e | ||
|
586cea7d44 | ||
|
aa5d6733b2 | ||
|
982dca1846 | ||
|
7de702ad23 | ||
|
13ef96cd02 | ||
|
4ec6f35c5d | ||
|
7f19f7aa47 | ||
|
fb5cd0f681 | ||
|
3feafc153c | ||
|
8f08674527 | ||
|
a7baaeb485 | ||
|
b0d81cdefd | ||
|
a33a1d7658 | ||
|
e436dec4ba | ||
|
eb1dfe69cd | ||
|
9f5720d393 | ||
|
675db6f651 | ||
|
fb5e86c2cb | ||
|
d384a4c520 | ||
|
6994271eca | ||
|
ed5fa8e4aa | ||
|
cbdf295d67 | ||
|
5d78946320 | ||
|
84fdc22861 | ||
|
a57645f824 | ||
|
e78f40c555 | ||
|
23f5fc9575 | ||
|
fc7a7b085f | ||
|
7ffa34b0f9 | ||
|
177e28121b | ||
|
68a35b8804 | ||
|
9ec9a680a6 | ||
|
7629931747 | ||
|
afb22e6c25 | ||
|
052fc86388 | ||
|
4de51f29c8 | ||
|
d1a1f4da38 | ||
|
d1153ed97d | ||
|
1c356560d5 | ||
|
bbd18d921b | ||
|
9e4860d97c | ||
|
50d42f721f | ||
|
9bfccb49e3 | ||
|
919126f9b0 | ||
|
d00903569f | ||
|
25e7b0d3e1 | ||
|
2aafe33417 | ||
|
e525f36824 | ||
|
5a5f5a8696 | ||
|
45d2968d9e | ||
|
c3e1465f31 | ||
|
df47ffb013 | ||
|
8900f96ce7 | ||
|
ed69780d52 | ||
|
691a3305e7 | ||
|
987f6dcac8 | ||
|
1113a94d67 | ||
|
8c6fc7d561 | ||
|
fb84927fc8 | ||
|
c452e66999 | ||
|
7ab91e08f4 | ||
|
bdd18dba4e | ||
|
026654d3a2 | ||
|
e9729bc9b3 | ||
|
a04159969e | ||
|
3624d186bc | ||
|
31ea4860cf | ||
|
5a074d7607 | ||
|
9d8b20148b | ||
|
0ffb9d0606 | ||
|
8258d8d5b4 | ||
|
94915b0496 | ||
|
053b4a22b8 | ||
|
e6a9f39477 | ||
|
ff4f80b7ef | ||
|
7e1218ce13 | ||
|
4c707271c3 | ||
|
04e56638ce | ||
|
674269c4c1 | ||
|
2b4627c3d6 | ||
|
4260c6d6b5 | ||
|
4057021cb9 | ||
|
151a99c2a3 | ||
|
bd606a4cf8 | ||
|
4dbf2551d9 | ||
|
8fefbccc83 | ||
|
65bc1e4efe | ||
|
9a257ec897 | ||
|
dd0a966a9f | ||
|
cec0327293 | ||
|
5b2c94758b | ||
|
2dd1d2ac56 | ||
|
3be7a5a69f | ||
|
3e6ea2633e | ||
|
2ef692edb8 | ||
|
3edfa5dfe7 | ||
|
8976ef04f6 | ||
|
cf81aedb60 | ||
|
78a225cde4 | ||
|
efdbbbc1f4 | ||
|
f82056a358 | ||
|
983b9c4ddc | ||
|
7a96312a83 | ||
|
9d49f3dd68 | ||
|
a1e8a50aaf | ||
|
35d895d562 | ||
|
be2d21384d | ||
|
74917a482e | ||
|
e5f49fda5e | ||
|
e556dac871 | ||
|
a71a9f92b4 | ||
|
0b1d4bb762 | ||
|
4a83cc6127 | ||
|
de250cc335 | ||
|
bf2a4f2977 | ||
|
d8fa0e6aeb | ||
|
c23f5fcf83 | ||
|
40ac137461 | ||
|
d47ecb2723 | ||
|
f25005536c | ||
|
9db5fb0de7 | ||
|
b0db04c119 | ||
|
13d5bd39af | ||
|
4cd03f35f7 | ||
|
12a8582e89 | ||
|
dfda1181e2 | ||
|
2330924fef | ||
|
1980e8a10e | ||
|
19d11a3ad9 | ||
|
548908b26f | ||
|
1be62cc7ad | ||
|
5fa69aadea | ||
|
bbb16204d8 | ||
|
b74207923c | ||
|
afde6c2ce2 | ||
|
f640bfc0c0 | ||
|
832cbef61f | ||
|
3b0fa31787 | ||
|
bac7347e43 | ||
|
f2bd1010ba | ||
|
14b03b7eb5 | ||
|
a0ebe72ede | ||
|
0f25e33213 | ||
|
757afd39da | ||
|
f935cc9cd8 | ||
|
57038c590e | ||
|
bd961ebbf0 | ||
|
0822c7b93a | ||
|
85cb42fc8d | ||
|
2f10dc8368 | ||
|
e0b1b78553 | ||
|
62089109ef | ||
|
fe31e4ffd1 | ||
|
58ba6f5fe7 | ||
|
44045b9e19 | ||
|
4cabfb7733 | ||
|
52dad5942e | ||
|
98d26c37ff | ||
|
99ffaed5fc | ||
|
3c9e04d243 | ||
|
37a96a01db | ||
|
a246e716fc | ||
|
dcef7f47ff | ||
|
a1c9d3aaa9 | ||
|
e827b97cd5 | ||
|
b12933e61d | ||
|
5d6b2b4398 | ||
|
291133c384 | ||
|
28dffea9c2 | ||
|
70e2fb994a | ||
|
17d897e227 | ||
|
e38e76adaf | ||
|
a55d9115c5 | ||
|
cf1bafa991 | ||
|
2784f14f36 | ||
|
dff24f9e4f | ||
|
e7c1f8116d | ||
|
5fabd851d6 | ||
|
e555b74396 | ||
|
1304af1407 | ||
|
a190d07fba | ||
|
043292cf67 | ||
|
359058dbfe | ||
|
02ebad0712 | ||
|
0e7b2e6fac |
18
.github/workflows/release.yml
vendored
18
.github/workflows/release.yml
vendored
@ -29,7 +29,7 @@ jobs:
|
||||
name: Releasing ${{ matrix.lane }} version to TestFlight
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.0'
|
||||
@ -38,10 +38,13 @@ jobs:
|
||||
run: |
|
||||
sed -i '' 's/match Development/match AppStore/' Yattee.xcodeproj/project.pbxproj
|
||||
sed -i '' 's/iPhone Developer/iPhone Distribution/' Yattee.xcodeproj/project.pbxproj
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: latest-stable
|
||||
- uses: maierj/fastlane-action@v3.0.0
|
||||
with:
|
||||
lane: ${{ matrix.lane }}
|
||||
- uses: actions/upload-artifact@v3
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.lane }} build
|
||||
path: fastlane/builds/**/*.ipa
|
||||
@ -50,7 +53,7 @@ jobs:
|
||||
name: Build and notarize macOS app
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: '3.0'
|
||||
@ -59,6 +62,9 @@ jobs:
|
||||
run: |
|
||||
sed -i '' 's/match AppStore/match Direct/' Yattee.xcodeproj/project.pbxproj
|
||||
sed -i '' 's/3rd Party Mac Developer Application/Developer ID Application/' Yattee.xcodeproj/project.pbxproj
|
||||
- uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
xcode-version: latest-stable
|
||||
- uses: maierj/fastlane-action@v3.0.0
|
||||
with:
|
||||
lane: mac build_and_notarize
|
||||
@ -70,7 +76,7 @@ jobs:
|
||||
echo "ZIP_PATH=fastlane/builds/${{ env.VERSION_NUMBER }}-${{ env.BUILD_NUMBER }}/macOS/Yattee-${{ env.VERSION_NUMBER }}-macOS.zip" >> $GITHUB_ENV
|
||||
- name: ZIP build
|
||||
run: /usr/bin/ditto -c -k --keepParent ${{ env.APP_PATH }} ${{ env.ZIP_PATH }}
|
||||
- uses: actions/upload-artifact@v3
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mac notarized build
|
||||
path: ${{ env.ZIP_PATH }}
|
||||
@ -80,10 +86,10 @@ jobs:
|
||||
name: Create GitHub release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v4
|
||||
- run: echo "BUILD_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 CURRENT_PROJECT_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
|
||||
- run: echo "VERSION_NUMBER=$(cat Yattee.xcodeproj/project.pbxproj | grep -m 1 MARKETING_VERSION | cut -d' ' -f3 | sed 's/;//g')" >> $GITHUB_ENV
|
||||
- uses: actions/download-artifact@v3
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
- uses: ncipollo/release-action@v1
|
||||
|
@ -6,15 +6,9 @@ disabled_rules:
|
||||
- opening_brace
|
||||
- number_separator
|
||||
- multiline_arguments
|
||||
opt_in_rules:
|
||||
- implicit_return
|
||||
excluded:
|
||||
- Vendor
|
||||
- Tests Apple TV
|
||||
- Tests iOS
|
||||
- Tests macOS
|
||||
|
||||
implicit_return:
|
||||
included:
|
||||
- function
|
||||
- getter
|
||||
|
184
CHANGELOG.md
184
CHANGELOG.md
@ -1,67 +1,123 @@
|
||||
## Build 155
|
||||
* Fixed reported crashes
|
||||
* Minor performance improvements
|
||||
## Build 199
|
||||
|
||||
## Previous Builds
|
||||
* Fixed issue where AVPlayer would pause playing on exiting fullscreen
|
||||
* Fixed issue with empty button appearing on subscriptions list on tvOS
|
||||
## What's Changed
|
||||
* Add support for invidious companion by @lifo9 in https://github.com/yattee/yattee/pull/863
|
||||
* Translations update from Hosted Weblate by @weblate in https://github.com/yattee/yattee/pull/851
|
||||
|
||||
* Fixed issue with AVPlayer not always using full width
|
||||
* Fixed issue with layout when switching backend while in fullscreen
|
||||
* Fixed issue with updating watched time on closing video
|
||||
* Fixed issue with adjusting AVPlayer playback rate when using system controls
|
||||
* Fixed issue where comments would load indefinitely
|
||||
* Improved Home buttons layout on tvOS
|
||||
* Reverted change to placeholders as were causing issues to properly display loading status, will be revisited in future
|
||||
* Fixed performance issue with swiping back to subscribed channels list
|
||||
* Fixed reported crashes
|
||||
## Previous builds
|
||||
* Add skip, play/pause, and fullscreen shortcuts to macOS player (by @rickykresslein)
|
||||
* Added Settings Import/Export
|
||||
* Export all settings, instances and accounts
|
||||
* Import selected elements from the file
|
||||
* Include unencrypted passwords in the export or provide them during the import
|
||||
* Import via URL for tvOS
|
||||
* Added Controls setting "Action button labels" icon or icon and text
|
||||
* Added Advanced setting for MPV: "deinterlace"
|
||||
* Add help text to all header buttons (by @rickykresslein)
|
||||
* History Setting: hide the recent activity in the sidebar or limit the number of items shown (by @rickykresslein)
|
||||
* Fix issues with empty comments (by @stonerl)
|
||||
* Improved Invidious comments (by @stonerl)
|
||||
* Allow import of accounts to manually added (not imported) instances
|
||||
* Add import export of missing settings
|
||||
* macOS: Fix settings windows layout
|
||||
* Fix seek OSD layout on tvOS, revert OSD position
|
||||
* Allow users to disable fullscreen swipe gesture by @stonerl in https://github.com/yattee/yattee/pull/814
|
||||
* Proper audio interrupt and route change handling by @stonerl in https://github.com/yattee/yattee/pull/815
|
||||
* Improved subtitle handling by @stonerl in https://github.com/yattee/yattee/pull/817
|
||||
* Improvements to MPVGLView by @stonerl in https://github.com/yattee/yattee/pull/818
|
||||
* Add drag gestures to video details by @stonerl in https://github.com/yattee/yattee/pull/820
|
||||
* Fix uneven playback when using MPV and not syncing refreshrate by @blennster in https://github.com/yattee/yattee/pull/833
|
||||
* Norwegian Language by @mmaalo in https://github.com/yattee/yattee/pull/834
|
||||
* Translations update from Hosted Weblate by @weblate in https://github.com/yattee/yattee/pull/836
|
||||
* Update MPVKit to v0.39.0 by @stonerl in https://github.com/yattee/yattee/pull/824
|
||||
* Update SwiftUI-Introspect by @stonerl in https://github.com/yattee/yattee/pull/813
|
||||
* Orientation/Fullscreen fixes and cleanup by @stonerl in https://github.com/yattee/yattee/pull/806
|
||||
* More robust resolution handling by @stonerl in https://github.com/yattee/yattee/pull/807
|
||||
* MPV: improved A/V sync by @stonerl in https://github.com/yattee/yattee/pull/805
|
||||
* Retry loading video before presenting error by @stonerl in https://github.com/yattee/yattee/pull/810
|
||||
* Refactor Search by @stonerl in https://github.com/yattee/yattee/pull/809
|
||||
* iOS: Simplified fullscreen and orientation by @stonerl in https://github.com/yattee/yattee/pull/786
|
||||
* macOS: only apply player shortcuts when window is active by @stonerl in https://github.com/yattee/yattee/pull/802
|
||||
* player controls: add background opacity selection by @stonerl in https://github.com/yattee/yattee/pull/799
|
||||
* add missing Shorts resolutions by @stonerl in https://github.com/yattee/yattee/pull/797
|
||||
* use -O1 on macOS by @stonerl in https://github.com/yattee/yattee/pull/801
|
||||
* Gestures: swipe up toggles fullscreen by @stonerl in https://github.com/yattee/yattee/pull/778
|
||||
* don’t open video when dismissing context menu by @stonerl in https://github.com/yattee/yattee/pull/780
|
||||
* mpv: remove video layer when entering background by @stonerl in https://github.com/yattee/yattee/pull/793
|
||||
* hi-res invidious logos by @stonerl in https://github.com/yattee/yattee/pull/791
|
||||
* enable -O3 by @stonerl in https://github.com/yattee/yattee/pull/794
|
||||
* Better audio ducking by @stonerl in https://github.com/yattee/yattee/pull/779
|
||||
* fix picture in picture by @stonerl in https://github.com/yattee/yattee/pull/789
|
||||
* Invidious: propper HTTP basic auth support by @stonerl in https://github.com/yattee/yattee/pull/762
|
||||
* Apply correct orientation by @stonerl in https://github.com/yattee/yattee/pull/770
|
||||
* Circular Invidious logo by @stonerl in https://github.com/yattee/yattee/pull/769
|
||||
* Video Thumbnails: retry 3 times fetching from URL by @stonerl in https://github.com/yattee/yattee/pull/768
|
||||
* Make thumbnail fill the view in music mode by @stonerl in https://github.com/yattee/yattee/pull/766
|
||||
* Changes to defaults by @stonerl in https://github.com/yattee/yattee/pull/767
|
||||
* Fixed fullscreen handling for backgrounding by @stonerl in https://github.com/yattee/yattee/pull/772
|
||||
* Update now playing info when using system controls – Partial fix for 503 by @stonerl in https://github.com/yattee/yattee/pull/765
|
||||
* Stop making videos with unknown length shorts. by @derspyy in https://github.com/yattee/yattee/pull/849
|
||||
* Add Hungarian to locales list
|
||||
* Fix crash on HLS live playback by @stonerl in https://github.com/yattee/yattee/pull/775
|
||||
* Fix mpv crashing on macOS by @stonerl in https://github.com/yattee/yattee/pull/754
|
||||
* Refreshed icons for iOS and macOS by @stonerl in https://github.com/yattee/yattee/pull/752
|
||||
* Add new MPVKit repo by @stonerl in https://github.com/yattee/yattee/pull/753
|
||||
* Add Chinese (Simplified) - zh-Hans to LanguageCodes by @stonerl in https://github.com/yattee/yattee/pull/757
|
||||
* Color changes to VideoActions by @stonerl in https://github.com/yattee/yattee/pull/759
|
||||
* Hide VideoActions Bar when no buttons is visible by @stonerl in https://github.com/yattee/yattee/pull/760
|
||||
* Improved stream resolution handling by @stonerl in https://github.com/yattee/yattee/pull/747
|
||||
* Fix some potential crashes by @stonerl in https://github.com/yattee/yattee/pull/748
|
||||
* Fix regression and improve curentChapter handling by @stonerl in https://github.com/yattee/yattee/pull/749
|
||||
* Refined chapter font scaling by @stonerl in https://github.com/yattee/yattee/pull/750
|
||||
* Improved thumbnail handling by @stonerl in https://github.com/yattee/yattee/pull/740
|
||||
* iOS: make timestamps in comments touchable by @stonerl in https://github.com/yattee/yattee/pull/741
|
||||
* Improvements to opening channels from Videos by @stonerl in https://github.com/yattee/yattee/pull/742
|
||||
* Allow hiding comments by @stonerl in https://github.com/yattee/yattee/pull/744
|
||||
* Add option to exit fullscreen on end by @stonerl in https://github.com/yattee/yattee/pull/570
|
||||
* Only updateWatch status while video is playing by @stonerl in https://github.com/yattee/yattee/pull/745
|
||||
* Xcode 16 - update recommended settings by @stonerl in https://github.com/yattee/yattee/pull/737
|
||||
* Translations update from Hosted Weblate by @weblate in https://github.com/yattee/yattee/pull/724
|
||||
* tvOS: Allow account picker by long pressing channels button in subscriptions view by @patelhiren in https://github.com/yattee/yattee/pull/704
|
||||
* tvOS: Refined Subscriptions View by @patelhiren in https://github.com/yattee/yattee/pull/697
|
||||
* More responsive UI when Favorites are used. by @stonerl in https://github.com/yattee/yattee/pull/695
|
||||
* Improved conditional proxying by @stonerl in https://github.com/yattee/yattee/pull/696
|
||||
* Don't show related in sidebar when disabled in settings by @stonerl in https://github.com/yattee/yattee/pull/635
|
||||
* Handle audio session interrupts by other media by @stonerl in https://github.com/yattee/yattee/pull/640
|
||||
* Only show Queue header in sidebar view by @stonerl in https://github.com/yattee/yattee/pull/642
|
||||
* SponsorBlock Improvements by @stonerl in https://github.com/yattee/yattee/pull/639
|
||||
* Chapter title on jump by @stonerl in https://github.com/yattee/yattee/pull/655
|
||||
* Restart finished video by @stonerl in https://github.com/yattee/yattee/pull/646
|
||||
* SponsorBlock jump to end instead of pausing by @stonerl in https://github.com/yattee/yattee/pull/648
|
||||
* Call correct class of SDImageAWebPCoder by @stonerl in https://github.com/yattee/yattee/pull/664
|
||||
* Fix handling and displaying captions by @stonerl in https://github.com/yattee/yattee/pull/636
|
||||
* Advanced settings: make number fields .numPad by @stonerl in https://github.com/yattee/yattee/pull/661
|
||||
* Preserve time on stream change by @stonerl in https://github.com/yattee/yattee/pull/651
|
||||
* Switch to previous backend when leaving PiP by @stonerl in https://github.com/yattee/yattee/pull/641
|
||||
* Handle deep links by @timonus in https://github.com/yattee/yattee/pull/645
|
||||
* Music Mode: don't bindPlayerToLayer when entering foreground by @stonerl in https://github.com/yattee/yattee/pull/644
|
||||
* Allow user to disable thumbnails and jump to current chapter in horizontal view by @stonerl in https://github.com/yattee/yattee/pull/665
|
||||
* Rework qualitiy settings by @stonerl in https://github.com/yattee/yattee/pull/650
|
||||
* HLS: set target bitrate / AVPlayer: higher resolution by @stonerl in https://github.com/yattee/yattee/pull/667
|
||||
* Fix #619: Remove ports from shared YouTube links by @0x000C in https://github.com/yattee/yattee/pull/627
|
||||
* XCode enable IDEPreferLogStreaming by @stonerl in https://github.com/yattee/yattee/pull/638
|
||||
* Conditional proxying by @stonerl in https://github.com/yattee/yattee/pull/662
|
||||
* HomeView: Changes to Favourites and History Widget by @stonerl in https://github.com/yattee/yattee/pull/672
|
||||
* Snappy UI - Offloading non UI task to background threads by @stonerl in https://github.com/yattee/yattee/pull/671
|
||||
* Fix PiP Mode Not Working Using MPV by @stonerl in https://github.com/yattee/yattee/pull/676
|
||||
* Fix thumbnails failing to load on tvOS by @patelhiren in https://github.com/yattee/yattee/pull/688
|
||||
* speed up sorting for Stream by @stonerl in https://github.com/yattee/yattee/pull/681
|
||||
* faster chapter extraction by @stonerl in https://github.com/yattee/yattee/pull/682
|
||||
* Invidious: add images to chapters by @stonerl in https://github.com/yattee/yattee/pull/685
|
||||
* Improved Captions handling by @stonerl in https://github.com/yattee/yattee/pull/684
|
||||
* Add User-Agent to request by @stonerl in https://github.com/yattee/yattee/pull/680
|
||||
* MPV: speed up playback start by @stonerl in https://github.com/yattee/yattee/pull/689
|
||||
* Advanced Settings: cache-pause-initial by @stonerl in https://github.com/yattee/yattee/pull/679
|
||||
* Changed description for Format reordering by @stonerl in https://github.com/yattee/yattee/pull/677
|
||||
* Add Chinese (Traditional) localization (by @rexcsk)
|
||||
* Localization fixes
|
||||
* Updated localizations
|
||||
* Upgraded dependencies
|
||||
* Fixed reported crash
|
||||
* Other minor changes and improvements
|
||||
|
||||
* Tapping second time on search tab button focuses the input field and selects entered query text (iOS)
|
||||
* Added Browsing setting "Keep channels with unwatched videos on top of subscriptions list"
|
||||
* Improved buttons and layout on tvOS
|
||||
* Fixed issue with trending categories (Invidious) not working when using non-English language
|
||||
* Fixed issue with search query suggestions not being displayed properly in some languages
|
||||
* Changed subscriptions page picker label from icon to text
|
||||
* Views will display information if there is no videos to show instead of always showing placeholders
|
||||
* Fixed AVPlayer issue with music mode playing video track
|
||||
* Added remove context menu option for all types of recent items in Search
|
||||
* Added advanced setting "Show video context menu options to force selected backend"
|
||||
* Fixed reported crashes
|
||||
|
||||
* Improved Home
|
||||
- Added menu with view options on iOS and toolbar buttons on macOS/tvOS
|
||||
- Added Home Settings
|
||||
- Moved settings from Browsing to Home Settings
|
||||
- Enhanced Favorites management: select listing type and videos limit for each element
|
||||
- Select listing type for History just like for Favorites
|
||||
* Added view option to hide watched videos
|
||||
* Added Browsing setting "Startup section"
|
||||
* Added feed/channels list segmented picker in Subscriptions and moved view options menu on iOS
|
||||
* Thumbnails in list view respect "Round corners" setting
|
||||
* Added watching progress indicator to list view
|
||||
* Moved "Show toggle watch status button" to History settings
|
||||
* Removed "Rotate to portrait when exiting fullscreen" setting - it is instead automatically decided depending on device type
|
||||
* Fixed channels view layout on tvOS
|
||||
* Fixed channels and playlists navigation on tvOS
|
||||
* Fixed issue where controls were not visible when music mode was enabled
|
||||
* Fixed issue with closing Picture in Picture on macOS
|
||||
* Fixed issue where playing video with AVPlayer would cause it to be immediately marked as watched
|
||||
* Fixed issue with playlists view showing duplicated buttons when "Show cache status" is enabled
|
||||
* Fixed issue where navigating to channel from list view in Playlists and Search would immediately go back
|
||||
* Fixed issue where first URL would fail to open
|
||||
|
||||
* Added support for AVPlayer native system controls on iOS and macOS
|
||||
- Use system features such as AirPlay, subtitles switching (Piped with HLS), text detection and copy and more
|
||||
- Added Controls setting: "Use system controls with AVPlayer"
|
||||
* Player rotates for landscape videos on entering full screen on iOS
|
||||
- Player > Orientation setting: "Rotate when entering fullscreen on landscape video"
|
||||
* Added Player > Playback setting: "Close video and player on end"
|
||||
* Added reporting for opening stream in OSD for AVPlayer
|
||||
* Fixed issue with opening channels and playlists links
|
||||
* Fixed issues where controls/player layout could break (e.g., when going to background and back)
|
||||
* Fixed issue where stream picker would show duplicate entries
|
||||
* Fixed issue where search suggestions would show unnecessary bottom padding
|
||||
* Fixed landscape channel sheet layout in player
|
||||
* Fixed reported crashes
|
||||
* Localization updates and fixes
|
||||
* Other minor fixes and improvements
|
||||
**Big thanks to the current, past and future project contributors!**
|
||||
|
@ -17,13 +17,13 @@ extension String {
|
||||
|
||||
var outputText = self
|
||||
|
||||
results.reversed().forEach { match in
|
||||
(1 ..< match.numberOfRanges).reversed().forEach { rangeIndex in
|
||||
for match in results.reversed() {
|
||||
for rangeIndex in (1 ..< match.numberOfRanges).reversed() {
|
||||
let matchingGroup: String = (self as NSString).substring(with: match.range(at: rangeIndex))
|
||||
let rangeBounds = match.range(at: rangeIndex)
|
||||
|
||||
guard let range = Range(rangeBounds, in: self) else {
|
||||
return
|
||||
continue
|
||||
}
|
||||
let replacement = replacementStringClosure(matchingGroup) ?? matchingGroup
|
||||
|
||||
|
@ -6,8 +6,10 @@ extension UIViewController {
|
||||
}
|
||||
|
||||
public class func swizzleHomeIndicatorProperty() {
|
||||
swizzle(origSelector: #selector(getter: UIViewController.prefersHomeIndicatorAutoHidden),
|
||||
withSelector: #selector(getter: UIViewController.swizzle_prefersHomeIndicatorAutoHidden),
|
||||
forClass: UIViewController.self)
|
||||
swizzle(
|
||||
origSelector: #selector(getter: UIViewController.prefersHomeIndicatorAutoHidden),
|
||||
withSelector: #selector(getter: UIViewController.swizzle_prefersHomeIndicatorAutoHidden),
|
||||
forClass: UIViewController.self
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -4,11 +4,11 @@ extension URL {
|
||||
func byReplacingYatteeProtocol(with urlProtocol: String = "https") -> URL! {
|
||||
var urlAbsoluteString = absoluteString
|
||||
|
||||
guard urlAbsoluteString.hasPrefix(Constants.yatteeProtocol) else {
|
||||
guard urlAbsoluteString.hasPrefix(Strings.yatteeProtocol) else {
|
||||
return self
|
||||
}
|
||||
|
||||
urlAbsoluteString = String(urlAbsoluteString.dropFirst(Constants.yatteeProtocol.count))
|
||||
urlAbsoluteString = String(urlAbsoluteString.dropFirst(Strings.yatteeProtocol.count))
|
||||
if absoluteString.contains("://") {
|
||||
return URL(string: urlAbsoluteString)
|
||||
}
|
||||
|
2
Gemfile
2
Gemfile
@ -1,6 +1,6 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane"
|
||||
gem 'fastlane'
|
||||
|
||||
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
|
||||
eval_gemfile(plugins_path) if File.exist?(plugins_path)
|
||||
|
163
Gemfile.lock
163
Gemfile.lock
@ -1,43 +1,46 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
CFPropertyList (3.0.6)
|
||||
CFPropertyList (3.0.7)
|
||||
base64
|
||||
nkf
|
||||
rexml
|
||||
addressable (2.8.4)
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
artifactory (3.0.15)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
artifactory (3.0.17)
|
||||
atomos (0.1.3)
|
||||
aws-eventstream (1.2.0)
|
||||
aws-partitions (1.769.0)
|
||||
aws-sdk-core (3.173.1)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-eventstream (1.3.2)
|
||||
aws-partitions (1.1072.0)
|
||||
aws-sdk-core (3.220.2)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.64.0)
|
||||
aws-sdk-core (~> 3, >= 3.165.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.122.0)
|
||||
aws-sdk-core (~> 3, >= 3.165.0)
|
||||
aws-sdk-kms (1.99.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.182.0)
|
||||
aws-sdk-core (~> 3, >= 3.216.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.4)
|
||||
aws-sigv4 (1.5.2)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.11.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
babosa (1.0.4)
|
||||
base64 (0.2.0)
|
||||
claide (1.1.0)
|
||||
colored (1.2)
|
||||
colored2 (3.1.2)
|
||||
commander (4.6.0)
|
||||
highline (~> 2.0.0)
|
||||
declarative (0.0.20)
|
||||
digest-crc (0.6.4)
|
||||
digest-crc (0.7.0)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
domain_name (0.5.20190701)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
domain_name (0.6.20240107)
|
||||
dotenv (2.8.1)
|
||||
emoji_regex (3.2.3)
|
||||
excon (0.99.0)
|
||||
faraday (1.10.3)
|
||||
excon (0.112.0)
|
||||
faraday (1.10.4)
|
||||
faraday-em_http (~> 1.0)
|
||||
faraday-em_synchrony (~> 1.0)
|
||||
faraday-excon (~> 1.1)
|
||||
@ -56,24 +59,24 @@ GEM
|
||||
faraday-em_synchrony (1.0.0)
|
||||
faraday-excon (1.1.0)
|
||||
faraday-httpclient (1.0.1)
|
||||
faraday-multipart (1.0.4)
|
||||
multipart-post (~> 2)
|
||||
faraday-net_http (1.0.1)
|
||||
faraday-multipart (1.1.0)
|
||||
multipart-post (~> 2.0)
|
||||
faraday-net_http (1.0.2)
|
||||
faraday-net_http_persistent (1.2.0)
|
||||
faraday-patron (1.0.0)
|
||||
faraday-rack (1.0.0)
|
||||
faraday-retry (1.0.3)
|
||||
faraday_middleware (1.2.0)
|
||||
faraday_middleware (1.2.1)
|
||||
faraday (~> 1.0)
|
||||
fastimage (2.2.7)
|
||||
fastlane (2.213.0)
|
||||
fastimage (2.4.0)
|
||||
fastlane (2.227.0)
|
||||
CFPropertyList (>= 2.3, < 4.0.0)
|
||||
addressable (>= 2.8, < 3.0.0)
|
||||
artifactory (~> 3.0)
|
||||
aws-sdk-s3 (~> 1.0)
|
||||
babosa (>= 1.0.3, < 2.0.0)
|
||||
bundler (>= 1.12.0, < 3.0.0)
|
||||
colored
|
||||
colored (~> 1.2)
|
||||
commander (~> 4.6)
|
||||
dotenv (>= 2.1.1, < 3.0.0)
|
||||
emoji_regex (>= 0.1, < 4.0)
|
||||
@ -82,33 +85,38 @@ GEM
|
||||
faraday-cookie_jar (~> 0.0.6)
|
||||
faraday_middleware (~> 1.0)
|
||||
fastimage (>= 2.1.0, < 3.0.0)
|
||||
fastlane-sirp (>= 1.0.0)
|
||||
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||
google-apis-androidpublisher_v3 (~> 0.3)
|
||||
google-apis-playcustomapp_v1 (~> 0.1)
|
||||
google-cloud-env (>= 1.6.0, < 2.0.0)
|
||||
google-cloud-storage (~> 1.31)
|
||||
highline (~> 2.0)
|
||||
http-cookie (~> 1.0.5)
|
||||
json (< 3.0.0)
|
||||
jwt (>= 2.1.0, < 3)
|
||||
mini_magick (>= 4.9.4, < 5.0.0)
|
||||
multipart-post (>= 2.0.0, < 3.0.0)
|
||||
naturally (~> 2.2)
|
||||
optparse (~> 0.1.1)
|
||||
optparse (>= 0.1.1, < 1.0.0)
|
||||
plist (>= 3.1.0, < 4.0.0)
|
||||
rubyzip (>= 2.0.0, < 3.0.0)
|
||||
security (= 0.1.3)
|
||||
security (= 0.1.5)
|
||||
simctl (~> 1.6.3)
|
||||
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||
terminal-table (>= 1.4.5, < 2.0.0)
|
||||
terminal-table (~> 3)
|
||||
tty-screen (>= 0.6.3, < 1.0.0)
|
||||
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||
word_wrap (~> 1.0.0)
|
||||
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||
xcpretty (~> 0.3.0)
|
||||
xcpretty-travis-formatter (>= 0.0.3)
|
||||
xcpretty (~> 0.4.0)
|
||||
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
|
||||
fastlane-sirp (1.0.0)
|
||||
sysrandom (~> 1.0)
|
||||
gh_inspector (1.1.3)
|
||||
google-apis-androidpublisher_v3 (0.42.0)
|
||||
google-apis-androidpublisher_v3 (0.54.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-core (0.11.0)
|
||||
google-apis-core (0.11.3)
|
||||
addressable (~> 2.5, >= 2.5.1)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
httpclient (>= 2.8.1, < 3.a)
|
||||
@ -116,64 +124,65 @@ GEM
|
||||
representable (~> 3.0)
|
||||
retriable (>= 2.0, < 4.a)
|
||||
rexml
|
||||
webrick
|
||||
google-apis-iamcredentials_v1 (0.17.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-playcustomapp_v1 (0.13.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-apis-storage_v1 (0.19.0)
|
||||
google-apis-core (>= 0.9.0, < 2.a)
|
||||
google-cloud-core (1.6.0)
|
||||
google-cloud-env (~> 1.0)
|
||||
google-apis-storage_v1 (0.31.0)
|
||||
google-apis-core (>= 0.11.0, < 2.a)
|
||||
google-cloud-core (1.8.0)
|
||||
google-cloud-env (>= 1.0, < 3.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.3.1)
|
||||
google-cloud-storage (1.44.0)
|
||||
google-cloud-errors (1.5.0)
|
||||
google-cloud-storage (1.47.0)
|
||||
addressable (~> 2.8)
|
||||
digest-crc (~> 0.4)
|
||||
google-apis-iamcredentials_v1 (~> 0.1)
|
||||
google-apis-storage_v1 (~> 0.19.0)
|
||||
google-apis-storage_v1 (~> 0.31.0)
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
mini_mime (~> 1.0)
|
||||
googleauth (1.5.2)
|
||||
googleauth (1.8.1)
|
||||
faraday (>= 0.17.3, < 3.a)
|
||||
jwt (>= 1.4, < 3.0)
|
||||
memoist (~> 0.16)
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (>= 0.16, < 2.a)
|
||||
highline (2.0.3)
|
||||
http-cookie (1.0.5)
|
||||
http-cookie (1.0.8)
|
||||
domain_name (~> 0.5)
|
||||
httpclient (2.8.3)
|
||||
httpclient (2.9.0)
|
||||
mutex_m
|
||||
jmespath (1.6.2)
|
||||
json (2.6.3)
|
||||
jwt (2.7.0)
|
||||
memoist (0.16.2)
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.2)
|
||||
json (2.10.2)
|
||||
jwt (2.10.1)
|
||||
base64
|
||||
mini_magick (4.13.2)
|
||||
mini_mime (1.1.5)
|
||||
multi_json (1.15.0)
|
||||
multipart-post (2.3.0)
|
||||
nanaimo (0.3.0)
|
||||
multipart-post (2.4.1)
|
||||
mutex_m (0.3.0)
|
||||
nanaimo (0.4.0)
|
||||
naturally (2.2.1)
|
||||
optparse (0.1.1)
|
||||
nkf (0.2.0)
|
||||
optparse (0.6.0)
|
||||
os (1.1.4)
|
||||
plist (3.7.0)
|
||||
public_suffix (5.0.1)
|
||||
rake (13.0.6)
|
||||
plist (3.7.2)
|
||||
public_suffix (6.0.1)
|
||||
rake (13.2.1)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||
uber (< 0.2.0)
|
||||
retriable (3.1.2)
|
||||
rexml (3.2.5)
|
||||
rouge (2.0.7)
|
||||
rexml (3.4.1)
|
||||
rouge (3.28.0)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (2.3.2)
|
||||
security (0.1.3)
|
||||
signet (0.17.0)
|
||||
rubyzip (2.4.1)
|
||||
security (0.1.5)
|
||||
signet (0.19.0)
|
||||
addressable (~> 2.8)
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
@ -181,37 +190,37 @@ GEM
|
||||
simctl (1.6.10)
|
||||
CFPropertyList
|
||||
naturally
|
||||
sysrandom (1.0.5)
|
||||
terminal-notifier (2.0.0)
|
||||
terminal-table (1.8.0)
|
||||
unicode-display_width (~> 1.1, >= 1.1.1)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
trailblazer-option (0.1.2)
|
||||
tty-cursor (0.7.1)
|
||||
tty-screen (0.8.1)
|
||||
tty-screen (0.8.2)
|
||||
tty-spinner (0.9.3)
|
||||
tty-cursor (~> 0.7)
|
||||
uber (0.1.0)
|
||||
unf (0.1.4)
|
||||
unf_ext
|
||||
unf_ext (0.0.8.2)
|
||||
unicode-display_width (1.8.0)
|
||||
webrick (1.8.1)
|
||||
unicode-display_width (2.6.0)
|
||||
word_wrap (1.0.0)
|
||||
xcodeproj (1.22.0)
|
||||
xcodeproj (1.27.0)
|
||||
CFPropertyList (>= 2.3.3, < 4.0)
|
||||
atomos (~> 0.1.3)
|
||||
claide (>= 1.0.2, < 2.0)
|
||||
colored2 (~> 3.1)
|
||||
nanaimo (~> 0.3.0)
|
||||
rexml (~> 3.2.4)
|
||||
xcpretty (0.3.0)
|
||||
rouge (~> 2.0.7)
|
||||
nanaimo (~> 0.4.0)
|
||||
rexml (>= 3.3.6, < 4.0)
|
||||
xcpretty (0.4.0)
|
||||
rouge (~> 3.28.0)
|
||||
xcpretty-travis-formatter (1.0.1)
|
||||
xcpretty (~> 0.2, >= 0.0.7)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-21
|
||||
arm64-darwin-23
|
||||
arm64-darwin-24
|
||||
x86_64-darwin-19
|
||||
x86_64-darwin-20
|
||||
x86_64-darwin-21
|
||||
x86_64-linux
|
||||
|
||||
DEPENDENCIES
|
||||
|
@ -60,6 +60,10 @@ struct Account: Defaults.Serializable, Hashable, Identifiable {
|
||||
instanceID.isNil
|
||||
}
|
||||
|
||||
var isPublicAddedToCustom: Bool {
|
||||
InstancesModel.shared.findByURLString(urlString) != nil
|
||||
}
|
||||
|
||||
var description: String {
|
||||
guard !isPublic else {
|
||||
return name
|
||||
|
@ -10,11 +10,28 @@ struct AccountsBridge: Defaults.Bridge {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse the urlString to check for embedded username and password
|
||||
var sanitizedUrlString = value.urlString
|
||||
if var urlComponents = URLComponents(string: value.urlString) {
|
||||
if let user = urlComponents.user, let password = urlComponents.password {
|
||||
// Sanitize the embedded username and password
|
||||
let sanitizedUser = user.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) ?? user
|
||||
let sanitizedPassword = password.addingPercentEncoding(withAllowedCharacters: .urlPasswordAllowed) ?? password
|
||||
|
||||
// Update the URL components with sanitized credentials
|
||||
urlComponents.user = sanitizedUser
|
||||
urlComponents.password = sanitizedPassword
|
||||
|
||||
// Reconstruct the sanitized URL
|
||||
sanitizedUrlString = urlComponents.string ?? value.urlString
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
"id": value.id,
|
||||
"instanceID": value.instanceID ?? "",
|
||||
"name": value.name,
|
||||
"apiURL": value.urlString,
|
||||
"apiURL": sanitizedUrlString,
|
||||
"username": value.username,
|
||||
"password": value.password ?? ""
|
||||
]
|
||||
|
@ -64,6 +64,10 @@ final class AccountsModel: ObservableObject {
|
||||
)
|
||||
}
|
||||
|
||||
func find(_ id: Account.ID) -> Account? {
|
||||
all.first { $0.id == id }
|
||||
}
|
||||
|
||||
func configureAccount() {
|
||||
if let account = lastUsed ??
|
||||
InstancesModel.shared.lastUsed?.anonymousAccount ??
|
||||
@ -108,8 +112,8 @@ final class AccountsModel: ObservableObject {
|
||||
Defaults[.accounts].first { $0.id == id }
|
||||
}
|
||||
|
||||
static func add(instance: Instance, name: String, username: String, password: String) -> Account {
|
||||
let account = Account(instanceID: instance.id, name: name, urlString: instance.apiURLString)
|
||||
static func add(instance: Instance, id: String? = UUID().uuidString, name: String, username: String, password: String) -> Account {
|
||||
let account = Account(id: id, instanceID: instance.id, name: name, urlString: instance.apiURLString)
|
||||
Defaults[.accounts].append(account)
|
||||
|
||||
setCredentials(account, username: username, password: password)
|
||||
|
@ -10,14 +10,16 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
||||
let apiURLString: String
|
||||
var frontendURL: String?
|
||||
var proxiesVideos: Bool
|
||||
var invidiousCompanion: Bool
|
||||
|
||||
init(app: VideosApp, id: String? = nil, name: String? = nil, apiURLString: String, frontendURL: String? = nil, proxiesVideos: Bool = false) {
|
||||
init(app: VideosApp, id: String? = nil, name: String? = nil, apiURLString: String, frontendURL: String? = nil, proxiesVideos: Bool = false, invidiousCompanion: Bool = false) {
|
||||
self.app = app
|
||||
self.id = id ?? UUID().uuidString
|
||||
self.name = name ?? app.rawValue
|
||||
self.apiURLString = apiURLString
|
||||
self.frontendURL = frontendURL
|
||||
self.proxiesVideos = proxiesVideos
|
||||
self.invidiousCompanion = invidiousCompanion
|
||||
}
|
||||
|
||||
var apiURL: URL! {
|
||||
@ -68,4 +70,8 @@ struct Instance: Defaults.Serializable, Hashable, Identifiable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(apiURL)
|
||||
}
|
||||
|
||||
var accounts: [Account] {
|
||||
AccountsModel.shared.all.filter { $0.instanceID == id }
|
||||
}
|
||||
}
|
||||
|
@ -16,7 +16,8 @@ struct InstancesBridge: Defaults.Bridge {
|
||||
"name": value.name,
|
||||
"apiURL": value.apiURLString,
|
||||
"frontendURL": value.frontendURL ?? "",
|
||||
"proxiesVideos": value.proxiesVideos ? "true" : "false"
|
||||
"proxiesVideos": value.proxiesVideos ? "true" : "false",
|
||||
"invidiousCompanion": value.invidiousCompanion ? "true" : "false"
|
||||
]
|
||||
}
|
||||
|
||||
@ -33,7 +34,8 @@ struct InstancesBridge: Defaults.Bridge {
|
||||
let name = object["name"] ?? ""
|
||||
let frontendURL: String? = object["frontendURL"]!.isEmpty ? nil : object["frontendURL"]
|
||||
let proxiesVideos = object["proxiesVideos"] == "true"
|
||||
let invidiousCompanion = object["invidiousCompanion"] == "true"
|
||||
|
||||
return Instance(app: app, id: id, name: name, apiURLString: apiURL, frontendURL: frontendURL, proxiesVideos: proxiesVideos)
|
||||
return Instance(app: app, id: id, name: name, apiURLString: apiURL, frontendURL: frontendURL, proxiesVideos: proxiesVideos, invidiousCompanion: invidiousCompanion)
|
||||
}
|
||||
}
|
||||
|
@ -32,19 +32,33 @@ final class InstancesModel: ObservableObject {
|
||||
return Defaults[.instances].first { $0.id == id }
|
||||
}
|
||||
|
||||
func findByURLString(_ urlString: String?) -> Instance? {
|
||||
guard let urlString else { return nil }
|
||||
|
||||
return Defaults[.instances].first { $0.apiURLString == urlString }
|
||||
}
|
||||
|
||||
func accounts(_ id: Instance.ID?) -> [Account] {
|
||||
Defaults[.accounts].filter { $0.instanceID == id }
|
||||
}
|
||||
|
||||
func add(app: VideosApp, name: String, url: String) -> Instance {
|
||||
func add(id: String? = UUID().uuidString, app: VideosApp, name: String, url: String) -> Instance {
|
||||
let instance = Instance(
|
||||
app: app, id: UUID().uuidString, name: name, apiURLString: standardizedURL(url)
|
||||
app: app, id: id, name: name, apiURLString: standardizedURL(url)
|
||||
)
|
||||
Defaults[.instances].append(instance)
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
func insert(id: String? = UUID().uuidString, app: VideosApp, name: String, url: String) -> Instance {
|
||||
if let instance = Defaults[.instances].first(where: { $0.apiURL.absoluteString == standardizedURL(url) }) {
|
||||
return instance
|
||||
}
|
||||
|
||||
return add(id: id, app: app, name: name, url: url)
|
||||
}
|
||||
|
||||
func setFrontendURL(_ instance: Instance, _ url: String) {
|
||||
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
|
||||
var instance = Defaults[.instances][index]
|
||||
@ -65,6 +79,17 @@ final class InstancesModel: ObservableObject {
|
||||
Defaults[.instances][index] = instance
|
||||
}
|
||||
|
||||
func setInvidiousCompanion(_ instance: Instance, _ invidiousCompanion: Bool) {
|
||||
guard let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) else {
|
||||
return
|
||||
}
|
||||
|
||||
var instance = Defaults[.instances][index]
|
||||
instance.invidiousCompanion = invidiousCompanion
|
||||
|
||||
Defaults[.instances][index] = instance
|
||||
}
|
||||
|
||||
func remove(_ instance: Instance) {
|
||||
let accounts = accounts(instance.id)
|
||||
if let index = Defaults[.instances].firstIndex(where: { $0.id == instance.id }) {
|
||||
@ -76,8 +101,7 @@ final class InstancesModel: ObservableObject {
|
||||
func standardizedURL(_ url: String) -> String {
|
||||
if url.count > 7, url.last == "/" {
|
||||
return String(url.dropLast())
|
||||
} else {
|
||||
return url
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
@ -65,9 +65,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
if type == "channel" {
|
||||
return ContentItem(channel: self.extractChannel(from: json))
|
||||
} else if type == "playlist" {
|
||||
}
|
||||
if type == "playlist" {
|
||||
return ContentItem(playlist: self.extractChannelPlaylist(from: json))
|
||||
} else if type == "video" {
|
||||
}
|
||||
if type == "video" {
|
||||
return ContentItem(video: self.extractVideo(from: json))
|
||||
}
|
||||
|
||||
@ -79,7 +81,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
configureTransformer(pathPattern("search/suggestions"), requestMethods: [.get]) { (content: Entity<JSON>) -> [String] in
|
||||
if let suggestions = content.json.dictionaryValue["suggestions"] {
|
||||
return suggestions.arrayValue.map { $0.stringValue.replacingHTMLEntities }
|
||||
return suggestions.arrayValue.map(\.stringValue).map(\.replacingHTMLEntities)
|
||||
}
|
||||
|
||||
return []
|
||||
@ -121,7 +123,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
content.json.dictionaryValue["videos"]?.arrayValue.map(self.extractVideo) ?? []
|
||||
}
|
||||
|
||||
["latest", "playlists", "streams", "shorts", "channels", "videos"].forEach { type in
|
||||
for type in ["latest", "playlists", "streams", "shorts", "channels", "videos", "releases", "podcasts"] {
|
||||
configureTransformer(pathPattern("channels/*/\(type)"), requestMethods: [.get]) { (content: Entity<JSON>) -> ChannelPage in
|
||||
self.extractChannelPage(from: content.json)
|
||||
}
|
||||
@ -245,27 +247,27 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
func feed(_ page: Int?) -> Resource? {
|
||||
resource(baseURL: account.url, path: "\(Self.basePath)/auth/feed")
|
||||
resourceWithAuthCheck(baseURL: account.url, path: "\(Self.basePath)/auth/feed")
|
||||
.withParam("page", String(page ?? 1))
|
||||
}
|
||||
|
||||
var feed: Resource? {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/feed"))
|
||||
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/feed"))
|
||||
}
|
||||
|
||||
var subscriptions: Resource? {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
}
|
||||
|
||||
func subscribe(_ channelID: String, onCompletion: @escaping () -> Void = {}) {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
.child(channelID)
|
||||
.request(.post)
|
||||
.onCompletion { _ in onCompletion() }
|
||||
}
|
||||
|
||||
func unsubscribe(_ channelID: String, onCompletion: @escaping () -> Void) {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/subscriptions"))
|
||||
.child(channelID)
|
||||
.request(.delete)
|
||||
.onCompletion { _ in onCompletion() }
|
||||
@ -306,11 +308,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
return nil
|
||||
}
|
||||
|
||||
return resource(baseURL: account.url, path: basePathAppending("auth/playlists"))
|
||||
return resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/playlists"))
|
||||
}
|
||||
|
||||
func playlist(_ id: String) -> Resource? {
|
||||
resource(baseURL: account.url, path: basePathAppending("auth/playlists/\(id)"))
|
||||
resourceWithAuthCheck(baseURL: account.url, path: basePathAppending("auth/playlists/\(id)"))
|
||||
}
|
||||
|
||||
func playlistVideos(_ id: String) -> Resource? {
|
||||
@ -443,6 +445,9 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
urlComponents.scheme = instanceURLComponents.scheme
|
||||
urlComponents.host = instanceURLComponents.host
|
||||
urlComponents.user = instanceURLComponents.user
|
||||
urlComponents.password = instanceURLComponents.password
|
||||
urlComponents.port = instanceURLComponents.port
|
||||
|
||||
guard let url = urlComponents.url else {
|
||||
return nil
|
||||
@ -493,14 +498,14 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
indexID: indexID,
|
||||
live: json["liveNow"].boolValue,
|
||||
upcoming: json["isUpcoming"].boolValue,
|
||||
short: length <= Video.shortLength,
|
||||
short: length <= Video.shortLength && length != 0.0,
|
||||
publishedAt: publishedAt,
|
||||
likes: json["likeCount"].int,
|
||||
dislikes: json["dislikeCount"].int,
|
||||
keywords: json["keywords"].arrayValue.compactMap { $0.string },
|
||||
streams: extractStreams(from: json),
|
||||
related: extractRelated(from: json),
|
||||
chapters: extractChapters(from: description),
|
||||
chapters: createChapters(from: description, thumbnails: json),
|
||||
captions: extractCaptions(from: json)
|
||||
)
|
||||
}
|
||||
@ -551,6 +556,30 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
)
|
||||
}
|
||||
|
||||
// Determines if the request requires Basic Auth credentials to be removed
|
||||
private func needsBasicAuthRemoval(for path: String) -> Bool {
|
||||
return path.hasPrefix("\(Self.basePath)/auth/")
|
||||
}
|
||||
|
||||
// Creates a resource URL with consideration for removing Basic Auth credentials
|
||||
private func createResourceURL(baseURL: URL, path: String) -> URL {
|
||||
var resourceURL = baseURL
|
||||
|
||||
// Remove Basic Auth credentials if required
|
||||
if needsBasicAuthRemoval(for: path), var urlComponents = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) {
|
||||
urlComponents.user = nil
|
||||
urlComponents.password = nil
|
||||
resourceURL = urlComponents.url ?? baseURL
|
||||
}
|
||||
|
||||
return resourceURL.appendingPathComponent(path)
|
||||
}
|
||||
|
||||
func resourceWithAuthCheck(baseURL: URL, path: String) -> Resource {
|
||||
let sanitizedURL = createResourceURL(baseURL: baseURL, path: path)
|
||||
return super.resource(absoluteURL: sanitizedURL)
|
||||
}
|
||||
|
||||
private func extractThumbnails(from details: JSON) -> [Thumbnail] {
|
||||
details["videoThumbnails"].arrayValue.compactMap { json in
|
||||
guard let url = json["url"].url,
|
||||
@ -561,18 +590,41 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
return nil
|
||||
}
|
||||
|
||||
// some of instances are not configured properly and return thumbnails links
|
||||
// with incorrect scheme
|
||||
// Some instances are not configured properly and return thumbnail links
|
||||
// with an incorrect scheme or a missing port.
|
||||
components.scheme = accountUrlComponents.scheme
|
||||
components.port = accountUrlComponents.port
|
||||
|
||||
// If basic HTTP authentication is used,
|
||||
// the username and password need to be prepended to the URL.
|
||||
components.user = accountUrlComponents.user
|
||||
components.password = accountUrlComponents.password
|
||||
|
||||
guard let thumbnailUrl = components.url else {
|
||||
return nil
|
||||
}
|
||||
print("Final thumbnail URL: \(thumbnailUrl)")
|
||||
|
||||
return Thumbnail(url: thumbnailUrl, quality: .init(rawValue: quality)!)
|
||||
}
|
||||
}
|
||||
|
||||
private func createChapters(from description: String, thumbnails: JSON) -> [Chapter] {
|
||||
var chapters = extractChapters(from: description)
|
||||
|
||||
if !chapters.isEmpty {
|
||||
let thumbnailsData = extractThumbnails(from: thumbnails)
|
||||
let thumbnailURL = thumbnailsData.first { $0.quality == .medium }?.url
|
||||
|
||||
for chapter in chapters.indices {
|
||||
if let url = thumbnailURL {
|
||||
chapters[chapter].image = url
|
||||
}
|
||||
}
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
|
||||
private static var contentItemsKeys = ["items", "videos", "latestVideos", "playlists", "relatedChannels"]
|
||||
|
||||
private func extractChannelPage(from json: JSON, forceNotLast: Bool = false) -> ChannelPage {
|
||||
@ -603,21 +655,29 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
if json["liveNow"].boolValue {
|
||||
return hls
|
||||
}
|
||||
let videoId = json["videoId"].stringValue
|
||||
|
||||
return extractFormatStreams(from: json["formatStreams"].arrayValue) +
|
||||
extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue) +
|
||||
return extractFormatStreams(from: json["formatStreams"].arrayValue, videoId: videoId) +
|
||||
extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue, videoId: videoId) +
|
||||
hls
|
||||
}
|
||||
|
||||
private func extractFormatStreams(from streams: [JSON]) -> [Stream] {
|
||||
private func extractFormatStreams(from streams: [JSON], videoId: String?) -> [Stream] {
|
||||
streams.compactMap { stream in
|
||||
guard let streamURL = stream["url"].url else {
|
||||
return nil
|
||||
}
|
||||
let finalURL: URL
|
||||
if let videoId, let itag = stream["itag"].string, account.instance.invidiousCompanion {
|
||||
let companionURLString = "\(account.instance.apiURLString)/latest_version?id=\(videoId)&itag=\(itag)"
|
||||
finalURL = URL(string: companionURLString) ?? streamURL
|
||||
} else {
|
||||
finalURL = streamURL
|
||||
}
|
||||
|
||||
return SingleAssetStream(
|
||||
instance: account.instance,
|
||||
avAsset: AVURLAsset(url: streamURL),
|
||||
avAsset: AVURLAsset(url: finalURL),
|
||||
resolution: Stream.Resolution.from(resolution: stream["resolution"].string ?? ""),
|
||||
kind: .stream,
|
||||
encoding: stream["encoding"].string ?? ""
|
||||
@ -625,7 +685,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
}
|
||||
|
||||
private func extractAdaptiveFormats(from streams: [JSON]) -> [Stream] {
|
||||
private func extractAdaptiveFormats(from streams: [JSON], videoId: String?) -> [Stream] {
|
||||
let audioStreams = streams
|
||||
.filter { $0["type"].stringValue.starts(with: "audio/mp4") }
|
||||
.sorted {
|
||||
@ -640,19 +700,35 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
return videoStreams.compactMap { videoStream in
|
||||
guard let audioAssetURL = audioStream["url"].url,
|
||||
let videoAssetURL = videoStream["url"].url
|
||||
let videoAssetURL = videoStream["url"].url,
|
||||
let audioItag = audioStream["itag"].string,
|
||||
let videoItag = videoStream["itag"].string
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let finalAudioURL: URL
|
||||
let finalVideoURL: URL
|
||||
|
||||
if let videoId, account.instance.invidiousCompanion {
|
||||
let audioCompanionURLString = "\(account.instance.apiURLString)/latest_version?id=\(videoId)&itag=\(audioItag)"
|
||||
let videoCompanionURLString = "\(account.instance.apiURLString)/latest_version?id=\(videoId)&itag=\(videoItag)"
|
||||
finalAudioURL = URL(string: audioCompanionURLString) ?? audioAssetURL
|
||||
finalVideoURL = URL(string: videoCompanionURLString) ?? videoAssetURL
|
||||
} else {
|
||||
finalAudioURL = audioAssetURL
|
||||
finalVideoURL = videoAssetURL
|
||||
}
|
||||
|
||||
return Stream(
|
||||
instance: account.instance,
|
||||
audioAsset: AVURLAsset(url: audioAssetURL),
|
||||
videoAsset: AVURLAsset(url: videoAssetURL),
|
||||
audioAsset: AVURLAsset(url: finalAudioURL),
|
||||
videoAsset: AVURLAsset(url: finalVideoURL),
|
||||
resolution: Stream.Resolution.from(resolution: videoStream["resolution"].stringValue),
|
||||
kind: .adaptive,
|
||||
encoding: videoStream["encoding"].string,
|
||||
videoFormat: videoStream["type"].string
|
||||
videoFormat: videoStream["type"].string,
|
||||
bitrate: videoStream["bitrate"].int,
|
||||
requestRange: videoStream["init"].string ?? videoStream["index"].string
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -689,6 +765,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
let author = details["author"]?.string ?? ""
|
||||
let channelId = details["authorId"]?.string ?? UUID().uuidString
|
||||
let authorAvatarURL = details["authorThumbnails"]?.arrayValue.last?.dictionaryValue["url"]?.string ?? ""
|
||||
let htmlContent = details["contentHtml"]?.string ?? ""
|
||||
let decodedContent = decodeHtml(htmlContent)
|
||||
return Comment(
|
||||
id: UUID().uuidString,
|
||||
author: author,
|
||||
@ -697,12 +775,25 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
pinned: false,
|
||||
hearted: false,
|
||||
likeCount: details["likeCount"]?.int ?? 0,
|
||||
text: details["content"]?.string ?? "",
|
||||
text: decodedContent,
|
||||
repliesPage: details["replies"]?.dictionaryValue["continuation"]?.string,
|
||||
channel: Channel(app: .invidious, id: channelId, name: author)
|
||||
)
|
||||
}
|
||||
|
||||
private func decodeHtml(_ htmlEncodedString: String) -> String {
|
||||
if let data = htmlEncodedString.data(using: .utf8) {
|
||||
let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
|
||||
.documentType: NSAttributedString.DocumentType.html,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue
|
||||
]
|
||||
if let attributedString = try? NSAttributedString(data: data, options: options, documentAttributes: nil) {
|
||||
return attributedString.string
|
||||
}
|
||||
}
|
||||
return htmlEncodedString
|
||||
}
|
||||
|
||||
private func extractCaptions(from content: JSON) -> [Captions] {
|
||||
content["captions"].arrayValue.compactMap { details in
|
||||
guard let url = URL(string: details["url"].stringValue, relativeTo: account.url) else { return nil }
|
||||
@ -724,9 +815,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
if type == "channel" {
|
||||
return ContentItem(channel: extractChannel(from: json))
|
||||
} else if type == "playlist" {
|
||||
}
|
||||
if type == "playlist" {
|
||||
return ContentItem(playlist: extractChannelPlaylist(from: json))
|
||||
} else if type == "video" {
|
||||
}
|
||||
if type == "video" {
|
||||
return ContentItem(video: extractVideo(from: json))
|
||||
}
|
||||
|
||||
|
@ -392,7 +392,7 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI {
|
||||
}
|
||||
|
||||
func search(_ query: SearchQuery, page _: String?) -> Resource {
|
||||
var resource = resource(baseURL: account.url, path: basePathAppending("search/videos"))
|
||||
resource(baseURL: account.url, path: basePathAppending("search/videos"))
|
||||
.withParam("search", query.query)
|
||||
// .withParam("sort_by", query.sortBy.parameter)
|
||||
// .withParam("type", "all")
|
||||
@ -409,7 +409,7 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI {
|
||||
// resource = resource.withParam("page", page)
|
||||
// }
|
||||
|
||||
return resource
|
||||
// return resource
|
||||
}
|
||||
|
||||
func searchSuggestions(query: String) -> Resource {
|
||||
@ -515,7 +515,8 @@ final class PeerTubeAPI: Service, ObservableObject, VideosAPI {
|
||||
.dictionaryValue["files"]?.arrayValue.first?
|
||||
.dictionaryValue["fileUrl"]?.url
|
||||
{
|
||||
streams.append(SingleAssetStream(instance: account.instance, avAsset: AVURLAsset(url: fileURL), resolution: .hd720p30, kind: .stream))
|
||||
let resolution = Stream.Resolution.predefined(.hd720p30)
|
||||
streams.append(SingleAssetStream(instance: account.instance, avAsset: AVURLAsset(url: fileURL), resolution: resolution, kind: .stream))
|
||||
}
|
||||
|
||||
return streams
|
||||
|
@ -1,3 +1,4 @@
|
||||
import Alamofire
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Siesta
|
||||
@ -112,8 +113,11 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
content.json.arrayValue.compactMap { self.extractVideo(from: $0) }
|
||||
}
|
||||
|
||||
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>) -> CommentsPage in
|
||||
let details = content.json.dictionaryValue
|
||||
configureTransformer(pathPattern("comments/*")) { (content: Entity<JSON>?) -> CommentsPage in
|
||||
guard let details = content?.json.dictionaryValue else {
|
||||
return CommentsPage(comments: [], nextPage: nil, disabled: true)
|
||||
}
|
||||
|
||||
let comments = details["comments"]?.arrayValue.compactMap { self.extractComment(from: $0) } ?? []
|
||||
let nextPage = details["nextpage"]?.string
|
||||
let disabled = details["disabled"]?.bool ?? false
|
||||
@ -148,28 +152,44 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
return
|
||||
}
|
||||
|
||||
login.request(
|
||||
.post,
|
||||
json: ["username": username, "password": password]
|
||||
AF.request(
|
||||
login.url,
|
||||
method: .post,
|
||||
parameters: ["username": username, "password": password],
|
||||
encoding: JSONEncoding.default
|
||||
)
|
||||
.onSuccess { response in
|
||||
let token = response.json.dictionaryValue["token"]?.string ?? ""
|
||||
if let error = response.json.dictionaryValue["error"]?.string {
|
||||
NavigationModel.shared.presentAlert(
|
||||
title: "Account Error",
|
||||
message: error
|
||||
)
|
||||
} else if !token.isEmpty {
|
||||
AccountsModel.setToken(self.account, token)
|
||||
self.objectWillChange.send()
|
||||
} else {
|
||||
NavigationModel.shared.presentAlert(
|
||||
title: "Account Error",
|
||||
message: "Could not update your token."
|
||||
)
|
||||
.responseDecodable(of: JSON.self) { [weak self] response in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.configure()
|
||||
switch response.result {
|
||||
case let .success(value):
|
||||
let json = JSON(value)
|
||||
let token = json.dictionaryValue["token"]?.string ?? ""
|
||||
if let error = json.dictionaryValue["error"]?.string {
|
||||
NavigationModel.shared.presentAlert(
|
||||
title: "Account Error",
|
||||
message: error
|
||||
)
|
||||
} else if !token.isEmpty {
|
||||
AccountsModel.setToken(self.account, token)
|
||||
self.objectWillChange.send()
|
||||
} else {
|
||||
NavigationModel.shared.presentAlert(
|
||||
title: "Account Error",
|
||||
message: "Could not update your token."
|
||||
)
|
||||
}
|
||||
|
||||
self.configure()
|
||||
|
||||
case let .failure(error):
|
||||
NavigationModel.shared.presentAlert(
|
||||
title: "Account Error",
|
||||
message: error.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -395,6 +415,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
if let channel = extractChannel(from: content) {
|
||||
return ContentItem(channel: channel)
|
||||
}
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@ -471,6 +492,35 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
)
|
||||
}
|
||||
|
||||
static func nonProxiedAsset(asset: AVURLAsset, completion: @escaping (AVURLAsset?) -> Void) {
|
||||
guard var urlComponents = URLComponents(url: asset.url, resolvingAgainstBaseURL: false) else {
|
||||
completion(asset)
|
||||
return
|
||||
}
|
||||
|
||||
guard let hostItem = urlComponents.queryItems?.first(where: { $0.name == "host" }),
|
||||
let hostValue = hostItem.value
|
||||
else {
|
||||
completion(asset)
|
||||
return
|
||||
}
|
||||
|
||||
urlComponents.host = hostValue
|
||||
|
||||
guard let newUrl = urlComponents.url else {
|
||||
completion(asset)
|
||||
return
|
||||
}
|
||||
|
||||
completion(AVURLAsset(url: newUrl))
|
||||
}
|
||||
|
||||
// Overload used for hlsURLS
|
||||
static func nonProxiedAsset(url: URL, completion: @escaping (AVURLAsset?) -> Void) {
|
||||
let asset = AVURLAsset(url: url)
|
||||
nonProxiedAsset(asset: asset, completion: completion)
|
||||
}
|
||||
|
||||
private func extractVideo(from content: JSON) -> Video? {
|
||||
let details = content.dictionaryValue
|
||||
|
||||
@ -496,7 +546,17 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
let uploaded = details["uploaded"]?.double
|
||||
var published = (uploaded.isNil || uploaded == -1) ? nil : (uploaded! / 1000).formattedAsRelativeTime()
|
||||
if published.isNil {
|
||||
var publishedAt: Date?
|
||||
|
||||
let dateFormatter = ISO8601DateFormatter()
|
||||
dateFormatter.formatOptions = [.withInternetDateTime]
|
||||
|
||||
if published.isNil,
|
||||
let date = details["uploadDate"]?.string,
|
||||
let formattedDate = dateFormatter.date(from: date)
|
||||
{
|
||||
publishedAt = formattedDate
|
||||
} else {
|
||||
published = (details["uploadedDate"] ?? details["uploadDate"])?.string ?? ""
|
||||
}
|
||||
|
||||
@ -526,6 +586,7 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
thumbnails: thumbnails,
|
||||
live: live,
|
||||
short: details["isShort"]?.bool ?? (length <= Video.shortLength),
|
||||
publishedAt: publishedAt,
|
||||
likes: details["likes"]?.int,
|
||||
dislikes: details["dislikes"]?.int,
|
||||
streams: extractStreams(from: content),
|
||||
@ -548,10 +609,11 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
return nil
|
||||
}
|
||||
|
||||
return URL(string: thumbnailURL
|
||||
.absoluteString
|
||||
.replacingOccurrences(of: "hqdefault", with: quality.filename)
|
||||
.replacingOccurrences(of: "maxresdefault", with: quality.filename)
|
||||
return URL(
|
||||
string: thumbnailURL
|
||||
.absoluteString
|
||||
.replacingOccurrences(of: "hqdefault", with: quality.filename)
|
||||
.replacingOccurrences(of: "maxresdefault", with: quality.filename)
|
||||
)
|
||||
}
|
||||
|
||||
@ -620,6 +682,10 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
.dictionaryValue["audioStreams"]?
|
||||
.arrayValue
|
||||
.filter { $0.dictionaryValue["format"]?.string == "M4A" }
|
||||
.filter { stream in
|
||||
let type = stream.dictionaryValue["audioTrackType"]?.string
|
||||
return type == nil || type == "ORIGINAL"
|
||||
}
|
||||
.sorted {
|
||||
$0.dictionaryValue["bitrate"]?.int ?? 0 >
|
||||
$1.dictionaryValue["bitrate"]?.int ?? 0
|
||||
@ -631,16 +697,16 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
let videoStreams = content.dictionaryValue["videoStreams"]?.arrayValue ?? []
|
||||
|
||||
videoStreams.forEach { videoStream in
|
||||
for videoStream in videoStreams {
|
||||
let videoCodec = videoStream.dictionaryValue["codec"]?.string ?? ""
|
||||
if Self.disallowedVideoCodecs.contains(where: videoCodec.contains) {
|
||||
return
|
||||
continue
|
||||
}
|
||||
|
||||
guard let audioAssetUrl = audioStream.dictionaryValue["url"]?.url,
|
||||
let videoAssetUrl = videoStream.dictionaryValue["url"]?.url
|
||||
else {
|
||||
return
|
||||
continue
|
||||
}
|
||||
|
||||
let audioAsset = AVURLAsset(url: audioAssetUrl)
|
||||
@ -652,6 +718,20 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
let fps = qualityComponents.count > 1 ? Int(qualityComponents[1]) : 30
|
||||
let resolution = Stream.Resolution.from(resolution: quality, fps: fps)
|
||||
let videoFormat = videoStream.dictionaryValue["format"]?.string
|
||||
let bitrate = videoStream.dictionaryValue["bitrate"]?.int
|
||||
var requestRange: String?
|
||||
|
||||
if let initStart = videoStream.dictionaryValue["initStart"]?.int,
|
||||
let initEnd = videoStream.dictionaryValue["initEnd"]?.int
|
||||
{
|
||||
requestRange = "\(initStart)-\(initEnd)"
|
||||
} else if let indexStart = videoStream.dictionaryValue["indexStart"]?.int,
|
||||
let indexEnd = videoStream.dictionaryValue["indexEnd"]?.int
|
||||
{
|
||||
requestRange = "\(indexStart)-\(indexEnd)"
|
||||
} else {
|
||||
requestRange = nil
|
||||
}
|
||||
|
||||
if videoOnly {
|
||||
streams.append(
|
||||
@ -661,7 +741,9 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
videoAsset: videoAsset,
|
||||
resolution: resolution,
|
||||
kind: .adaptive,
|
||||
videoFormat: videoFormat
|
||||
videoFormat: videoFormat,
|
||||
bitrate: bitrate,
|
||||
requestRange: requestRange
|
||||
)
|
||||
)
|
||||
} else {
|
||||
@ -692,15 +774,23 @@ final class PipedAPI: Service, ObservableObject, VideosAPI {
|
||||
let commentorUrl = details["commentorUrl"]?.string
|
||||
let channelId = commentorUrl?.components(separatedBy: "/")[2] ?? ""
|
||||
|
||||
let commentText = extractCommentText(from: details["commentText"]?.stringValue)
|
||||
let commentId = details["commentId"]?.string ?? UUID().uuidString
|
||||
|
||||
// Sanity checks: return nil if required data is missing
|
||||
if commentText.isEmpty || commentId.isEmpty || author.isEmpty {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Comment(
|
||||
id: details["commentId"]?.string ?? UUID().uuidString,
|
||||
id: commentId,
|
||||
author: author,
|
||||
authorAvatarURL: details["thumbnail"]?.string ?? "",
|
||||
time: details["commentedTime"]?.string ?? "",
|
||||
pinned: details["pinned"]?.bool ?? false,
|
||||
hearted: details["hearted"]?.bool ?? false,
|
||||
likeCount: details["likeCount"]?.int ?? 0,
|
||||
text: extractCommentText(from: details["commentText"]?.stringValue),
|
||||
text: commentText,
|
||||
repliesPage: details["repliesPage"]?.string,
|
||||
channel: Channel(app: .piped, id: channelId, name: author)
|
||||
)
|
||||
|
@ -66,7 +66,7 @@ protocol VideosAPI {
|
||||
failureHandler: ((RequestError) -> Void)?,
|
||||
completionHandler: @escaping (PlayerQueueItem) -> Void
|
||||
)
|
||||
func shareURL(_ item: ContentItem, frontendHost: String?, time: CMTime?) -> URL?
|
||||
func shareURL(_ item: ContentItem, frontendURLString: String?, time: CMTime?) -> URL?
|
||||
|
||||
func comments(_ id: Video.ID, page: String?) -> Resource?
|
||||
}
|
||||
@ -108,14 +108,19 @@ extension VideosAPI {
|
||||
.onFailure { failureHandler?($0) }
|
||||
}
|
||||
|
||||
func shareURL(_ item: ContentItem, frontendHost: String? = nil, time: CMTime? = nil) -> URL? {
|
||||
guard let frontendHost = frontendHost ?? account?.instance?.frontendHost,
|
||||
var urlComponents = account?.instance?.urlComponents
|
||||
else {
|
||||
return nil
|
||||
func shareURL(_ item: ContentItem, frontendURLString: String? = nil, time: CMTime? = nil) -> URL? {
|
||||
var urlComponents: URLComponents?
|
||||
if let frontendURLString,
|
||||
let frontendURL = URL(string: frontendURLString)
|
||||
{
|
||||
urlComponents = URLComponents(url: frontendURL, resolvingAgainstBaseURL: false)
|
||||
} else if let instanceComponents = account?.instance?.urlComponents {
|
||||
urlComponents = instanceComponents
|
||||
}
|
||||
|
||||
urlComponents.host = frontendHost
|
||||
guard var urlComponents else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var queryItems = [URLQueryItem]()
|
||||
|
||||
@ -144,52 +149,99 @@ extension VideosAPI {
|
||||
}
|
||||
|
||||
func extractChapters(from description: String) -> [Chapter] {
|
||||
guard let chaptersRegularExpression = try? NSRegularExpression(
|
||||
pattern: "(?<start>(?:[0-9]+:){1,}(?:[0-9]+))(?:\\s)+(?:- ?)?(?<title>.*)",
|
||||
options: .caseInsensitive
|
||||
) else { return [] }
|
||||
/*
|
||||
The following chapter patterns are covered:
|
||||
|
||||
let chapterLines = chaptersRegularExpression.matches(
|
||||
in: description,
|
||||
range: NSRange(description.startIndex..., in: description)
|
||||
)
|
||||
1) "start - end - title" / "start - end: Title" / "start - end title"
|
||||
2) "start - title" / "start: title" / "start title" / "[start] - title" / "[start]: title" / "[start] title"
|
||||
3) "index. title - start" / "index. title start"
|
||||
4) "title: (start)"
|
||||
5) "(start) title"
|
||||
|
||||
return chapterLines.compactMap { line in
|
||||
let titleRange = line.range(withName: "title")
|
||||
let startRange = line.range(withName: "start")
|
||||
These represent:
|
||||
|
||||
guard let titleSubstringRange = Range(titleRange, in: description),
|
||||
let startSubstringRange = Range(startRange, in: description) else { return nil }
|
||||
- "start" and "end" are timestamps, defining the start and end of the individual chapter
|
||||
- "title" is the name of the chapter
|
||||
- "index" is the chapter's position in a list
|
||||
|
||||
let titleCapture = String(description[titleSubstringRange])
|
||||
let startCapture = String(description[startSubstringRange])
|
||||
let startComponents = startCapture.components(separatedBy: ":")
|
||||
guard startComponents.count <= 3 else { return nil }
|
||||
The order of these patterns is important as it determines the priority. The patterns listed first have a higher priority.
|
||||
In the case of multiple matches, the pattern with the highest priority will be chosen - lower number means higher priority.
|
||||
*/
|
||||
let patterns = [
|
||||
"(?<=\\n|^)\\s*(?:►\\s*)?\\[?(?<start>(?:[0-9]+:){1,2}[0-9]+)\\]?(?:\\s*-\\s*)?(?<end>(?:[0-9]+:){1,2}[0-9]+)?(?:\\s*-\\s*|\\s*[:]\\s*)?(?<title>.*)(?=\\n|$)",
|
||||
"(?<=\\n|^)\\s*(?:►\\s*)?\\[?(?<start>(?:[0-9]+:){1,2}[0-9]+)\\]?\\s*[-:]?\\s*(?<title>.+)(?=\\n|$)",
|
||||
"(?<=\\n|^)(?<index>[0-9]+\\.\\s)(?<title>.+?)(?:\\s*-\\s*)?(?<start>(?:[0-9]+:){1,2}[0-9]+)(?=\\n|$)",
|
||||
"(?<=\\n|^)(?<title>.+?):\\s*\\((?<start>(?:[0-9]+:){1,2}[0-9]+)\\)(?=\\n|$)",
|
||||
"(?<=^|\\n)\\((?<start>(?:[0-9]+:){1,2}[0-9]+)\\)\\s*(?<title>.+?)(?=\\n|$)"
|
||||
]
|
||||
|
||||
var hours: Double?
|
||||
var minutes: Double?
|
||||
var seconds: Double?
|
||||
let extractChaptersGroup = DispatchGroup()
|
||||
var capturedChapters: [Int: [Chapter]] = [:]
|
||||
let lock = NSLock()
|
||||
|
||||
if startComponents.count == 3 {
|
||||
hours = Double(startComponents[0])
|
||||
minutes = Double(startComponents[1])
|
||||
seconds = Double(startComponents[2])
|
||||
} else if startComponents.count == 2 {
|
||||
minutes = Double(startComponents[0])
|
||||
seconds = Double(startComponents[1])
|
||||
for (index, pattern) in patterns.enumerated() {
|
||||
extractChaptersGroup.enter()
|
||||
DispatchQueue.global().async {
|
||||
if let chaptersRegularExpression = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) {
|
||||
let chapterLines = chaptersRegularExpression.matches(in: description, range: NSRange(description.startIndex..., in: description))
|
||||
let extractedChapters = chapterLines.compactMap { line -> Chapter? in
|
||||
let titleRange = line.range(withName: "title")
|
||||
let startRange = line.range(withName: "start")
|
||||
|
||||
guard let titleSubstringRange = Range(titleRange, in: description),
|
||||
let startSubstringRange = Range(startRange, in: description)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let titleCapture = String(description[titleSubstringRange]).trimmingCharacters(in: .whitespaces)
|
||||
let startCapture = String(description[startSubstringRange])
|
||||
let startComponents = startCapture.components(separatedBy: ":")
|
||||
guard startComponents.count <= 3 else { return nil }
|
||||
|
||||
var hours: Double?
|
||||
var minutes: Double?
|
||||
var seconds: Double?
|
||||
|
||||
if startComponents.count == 3 {
|
||||
hours = Double(startComponents[0])
|
||||
minutes = Double(startComponents[1])
|
||||
seconds = Double(startComponents[2])
|
||||
} else if startComponents.count == 2 {
|
||||
minutes = Double(startComponents[0])
|
||||
seconds = Double(startComponents[1])
|
||||
}
|
||||
|
||||
guard var startSeconds = seconds else { return nil }
|
||||
|
||||
startSeconds += (minutes ?? 0) * 60
|
||||
startSeconds += (hours ?? 0) * 60 * 60
|
||||
|
||||
return Chapter(title: titleCapture, start: startSeconds)
|
||||
}
|
||||
|
||||
if !extractedChapters.isEmpty {
|
||||
lock.lock()
|
||||
capturedChapters[index] = extractedChapters
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
extractChaptersGroup.leave()
|
||||
}
|
||||
|
||||
guard var startSeconds = seconds else { return nil }
|
||||
|
||||
if let minutes {
|
||||
startSeconds += 60 * minutes
|
||||
}
|
||||
|
||||
if let hours {
|
||||
startSeconds += 60 * 60 * hours
|
||||
}
|
||||
|
||||
return .init(title: titleCapture, start: startSeconds)
|
||||
}
|
||||
|
||||
extractChaptersGroup.wait()
|
||||
|
||||
// Now we sort the keys of the capturedChapters dictionary.
|
||||
// These keys correspond to the priority of each pattern.
|
||||
let sortedKeys = Array(capturedChapters.keys).sorted(by: <)
|
||||
|
||||
// Return first non-empty result in the order of patterns
|
||||
for key in sortedKeys {
|
||||
if let chapters = capturedChapters[key], !chapters.isEmpty {
|
||||
return chapters
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
@ -95,7 +95,7 @@ enum VideosApp: String, CaseIterable {
|
||||
}
|
||||
|
||||
var allowsDisablingVidoesProxying: Bool {
|
||||
self == .invidious
|
||||
self == .invidious || self == .piped
|
||||
}
|
||||
|
||||
var supportsOpeningVideosByID: Bool {
|
||||
|
@ -13,6 +13,7 @@ struct ChannelPlaylistsCacheModel: CacheModel {
|
||||
var storage = try? Storage<String, JSON>(
|
||||
diskConfig: Self.diskConfig,
|
||||
memoryConfig: Self.memoryConfig,
|
||||
fileManager: FileManager.default,
|
||||
transformer: BaseCacheModel.jsonTransformer
|
||||
)
|
||||
|
||||
|
@ -13,6 +13,7 @@ struct ChannelsCacheModel: CacheModel {
|
||||
let storage = try? Storage<String, JSON>(
|
||||
diskConfig: Self.diskConfig,
|
||||
memoryConfig: Self.memoryConfig,
|
||||
fileManager: FileManager.default,
|
||||
transformer: BaseCacheModel.jsonTransformer
|
||||
)
|
||||
|
||||
|
@ -14,16 +14,19 @@ struct FeedCacheModel: CacheModel {
|
||||
let storage = try? Storage<String, JSON>(
|
||||
diskConfig: Self.diskConfig,
|
||||
memoryConfig: Self.memoryConfig,
|
||||
fileManager: FileManager.default,
|
||||
transformer: BaseCacheModel.jsonTransformer
|
||||
)
|
||||
|
||||
func storeFeed(account: Account, videos: [Video]) {
|
||||
let date = iso8601DateFormatter.string(from: Date())
|
||||
logger.info("caching feed \(account.feedCacheKey) -- \(date)")
|
||||
let feedTimeObject: JSON = ["date": date]
|
||||
let videosObject: JSON = ["videos": videos.prefix(cacheLimit).map { $0.json.object }]
|
||||
try? storage?.setObject(feedTimeObject, forKey: feedTimeCacheKey(account.feedCacheKey))
|
||||
try? storage?.setObject(videosObject, forKey: account.feedCacheKey)
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
let date = iso8601DateFormatter.string(from: Date())
|
||||
logger.info("caching feed \(account.feedCacheKey) -- \(date)")
|
||||
let feedTimeObject: JSON = ["date": date]
|
||||
let videosObject: JSON = ["videos": videos.prefix(cacheLimit).map(\.json.object)]
|
||||
try? storage?.setObject(feedTimeObject, forKey: feedTimeCacheKey(account.feedCacheKey))
|
||||
try? storage?.setObject(videosObject, forKey: account.feedCacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
func retrieveFeed(account: Account) -> [Video] {
|
||||
|
@ -14,6 +14,7 @@ struct PlaylistsCacheModel: CacheModel {
|
||||
let storage = try? Storage<String, JSON>(
|
||||
diskConfig: Self.diskConfig,
|
||||
memoryConfig: Self.memoryConfig,
|
||||
fileManager: FileManager.default,
|
||||
transformer: BaseCacheModel.jsonTransformer
|
||||
)
|
||||
|
||||
@ -21,7 +22,7 @@ struct PlaylistsCacheModel: CacheModel {
|
||||
let date = iso8601DateFormatter.string(from: Date())
|
||||
logger.info("caching \(playlistCacheKey(account)) -- \(date)")
|
||||
let feedTimeObject: JSON = ["date": date]
|
||||
let playlistsObject: JSON = ["playlists": playlists.map { $0.json.object }]
|
||||
let playlistsObject: JSON = ["playlists": playlists.map(\.json.object)]
|
||||
try? storage?.setObject(feedTimeObject, forKey: playlistTimeCacheKey(account))
|
||||
try? storage?.setObject(playlistsObject, forKey: playlistCacheKey(account))
|
||||
}
|
||||
|
@ -15,6 +15,7 @@ final class SubscribedChannelsModel: ObservableObject, CacheModel {
|
||||
let storage = try? Storage<String, JSON>(
|
||||
diskConfig: SubscribedChannelsModel.diskConfig,
|
||||
memoryConfig: SubscribedChannelsModel.memoryConfig,
|
||||
fileManager: FileManager.default,
|
||||
transformer: BaseCacheModel.jsonTransformer
|
||||
)
|
||||
|
||||
@ -85,7 +86,6 @@ final class SubscribedChannelsModel: ObservableObject, CacheModel {
|
||||
self.error = nil
|
||||
if let channels: [Channel] = resource.typedContent() {
|
||||
self.channels = channels
|
||||
channels.forEach { ChannelsCacheModel.shared.storeIfMissing($0) }
|
||||
self.storeChannels(account: account, channels: channels)
|
||||
FeedModel.shared.calculateUnwatchedFeed()
|
||||
onSuccess()
|
||||
@ -105,16 +105,18 @@ final class SubscribedChannelsModel: ObservableObject, CacheModel {
|
||||
}
|
||||
|
||||
func storeChannels(account: Account, channels: [Channel]) {
|
||||
let date = iso8601DateFormatter.string(from: Date())
|
||||
logger.info("caching channels \(channelsDateCacheKey(account)) -- \(date)")
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
let date = self.iso8601DateFormatter.string(from: Date())
|
||||
self.logger.info("caching channels \(self.channelsDateCacheKey(account)) -- \(date)")
|
||||
|
||||
channels.forEach { ChannelsCacheModel.shared.storeIfMissing($0) }
|
||||
channels.forEach { ChannelsCacheModel.shared.storeIfMissing($0) }
|
||||
|
||||
let dateObject: JSON = ["date": date]
|
||||
let channelsObject: JSON = ["channels": channels.map(\.json).map(\.object)]
|
||||
let dateObject: JSON = ["date": date]
|
||||
let channelsObject: JSON = ["channels": channels.map(\.json).map(\.object)]
|
||||
|
||||
try? storage?.setObject(dateObject, forKey: channelsDateCacheKey(account))
|
||||
try? storage?.setObject(channelsObject, forKey: channelsCacheKey(account))
|
||||
try? self.storage?.setObject(dateObject, forKey: self.channelsDateCacheKey(account))
|
||||
try? self.storage?.setObject(channelsObject, forKey: self.channelsCacheKey(account))
|
||||
}
|
||||
}
|
||||
|
||||
func getChannels(account: Account) -> [Channel] {
|
||||
|
@ -13,6 +13,7 @@ struct VideosCacheModel: CacheModel {
|
||||
let storage = try? Storage<String, JSON>(
|
||||
diskConfig: Self.diskConfig,
|
||||
memoryConfig: Self.memoryConfig,
|
||||
fileManager: FileManager.default,
|
||||
transformer: BaseCacheModel.jsonTransformer
|
||||
)
|
||||
|
||||
|
@ -10,6 +10,8 @@ struct Channel: Identifiable, Hashable {
|
||||
case livestreams
|
||||
case shorts
|
||||
case channels
|
||||
case releases
|
||||
case podcasts
|
||||
|
||||
static func from(_ name: String) -> Self? {
|
||||
let rawValueMatch = allCases.first { $0.rawValue == name }
|
||||
@ -45,6 +47,10 @@ struct Channel: Identifiable, Hashable {
|
||||
return "1.square"
|
||||
case .channels:
|
||||
return "person.3"
|
||||
case .releases:
|
||||
return "square.stack"
|
||||
case .podcasts:
|
||||
return "radio"
|
||||
}
|
||||
}
|
||||
|
||||
@ -110,7 +116,7 @@ struct Channel: Identifiable, Hashable {
|
||||
}
|
||||
|
||||
func hasData(for contentType: ContentType) -> Bool {
|
||||
return tabs.contains { $0.contentType == contentType }
|
||||
tabs.contains { $0.contentType == contentType }
|
||||
}
|
||||
|
||||
var cacheKey: String {
|
||||
@ -146,7 +152,7 @@ struct Channel: Identifiable, Hashable {
|
||||
"subscriptionsText": subscriptionsText as Any,
|
||||
"totalViews": totalViews as Any,
|
||||
"verified": verified as Any,
|
||||
"videos": videos.map { $0.json.object }
|
||||
"videos": videos.map(\.json.object)
|
||||
]
|
||||
}
|
||||
|
||||
|
@ -19,7 +19,7 @@ struct ChannelPlaylist: Identifiable {
|
||||
"title": title,
|
||||
"thumbnailURL": thumbnailURL?.absoluteString ?? "",
|
||||
"channel": channel?.json.object ?? "",
|
||||
"videos": videos.map { $0.json.object },
|
||||
"videos": videos.map(\.json.object),
|
||||
"videosCount": String(videosCount ?? 0)
|
||||
]
|
||||
}
|
||||
|
@ -35,26 +35,22 @@ final class CommentsModel: ObservableObject {
|
||||
|
||||
func load(page: String? = nil) {
|
||||
guard let video = player.currentVideo else { return }
|
||||
|
||||
if !firstPage && !nextPageAvailable {
|
||||
return
|
||||
}
|
||||
|
||||
firstPage = page.isNil || page!.isEmpty
|
||||
guard firstPage || nextPageAvailable else { return }
|
||||
|
||||
player
|
||||
.playerAPI(video)?
|
||||
.comments(video.videoID, page: page)?
|
||||
.load()
|
||||
.onSuccess { [weak self] response in
|
||||
if let page: CommentsPage = response.typedContent() {
|
||||
self?.all += page.comments
|
||||
self?.nextPage = page.nextPage
|
||||
self?.disabled = page.disabled
|
||||
guard let self else { return }
|
||||
if let commentsPage: CommentsPage = response.typedContent() {
|
||||
self.all += commentsPage.comments
|
||||
self.nextPage = commentsPage.nextPage
|
||||
self.disabled = commentsPage.disabled
|
||||
}
|
||||
}
|
||||
.onFailure { [weak self] requestError in
|
||||
self?.disabled = !requestError.json.dictionaryValue["error"].isNil
|
||||
.onFailure { [weak self] _ in
|
||||
self?.disabled = true
|
||||
}
|
||||
.onCompletion { [weak self] _ in
|
||||
self?.loaded = true
|
||||
|
@ -15,7 +15,7 @@ struct ContentItem: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
static func < (lhs: ContentType, rhs: ContentType) -> Bool {
|
||||
static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.sortOrder < rhs.sortOrder
|
||||
}
|
||||
}
|
||||
@ -30,19 +30,19 @@ struct ContentItem: Identifiable {
|
||||
|
||||
var id: String = UUID().uuidString
|
||||
|
||||
static func array(of videos: [Video]) -> [ContentItem] {
|
||||
static func array(of videos: [Video]) -> [Self] {
|
||||
videos.map { Self(video: $0) }
|
||||
}
|
||||
|
||||
static func array(of playlists: [ChannelPlaylist]) -> [ContentItem] {
|
||||
static func array(of playlists: [ChannelPlaylist]) -> [Self] {
|
||||
playlists.map { Self(playlist: $0) }
|
||||
}
|
||||
|
||||
static func array(of channels: [Channel]) -> [ContentItem] {
|
||||
static func array(of channels: [Channel]) -> [Self] {
|
||||
channels.map { Self(channel: $0) }
|
||||
}
|
||||
|
||||
static func < (lhs: ContentItem, rhs: ContentItem) -> Bool {
|
||||
static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.contentType < rhs.contentType
|
||||
}
|
||||
|
||||
|
@ -215,7 +215,7 @@ extension Country {
|
||||
case .lk: return "Sri Lanka"
|
||||
case .se: return "Sweden"
|
||||
case .ch: return "Switzerland"
|
||||
case .tw: return "Taiwan, Province of China[a]"
|
||||
case .tw: return "Taiwan"
|
||||
case .tz: return "Tanzania, United Republic of"
|
||||
case .th: return "Thailand"
|
||||
case .tn: return "Tunisia"
|
||||
@ -274,7 +274,7 @@ extension Country {
|
||||
|
||||
private static func filteredCountries(_ predicate: (String) -> Bool) -> [Country] {
|
||||
Country.allCases
|
||||
.map { $0.name }
|
||||
.map(\.name)
|
||||
.filter(predicate)
|
||||
.compactMap { string in Country.allCases.first { $0.name == string } }
|
||||
}
|
||||
|
@ -47,7 +47,7 @@ struct FavoriteItem: Codable, Equatable, Identifiable, Defaults.Serializable {
|
||||
}
|
||||
}
|
||||
|
||||
static func == (lhs: FavoriteItem, rhs: FavoriteItem) -> Bool {
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.section == rhs.section
|
||||
}
|
||||
|
||||
|
@ -17,10 +17,15 @@ struct FavoritesModel {
|
||||
}
|
||||
|
||||
func toggle(_ item: FavoriteItem) {
|
||||
contains(item) ? remove(item) : add(item)
|
||||
if contains(item) {
|
||||
remove(item)
|
||||
} else {
|
||||
add(item)
|
||||
}
|
||||
}
|
||||
|
||||
func add(_ item: FavoriteItem) {
|
||||
if contains(item) { return }
|
||||
all.append(item)
|
||||
}
|
||||
|
||||
@ -118,4 +123,12 @@ struct FavoritesModel {
|
||||
func widgetSettings(_ item: FavoriteItem) -> WidgetSettings {
|
||||
widgetsSettings.first { $0.id == item.widgetSettingsKey } ?? WidgetSettings(id: item.widgetSettingsKey)
|
||||
}
|
||||
|
||||
func updateWidgetSettings(_ settings: WidgetSettings) {
|
||||
if let index = widgetsSettings.firstIndex(where: { $0.id == settings.id }) {
|
||||
widgetsSettings[index] = settings
|
||||
} else {
|
||||
widgetsSettings.append(settings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -121,7 +121,7 @@ final class FeedModel: ObservableObject, CacheModel {
|
||||
backgroundContext.perform { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
let watched = self.watchFetchRequestResult(feed, context: self.backgroundContext).filter { $0.finished }
|
||||
let watched = self.watchFetchRequestResult(feed, context: self.backgroundContext).filter(\.finished)
|
||||
let unwatched = feed.filter { video in !watched.contains { $0.videoID == video.videoID } }
|
||||
let unwatchedCount = max(0, feed.count - watched.count)
|
||||
|
||||
|
@ -47,12 +47,11 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
func updateWatch(finished: Bool = false, time: CMTime? = nil) {
|
||||
guard let currentVideo, saveHistory else { return }
|
||||
guard let currentVideo, saveHistory, isPlaying else { return }
|
||||
|
||||
let id = currentVideo.videoID
|
||||
let time = time ?? backend.currentTime
|
||||
let seconds = time?.seconds ?? 0
|
||||
let duration = playerTime.duration.seconds
|
||||
if seconds < 3 {
|
||||
return
|
||||
}
|
||||
|
@ -0,0 +1,23 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class AdvancedSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"showPlayNowInBackendContextMenu": Defaults[.showPlayNowInBackendContextMenu],
|
||||
"videoLoadingRetryCount": Defaults[.videoLoadingRetryCount],
|
||||
"showMPVPlaybackStats": Defaults[.showMPVPlaybackStats],
|
||||
"mpvEnableLogging": Defaults[.mpvEnableLogging],
|
||||
"mpvCacheSecs": Defaults[.mpvCacheSecs],
|
||||
"mpvCachePauseWait": Defaults[.mpvCachePauseWait],
|
||||
"mpvCachePauseInital": Defaults[.mpvCachePauseInital],
|
||||
"mpvDeinterlace": Defaults[.mpvDeinterlace],
|
||||
"mpvHWdec": Defaults[.mpvHWdec],
|
||||
"mpvDemuxerLavfProbeInfo": Defaults[.mpvDemuxerLavfProbeInfo],
|
||||
"mpvSetRefreshToContentFPS": Defaults[.mpvSetRefreshToContentFPS],
|
||||
"mpvInitialAudioSync": Defaults[.mpvInitialAudioSync],
|
||||
"showCacheStatus": Defaults[.showCacheStatus],
|
||||
"feedCacheSize": Defaults[.feedCacheSize]
|
||||
]
|
||||
}
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class BrowsingSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"showHome": Defaults[.showHome],
|
||||
"showOpenActionsInHome": Defaults[.showOpenActionsInHome],
|
||||
"showQueueInHome": Defaults[.showQueueInHome],
|
||||
"showFavoritesInHome": Defaults[.showFavoritesInHome],
|
||||
"favorites": Defaults[.favorites].compactMap { jsonFromString(FavoriteItem.bridge.serialize($0)) },
|
||||
"widgetsSettings": Defaults[.widgetsSettings].compactMap { widgetSettingsJSON($0) },
|
||||
"startupSection": Defaults[.startupSection].rawValue,
|
||||
"showSearchSuggestions": Defaults[.showSearchSuggestions],
|
||||
"visibleSections": Defaults[.visibleSections].compactMap { $0.rawValue },
|
||||
"showOpenActionsToolbarItem": Defaults[.showOpenActionsToolbarItem],
|
||||
"accountPickerDisplaysAnonymousAccounts": Defaults[.accountPickerDisplaysAnonymousAccounts],
|
||||
"showUnwatchedFeedBadges": Defaults[.showUnwatchedFeedBadges],
|
||||
"expandChannelDescription": Defaults[.expandChannelDescription],
|
||||
"keepChannelsWithUnwatchedFeedOnTop": Defaults[.keepChannelsWithUnwatchedFeedOnTop],
|
||||
"showChannelAvatarInChannelsLists": Defaults[.showChannelAvatarInChannelsLists],
|
||||
"showChannelAvatarInVideosListing": Defaults[.showChannelAvatarInVideosListing],
|
||||
"playerButtonSingleTapGesture": Defaults[.playerButtonSingleTapGesture].rawValue,
|
||||
"playerButtonDoubleTapGesture": Defaults[.playerButtonDoubleTapGesture].rawValue,
|
||||
"playerButtonShowsControlButtonsWhenMinimized": Defaults[.playerButtonShowsControlButtonsWhenMinimized],
|
||||
"playerButtonIsExpanded": Defaults[.playerButtonIsExpanded],
|
||||
"playerBarMaxWidth": Defaults[.playerBarMaxWidth],
|
||||
"channelOnThumbnail": Defaults[.channelOnThumbnail],
|
||||
"timeOnThumbnail": Defaults[.timeOnThumbnail],
|
||||
"roundedThumbnails": Defaults[.roundedThumbnails],
|
||||
"thumbnailsQuality": Defaults[.thumbnailsQuality].rawValue
|
||||
]
|
||||
}
|
||||
|
||||
override var platformJSON: JSON {
|
||||
var export = JSON()
|
||||
|
||||
#if os(iOS)
|
||||
export["showDocuments"].bool = Defaults[.showDocuments]
|
||||
export["lockPortraitWhenBrowsing"].bool = Defaults[.lockPortraitWhenBrowsing]
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
export["accountPickerDisplaysUsername"].bool = Defaults[.accountPickerDisplaysUsername]
|
||||
#endif
|
||||
|
||||
return export
|
||||
}
|
||||
|
||||
private func widgetSettingsJSON(_ settings: WidgetSettings) -> JSON {
|
||||
var json = JSON()
|
||||
json.dictionaryObject = WidgetSettingsBridge().serialize(settings)
|
||||
return json
|
||||
}
|
||||
}
|
@ -0,0 +1,42 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class ConstrolsSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"avPlayerUsesSystemControls": Defaults[.avPlayerUsesSystemControls],
|
||||
"fullscreenPlayerGestureEnabled": Defaults[.fullscreenPlayerGestureEnabled],
|
||||
"horizontalPlayerGestureEnabled": Defaults[.horizontalPlayerGestureEnabled],
|
||||
"seekGestureSensitivity": Defaults[.seekGestureSensitivity],
|
||||
"seekGestureSpeed": Defaults[.seekGestureSpeed],
|
||||
"playerControlsLayout": Defaults[.playerControlsLayout].rawValue,
|
||||
"fullScreenPlayerControlsLayout": Defaults[.fullScreenPlayerControlsLayout].rawValue,
|
||||
"playerControlsBackgroundOpacity": Defaults[.playerControlsBackgroundOpacity],
|
||||
"systemControlsCommands": Defaults[.systemControlsCommands].rawValue,
|
||||
"buttonBackwardSeekDuration": Defaults[.buttonBackwardSeekDuration],
|
||||
"buttonForwardSeekDuration": Defaults[.buttonForwardSeekDuration],
|
||||
"gestureBackwardSeekDuration": Defaults[.gestureBackwardSeekDuration],
|
||||
"gestureForwardSeekDuration": Defaults[.gestureForwardSeekDuration],
|
||||
"systemControlsSeekDuration": Defaults[.systemControlsSeekDuration],
|
||||
"playerControlsSettingsEnabled": Defaults[.playerControlsSettingsEnabled],
|
||||
"playerControlsCloseEnabled": Defaults[.playerControlsCloseEnabled],
|
||||
"playerControlsRestartEnabled": Defaults[.playerControlsRestartEnabled],
|
||||
"playerControlsAdvanceToNextEnabled": Defaults[.playerControlsAdvanceToNextEnabled],
|
||||
"playerControlsPlaybackModeEnabled": Defaults[.playerControlsPlaybackModeEnabled],
|
||||
"playerControlsMusicModeEnabled": Defaults[.playerControlsMusicModeEnabled],
|
||||
"playerActionsButtonLabelStyle": Defaults[.playerActionsButtonLabelStyle].rawValue,
|
||||
"actionButtonShareEnabled": Defaults[.actionButtonShareEnabled],
|
||||
"actionButtonAddToPlaylistEnabled": Defaults[.actionButtonAddToPlaylistEnabled],
|
||||
"actionButtonSubscribeEnabled": Defaults[.actionButtonSubscribeEnabled],
|
||||
"actionButtonSettingsEnabled": Defaults[.actionButtonSettingsEnabled],
|
||||
"actionButtonHideEnabled": Defaults[.actionButtonHideEnabled],
|
||||
"actionButtonCloseEnabled": Defaults[.actionButtonCloseEnabled],
|
||||
"actionButtonFullScreenEnabled": Defaults[.actionButtonFullScreenEnabled],
|
||||
"actionButtonPipEnabled": Defaults[.actionButtonPipEnabled],
|
||||
"actionButtonLockOrientationEnabled": Defaults[.actionButtonLockOrientationEnabled],
|
||||
"actionButtonRestartEnabled": Defaults[.actionButtonRestartEnabled],
|
||||
"actionButtonAdvanceToNextItemEnabled": Defaults[.actionButtonAdvanceToNextItemEnabled],
|
||||
"actionButtonMusicModeEnabled": Defaults[.actionButtonMusicModeEnabled]
|
||||
]
|
||||
}
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class HistorySettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"saveRecents": Defaults[.saveRecents],
|
||||
"saveHistory": Defaults[.saveHistory],
|
||||
"showRecents": Defaults[.showRecents],
|
||||
"limitRecents": Defaults[.limitRecents],
|
||||
"limitRecentsAmount": Defaults[.limitRecentsAmount],
|
||||
"showWatchingProgress": Defaults[.showWatchingProgress],
|
||||
"saveLastPlayed": Defaults[.saveLastPlayed],
|
||||
|
||||
"watchedVideoPlayNowBehavior": Defaults[.watchedVideoPlayNowBehavior].rawValue,
|
||||
"watchedThreshold": Defaults[.watchedThreshold],
|
||||
"resetWatchedStatusOnPlaying": Defaults[.resetWatchedStatusOnPlaying],
|
||||
|
||||
"watchedVideoStyle": Defaults[.watchedVideoStyle].rawValue,
|
||||
"watchedVideoBadgeColor": Defaults[.watchedVideoBadgeColor].rawValue,
|
||||
"showToggleWatchedStatusButton": Defaults[.showToggleWatchedStatusButton]
|
||||
]
|
||||
}
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class LocationsSettingsGroupExporter: SettingsGroupExporter {
|
||||
var includePublicInstances = true
|
||||
var includeInstances = true
|
||||
var includeAccounts = true
|
||||
var includeAccountsUnencryptedPasswords = false
|
||||
|
||||
init(includePublicInstances: Bool = true, includeInstances: Bool = true, includeAccounts: Bool = true, includeAccountsUnencryptedPasswords: Bool = false) {
|
||||
self.includePublicInstances = includePublicInstances
|
||||
self.includeInstances = includeInstances
|
||||
self.includeAccounts = includeAccounts
|
||||
self.includeAccountsUnencryptedPasswords = includeAccountsUnencryptedPasswords
|
||||
}
|
||||
|
||||
override var globalJSON: JSON {
|
||||
var json = JSON()
|
||||
|
||||
if includePublicInstances {
|
||||
json["instancesManifest"].string = Defaults[.instancesManifest]
|
||||
json["countryOfPublicInstances"].string = Defaults[.countryOfPublicInstances] ?? ""
|
||||
}
|
||||
|
||||
if includeInstances {
|
||||
json["instances"].arrayObject = Defaults[.instances].compactMap { instanceJSON($0) }
|
||||
}
|
||||
|
||||
if includeAccounts {
|
||||
json["accounts"].arrayObject = Defaults[.accounts].compactMap { account in
|
||||
var account = account
|
||||
let (username, password) = AccountsModel.getCredentials(account)
|
||||
account.username = username ?? ""
|
||||
if includeAccountsUnencryptedPasswords {
|
||||
account.password = password ?? ""
|
||||
}
|
||||
|
||||
return accountJSON(account).dictionaryObject
|
||||
}
|
||||
}
|
||||
|
||||
return json
|
||||
}
|
||||
|
||||
private func instanceJSON(_ instance: Instance) -> JSON {
|
||||
var json = JSON()
|
||||
json.dictionaryObject = InstancesBridge().serialize(instance)
|
||||
return json
|
||||
}
|
||||
|
||||
private func accountJSON(_ account: Account) -> JSON {
|
||||
var json = JSON()
|
||||
json.dictionaryObject = AccountsBridge().serialize(account)
|
||||
return json
|
||||
}
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class OtherDataSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"lastAccountID": Defaults[.lastAccountID] ?? "",
|
||||
"lastInstanceID": Defaults[.lastInstanceID] ?? "",
|
||||
|
||||
"playerRate": Defaults[.playerRate],
|
||||
|
||||
"trendingCategory": Defaults[.trendingCategory].rawValue,
|
||||
"trendingCountry": Defaults[.trendingCountry].rawValue,
|
||||
|
||||
"subscriptionsViewPage": Defaults[.subscriptionsViewPage].rawValue,
|
||||
"subscriptionsListingStyle": Defaults[.subscriptionsListingStyle].rawValue,
|
||||
"popularListingStyle": Defaults[.popularListingStyle].rawValue,
|
||||
"trendingListingStyle": Defaults[.trendingListingStyle].rawValue,
|
||||
"playlistListingStyle": Defaults[.playlistListingStyle].rawValue,
|
||||
"channelPlaylistListingStyle": Defaults[.channelPlaylistListingStyle].rawValue,
|
||||
"searchListingStyle": Defaults[.searchListingStyle].rawValue,
|
||||
|
||||
"hideShorts": Defaults[.hideShorts],
|
||||
"hideWatched": Defaults[.hideWatched]
|
||||
]
|
||||
}
|
||||
}
|
@ -0,0 +1,54 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class PlayerSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"playerInstanceID": Defaults[.playerInstanceID] ?? "",
|
||||
"pauseOnHidingPlayer": Defaults[.pauseOnHidingPlayer],
|
||||
"closeVideoOnEOF": Defaults[.closeVideoOnEOF],
|
||||
"exitFullscreenOnEOF": Defaults[.exitFullscreenOnEOF],
|
||||
"expandVideoDescription": Defaults[.expandVideoDescription],
|
||||
"collapsedLinesDescription": Defaults[.collapsedLinesDescription],
|
||||
"showChapters": Defaults[.showChapters],
|
||||
"showChapterThumbnails": Defaults[.showChapterThumbnails],
|
||||
"showChapterThumbnailsOnlyWhenDifferent": Defaults[.showChapterThumbnailsOnlyWhenDifferent],
|
||||
"expandChapters": Defaults[.expandChapters],
|
||||
"showRelated": Defaults[.showRelated],
|
||||
"showInspector": Defaults[.showInspector].rawValue,
|
||||
"playerSidebar": Defaults[.playerSidebar].rawValue,
|
||||
"showKeywords": Defaults[.showKeywords],
|
||||
"enableReturnYouTubeDislike": Defaults[.enableReturnYouTubeDislike],
|
||||
"closePiPOnNavigation": Defaults[.closePiPOnNavigation],
|
||||
"closePiPOnOpeningPlayer": Defaults[.closePiPOnOpeningPlayer],
|
||||
"closePlayerOnOpeningPiP": Defaults[.closePlayerOnOpeningPiP],
|
||||
"captionsAutoShow": Defaults[.captionsAutoShow],
|
||||
"captionsDefaultLanguageCode": Defaults[.captionsDefaultLanguageCode],
|
||||
"captionsFallbackLanguageCode": Defaults[.captionsFallbackLanguageCode],
|
||||
"captionsFontScaleSize": Defaults[.captionsFontScaleSize],
|
||||
"captionsFontColor": Defaults[.captionsFontColor]
|
||||
]
|
||||
}
|
||||
|
||||
override var platformJSON: JSON {
|
||||
var export = JSON()
|
||||
|
||||
#if !os(macOS)
|
||||
export["pauseOnEnteringBackground"].bool = Defaults[.pauseOnEnteringBackground]
|
||||
#endif
|
||||
|
||||
export["showComments"].bool = Defaults[.showComments]
|
||||
|
||||
#if !os(tvOS)
|
||||
export["showScrollToTopInComments"].bool = Defaults[.showScrollToTopInComments]
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
export["isOrientationLocked"].bool = Defaults[.isOrientationLocked]
|
||||
export["enterFullscreenInLandscape"].bool = Defaults[.enterFullscreenInLandscape]
|
||||
export["rotateToLandscapeOnEnterFullScreen"].string = Defaults[.rotateToLandscapeOnEnterFullScreen].rawValue
|
||||
#endif
|
||||
|
||||
return export
|
||||
}
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class QualitySettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"batteryCellularProfile": Defaults[.batteryCellularProfile],
|
||||
"batteryNonCellularProfile": Defaults[.batteryNonCellularProfile],
|
||||
"chargingCellularProfile": Defaults[.chargingCellularProfile],
|
||||
"chargingNonCellularProfile": Defaults[.chargingNonCellularProfile],
|
||||
"forceAVPlayerForLiveStreams": Defaults[.forceAVPlayerForLiveStreams],
|
||||
"qualityProfiles": Defaults[.qualityProfiles].compactMap { qualityProfileJSON($0) }
|
||||
]
|
||||
}
|
||||
|
||||
func qualityProfileJSON(_ profile: QualityProfile) -> JSON {
|
||||
var json = JSON()
|
||||
json.dictionaryObject = QualityProfileBridge().serialize(profile)
|
||||
return json
|
||||
}
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class RecentlyOpenedExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"recentlyOpened": Defaults[.recentlyOpened].compactMap { recentItemJSON($0) }
|
||||
]
|
||||
}
|
||||
|
||||
private func recentItemJSON(_ recentItem: RecentItem) -> JSON {
|
||||
var json = JSON()
|
||||
json.dictionaryObject = RecentItemBridge().serialize(recentItem)
|
||||
return json
|
||||
}
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
class SettingsGroupExporter { // swiftlint:disable:this final_class
|
||||
var globalJSON: JSON {
|
||||
[]
|
||||
}
|
||||
|
||||
var platformJSON: JSON {
|
||||
[]
|
||||
}
|
||||
|
||||
var exportJSON: JSON {
|
||||
var json = globalJSON
|
||||
|
||||
if !platformJSON.isEmpty {
|
||||
try? json.merge(with: platformJSON)
|
||||
}
|
||||
|
||||
return json
|
||||
}
|
||||
|
||||
func jsonFromString(_ string: String?) -> JSON? {
|
||||
if let data = string?.data(using: .utf8, allowLossyConversion: false),
|
||||
let json = try? JSON(data: data)
|
||||
{
|
||||
return json
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
final class SponsorBlockSettingsGroupExporter: SettingsGroupExporter {
|
||||
override var globalJSON: JSON {
|
||||
[
|
||||
"sponsorBlockInstance": Defaults[.sponsorBlockInstance],
|
||||
"sponsorBlockCategories": Array(Defaults[.sponsorBlockCategories]),
|
||||
"sponsorBlockColors": Defaults[.sponsorBlockColors],
|
||||
"sponsorBlockShowTimeWithSkipsRemoved": Defaults[.sponsorBlockShowTimeWithSkipsRemoved],
|
||||
"sponsorBlockShowCategoriesInTimeline": Defaults[.sponsorBlockShowCategoriesInTimeline],
|
||||
"sponsorBlockShowNoticeAfterSkip": Defaults[.sponsorBlockShowNoticeAfterSkip]
|
||||
]
|
||||
}
|
||||
}
|
193
Model/Import Export Settings/ImportExportSettingsModel.swift
Normal file
193
Model/Import Export Settings/ImportExportSettingsModel.swift
Normal file
@ -0,0 +1,193 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import SwiftyJSON
|
||||
|
||||
final class ImportExportSettingsModel: ObservableObject {
|
||||
static let shared = ImportExportSettingsModel()
|
||||
|
||||
static var exportFile: URL {
|
||||
YatteeApp.settingsExportDirectory
|
||||
.appendingPathComponent("Yattee Settings from \(Constants.deviceName).\(settingsExtension)")
|
||||
}
|
||||
|
||||
static var settingsExtension: String {
|
||||
"yatteesettings"
|
||||
}
|
||||
|
||||
enum ExportGroup: String, Identifiable, CaseIterable {
|
||||
case browsingSettings
|
||||
case playerSettings
|
||||
case controlsSettings
|
||||
case qualitySettings
|
||||
case historySettings
|
||||
case sponsorBlockSettings
|
||||
case advancedSettings
|
||||
|
||||
case locationsSettings
|
||||
case instances
|
||||
case accounts
|
||||
case accountsUnencryptedPasswords
|
||||
|
||||
case recentlyOpened
|
||||
case otherData
|
||||
|
||||
static var settingsGroups: [Self] {
|
||||
[.browsingSettings, .playerSettings, .controlsSettings, .qualitySettings, .historySettings, .sponsorBlockSettings, .advancedSettings]
|
||||
}
|
||||
|
||||
static var locationsGroups: [Self] {
|
||||
[.locationsSettings, .instances, .accounts, .accountsUnencryptedPasswords]
|
||||
}
|
||||
|
||||
static var otherGroups: [Self] {
|
||||
[.recentlyOpened, .otherData]
|
||||
}
|
||||
|
||||
var id: RawValue {
|
||||
rawValue
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .browsingSettings:
|
||||
return "Browsing"
|
||||
case .playerSettings:
|
||||
return "Player"
|
||||
case .controlsSettings:
|
||||
return "Controls"
|
||||
case .qualitySettings:
|
||||
return "Quality"
|
||||
case .historySettings:
|
||||
return "History"
|
||||
case .sponsorBlockSettings:
|
||||
return "SponsorBlock"
|
||||
case .locationsSettings:
|
||||
return "Public Locations"
|
||||
case .instances:
|
||||
return "Custom Locations"
|
||||
case .accounts:
|
||||
return "Accounts"
|
||||
case .accountsUnencryptedPasswords:
|
||||
return "Accounts passwords (unencrypted)"
|
||||
case .advancedSettings:
|
||||
return "Advanced"
|
||||
case .recentlyOpened:
|
||||
return "Recents"
|
||||
case .otherData:
|
||||
return "Other data"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var selectedExportGroups = Set<ExportGroup>()
|
||||
static var defaultExportGroups = Set<ExportGroup>([
|
||||
.browsingSettings,
|
||||
.playerSettings,
|
||||
.controlsSettings,
|
||||
.qualitySettings,
|
||||
.historySettings,
|
||||
.sponsorBlockSettings,
|
||||
.locationsSettings,
|
||||
.instances,
|
||||
.accounts,
|
||||
.advancedSettings
|
||||
])
|
||||
|
||||
@Published var isExportInProgress = false
|
||||
|
||||
private var navigation = NavigationModel.shared
|
||||
private var settings = SettingsModel.shared
|
||||
|
||||
func toggleExportGroupSelection(_ group: ExportGroup) {
|
||||
if isGroupSelected(group) {
|
||||
selectedExportGroups.remove(group)
|
||||
} else {
|
||||
selectedExportGroups.insert(group)
|
||||
}
|
||||
|
||||
removeNotEnabledSelectedGroups()
|
||||
}
|
||||
|
||||
func reset() {
|
||||
isExportInProgress = false
|
||||
selectedExportGroups = Self.defaultExportGroups
|
||||
}
|
||||
|
||||
func reset(_ model: ImportSettingsFileModel? = nil) {
|
||||
reset()
|
||||
|
||||
guard let model else { return }
|
||||
|
||||
selectedExportGroups = selectedExportGroups.filter { model.isGroupIncludedInFile($0) }
|
||||
}
|
||||
|
||||
func exportAction() {
|
||||
DispatchQueue.global(qos: .background).async { [weak self] in
|
||||
var writingOptions: JSONSerialization.WritingOptions = []
|
||||
#if DEBUG
|
||||
writingOptions.insert(.prettyPrinted)
|
||||
writingOptions.insert(.sortedKeys)
|
||||
#endif
|
||||
try? self?.jsonForExport?.rawString(options: writingOptions)?.write(to: Self.exportFile, atomically: true, encoding: String.Encoding.utf8)
|
||||
#if os(macOS)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.isExportInProgress = false
|
||||
}
|
||||
NSWorkspace.shared.selectFile(Self.exportFile.path, inFileViewerRootedAtPath: YatteeApp.settingsExportDirectory.path)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var jsonForExport: JSON? {
|
||||
[
|
||||
"metadata": metadataJSON,
|
||||
"browsingSettings": selectedExportGroups.contains(.browsingSettings) ? BrowsingSettingsGroupExporter().exportJSON : JSON(),
|
||||
"playerSettings": selectedExportGroups.contains(.playerSettings) ? PlayerSettingsGroupExporter().exportJSON : JSON(),
|
||||
"controlsSettings": selectedExportGroups.contains(.controlsSettings) ? ConstrolsSettingsGroupExporter().exportJSON : JSON(),
|
||||
"qualitySettings": selectedExportGroups.contains(.qualitySettings) ? QualitySettingsGroupExporter().exportJSON : JSON(),
|
||||
"historySettings": selectedExportGroups.contains(.historySettings) ? HistorySettingsGroupExporter().exportJSON : JSON(),
|
||||
"sponsorBlockSettings": selectedExportGroups.contains(.sponsorBlockSettings) ? SponsorBlockSettingsGroupExporter().exportJSON : JSON(),
|
||||
"locationsSettings": LocationsSettingsGroupExporter(
|
||||
includePublicInstances: isGroupSelected(.locationsSettings),
|
||||
includeInstances: isGroupSelected(.instances),
|
||||
includeAccounts: isGroupSelected(.accounts),
|
||||
includeAccountsUnencryptedPasswords: isGroupSelected(.accountsUnencryptedPasswords)
|
||||
).exportJSON,
|
||||
"advancedSettings": selectedExportGroups.contains(.advancedSettings) ? AdvancedSettingsGroupExporter().exportJSON : JSON(),
|
||||
"recentlyOpened": selectedExportGroups.contains(.recentlyOpened) ? RecentlyOpenedExporter().exportJSON : JSON(),
|
||||
"otherData": selectedExportGroups.contains(.otherData) ? OtherDataSettingsGroupExporter().exportJSON : JSON()
|
||||
]
|
||||
}
|
||||
|
||||
private var metadataJSON: JSON {
|
||||
[
|
||||
"build": YatteeApp.build,
|
||||
"timestamp": "\(Date().timeIntervalSince1970)",
|
||||
"platform": Constants.platform
|
||||
]
|
||||
}
|
||||
|
||||
func isGroupSelected(_ group: ExportGroup) -> Bool {
|
||||
selectedExportGroups.contains(group)
|
||||
}
|
||||
|
||||
func isGroupEnabled(_ group: ExportGroup) -> Bool {
|
||||
switch group {
|
||||
case .accounts:
|
||||
return selectedExportGroups.contains(.instances)
|
||||
case .accountsUnencryptedPasswords:
|
||||
return selectedExportGroups.contains(.instances) && selectedExportGroups.contains(.accounts)
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func removeNotEnabledSelectedGroups() {
|
||||
selectedExportGroups = selectedExportGroups.filter { isGroupEnabled($0) }
|
||||
}
|
||||
|
||||
var isExportAvailable: Bool {
|
||||
!selectedExportGroups.isEmpty && !isExportInProgress
|
||||
}
|
||||
}
|
150
Model/Import Export Settings/ImportSettingsFileModel.swift
Normal file
150
Model/Import Export Settings/ImportSettingsFileModel.swift
Normal file
@ -0,0 +1,150 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
import SwiftyJSON
|
||||
|
||||
final class ImportSettingsFileModel: ObservableObject {
|
||||
static let shared = ImportSettingsFileModel()
|
||||
|
||||
var locationsSettingsGroupImporter: LocationsSettingsGroupImporter? {
|
||||
if let locationsSettings = json.dictionaryValue["locationsSettings"] {
|
||||
return LocationsSettingsGroupImporter(
|
||||
json: locationsSettings,
|
||||
includePublicLocations: importExportModel.isGroupEnabled(.locationsSettings),
|
||||
includedInstancesIDs: sheetViewModel.selectedInstances,
|
||||
includedAccountsIDs: sheetViewModel.selectedAccounts,
|
||||
includedAccountsPasswords: sheetViewModel.importableAccountsPasswords
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var importExportModel = ImportExportSettingsModel.shared
|
||||
var sheetViewModel = ImportSettingsSheetViewModel.shared
|
||||
|
||||
var loadTask: URLSessionTask?
|
||||
|
||||
func isGroupIncludedInFile(_ group: ImportExportSettingsModel.ExportGroup) -> Bool {
|
||||
switch group {
|
||||
case .locationsSettings:
|
||||
return isPublicInstancesSettingsGroupInFile || instancesOrAccountsInFile
|
||||
default:
|
||||
return !groupJSON(group).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
var isPublicInstancesSettingsGroupInFile: Bool {
|
||||
guard let dict = groupJSON(.locationsSettings).dictionary else { return false }
|
||||
|
||||
return dict.keys.contains("instancesManifest") || dict.keys.contains("countryOfPublicInstances")
|
||||
}
|
||||
|
||||
var instancesOrAccountsInFile: Bool {
|
||||
guard let dict = groupJSON(.locationsSettings).dictionary else { return false }
|
||||
|
||||
return (dict.keys.contains("instances") && !(dict["instances"]?.arrayValue.isEmpty ?? true)) ||
|
||||
(dict.keys.contains("accounts") && !(dict["accounts"]?.arrayValue.isEmpty ?? true))
|
||||
}
|
||||
|
||||
func groupJSON(_ group: ImportExportSettingsModel.ExportGroup) -> JSON {
|
||||
json.dictionaryValue[group.rawValue] ?? .init()
|
||||
}
|
||||
|
||||
func performImport() {
|
||||
if importExportModel.isGroupSelected(.browsingSettings), isGroupIncludedInFile(.browsingSettings) {
|
||||
BrowsingSettingsGroupImporter(json: groupJSON(.browsingSettings)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.playerSettings), isGroupIncludedInFile(.playerSettings) {
|
||||
PlayerSettingsGroupImporter(json: groupJSON(.playerSettings)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.controlsSettings), isGroupIncludedInFile(.controlsSettings) {
|
||||
ConstrolsSettingsGroupImporter(json: groupJSON(.controlsSettings)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.qualitySettings), isGroupIncludedInFile(.qualitySettings) {
|
||||
QualitySettingsGroupImporter(json: groupJSON(.qualitySettings)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.historySettings), isGroupIncludedInFile(.historySettings) {
|
||||
HistorySettingsGroupImporter(json: groupJSON(.historySettings)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.sponsorBlockSettings), isGroupIncludedInFile(.sponsorBlockSettings) {
|
||||
SponsorBlockSettingsGroupImporter(json: groupJSON(.sponsorBlockSettings)).performImport()
|
||||
}
|
||||
|
||||
locationsSettingsGroupImporter?.performImport()
|
||||
|
||||
if importExportModel.isGroupSelected(.advancedSettings), isGroupIncludedInFile(.advancedSettings) {
|
||||
AdvancedSettingsGroupImporter(json: groupJSON(.advancedSettings)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.recentlyOpened), isGroupIncludedInFile(.recentlyOpened) {
|
||||
RecentlyOpenedImporter(json: groupJSON(.recentlyOpened)).performImport()
|
||||
}
|
||||
|
||||
if importExportModel.isGroupSelected(.otherData), isGroupIncludedInFile(.otherData) {
|
||||
OtherDataSettingsGroupImporter(json: groupJSON(.otherData)).performImport()
|
||||
}
|
||||
}
|
||||
|
||||
@Published var json = JSON()
|
||||
|
||||
func loadData(_ url: URL) {
|
||||
json = JSON()
|
||||
loadTask?.cancel()
|
||||
|
||||
loadTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in
|
||||
guard let data else { return }
|
||||
|
||||
if let json = try? JSON(data: data) {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.json = json
|
||||
|
||||
self.sheetViewModel.reset(locationsSettingsGroupImporter)
|
||||
self.importExportModel.reset(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
loadTask?.resume()
|
||||
}
|
||||
|
||||
func filename(_ url: URL) -> String {
|
||||
String(url.lastPathComponent.dropLast(ImportExportSettingsModel.settingsExtension.count + 1))
|
||||
}
|
||||
|
||||
var metadataBuild: String? {
|
||||
if let build = json.dictionaryValue["metadata"]?.dictionaryValue["build"]?.string {
|
||||
return build
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var metadataPlatform: String? {
|
||||
if let platform = json.dictionaryValue["metadata"]?.dictionaryValue["platform"]?.string {
|
||||
return platform
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var metadataDate: String? {
|
||||
if let timestamp = json.dictionaryValue["metadata"]?.dictionaryValue["timestamp"]?.doubleValue {
|
||||
let date = Date(timeIntervalSince1970: timestamp)
|
||||
return dateFormatter.string(from: date)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var dateFormatter: DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .long
|
||||
formatter.timeStyle = .medium
|
||||
|
||||
return formatter
|
||||
}
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct AdvancedSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let showPlayNowInBackendContextMenu = json["showPlayNowInBackendContextMenu"].bool {
|
||||
Defaults[.showPlayNowInBackendContextMenu] = showPlayNowInBackendContextMenu
|
||||
}
|
||||
|
||||
if let videoLoadingRetryCount = json["videoLoadingRetryCount"].int {
|
||||
Defaults[.videoLoadingRetryCount] = videoLoadingRetryCount
|
||||
}
|
||||
|
||||
if let showMPVPlaybackStats = json["showMPVPlaybackStats"].bool {
|
||||
Defaults[.showMPVPlaybackStats] = showMPVPlaybackStats
|
||||
}
|
||||
|
||||
if let mpvEnableLogging = json["mpvEnableLogging"].bool {
|
||||
Defaults[.mpvEnableLogging] = mpvEnableLogging
|
||||
}
|
||||
|
||||
if let mpvCacheSecs = json["mpvCacheSecs"].string {
|
||||
Defaults[.mpvCacheSecs] = mpvCacheSecs
|
||||
}
|
||||
|
||||
if let mpvCachePauseWait = json["mpvCachePauseWait"].string {
|
||||
Defaults[.mpvCachePauseWait] = mpvCachePauseWait
|
||||
}
|
||||
|
||||
if let mpvCachePauseInital = json["mpvCachePauseInital"].bool {
|
||||
Defaults[.mpvCachePauseInital] = mpvCachePauseInital
|
||||
}
|
||||
|
||||
if let mpvDeinterlace = json["mpvDeinterlace"].bool {
|
||||
Defaults[.mpvDeinterlace] = mpvDeinterlace
|
||||
}
|
||||
|
||||
if let mpvHWdec = json["mpvHWdec"].string {
|
||||
Defaults[.mpvHWdec] = mpvHWdec
|
||||
}
|
||||
|
||||
if let mpvDemuxerLavfProbeInfo = json["mpvDemuxerLavfProbeInfo"].string {
|
||||
Defaults[.mpvDemuxerLavfProbeInfo] = mpvDemuxerLavfProbeInfo
|
||||
}
|
||||
|
||||
if let mpvSetRefreshToContentFPS = json["mpvSetRefreshToContentFPS"].bool {
|
||||
Defaults[.mpvSetRefreshToContentFPS] = mpvSetRefreshToContentFPS
|
||||
}
|
||||
|
||||
if let mpvInitialAudioSync = json["mpvInitialAudioSync"].bool {
|
||||
Defaults[.mpvInitialAudioSync] = mpvInitialAudioSync
|
||||
}
|
||||
|
||||
if let showCacheStatus = json["showCacheStatus"].bool {
|
||||
Defaults[.showCacheStatus] = showCacheStatus
|
||||
}
|
||||
|
||||
if let feedCacheSize = json["feedCacheSize"].string {
|
||||
Defaults[.feedCacheSize] = feedCacheSize
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,148 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct BrowsingSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let showHome = json["showHome"].bool {
|
||||
Defaults[.showHome] = showHome
|
||||
}
|
||||
|
||||
if let showOpenActionsInHome = json["showOpenActionsInHome"].bool {
|
||||
Defaults[.showOpenActionsInHome] = showOpenActionsInHome
|
||||
}
|
||||
|
||||
if let showQueueInHome = json["showQueueInHome"].bool {
|
||||
Defaults[.showQueueInHome] = showQueueInHome
|
||||
}
|
||||
|
||||
if let showFavoritesInHome = json["showFavoritesInHome"].bool {
|
||||
Defaults[.showFavoritesInHome] = showFavoritesInHome
|
||||
}
|
||||
|
||||
if let favorites = json["favorites"].array {
|
||||
for favoriteJSON in favorites {
|
||||
if let jsonString = favoriteJSON.rawString(options: []),
|
||||
let item = FavoriteItem.bridge.deserialize(jsonString)
|
||||
{
|
||||
FavoritesModel.shared.add(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let widgetsFavorites = json["widgetsSettings"].array {
|
||||
for widgetJSON in widgetsFavorites {
|
||||
let dict = widgetJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
if let item = WidgetSettingsBridge().deserialize(dict) {
|
||||
FavoritesModel.shared.updateWidgetSettings(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let startupSectionString = json["startupSection"].string,
|
||||
let startupSection = StartupSection(rawValue: startupSectionString)
|
||||
{
|
||||
Defaults[.startupSection] = startupSection
|
||||
}
|
||||
|
||||
if let showSearchSuggestions = json["showSearchSuggestions"].bool {
|
||||
Defaults[.showSearchSuggestions] = showSearchSuggestions
|
||||
}
|
||||
|
||||
if let visibleSections = json["visibleSections"].array {
|
||||
let sections = visibleSections.compactMap { visibleSectionJSON in
|
||||
if let visibleSectionString = visibleSectionJSON.rawString(options: []),
|
||||
let section = VisibleSection(rawValue: visibleSectionString)
|
||||
{
|
||||
return section
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
Defaults[.visibleSections] = Set(sections)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
if let showOpenActionsToolbarItem = json["showOpenActionsToolbarItem"].bool {
|
||||
Defaults[.showOpenActionsToolbarItem] = showOpenActionsToolbarItem
|
||||
}
|
||||
|
||||
if let lockPortraitWhenBrowsing = json["lockPortraitWhenBrowsing"].bool {
|
||||
Defaults[.lockPortraitWhenBrowsing] = lockPortraitWhenBrowsing
|
||||
}
|
||||
#endif
|
||||
|
||||
#if !os(tvOS)
|
||||
if let accountPickerDisplaysUsername = json["accountPickerDisplaysUsername"].bool {
|
||||
Defaults[.accountPickerDisplaysUsername] = accountPickerDisplaysUsername
|
||||
}
|
||||
#endif
|
||||
|
||||
if let accountPickerDisplaysAnonymousAccounts = json["accountPickerDisplaysAnonymousAccounts"].bool {
|
||||
Defaults[.accountPickerDisplaysAnonymousAccounts] = accountPickerDisplaysAnonymousAccounts
|
||||
}
|
||||
|
||||
if let showUnwatchedFeedBadges = json["showUnwatchedFeedBadges"].bool {
|
||||
Defaults[.showUnwatchedFeedBadges] = showUnwatchedFeedBadges
|
||||
}
|
||||
|
||||
if let expandChannelDescription = json["expandChannelDescription"].bool {
|
||||
Defaults[.expandChannelDescription] = expandChannelDescription
|
||||
}
|
||||
|
||||
if let keepChannelsWithUnwatchedFeedOnTop = json["keepChannelsWithUnwatchedFeedOnTop"].bool {
|
||||
Defaults[.keepChannelsWithUnwatchedFeedOnTop] = keepChannelsWithUnwatchedFeedOnTop
|
||||
}
|
||||
|
||||
if let showChannelAvatarInChannelsLists = json["showChannelAvatarInChannelsLists"].bool {
|
||||
Defaults[.showChannelAvatarInChannelsLists] = showChannelAvatarInChannelsLists
|
||||
}
|
||||
|
||||
if let showChannelAvatarInVideosListing = json["showChannelAvatarInVideosListing"].bool {
|
||||
Defaults[.showChannelAvatarInVideosListing] = showChannelAvatarInVideosListing
|
||||
}
|
||||
|
||||
if let playerButtonSingleTapGestureString = json["playerButtonSingleTapGesture"].string,
|
||||
let playerButtonSingleTapGesture = PlayerTapGestureAction(rawValue: playerButtonSingleTapGestureString)
|
||||
{
|
||||
Defaults[.playerButtonSingleTapGesture] = playerButtonSingleTapGesture
|
||||
}
|
||||
|
||||
if let playerButtonDoubleTapGestureString = json["playerButtonDoubleTapGesture"].string,
|
||||
let playerButtonDoubleTapGesture = PlayerTapGestureAction(rawValue: playerButtonDoubleTapGestureString)
|
||||
{
|
||||
Defaults[.playerButtonDoubleTapGesture] = playerButtonDoubleTapGesture
|
||||
}
|
||||
|
||||
if let playerButtonShowsControlButtonsWhenMinimized = json["playerButtonShowsControlButtonsWhenMinimized"].bool {
|
||||
Defaults[.playerButtonShowsControlButtonsWhenMinimized] = playerButtonShowsControlButtonsWhenMinimized
|
||||
}
|
||||
|
||||
if let playerButtonIsExpanded = json["playerButtonIsExpanded"].bool {
|
||||
Defaults[.playerButtonIsExpanded] = playerButtonIsExpanded
|
||||
}
|
||||
|
||||
if let playerBarMaxWidth = json["playerBarMaxWidth"].string {
|
||||
Defaults[.playerBarMaxWidth] = playerBarMaxWidth
|
||||
}
|
||||
|
||||
if let channelOnThumbnail = json["channelOnThumbnail"].bool {
|
||||
Defaults[.channelOnThumbnail] = channelOnThumbnail
|
||||
}
|
||||
|
||||
if let timeOnThumbnail = json["timeOnThumbnail"].bool {
|
||||
Defaults[.timeOnThumbnail] = timeOnThumbnail
|
||||
}
|
||||
|
||||
if let roundedThumbnails = json["roundedThumbnails"].bool {
|
||||
Defaults[.roundedThumbnails] = roundedThumbnails
|
||||
}
|
||||
|
||||
if let thumbnailsQualityString = json["thumbnailsQuality"].string,
|
||||
let thumbnailsQuality = ThumbnailsQuality(rawValue: thumbnailsQualityString)
|
||||
{
|
||||
Defaults[.thumbnailsQuality] = thumbnailsQuality
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,148 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct ConstrolsSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let avPlayerUsesSystemControls = json["avPlayerUsesSystemControls"].bool {
|
||||
Defaults[.avPlayerUsesSystemControls] = avPlayerUsesSystemControls
|
||||
}
|
||||
|
||||
if let fullscreenPlayerGestureEnabled = json["fullscreenPlayerGestureEnabled"].bool {
|
||||
Defaults[.fullscreenPlayerGestureEnabled] = fullscreenPlayerGestureEnabled
|
||||
}
|
||||
|
||||
if let horizontalPlayerGestureEnabled = json["horizontalPlayerGestureEnabled"].bool {
|
||||
Defaults[.horizontalPlayerGestureEnabled] = horizontalPlayerGestureEnabled
|
||||
}
|
||||
|
||||
if let seekGestureSensitivity = json["seekGestureSensitivity"].double {
|
||||
Defaults[.seekGestureSensitivity] = seekGestureSensitivity
|
||||
}
|
||||
|
||||
if let seekGestureSpeed = json["seekGestureSpeed"].double {
|
||||
Defaults[.seekGestureSpeed] = seekGestureSpeed
|
||||
}
|
||||
|
||||
if let playerControlsLayoutString = json["playerControlsLayout"].string,
|
||||
let playerControlsLayout = PlayerControlsLayout(rawValue: playerControlsLayoutString)
|
||||
{
|
||||
Defaults[.playerControlsLayout] = playerControlsLayout
|
||||
}
|
||||
|
||||
if let fullScreenPlayerControlsLayoutString = json["fullScreenPlayerControlsLayout"].string,
|
||||
let fullScreenPlayerControlsLayout = PlayerControlsLayout(rawValue: fullScreenPlayerControlsLayoutString)
|
||||
{
|
||||
Defaults[.fullScreenPlayerControlsLayout] = fullScreenPlayerControlsLayout
|
||||
}
|
||||
|
||||
if let playerControlsBackgroundOpacity = json["playerControlsBackgroundOpacity"].double {
|
||||
Defaults[.playerControlsBackgroundOpacity] = playerControlsBackgroundOpacity
|
||||
}
|
||||
|
||||
if let systemControlsCommandsString = json["systemControlsCommands"].string,
|
||||
let systemControlsCommands = SystemControlsCommands(rawValue: systemControlsCommandsString)
|
||||
{
|
||||
Defaults[.systemControlsCommands] = systemControlsCommands
|
||||
}
|
||||
|
||||
if let buttonBackwardSeekDuration = json["buttonBackwardSeekDuration"].string {
|
||||
Defaults[.buttonBackwardSeekDuration] = buttonBackwardSeekDuration
|
||||
}
|
||||
|
||||
if let buttonForwardSeekDuration = json["buttonForwardSeekDuration"].string {
|
||||
Defaults[.buttonForwardSeekDuration] = buttonForwardSeekDuration
|
||||
}
|
||||
|
||||
if let gestureBackwardSeekDuration = json["gestureBackwardSeekDuration"].string {
|
||||
Defaults[.gestureBackwardSeekDuration] = gestureBackwardSeekDuration
|
||||
}
|
||||
|
||||
if let gestureForwardSeekDuration = json["gestureForwardSeekDuration"].string {
|
||||
Defaults[.gestureForwardSeekDuration] = gestureForwardSeekDuration
|
||||
}
|
||||
|
||||
if let systemControlsSeekDuration = json["systemControlsSeekDuration"].string {
|
||||
Defaults[.systemControlsSeekDuration] = systemControlsSeekDuration
|
||||
}
|
||||
|
||||
if let playerControlsSettingsEnabled = json["playerControlsSettingsEnabled"].bool {
|
||||
Defaults[.playerControlsSettingsEnabled] = playerControlsSettingsEnabled
|
||||
}
|
||||
|
||||
if let playerControlsCloseEnabled = json["playerControlsCloseEnabled"].bool {
|
||||
Defaults[.playerControlsCloseEnabled] = playerControlsCloseEnabled
|
||||
}
|
||||
|
||||
if let playerControlsRestartEnabled = json["playerControlsRestartEnabled"].bool {
|
||||
Defaults[.playerControlsRestartEnabled] = playerControlsRestartEnabled
|
||||
}
|
||||
|
||||
if let playerControlsAdvanceToNextEnabled = json["playerControlsAdvanceToNextEnabled"].bool {
|
||||
Defaults[.playerControlsAdvanceToNextEnabled] = playerControlsAdvanceToNextEnabled
|
||||
}
|
||||
|
||||
if let playerControlsPlaybackModeEnabled = json["playerControlsPlaybackModeEnabled"].bool {
|
||||
Defaults[.playerControlsPlaybackModeEnabled] = playerControlsPlaybackModeEnabled
|
||||
}
|
||||
|
||||
if let playerControlsMusicModeEnabled = json["playerControlsMusicModeEnabled"].bool {
|
||||
Defaults[.playerControlsMusicModeEnabled] = playerControlsMusicModeEnabled
|
||||
}
|
||||
|
||||
if let playerActionsButtonLabelStyleString = json["playerActionsButtonLabelStyle"].string,
|
||||
let playerActionsButtonLabelStyle = ButtonLabelStyle(rawValue: playerActionsButtonLabelStyleString)
|
||||
{
|
||||
Defaults[.playerActionsButtonLabelStyle] = playerActionsButtonLabelStyle
|
||||
}
|
||||
|
||||
if let actionButtonShareEnabled = json["actionButtonShareEnabled"].bool {
|
||||
Defaults[.actionButtonShareEnabled] = actionButtonShareEnabled
|
||||
}
|
||||
|
||||
if let actionButtonAddToPlaylistEnabled = json["actionButtonAddToPlaylistEnabled"].bool {
|
||||
Defaults[.actionButtonAddToPlaylistEnabled] = actionButtonAddToPlaylistEnabled
|
||||
}
|
||||
|
||||
if let actionButtonSubscribeEnabled = json["actionButtonSubscribeEnabled"].bool {
|
||||
Defaults[.actionButtonSubscribeEnabled] = actionButtonSubscribeEnabled
|
||||
}
|
||||
|
||||
if let actionButtonSettingsEnabled = json["actionButtonSettingsEnabled"].bool {
|
||||
Defaults[.actionButtonSettingsEnabled] = actionButtonSettingsEnabled
|
||||
}
|
||||
|
||||
if let actionButtonHideEnabled = json["actionButtonHideEnabled"].bool {
|
||||
Defaults[.actionButtonHideEnabled] = actionButtonHideEnabled
|
||||
}
|
||||
|
||||
if let actionButtonCloseEnabled = json["actionButtonCloseEnabled"].bool {
|
||||
Defaults[.actionButtonCloseEnabled] = actionButtonCloseEnabled
|
||||
}
|
||||
|
||||
if let actionButtonFullScreenEnabled = json["actionButtonFullScreenEnabled"].bool {
|
||||
Defaults[.actionButtonFullScreenEnabled] = actionButtonFullScreenEnabled
|
||||
}
|
||||
|
||||
if let actionButtonPipEnabled = json["actionButtonPipEnabled"].bool {
|
||||
Defaults[.actionButtonPipEnabled] = actionButtonPipEnabled
|
||||
}
|
||||
|
||||
if let actionButtonLockOrientationEnabled = json["actionButtonLockOrientationEnabled"].bool {
|
||||
Defaults[.actionButtonLockOrientationEnabled] = actionButtonLockOrientationEnabled
|
||||
}
|
||||
|
||||
if let actionButtonRestartEnabled = json["actionButtonRestartEnabled"].bool {
|
||||
Defaults[.actionButtonRestartEnabled] = actionButtonRestartEnabled
|
||||
}
|
||||
|
||||
if let actionButtonAdvanceToNextItemEnabled = json["actionButtonAdvanceToNextItemEnabled"].bool {
|
||||
Defaults[.actionButtonAdvanceToNextItemEnabled] = actionButtonAdvanceToNextItemEnabled
|
||||
}
|
||||
|
||||
if let actionButtonMusicModeEnabled = json["actionButtonMusicModeEnabled"].bool {
|
||||
Defaults[.actionButtonMusicModeEnabled] = actionButtonMusicModeEnabled
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,66 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct HistorySettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let saveRecents = json["saveRecents"].bool {
|
||||
Defaults[.saveRecents] = saveRecents
|
||||
}
|
||||
|
||||
if let saveHistory = json["saveHistory"].bool {
|
||||
Defaults[.saveHistory] = saveHistory
|
||||
}
|
||||
|
||||
if let showRecents = json["showRecents"].bool {
|
||||
Defaults[.showRecents] = showRecents
|
||||
}
|
||||
|
||||
if let limitRecents = json["limitRecents"].bool {
|
||||
Defaults[.limitRecents] = limitRecents
|
||||
}
|
||||
|
||||
if let limitRecentsAmount = json["limitRecentsAmount"].int {
|
||||
Defaults[.limitRecentsAmount] = limitRecentsAmount
|
||||
}
|
||||
|
||||
if let showWatchingProgress = json["showWatchingProgress"].bool {
|
||||
Defaults[.showWatchingProgress] = showWatchingProgress
|
||||
}
|
||||
|
||||
if let saveLastPlayed = json["saveLastPlayed"].bool {
|
||||
Defaults[.saveLastPlayed] = saveLastPlayed
|
||||
}
|
||||
|
||||
if let watchedVideoPlayNowBehaviorString = json["watchedVideoPlayNowBehavior"].string,
|
||||
let watchedVideoPlayNowBehavior = WatchedVideoPlayNowBehavior(rawValue: watchedVideoPlayNowBehaviorString)
|
||||
{
|
||||
Defaults[.watchedVideoPlayNowBehavior] = watchedVideoPlayNowBehavior
|
||||
}
|
||||
|
||||
if let watchedThreshold = json["watchedThreshold"].int {
|
||||
Defaults[.watchedThreshold] = watchedThreshold
|
||||
}
|
||||
|
||||
if let resetWatchedStatusOnPlaying = json["resetWatchedStatusOnPlaying"].bool {
|
||||
Defaults[.resetWatchedStatusOnPlaying] = resetWatchedStatusOnPlaying
|
||||
}
|
||||
|
||||
if let watchedVideoStyleString = json["watchedVideoStyle"].string,
|
||||
let watchedVideoStyle = WatchedVideoStyle(rawValue: watchedVideoStyleString)
|
||||
{
|
||||
Defaults[.watchedVideoStyle] = watchedVideoStyle
|
||||
}
|
||||
|
||||
if let watchedVideoBadgeColorString = json["watchedVideoBadgeColor"].string,
|
||||
let watchedVideoBadgeColor = WatchedVideoBadgeColor(rawValue: watchedVideoBadgeColorString)
|
||||
{
|
||||
Defaults[.watchedVideoBadgeColor] = watchedVideoBadgeColor
|
||||
}
|
||||
|
||||
if let showToggleWatchedStatusButton = json["showToggleWatchedStatusButton"].bool {
|
||||
Defaults[.showToggleWatchedStatusButton] = showToggleWatchedStatusButton
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,84 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct LocationsSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
var includePublicLocations = true
|
||||
var includedInstancesIDs = Set<Instance.ID>()
|
||||
var includedAccountsIDs = Set<Account.ID>()
|
||||
var includedAccountsPasswords = [Account.ID: String]()
|
||||
|
||||
init(
|
||||
json: JSON,
|
||||
includePublicLocations: Bool = true,
|
||||
includedInstancesIDs: Set<Instance.ID> = [],
|
||||
includedAccountsIDs: Set<Account.ID> = [],
|
||||
includedAccountsPasswords: [Account.ID: String] = [:]
|
||||
) {
|
||||
self.json = json
|
||||
self.includePublicLocations = includePublicLocations
|
||||
self.includedInstancesIDs = includedInstancesIDs
|
||||
self.includedAccountsIDs = includedAccountsIDs
|
||||
self.includedAccountsPasswords = includedAccountsPasswords
|
||||
}
|
||||
|
||||
var instances: [Instance] {
|
||||
if let instances = json["instances"].array {
|
||||
return instances.compactMap { instanceJSON in
|
||||
let dict = instanceJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
return InstancesBridge().deserialize(dict)
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
var accounts: [Account] {
|
||||
if let accounts = json["accounts"].array {
|
||||
return accounts.compactMap { accountJSON in
|
||||
let dict = accountJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
return AccountsBridge().deserialize(dict)
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
func performImport() {
|
||||
if includePublicLocations {
|
||||
Defaults[.instancesManifest] = json["instancesManifest"].string ?? ""
|
||||
Defaults[.countryOfPublicInstances] = json["countryOfPublicInstances"].string ?? ""
|
||||
}
|
||||
|
||||
instances.filter { includedInstancesIDs.contains($0.id) }.forEach { instance in
|
||||
_ = InstancesModel.shared.insert(id: instance.id, app: instance.app, name: instance.name, url: instance.apiURLString)
|
||||
}
|
||||
|
||||
if let accounts = json["accounts"].array {
|
||||
for accountJSON in accounts {
|
||||
let dict = accountJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
if let account = AccountsBridge().deserialize(dict),
|
||||
includedAccountsIDs.contains(account.id)
|
||||
{
|
||||
var password = account.password
|
||||
if password?.isEmpty ?? true {
|
||||
password = includedAccountsPasswords[account.id]
|
||||
}
|
||||
if let password,
|
||||
!password.isEmpty,
|
||||
let instanceID = account.instanceID,
|
||||
let instance = InstancesModel.shared.find(instanceID) ?? InstancesModel.shared.findByURLString(account.urlString)
|
||||
{
|
||||
if !instance.accounts.contains(where: { instanceAccount in
|
||||
let (username, _) = instanceAccount.credentials
|
||||
return username == account.username
|
||||
}) {
|
||||
_ = AccountsModel.add(instance: instance, id: account.id, name: account.name, username: account.username, password: password)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,70 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct OtherDataSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let lastAccountID = json["lastAccountID"].string {
|
||||
Defaults[.lastAccountID] = lastAccountID
|
||||
}
|
||||
|
||||
if let lastInstanceID = json["lastInstanceID"].string {
|
||||
Defaults[.lastInstanceID] = lastInstanceID
|
||||
}
|
||||
|
||||
if let playerRate = json["playerRate"].double {
|
||||
Defaults[.playerRate] = playerRate
|
||||
}
|
||||
|
||||
if let trendingCategoryString = json["trendingCategory"].string,
|
||||
let trendingCategory = TrendingCategory(rawValue: trendingCategoryString)
|
||||
{
|
||||
Defaults[.trendingCategory] = trendingCategory
|
||||
}
|
||||
|
||||
if let trendingCountryString = json["trendingCountry"].string,
|
||||
let trendingCountry = Country(rawValue: trendingCountryString)
|
||||
{
|
||||
Defaults[.trendingCountry] = trendingCountry
|
||||
}
|
||||
|
||||
if let subscriptionsViewPageString = json["subscriptionsViewPage"].string,
|
||||
let subscriptionsViewPage = SubscriptionsView.Page(rawValue: subscriptionsViewPageString)
|
||||
{
|
||||
Defaults[.subscriptionsViewPage] = subscriptionsViewPage
|
||||
}
|
||||
|
||||
if let subscriptionsListingStyle = json["subscriptionsListingStyle"].string {
|
||||
Defaults[.subscriptionsListingStyle] = ListingStyle(rawValue: subscriptionsListingStyle) ?? .list
|
||||
}
|
||||
|
||||
if let popularListingStyle = json["popularListingStyle"].string {
|
||||
Defaults[.popularListingStyle] = ListingStyle(rawValue: popularListingStyle) ?? .list
|
||||
}
|
||||
|
||||
if let trendingListingStyle = json["trendingListingStyle"].string {
|
||||
Defaults[.trendingListingStyle] = ListingStyle(rawValue: trendingListingStyle) ?? .list
|
||||
}
|
||||
|
||||
if let playlistListingStyle = json["playlistListingStyle"].string {
|
||||
Defaults[.playlistListingStyle] = ListingStyle(rawValue: playlistListingStyle) ?? .list
|
||||
}
|
||||
|
||||
if let channelPlaylistListingStyle = json["channelPlaylistListingStyle"].string {
|
||||
Defaults[.channelPlaylistListingStyle] = ListingStyle(rawValue: channelPlaylistListingStyle) ?? .list
|
||||
}
|
||||
|
||||
if let searchListingStyle = json["searchListingStyle"].string {
|
||||
Defaults[.searchListingStyle] = ListingStyle(rawValue: searchListingStyle) ?? .list
|
||||
}
|
||||
|
||||
if let hideShorts = json["hideShorts"].bool {
|
||||
Defaults[.hideShorts] = hideShorts
|
||||
}
|
||||
|
||||
if let hideWatched = json["hideWatched"].bool {
|
||||
Defaults[.hideWatched] = hideWatched
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,135 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct PlayerSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let playerInstanceID = json["playerInstanceID"].string {
|
||||
Defaults[.playerInstanceID] = playerInstanceID
|
||||
}
|
||||
|
||||
if let pauseOnHidingPlayer = json["pauseOnHidingPlayer"].bool {
|
||||
Defaults[.pauseOnHidingPlayer] = pauseOnHidingPlayer
|
||||
}
|
||||
|
||||
if let closeVideoOnEOF = json["closeVideoOnEOF"].bool {
|
||||
Defaults[.closeVideoOnEOF] = closeVideoOnEOF
|
||||
}
|
||||
|
||||
if let exitFullscreenOnEOF = json["exitFullscreenOnEOF"].bool {
|
||||
Defaults[.exitFullscreenOnEOF] = exitFullscreenOnEOF
|
||||
}
|
||||
|
||||
if let expandVideoDescription = json["expandVideoDescription"].bool {
|
||||
Defaults[.expandVideoDescription] = expandVideoDescription
|
||||
}
|
||||
|
||||
if let collapsedLinesDescription = json["collapsedLinesDescription"].int {
|
||||
Defaults[.collapsedLinesDescription] = collapsedLinesDescription
|
||||
}
|
||||
|
||||
if let showChapters = json["showChapters"].bool {
|
||||
Defaults[.showChapters] = showChapters
|
||||
}
|
||||
|
||||
if let showChapterThumbnails = json["showChapterThumbnails"].bool {
|
||||
Defaults[.showChapterThumbnails] = showChapterThumbnails
|
||||
}
|
||||
|
||||
if let showChapterThumbnailsOnlyWhenDifferent = json["showChapterThumbnailsOnlyWhenDifferent"].bool {
|
||||
Defaults[.showChapterThumbnailsOnlyWhenDifferent] = showChapterThumbnailsOnlyWhenDifferent
|
||||
}
|
||||
|
||||
if let expandChapters = json["expandChapters"].bool {
|
||||
Defaults[.expandChapters] = expandChapters
|
||||
}
|
||||
|
||||
if let showRelated = json["showRelated"].bool {
|
||||
Defaults[.showRelated] = showRelated
|
||||
}
|
||||
|
||||
if let showInspectorString = json["showInspector"].string,
|
||||
let showInspector = ShowInspectorSetting(rawValue: showInspectorString)
|
||||
{
|
||||
Defaults[.showInspector] = showInspector
|
||||
}
|
||||
|
||||
if let playerSidebarString = json["playerSidebar"].string,
|
||||
let playerSidebar = PlayerSidebarSetting(rawValue: playerSidebarString)
|
||||
{
|
||||
Defaults[.playerSidebar] = playerSidebar
|
||||
}
|
||||
|
||||
if let showKeywords = json["showKeywords"].bool {
|
||||
Defaults[.showKeywords] = showKeywords
|
||||
}
|
||||
|
||||
if let enableReturnYouTubeDislike = json["enableReturnYouTubeDislike"].bool {
|
||||
Defaults[.enableReturnYouTubeDislike] = enableReturnYouTubeDislike
|
||||
}
|
||||
|
||||
if let closePiPOnNavigation = json["closePiPOnNavigation"].bool {
|
||||
Defaults[.closePiPOnNavigation] = closePiPOnNavigation
|
||||
}
|
||||
|
||||
if let closePiPOnOpeningPlayer = json["closePiPOnOpeningPlayer"].bool {
|
||||
Defaults[.closePiPOnOpeningPlayer] = closePiPOnOpeningPlayer
|
||||
}
|
||||
|
||||
if let closePlayerOnOpeningPiP = json["closePlayerOnOpeningPiP"].bool {
|
||||
Defaults[.closePlayerOnOpeningPiP] = closePlayerOnOpeningPiP
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
if let pauseOnEnteringBackground = json["pauseOnEnteringBackground"].bool {
|
||||
Defaults[.pauseOnEnteringBackground] = pauseOnEnteringBackground
|
||||
}
|
||||
#endif
|
||||
|
||||
if let showComments = json["showComments"].bool {
|
||||
Defaults[.showComments] = showComments
|
||||
}
|
||||
#if !os(tvOS)
|
||||
if let showScrollToTopInComments = json["showScrollToTopInComments"].bool {
|
||||
Defaults[.showScrollToTopInComments] = showScrollToTopInComments
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
if let isOrientationLocked = json["isOrientationLocked"].bool {
|
||||
Defaults[.isOrientationLocked] = isOrientationLocked
|
||||
}
|
||||
|
||||
if let enterFullscreenInLandscape = json["enterFullscreenInLandscape"].bool {
|
||||
Defaults[.enterFullscreenInLandscape] = enterFullscreenInLandscape
|
||||
}
|
||||
|
||||
if let rotateToLandscapeOnEnterFullScreenString = json["rotateToLandscapeOnEnterFullScreen"].string,
|
||||
let rotateToLandscapeOnEnterFullScreen = FullScreenRotationSetting(rawValue: rotateToLandscapeOnEnterFullScreenString)
|
||||
{
|
||||
Defaults[.rotateToLandscapeOnEnterFullScreen] = rotateToLandscapeOnEnterFullScreen
|
||||
}
|
||||
#endif
|
||||
|
||||
if let captionsAutoShow = json["captionsAutoShow"].bool {
|
||||
Defaults[.captionsAutoShow] = captionsAutoShow
|
||||
}
|
||||
|
||||
if let captionsDefaultLanguageCode = json["captionsDefaultLanguageCode"].string {
|
||||
Defaults[.captionsDefaultLanguageCode] = captionsDefaultLanguageCode
|
||||
}
|
||||
|
||||
if let captionsFallbackLanguageCode = json["captionsFallbackLanguageCode"].string {
|
||||
Defaults[.captionsFallbackLanguageCode] = captionsFallbackLanguageCode
|
||||
}
|
||||
|
||||
if let captionsFontScaleSize = json["captionsFontScaleSize"].string {
|
||||
Defaults[.captionsFontScaleSize] = captionsFontScaleSize
|
||||
}
|
||||
|
||||
if let captionsFontColor = json["captionsFontColor"].string {
|
||||
Defaults[.captionsFontColor] = captionsFontColor
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct QualitySettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let batteryCellularProfileString = json["batteryCellularProfile"].string {
|
||||
Defaults[.batteryCellularProfile] = batteryCellularProfileString
|
||||
}
|
||||
|
||||
if let batteryNonCellularProfileString = json["batteryNonCellularProfile"].string {
|
||||
Defaults[.batteryNonCellularProfile] = batteryNonCellularProfileString
|
||||
}
|
||||
|
||||
if let chargingCellularProfileString = json["chargingCellularProfile"].string {
|
||||
Defaults[.chargingCellularProfile] = chargingCellularProfileString
|
||||
}
|
||||
|
||||
if let chargingNonCellularProfileString = json["chargingNonCellularProfile"].string {
|
||||
Defaults[.chargingNonCellularProfile] = chargingNonCellularProfileString
|
||||
}
|
||||
|
||||
if let forceAVPlayerForLiveStreams = json["forceAVPlayerForLiveStreams"].bool {
|
||||
Defaults[.forceAVPlayerForLiveStreams] = forceAVPlayerForLiveStreams
|
||||
}
|
||||
|
||||
if let qualityProfiles = json["qualityProfiles"].array {
|
||||
for qualityProfileJSON in qualityProfiles {
|
||||
let dict = qualityProfileJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
if let item = QualityProfileBridge().deserialize(dict) {
|
||||
QualityProfilesModel.shared.update(item, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct RecentlyOpenedImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let recentlyOpened = json["recentlyOpened"].array {
|
||||
for recentlyOpenedJSON in recentlyOpened {
|
||||
let dict = recentlyOpenedJSON.dictionaryValue.mapValues { json in json.stringValue }
|
||||
if let item = RecentItemBridge().deserialize(dict) {
|
||||
RecentsModel.shared.add(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,33 @@
|
||||
import Defaults
|
||||
import SwiftyJSON
|
||||
|
||||
struct SponsorBlockSettingsGroupImporter {
|
||||
var json: JSON
|
||||
|
||||
func performImport() {
|
||||
if let sponsorBlockInstance = json["sponsorBlockInstance"].string {
|
||||
Defaults[.sponsorBlockInstance] = sponsorBlockInstance
|
||||
}
|
||||
|
||||
if let sponsorBlockCategories = json["sponsorBlockCategories"].array {
|
||||
Defaults[.sponsorBlockCategories] = Set(sponsorBlockCategories.compactMap { $0.string })
|
||||
}
|
||||
|
||||
if let sponsorBlockColors = json["sponsorBlockColors"].dictionary {
|
||||
let colors = sponsorBlockColors.mapValues { json in json.stringValue }
|
||||
Defaults[.sponsorBlockColors] = colors
|
||||
}
|
||||
|
||||
if let sponsorBlockShowTimeWithSkipsRemoved = json["sponsorBlockShowTimeWithSkipsRemoved"].bool {
|
||||
Defaults[.sponsorBlockShowTimeWithSkipsRemoved] = sponsorBlockShowTimeWithSkipsRemoved
|
||||
}
|
||||
|
||||
if let sponsorBlockShowCategoriesInTimeline = json["sponsorBlockShowCategoriesInTimeline"].bool {
|
||||
Defaults[.sponsorBlockShowCategoriesInTimeline] = sponsorBlockShowCategoriesInTimeline
|
||||
}
|
||||
|
||||
if let sponsorBlockShowNoticeAfterSkip = json["sponsorBlockShowNoticeAfterSkip"].bool {
|
||||
Defaults[.sponsorBlockShowNoticeAfterSkip] = sponsorBlockShowNoticeAfterSkip
|
||||
}
|
||||
}
|
||||
}
|
@ -45,7 +45,8 @@ final class InstancesManifest: Service, ObservableObject {
|
||||
|
||||
instancesList?.load().onSuccess { response in
|
||||
if let instances: [ManifestedInstance] = response.typedContent() {
|
||||
guard let instance = instances.filter { $0.country == country }.randomElement() else { return }
|
||||
let countryInstances = instances.filter { $0.country == country }
|
||||
guard let instance = countryInstances.randomElement() else { return }
|
||||
let account = instance.anonymousAccount
|
||||
AccountsModel.shared.publicAccount = account
|
||||
if asCurrent {
|
||||
|
@ -3,7 +3,7 @@ import Foundation
|
||||
|
||||
final class MenuModel: ObservableObject {
|
||||
static let shared = MenuModel()
|
||||
private var cancellables = [AnyCancellable]()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init() {
|
||||
registerChildModel(AccountsModel.shared)
|
||||
@ -12,10 +12,16 @@ final class MenuModel: ObservableObject {
|
||||
}
|
||||
|
||||
func registerChildModel<T: ObservableObject>(_ model: T?) {
|
||||
guard !model.isNil else {
|
||||
guard let model else {
|
||||
return
|
||||
}
|
||||
|
||||
cancellables.append(model!.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() })
|
||||
model.objectWillChange
|
||||
.receive(on: DispatchQueue.main) // Ensure the update occurs on the main thread
|
||||
.debounce(for: .milliseconds(10), scheduler: DispatchQueue.main) // Debounce to avoid immediate feedback loops
|
||||
.sink { [weak self] _ in
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
}
|
||||
|
@ -65,6 +65,11 @@ final class NavigationModel: ObservableObject {
|
||||
|
||||
@Published var tabSelection: TabSelection! { didSet {
|
||||
if oldValue == tabSelection { multipleTapHandler() }
|
||||
if tabSelection == nil, let item = recents.presentedItem {
|
||||
Delay.by(0.2) { [weak self] in
|
||||
self?.tabSelection = .recentlyOpened(item.tag)
|
||||
}
|
||||
}
|
||||
}}
|
||||
|
||||
@Published var presentingAddToPlaylist = false
|
||||
@ -102,6 +107,10 @@ final class NavigationModel: ObservableObject {
|
||||
|
||||
@Published var presentingFileImporter = false
|
||||
|
||||
@Published var presentingSettingsImportSheet = false
|
||||
@Published var presentingSettingsFileImporter = false
|
||||
@Published var settingsImportURL: URL?
|
||||
|
||||
func openChannel(_ channel: Channel, navigationStyle: NavigationStyle) {
|
||||
guard channel.id != Video.fixtureChannelID else {
|
||||
return
|
||||
@ -264,6 +273,8 @@ final class NavigationModel: ObservableObject {
|
||||
presentingChannel = false
|
||||
presentingPlaylist = false
|
||||
presentingOpenVideos = false
|
||||
presentingFileImporter = false
|
||||
presentingSettingsImportSheet = false
|
||||
}
|
||||
|
||||
func hideKeyboard() {
|
||||
@ -274,8 +285,9 @@ final class NavigationModel: ObservableObject {
|
||||
|
||||
func presentAlert(title: String, message: String? = nil) {
|
||||
let message = message.isNil ? nil : Text(message!)
|
||||
alert = Alert(title: Text(title), message: message)
|
||||
presentingAlert = true
|
||||
let alert = Alert(title: Text(title), message: message)
|
||||
|
||||
presentAlert(alert)
|
||||
}
|
||||
|
||||
func presentRequestErrorAlert(_ error: RequestError) {
|
||||
@ -284,6 +296,11 @@ final class NavigationModel: ObservableObject {
|
||||
}
|
||||
|
||||
func presentAlert(_ alert: Alert) {
|
||||
guard !presentingSettings else {
|
||||
SettingsModel.shared.presentAlert(alert)
|
||||
return
|
||||
}
|
||||
|
||||
self.alert = alert
|
||||
presentingAlert = true
|
||||
}
|
||||
@ -306,6 +323,16 @@ final class NavigationModel: ObservableObject {
|
||||
print("not implemented")
|
||||
}
|
||||
}
|
||||
|
||||
func presentSettingsImportSheet(_ url: URL, forceSettings: Bool = false) {
|
||||
guard !presentingSettings, !forceSettings else {
|
||||
ImportExportSettingsModel.shared.reset()
|
||||
SettingsModel.shared.presentSettingsImportSheet(url)
|
||||
return
|
||||
}
|
||||
settingsImportURL = url
|
||||
presentingSettingsImportSheet = true
|
||||
}
|
||||
}
|
||||
|
||||
typealias TabSelection = NavigationModel.TabSelection
|
||||
|
@ -26,7 +26,7 @@ final class NetworkStateModel: ObservableObject {
|
||||
}
|
||||
|
||||
var bufferingStateText: String? {
|
||||
guard detailsAvailable else { return nil }
|
||||
guard detailsAvailable && player.hasStarted else { return nil }
|
||||
return String(format: "%.0f%%", bufferingState)
|
||||
}
|
||||
|
||||
|
@ -63,7 +63,7 @@ struct OpenVideosModel {
|
||||
return []
|
||||
}
|
||||
|
||||
func openURLsFromClipboard(removeQueueItems: Bool = false, playbackMode: OpenVideosModel.PlaybackMode = .playNow) {
|
||||
func openURLsFromClipboard(removeQueueItems: Bool = false, playbackMode: Self.PlaybackMode = .playNow) {
|
||||
if urlsFromClipboard.isEmpty {
|
||||
NavigationModel.shared.alert = Alert(title: Text("Could not find any links to open in your clipboard".localized()))
|
||||
if NavigationModel.shared.presentingOpenVideos {
|
||||
@ -76,7 +76,7 @@ struct OpenVideosModel {
|
||||
}
|
||||
}
|
||||
|
||||
func openURLs(_ urls: [URL], removeQueueItems: Bool = false, playbackMode: OpenVideosModel.PlaybackMode = .playNow) {
|
||||
func openURLs(_ urls: [URL], removeQueueItems: Bool = false, playbackMode: Self.PlaybackMode = .playNow) {
|
||||
guard !urls.isEmpty else {
|
||||
return
|
||||
}
|
||||
@ -147,7 +147,7 @@ struct OpenVideosModel {
|
||||
if prepending {
|
||||
videos.reverse()
|
||||
}
|
||||
videos.forEach { video in
|
||||
for video in videos {
|
||||
player.enqueueVideo(video, play: false, prepending: prepending, loadDetails: false)
|
||||
}
|
||||
}
|
||||
|
@ -40,6 +40,11 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
|
||||
var isLoadingVideo = false
|
||||
|
||||
var hasStarted = false
|
||||
var isPaused: Bool {
|
||||
avPlayer.timeControlStatus == .paused
|
||||
}
|
||||
|
||||
var isPlaying: Bool {
|
||||
avPlayer.timeControlStatus == .playing
|
||||
}
|
||||
@ -97,13 +102,13 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
|
||||
private var frequentTimeObserver: Any?
|
||||
private var infrequentTimeObserver: Any?
|
||||
private var playerTimeControlStatusObserver: Any?
|
||||
private var playerTimeControlStatusObserver: NSKeyValueObservation?
|
||||
|
||||
private var statusObservation: NSKeyValueObservation?
|
||||
|
||||
private var timeObserverThrottle = Throttle(interval: 2)
|
||||
|
||||
internal var controlsUpdates = false
|
||||
var controlsUpdates = false
|
||||
|
||||
init() {
|
||||
addFrequentTimeObserver()
|
||||
@ -114,27 +119,37 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
#if os(iOS)
|
||||
controller.player = avPlayer
|
||||
#endif
|
||||
logger.info("AVPlayerBackend initialized.")
|
||||
}
|
||||
|
||||
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream? {
|
||||
let sortedByResolution = streams
|
||||
.filter { ($0.kind == .adaptive || $0.kind == .stream) && $0.resolution <= maxResolution.value }
|
||||
.sorted { $0.resolution > $1.resolution }
|
||||
deinit {
|
||||
// Invalidate any observers to avoid memory leaks
|
||||
statusObservation?.invalidate()
|
||||
playerTimeControlStatusObserver?.invalidate()
|
||||
|
||||
return streams.first { $0.kind == .hls } ??
|
||||
sortedByResolution.first { $0.kind == .stream } ??
|
||||
sortedByResolution.first
|
||||
// Remove any time observers added to AVPlayer
|
||||
if let frequentObserver = frequentTimeObserver {
|
||||
avPlayer.removeTimeObserver(frequentObserver)
|
||||
}
|
||||
if let infrequentObserver = infrequentTimeObserver {
|
||||
avPlayer.removeTimeObserver(infrequentObserver)
|
||||
}
|
||||
|
||||
// Remove notification observers
|
||||
removeItemDidPlayToEndTimeObserver()
|
||||
|
||||
logger.info("AVPlayerBackend deinitialized.")
|
||||
}
|
||||
|
||||
func canPlay(_ stream: Stream) -> Bool {
|
||||
stream.kind == .hls || stream.kind == .stream || (stream.kind == .adaptive && stream.format == .mp4)
|
||||
stream.kind == .hls || stream.kind == .stream
|
||||
}
|
||||
|
||||
func playStream(
|
||||
_ stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool,
|
||||
upgrading _: Bool
|
||||
upgrading: Bool
|
||||
) {
|
||||
isLoadingVideo = true
|
||||
|
||||
@ -145,7 +160,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
_ = url.startAccessingSecurityScopedResource()
|
||||
}
|
||||
|
||||
loadSingleAsset(url, stream: stream, of: video, preservingTime: preservingTime)
|
||||
loadSingleAsset(url, stream: stream, of: video, preservingTime: preservingTime, upgrading: upgrading)
|
||||
} else {
|
||||
model.logger.info("playing stream with many assets:")
|
||||
model.logger.info("composition audio asset: \(stream.audioAsset.url)")
|
||||
@ -160,7 +175,22 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
return
|
||||
}
|
||||
|
||||
// After the video has ended, hitting play restarts the video from the beginning.
|
||||
if currentTime?.seconds.formattedAsPlaybackTime() == model.playerTime.duration.seconds.formattedAsPlaybackTime() &&
|
||||
currentTime!.seconds > 0 && model.playerTime.duration.seconds > 0
|
||||
{
|
||||
seek(to: 0, seekType: .loopRestart)
|
||||
}
|
||||
#if !os(macOS)
|
||||
model.setAudioSessionActive(true)
|
||||
#endif
|
||||
avPlayer.play()
|
||||
|
||||
// Setting hasStarted to true the first time player started
|
||||
if !hasStarted {
|
||||
hasStarted = true
|
||||
}
|
||||
|
||||
model.objectWillChange.send()
|
||||
}
|
||||
|
||||
@ -168,17 +198,27 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
guard avPlayer.timeControlStatus != .paused else {
|
||||
return
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
model.setAudioSessionActive(false)
|
||||
#endif
|
||||
avPlayer.pause()
|
||||
model.objectWillChange.send()
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
isPlaying ? pause() : play()
|
||||
if isPlaying {
|
||||
pause()
|
||||
} else {
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
#if !os(macOS)
|
||||
model.setAudioSessionActive(false)
|
||||
#endif
|
||||
avPlayer.replaceCurrentItem(with: nil)
|
||||
hasStarted = false
|
||||
}
|
||||
|
||||
func cancelLoads() {
|
||||
@ -215,16 +255,20 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
_ url: URL,
|
||||
stream: Stream,
|
||||
of video: Video,
|
||||
preservingTime: Bool = false
|
||||
preservingTime: Bool = false,
|
||||
upgrading: Bool = false
|
||||
) {
|
||||
asset?.cancelLoading()
|
||||
asset = AVURLAsset(url: url)
|
||||
asset = AVURLAsset(
|
||||
url: url,
|
||||
options: ["AVURLAssetHTTPHeaderFieldsKey": ["User-Agent": "\(UserAgentManager.shared.userAgent)"]]
|
||||
)
|
||||
asset?.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in
|
||||
var error: NSError?
|
||||
switch self?.asset?.statusOfValue(forKey: "duration", error: &error) {
|
||||
case .loaded:
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.insertPlayerItem(stream, for: video, preservingTime: preservingTime)
|
||||
self?.insertPlayerItem(stream, for: video, preservingTime: preservingTime, upgrading: upgrading)
|
||||
}
|
||||
case .failed:
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
@ -299,11 +343,17 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
private func insertPlayerItem(
|
||||
_ stream: Stream,
|
||||
for video: Video,
|
||||
preservingTime: Bool = false
|
||||
preservingTime: Bool = false,
|
||||
upgrading: Bool = false
|
||||
) {
|
||||
removeItemDidPlayToEndTimeObserver()
|
||||
|
||||
model.playerItem = playerItem(stream)
|
||||
|
||||
if stream.isHLS {
|
||||
model.playerItem?.preferredPeakBitRate = Double(model.qualityProfile?.resolution.value.bitrate ?? 0)
|
||||
}
|
||||
|
||||
guard model.playerItem != nil else {
|
||||
return
|
||||
}
|
||||
@ -321,7 +371,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
|
||||
let startPlaying = {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
self.model.setAudioSessionActive(true)
|
||||
#endif
|
||||
|
||||
self.setRate(self.model.currentRate)
|
||||
@ -383,7 +433,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
if preservingTime {
|
||||
if model.preservedTime.isNil {
|
||||
if model.preservedTime.isNil || upgrading {
|
||||
model.saveTime {
|
||||
replaceItemAndSeek()
|
||||
startPlaying()
|
||||
@ -414,9 +464,8 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
private func playerItem(_: Stream) -> AVPlayerItem? {
|
||||
if let asset {
|
||||
return AVPlayerItem(asset: asset)
|
||||
} else {
|
||||
return AVPlayerItem(asset: composition)
|
||||
}
|
||||
return AVPlayerItem(asset: composition)
|
||||
}
|
||||
|
||||
private func attachMetadata() {
|
||||
@ -520,6 +569,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .failed:
|
||||
DispatchQueue.main.async {
|
||||
self.model.playerError = item.error
|
||||
@ -593,6 +643,8 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
if self.controlsUpdates {
|
||||
self.updateControls()
|
||||
}
|
||||
|
||||
self.model.updateTime(self.currentTime!)
|
||||
}
|
||||
}
|
||||
|
||||
@ -663,6 +715,10 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
} else {
|
||||
ScreenSaverManager.shared.enable()
|
||||
}
|
||||
#else
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.isIdleTimerDisabled = self.model.presentingPlayer && self.isPlaying
|
||||
}
|
||||
#endif
|
||||
|
||||
self.timeObserverThrottle.execute {
|
||||
@ -750,7 +806,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
opened = true
|
||||
controller.startPictureInPicture()
|
||||
} else {
|
||||
print("PiP not possible, waited \(delay) seconds")
|
||||
self.logger.info("PiP not possible, waited \(delay) seconds")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import AVFAudio
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Libmpv
|
||||
import Logging
|
||||
import MediaPlayer
|
||||
import Repeat
|
||||
@ -9,7 +10,8 @@ import SwiftUI
|
||||
|
||||
final class MPVBackend: PlayerBackend {
|
||||
static var timeUpdateInterval = 0.5
|
||||
static var networkStateUpdateInterval = 1.0
|
||||
static var networkStateUpdateInterval = 0.1
|
||||
static var refreshRateUpdateInterval = 0.5
|
||||
|
||||
private var logger = Logger(label: "mpv-backend")
|
||||
|
||||
@ -21,13 +23,14 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
var stream: Stream?
|
||||
var video: Video?
|
||||
var captions: Captions? { didSet {
|
||||
guard let captions else {
|
||||
client?.removeSubs()
|
||||
return
|
||||
var captions: Captions? {
|
||||
didSet {
|
||||
Task {
|
||||
await handleCaptionsChange()
|
||||
}
|
||||
}
|
||||
addSubTrack(captions.url)
|
||||
}}
|
||||
}
|
||||
|
||||
var currentTime: CMTime?
|
||||
|
||||
var loadedVideo = false
|
||||
@ -43,6 +46,8 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
}}
|
||||
|
||||
var hasStarted = false
|
||||
var isPaused = false
|
||||
var isPlaying = true { didSet {
|
||||
networkStateTimer.start()
|
||||
|
||||
@ -86,10 +91,11 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
private var clientTimer: Repeater!
|
||||
private var networkStateTimer: Repeater!
|
||||
private var refreshRateTimer: Repeater!
|
||||
|
||||
private var onFileLoaded: (() -> Void)?
|
||||
|
||||
internal var controlsUpdates = false
|
||||
var controlsUpdates = false
|
||||
private var timeObserverThrottle = Throttle(interval: 2)
|
||||
|
||||
var suggestedPlaybackRates: [Double] {
|
||||
@ -182,41 +188,29 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
init() {
|
||||
clientTimer = .init(interval: .seconds(Self.timeUpdateInterval), mode: .infinite) { [weak self] _ in
|
||||
self?.getTimeUpdates()
|
||||
guard let self, self.model.activeBackend == .mpv else {
|
||||
return
|
||||
}
|
||||
self.getTimeUpdates()
|
||||
}
|
||||
|
||||
networkStateTimer = .init(interval: .seconds(Self.networkStateUpdateInterval), mode: .infinite) { [weak self] _ in
|
||||
self?.updateNetworkState()
|
||||
guard let self, self.model.activeBackend == .mpv else {
|
||||
return
|
||||
}
|
||||
self.updateNetworkState()
|
||||
}
|
||||
|
||||
refreshRateTimer = .init(interval: .seconds(Self.refreshRateUpdateInterval), mode: .infinite) { [weak self] _ in
|
||||
guard let self, self.model.activeBackend == .mpv else { return }
|
||||
self.checkAndUpdateRefreshRate()
|
||||
}
|
||||
}
|
||||
|
||||
typealias AreInIncreasingOrder = (Stream, Stream) -> Bool
|
||||
|
||||
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream? {
|
||||
streams
|
||||
.filter { $0.kind != .hls && $0.resolution <= maxResolution.value }
|
||||
.max { lhs, rhs in
|
||||
let predicates: [AreInIncreasingOrder] = [
|
||||
{ $0.resolution < $1.resolution },
|
||||
{ $0.format > $1.format }
|
||||
]
|
||||
|
||||
for predicate in predicates {
|
||||
if !predicate(lhs, rhs), !predicate(rhs, lhs) {
|
||||
continue
|
||||
}
|
||||
|
||||
return predicate(lhs, rhs)
|
||||
}
|
||||
|
||||
return false
|
||||
} ??
|
||||
streams.first { $0.kind == .hls } ??
|
||||
streams.first
|
||||
}
|
||||
|
||||
func canPlay(_ stream: Stream) -> Bool {
|
||||
stream.resolution != .unknown && stream.format != .av1
|
||||
stream.format != .av1
|
||||
}
|
||||
|
||||
func playStream(_ stream: Stream, of video: Video, preservingTime: Bool, upgrading: Bool) {
|
||||
@ -229,9 +223,22 @@ final class MPVBackend: PlayerBackend {
|
||||
#endif
|
||||
|
||||
var captions: Captions?
|
||||
if let captionsLanguageCode = Defaults[.captionsLanguageCode] {
|
||||
captions = video.captions.first { $0.code == captionsLanguageCode } ??
|
||||
video.captions.first { $0.code.contains(captionsLanguageCode) }
|
||||
|
||||
if Defaults[.captionsAutoShow] == true {
|
||||
let captionsDefaultLanguageCode = Defaults[.captionsDefaultLanguageCode],
|
||||
captionsFallbackLanguageCode = Defaults[.captionsFallbackLanguageCode]
|
||||
|
||||
// Try to get captions with the default language code first
|
||||
captions = video.captions.first { $0.code == captionsDefaultLanguageCode } ??
|
||||
video.captions.first { $0.code.contains(captionsDefaultLanguageCode) }
|
||||
|
||||
// If there are still no captions, try to get captions with the fallback language code
|
||||
if captions.isNil && !captionsFallbackLanguageCode.isEmpty {
|
||||
captions = video.captions.first { $0.code == captionsFallbackLanguageCode } ??
|
||||
video.captions.first { $0.code.contains(captionsFallbackLanguageCode) }
|
||||
}
|
||||
} else {
|
||||
captions = nil
|
||||
}
|
||||
|
||||
let updateCurrentStream = {
|
||||
@ -245,7 +252,7 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
let startPlaying = {
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(true)
|
||||
self.model.setAudioSessionActive(true)
|
||||
#endif
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
@ -255,6 +262,9 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
self.startClientUpdates()
|
||||
|
||||
if Defaults[.captionsAutoShow] { self.client?.setSubToAuto() } else { self.client?.setSubToNo() }
|
||||
PlayerModel.shared.captions = self.captions
|
||||
|
||||
if !preservingTime,
|
||||
!upgrading,
|
||||
let segment = self.model.sponsorBlock.segments.first,
|
||||
@ -300,7 +310,7 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
}
|
||||
|
||||
client.loadFile(url, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
|
||||
client.loadFile(url, bitrate: stream.bitrate, kind: stream.kind, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
|
||||
self?.isLoadingVideo = true
|
||||
}
|
||||
} else {
|
||||
@ -312,7 +322,7 @@ final class MPVBackend: PlayerBackend {
|
||||
let fileToLoad = self.model.musicMode ? stream.audioAsset.url : stream.videoAsset.url
|
||||
let audioTrack = self.model.musicMode ? nil : stream.audioAsset.url
|
||||
|
||||
client.loadFile(fileToLoad, audio: audioTrack, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
|
||||
client.loadFile(fileToLoad, audio: audioTrack, bitrate: stream.bitrate, kind: stream.kind, sub: captions?.url, time: time, forceSeekable: stream.kind == .hls) { [weak self] _ in
|
||||
self?.isLoadingVideo = true
|
||||
self?.pause()
|
||||
}
|
||||
@ -321,7 +331,7 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
if preservingTime {
|
||||
if model.preservedTime.isNil {
|
||||
if model.preservedTime.isNil || upgrading {
|
||||
model.saveTime {
|
||||
replaceItem(self.model.preservedTime)
|
||||
}
|
||||
@ -335,9 +345,20 @@ final class MPVBackend: PlayerBackend {
|
||||
startClientUpdates()
|
||||
}
|
||||
|
||||
func startRefreshRateUpdates() {
|
||||
refreshRateTimer.start()
|
||||
}
|
||||
|
||||
func stopRefreshRateUpdates() {
|
||||
refreshRateTimer.pause()
|
||||
}
|
||||
|
||||
func play() {
|
||||
isPlaying = true
|
||||
#if !os(macOS)
|
||||
model.setAudioSessionActive(true)
|
||||
#endif
|
||||
startClientUpdates()
|
||||
startRefreshRateUpdates()
|
||||
|
||||
if controls.presentingControls {
|
||||
startControlsUpdates()
|
||||
@ -345,18 +366,42 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
setRate(model.currentRate)
|
||||
|
||||
// After the video has ended, hitting play restarts the video from the beginning.
|
||||
if let currentTime, currentTime.seconds.formattedAsPlaybackTime() == model.playerTime.duration.seconds.formattedAsPlaybackTime() &&
|
||||
currentTime.seconds > 0 && model.playerTime.duration.seconds > 0
|
||||
{
|
||||
seek(to: 0, seekType: .loopRestart)
|
||||
}
|
||||
|
||||
client?.play()
|
||||
|
||||
isPlaying = true
|
||||
isPaused = false
|
||||
|
||||
// Setting hasStarted to true the first time player started
|
||||
if !hasStarted {
|
||||
hasStarted = true
|
||||
}
|
||||
}
|
||||
|
||||
func pause() {
|
||||
isPlaying = false
|
||||
#if !os(macOS)
|
||||
model.setAudioSessionActive(false)
|
||||
#endif
|
||||
stopClientUpdates()
|
||||
stopRefreshRateUpdates()
|
||||
|
||||
client?.pause()
|
||||
isPaused = true
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
isPlaying ? pause() : play()
|
||||
if isPlaying {
|
||||
pause()
|
||||
} else {
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
func cancelLoads() {
|
||||
@ -364,7 +409,15 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
func stop() {
|
||||
#if !os(macOS)
|
||||
model.setAudioSessionActive(false)
|
||||
#endif
|
||||
stopClientUpdates()
|
||||
stopRefreshRateUpdates()
|
||||
client?.stop()
|
||||
isPlaying = false
|
||||
isPaused = false
|
||||
hasStarted = false
|
||||
}
|
||||
|
||||
func seek(to time: CMTime, seekType _: SeekType, completionHandler: ((Bool) -> Void)?) {
|
||||
@ -380,8 +433,8 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
func closeItem() {
|
||||
client?.pause()
|
||||
client?.stop()
|
||||
pause()
|
||||
stop()
|
||||
self.video = nil
|
||||
self.stream = nil
|
||||
}
|
||||
@ -427,6 +480,8 @@ final class MPVBackend: PlayerBackend {
|
||||
timeObserverThrottle.execute {
|
||||
self.model.updateWatch(time: self.currentTime)
|
||||
}
|
||||
|
||||
self.model.updateTime(self.currentTime!)
|
||||
}
|
||||
|
||||
private func stopClientUpdates() {
|
||||
@ -440,6 +495,52 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
}
|
||||
|
||||
private func checkAndUpdateRefreshRate() {
|
||||
guard let screenRefreshRate = client?.getScreenRefreshRate() else {
|
||||
logger.warning("Failed to get screen refresh rate.")
|
||||
return
|
||||
}
|
||||
|
||||
let contentFps = client?.currentContainerFps ?? screenRefreshRate
|
||||
|
||||
guard Defaults[.mpvSetRefreshToContentFPS] else {
|
||||
// If the current refresh rate doesn't match the screen refresh rate, reset it
|
||||
if client?.currentRefreshRate != screenRefreshRate {
|
||||
client?.updateRefreshRate(to: screenRefreshRate)
|
||||
client?.currentRefreshRate = screenRefreshRate
|
||||
#if !os(macOS)
|
||||
notifyViewToUpdateDisplayLink(with: screenRefreshRate)
|
||||
#endif
|
||||
logger.info("Reset refresh rate to screen's rate: \(screenRefreshRate) Hz")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Adjust the refresh rate to match the content if it differs
|
||||
if screenRefreshRate != contentFps {
|
||||
client?.updateRefreshRate(to: contentFps)
|
||||
client?.currentRefreshRate = contentFps
|
||||
#if !os(macOS)
|
||||
notifyViewToUpdateDisplayLink(with: contentFps)
|
||||
#endif
|
||||
logger.info("Adjusted screen refresh rate to match content: \(contentFps) Hz")
|
||||
} else if client?.currentRefreshRate != screenRefreshRate {
|
||||
// Ensure the refresh rate is set back to the screen's rate if no adjustment is needed
|
||||
client?.updateRefreshRate(to: screenRefreshRate)
|
||||
client?.currentRefreshRate = screenRefreshRate
|
||||
#if !os(macOS)
|
||||
notifyViewToUpdateDisplayLink(with: screenRefreshRate)
|
||||
#endif
|
||||
logger.info("Checked and reset refresh rate to screen's rate: \(screenRefreshRate) Hz")
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
private func notifyViewToUpdateDisplayLink(with refreshRate: Int) {
|
||||
NotificationCenter.default.post(name: .updateDisplayLinkFrameRate, object: nil, userInfo: ["refreshRate": refreshRate])
|
||||
}
|
||||
#endif
|
||||
|
||||
func handle(_ event: UnsafePointer<mpv_event>!) {
|
||||
logger.info(.init(stringLiteral: "RECEIVED event: \(String(cString: mpv_event_name(event.pointee.event_id)))"))
|
||||
|
||||
@ -459,6 +560,13 @@ final class MPVBackend: PlayerBackend {
|
||||
startClientUpdates()
|
||||
onFileLoaded = nil
|
||||
|
||||
case MPV_EVENT_PROPERTY_CHANGE:
|
||||
let dataOpaquePtr = OpaquePointer(event.pointee.data)
|
||||
if let property = UnsafePointer<mpv_event_property>(dataOpaquePtr)?.pointee {
|
||||
let propertyName = String(cString: property.name)
|
||||
handlePropertyChange(propertyName, property)
|
||||
}
|
||||
|
||||
case MPV_EVENT_PLAYBACK_RESTART:
|
||||
isLoadingVideo = false
|
||||
isSeeking = false
|
||||
@ -467,17 +575,6 @@ final class MPVBackend: PlayerBackend {
|
||||
startClientUpdates()
|
||||
onFileLoaded = nil
|
||||
|
||||
case MPV_EVENT_PAUSE:
|
||||
DispatchQueue.main.async { [weak self] in self?.handleEndOfFile() }
|
||||
isPlaying = false
|
||||
networkStateTimer.start()
|
||||
|
||||
case MPV_EVENT_UNPAUSE:
|
||||
isPlaying = true
|
||||
isLoadingVideo = false
|
||||
isSeeking = false
|
||||
networkStateTimer.start()
|
||||
|
||||
case MPV_EVENT_VIDEO_RECONFIG:
|
||||
model.updateAspectRatio()
|
||||
|
||||
@ -508,8 +605,6 @@ final class MPVBackend: PlayerBackend {
|
||||
guard client.eofReached else {
|
||||
return
|
||||
}
|
||||
|
||||
getTimeUpdates()
|
||||
eofPlaybackModeAction()
|
||||
}
|
||||
|
||||
@ -526,8 +621,14 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
func addSubTrack(_ url: URL) {
|
||||
client?.removeSubs()
|
||||
client?.addSubTrack(url)
|
||||
Task {
|
||||
if let areSubtitlesAdded = client?.areSubtitlesAdded {
|
||||
if await areSubtitlesAdded() {
|
||||
await client?.removeSubs()
|
||||
}
|
||||
}
|
||||
await client?.addSubTrack(url)
|
||||
}
|
||||
}
|
||||
|
||||
func setVideoToAuto() {
|
||||
@ -590,4 +691,41 @@ final class MPVBackend: PlayerBackend {
|
||||
stopMusicMode()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCaptionsChange() async {
|
||||
guard let captions else {
|
||||
if let isSubtitlesAdded = client?.areSubtitlesAdded, await isSubtitlesAdded() {
|
||||
await client?.removeSubs()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
addSubTrack(captions.url)
|
||||
}
|
||||
|
||||
private func handlePropertyChange(_ name: String, _ property: mpv_event_property) {
|
||||
switch name {
|
||||
case "pause":
|
||||
if let paused = UnsafePointer<Bool>(OpaquePointer(property.data))?.pointee {
|
||||
if paused {
|
||||
DispatchQueue.main.async { [weak self] in self?.handleEndOfFile() }
|
||||
} else {
|
||||
isLoadingVideo = false
|
||||
isSeeking = false
|
||||
}
|
||||
isPlaying = !paused
|
||||
networkStateTimer.start()
|
||||
}
|
||||
case "core-idle":
|
||||
if let idle = UnsafePointer<Bool>(OpaquePointer(property.data))?.pointee {
|
||||
if !idle {
|
||||
isLoadingVideo = false
|
||||
isSeeking = false
|
||||
networkStateTimer.start()
|
||||
}
|
||||
}
|
||||
default:
|
||||
logger.info("MPV backend received unhandled property: \(name)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,13 @@
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Libmpv
|
||||
import Logging
|
||||
#if !os(macOS)
|
||||
import Siesta
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
final class MPVClient: ObservableObject {
|
||||
@ -13,6 +16,8 @@ final class MPVClient: ObservableObject {
|
||||
}
|
||||
|
||||
private var logger = Logger(label: "mpv-client")
|
||||
private var needsDrawingCooldown = false
|
||||
private var needsDrawingWorkItem: DispatchWorkItem?
|
||||
|
||||
var mpv: OpaquePointer!
|
||||
var mpvGL: OpaquePointer!
|
||||
@ -26,6 +31,7 @@ final class MPVClient: ObservableObject {
|
||||
var backend: MPVBackend!
|
||||
|
||||
var seeking = false
|
||||
var currentRefreshRate = 60
|
||||
|
||||
func create(frame: CGRect? = nil) {
|
||||
#if !os(macOS)
|
||||
@ -36,7 +42,7 @@ final class MPVClient: ObservableObject {
|
||||
|
||||
mpv = mpv_create()
|
||||
if mpv == nil {
|
||||
print("failed creating context\n")
|
||||
logger.critical("failed creating context\n")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
@ -59,24 +65,81 @@ final class MPVClient: ObservableObject {
|
||||
checkError(mpv_set_option_string(mpv, "input-media-keys", "yes"))
|
||||
#endif
|
||||
|
||||
checkError(mpv_set_option_string(mpv, "cache-pause-initial", "yes"))
|
||||
// CACHING //
|
||||
|
||||
checkError(mpv_set_option_string(mpv, "cache-pause-initial", Defaults[.mpvCachePauseInital] ? "yes" : "no"))
|
||||
checkError(mpv_set_option_string(mpv, "cache-secs", Defaults[.mpvCacheSecs]))
|
||||
checkError(mpv_set_option_string(mpv, "cache-pause-wait", Defaults[.mpvCachePauseWait]))
|
||||
|
||||
// PLAYBACK //
|
||||
checkError(mpv_set_option_string(mpv, "keep-open", "yes"))
|
||||
checkError(mpv_set_option_string(mpv, "hwdec", machine == "x86_64" ? "no" : "auto-safe"))
|
||||
checkError(mpv_set_option_string(mpv, "deinterlace", Defaults[.mpvDeinterlace] ? "yes" : "no"))
|
||||
checkError(mpv_set_option_string(mpv, "sub-scale", Defaults[.captionsFontScaleSize]))
|
||||
checkError(mpv_set_option_string(mpv, "sub-color", Defaults[.captionsFontColor]))
|
||||
checkError(mpv_set_option_string(mpv, "user-agent", UserAgentManager.shared.userAgent))
|
||||
checkError(mpv_set_option_string(mpv, "initial-audio-sync", Defaults[.mpvInitialAudioSync] ? "yes" : "no"))
|
||||
|
||||
// Enable VSYNC – needed for `video-sync`
|
||||
if Defaults[.mpvSetRefreshToContentFPS] {
|
||||
checkError(mpv_set_option_string(mpv, "opengl-swapinterval", "1"))
|
||||
checkError(mpv_set_option_string(mpv, "video-sync", "display-resample"))
|
||||
checkError(mpv_set_option_string(mpv, "interpolation", "yes"))
|
||||
checkError(mpv_set_option_string(mpv, "tscale", "mitchell"))
|
||||
checkError(mpv_set_option_string(mpv, "tscale-window", "blackman"))
|
||||
checkError(mpv_set_option_string(mpv, "vd-lavc-framedrop", "nonref"))
|
||||
checkError(mpv_set_option_string(mpv, "display-fps-override", "\(String(getScreenRefreshRate()))"))
|
||||
}
|
||||
|
||||
// CPU //
|
||||
|
||||
// Determine number of threads based on system core count
|
||||
let numberOfCores = ProcessInfo.processInfo.processorCount
|
||||
let threads = numberOfCores * 2
|
||||
|
||||
// Log the number of cores and threads
|
||||
logger.info("Number of CPU cores: \(numberOfCores)")
|
||||
|
||||
// Set the number of threads dynamically
|
||||
checkError(mpv_set_option_string(mpv, "vd-lavc-threads", "\(threads)"))
|
||||
|
||||
// GPU //
|
||||
|
||||
checkError(mpv_set_option_string(mpv, "hwdec", Defaults[.mpvHWdec]))
|
||||
checkError(mpv_set_option_string(mpv, "vo", "libmpv"))
|
||||
|
||||
// We set set everything to OpenGL so MPV doesn't have to probe for other APIs.
|
||||
checkError(mpv_set_option_string(mpv, "gpu-api", "opengl"))
|
||||
|
||||
#if !os(macOS)
|
||||
checkError(mpv_set_option_string(mpv, "opengl-es", "yes"))
|
||||
#endif
|
||||
|
||||
// We set this to ordered since we use OpenGL and Apple's implementation is ancient.
|
||||
checkError(mpv_set_option_string(mpv, "dither", "ordered"))
|
||||
|
||||
// DEMUXER //
|
||||
|
||||
// We request to test for lavf first and skip probing other demuxer.
|
||||
checkError(mpv_set_option_string(mpv, "demuxer", "lavf"))
|
||||
checkError(mpv_set_option_string(mpv, "audio-demuxer", "lavf"))
|
||||
checkError(mpv_set_option_string(mpv, "sub-demuxer", "lavf"))
|
||||
checkError(mpv_set_option_string(mpv, "demuxer-lavf-analyzeduration", "1"))
|
||||
checkError(mpv_set_option_string(mpv, "demuxer-lavf-probe-info", Defaults[.mpvDemuxerLavfProbeInfo]))
|
||||
|
||||
// Disable ytdl, since it causes crashes on macOS.
|
||||
#if os(macOS)
|
||||
checkError(mpv_set_option_string(mpv, "ytdl", "no"))
|
||||
#endif
|
||||
|
||||
checkError(mpv_initialize(mpv))
|
||||
|
||||
let api = UnsafeMutableRawPointer(mutating: (MPV_RENDER_API_TYPE_OPENGL as NSString).utf8String)
|
||||
var initParams = mpv_opengl_init_params(
|
||||
get_proc_address: getProcAddress,
|
||||
get_proc_address_ctx: nil,
|
||||
extra_exts: nil
|
||||
get_proc_address_ctx: nil
|
||||
)
|
||||
|
||||
queue = DispatchQueue(label: "mpv")
|
||||
queue = DispatchQueue(label: "mpv", qos: .userInteractive, attributes: [.concurrent])
|
||||
|
||||
withUnsafeMutablePointer(to: &initParams) { initParams in
|
||||
var params = [
|
||||
@ -86,7 +149,7 @@ final class MPVClient: ObservableObject {
|
||||
]
|
||||
|
||||
if mpv_render_context_create(&mpvGL, mpv, ¶ms) < 0 {
|
||||
puts("failed to initialize mpv GL context")
|
||||
logger.critical("failed to initialize mpv GL context")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
@ -107,9 +170,9 @@ final class MPVClient: ObservableObject {
|
||||
#endif
|
||||
}
|
||||
|
||||
queue!.async {
|
||||
mpv_set_wakeup_callback(self.mpv, wakeUp, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()))
|
||||
}
|
||||
mpv_set_wakeup_callback(mpv, wakeUp, UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()))
|
||||
mpv_observe_property(mpv, 0, "pause", MPV_FORMAT_FLAG)
|
||||
mpv_observe_property(mpv, 0, "core-idle", MPV_FORMAT_FLAG)
|
||||
}
|
||||
|
||||
func readEvents() {
|
||||
@ -127,6 +190,8 @@ final class MPVClient: ObservableObject {
|
||||
func loadFile(
|
||||
_ url: URL,
|
||||
audio: URL? = nil,
|
||||
bitrate: Int? = nil,
|
||||
kind: Stream.Kind,
|
||||
sub: URL? = nil,
|
||||
time: CMTime? = nil,
|
||||
forceSeekable: Bool = false,
|
||||
@ -137,6 +202,10 @@ final class MPVClient: ObservableObject {
|
||||
|
||||
args.append("replace")
|
||||
|
||||
// needed since mpvkit 0.38.0
|
||||
// https://github.com/mpv-player/mpv/issues/13806#issuecomment-2029818905
|
||||
args.append("-1")
|
||||
|
||||
if let time, time.seconds > 0 {
|
||||
options.append("start=\(Int(time.seconds))")
|
||||
}
|
||||
@ -159,6 +228,10 @@ final class MPVClient: ObservableObject {
|
||||
args.append(options.joined(separator: ","))
|
||||
}
|
||||
|
||||
if kind == .hls, bitrate != 0 {
|
||||
checkError(mpv_set_option_string(mpv, "hls-bitrate", String(describing: bitrate)))
|
||||
}
|
||||
|
||||
command("loadfile", args: args, returnValueCallback: completionHandler)
|
||||
}
|
||||
|
||||
@ -272,6 +345,31 @@ final class MPVClient: ObservableObject {
|
||||
mpv.isNil ? false : getFlag("eof-reached")
|
||||
}
|
||||
|
||||
var currentContainerFps: Int {
|
||||
guard !mpv.isNil else { return 30 }
|
||||
let fps = getDouble("container-fps")
|
||||
return Int(fps.rounded())
|
||||
}
|
||||
|
||||
func areSubtitlesAdded() async -> Bool {
|
||||
guard !mpv.isNil else { return false }
|
||||
|
||||
let trackCount = await Task(operation: { getInt("track-list/count") }).value
|
||||
guard trackCount > 0 else { return false }
|
||||
|
||||
for index in 0 ..< trackCount {
|
||||
if let trackType = await Task(operation: { getString("track-list/\(index)/type") }).value, trackType == "sub" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func logCurrentFps() {
|
||||
let fps = currentContainerFps
|
||||
logger.info("Current container FPS: \(fps)")
|
||||
}
|
||||
|
||||
func seek(relative time: CMTime, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
guard !seeking else {
|
||||
logger.warning("ignoring seek, another in progress")
|
||||
@ -315,7 +413,7 @@ final class MPVClient: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
DispatchQueue.main.async(qos: .userInteractive) { [weak self] in
|
||||
guard let self else { return }
|
||||
let model = self.backend.model
|
||||
let aspectRatio = self.aspectRatio > 0 && self.aspectRatio < VideoPlayerView.defaultAspectRatio ? self.aspectRatio : VideoPlayerView.defaultAspectRatio
|
||||
@ -343,10 +441,30 @@ final class MPVClient: ObservableObject {
|
||||
}
|
||||
|
||||
func setNeedsDrawing(_ needsDrawing: Bool) {
|
||||
// Check if we are currently in a cooldown period
|
||||
guard !needsDrawingCooldown else {
|
||||
logger.info("Not drawing, cooldown in progress")
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("needs drawing: \(needsDrawing)")
|
||||
|
||||
// Set the cooldown flag to true and cancel any existing work item
|
||||
needsDrawingCooldown = true
|
||||
needsDrawingWorkItem?.cancel()
|
||||
|
||||
#if !os(macOS)
|
||||
glView?.needsDrawing = needsDrawing
|
||||
#endif
|
||||
|
||||
// Create a new DispatchWorkItem to reset the cooldown flag after 0.1 seconds
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
self?.needsDrawingCooldown = false
|
||||
}
|
||||
needsDrawingWorkItem = workItem
|
||||
|
||||
// Schedule the cooldown reset after 0.1 seconds
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: workItem)
|
||||
}
|
||||
|
||||
func command(
|
||||
@ -374,16 +492,59 @@ final class MPVClient: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func updateRefreshRate(to refreshRate: Int) {
|
||||
setString("display-fps-override", "\(String(refreshRate))")
|
||||
logger.info("Updated refresh rate during playback to: \(refreshRate) Hz")
|
||||
}
|
||||
|
||||
// Retrieve the screen's current refresh rate dynamically.
|
||||
func getScreenRefreshRate() -> Int {
|
||||
var refreshRate = 60 // Default to 60 Hz in case of failure
|
||||
|
||||
#if os(macOS)
|
||||
// macOS implementation using NSScreen
|
||||
if let screen = NSScreen.main,
|
||||
let displayID = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID,
|
||||
let mode = CGDisplayCopyDisplayMode(displayID),
|
||||
mode.refreshRate > 0
|
||||
{
|
||||
refreshRate = Int(mode.refreshRate)
|
||||
logger.info("Screen refresh rate: \(refreshRate) Hz")
|
||||
} else {
|
||||
logger.warning("Failed to get refresh rate from NSScreen.")
|
||||
}
|
||||
#else
|
||||
// iOS implementation using UIScreen with a failover
|
||||
let mainScreen = UIScreen.main
|
||||
refreshRate = mainScreen.maximumFramesPerSecond
|
||||
|
||||
// Failover: if maximumFramesPerSecond is 0 or an unexpected value
|
||||
if refreshRate <= 0 {
|
||||
refreshRate = 60 // Fallback to 60 Hz
|
||||
logger.warning("Failed to get refresh rate from UIScreen, falling back to 60 Hz.")
|
||||
} else {
|
||||
logger.info("Screen refresh rate: \(refreshRate) Hz")
|
||||
}
|
||||
#endif
|
||||
|
||||
currentRefreshRate = refreshRate
|
||||
return refreshRate
|
||||
}
|
||||
|
||||
func addVideoTrack(_ url: URL) {
|
||||
command("video-add", args: [url.absoluteString])
|
||||
}
|
||||
|
||||
func addSubTrack(_ url: URL) {
|
||||
command("sub-add", args: [url.absoluteString])
|
||||
func addSubTrack(_ url: URL) async {
|
||||
await Task {
|
||||
command("sub-add", args: [url.absoluteString])
|
||||
}.value
|
||||
}
|
||||
|
||||
func removeSubs() {
|
||||
command("sub-remove")
|
||||
func removeSubs() async {
|
||||
await Task {
|
||||
command("sub-remove")
|
||||
}.value
|
||||
}
|
||||
|
||||
func setVideoToAuto() {
|
||||
@ -394,6 +555,22 @@ final class MPVClient: ObservableObject {
|
||||
setString("video", "no")
|
||||
}
|
||||
|
||||
func setSubToAuto() {
|
||||
setString("sub", "auto")
|
||||
}
|
||||
|
||||
func setSubToNo() {
|
||||
setString("sub", "no")
|
||||
}
|
||||
|
||||
func setSubFontSize(scaleSize: String) {
|
||||
setString("sub-scale", scaleSize)
|
||||
}
|
||||
|
||||
func setSubFontColor(color: String) {
|
||||
setString("sub-color", color)
|
||||
}
|
||||
|
||||
var tracksCount: Int {
|
||||
Int(getString("track-list/count") ?? "-1") ?? -1
|
||||
}
|
||||
@ -431,6 +608,7 @@ final class MPVClient: ObservableObject {
|
||||
}
|
||||
|
||||
func getString(_ name: String) -> String? {
|
||||
guard mpv != nil else { return nil }
|
||||
let cstr = mpv_get_property_string(mpv, name)
|
||||
let str: String? = cstr == nil ? nil : String(cString: cstr!)
|
||||
mpv_free(cstr)
|
||||
@ -471,9 +649,8 @@ final class MPVClient: ObservableObject {
|
||||
let data = Data(bufPtr)
|
||||
if let lastIndex = data.lastIndex(where: { $0 != 0 }) {
|
||||
return String(data: data[0 ... lastIndex], encoding: .isoLatin1)!
|
||||
} else {
|
||||
return String(data: data, encoding: .isoLatin1)!
|
||||
}
|
||||
return String(data: data, encoding: .isoLatin1)!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import CoreMedia
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Logging
|
||||
#if !os(macOS)
|
||||
import UIKit
|
||||
#endif
|
||||
@ -19,6 +20,8 @@ protocol PlayerBackend {
|
||||
var loadedVideo: Bool { get }
|
||||
var isLoadingVideo: Bool { get }
|
||||
|
||||
var hasStarted: Bool { get }
|
||||
var isPaused: Bool { get }
|
||||
var isPlaying: Bool { get }
|
||||
var isSeeking: Bool { get }
|
||||
var playerItemDuration: CMTime? { get }
|
||||
@ -29,7 +32,6 @@ protocol PlayerBackend {
|
||||
var videoWidth: Double? { get }
|
||||
var videoHeight: Double? { get }
|
||||
|
||||
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting) -> Stream?
|
||||
func canPlay(_ stream: Stream) -> Bool
|
||||
func canPlayAtRate(_ rate: Double) -> Bool
|
||||
|
||||
@ -74,6 +76,10 @@ protocol PlayerBackend {
|
||||
}
|
||||
|
||||
extension PlayerBackend {
|
||||
var logger: Logger {
|
||||
return Logger(label: "stream.yattee.player.backend")
|
||||
}
|
||||
|
||||
func seek(to time: CMTime, seekType: SeekType, completionHandler: ((Bool) -> Void)? = nil) {
|
||||
model.seek.registerSeek(at: time, type: seekType, restore: currentTime)
|
||||
seek(to: time, seekType: seekType, completionHandler: completionHandler)
|
||||
@ -110,15 +116,22 @@ extension PlayerBackend {
|
||||
model.prepareCurrentItemForHistory(finished: true)
|
||||
|
||||
if model.queue.isEmpty {
|
||||
if Defaults[.closeVideoOnEOF] {
|
||||
#if os(tvOS)
|
||||
#if os(tvOS)
|
||||
if Defaults[.closeVideoOnEOF] {
|
||||
if model.activeBackend == .appleAVPlayer {
|
||||
model.avPlayerBackend.controller?.dismiss(animated: false)
|
||||
}
|
||||
#endif
|
||||
model.resetQueue()
|
||||
model.hide()
|
||||
}
|
||||
model.resetQueue()
|
||||
model.hide()
|
||||
}
|
||||
#else
|
||||
if Defaults[.closeVideoOnEOF] {
|
||||
model.resetQueue()
|
||||
model.hide()
|
||||
} else if Defaults[.exitFullscreenOnEOF], model.playingFullScreen {
|
||||
model.exitFullScreen()
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
model.advanceToNextItem()
|
||||
}
|
||||
@ -131,11 +144,91 @@ extension PlayerBackend {
|
||||
}
|
||||
}
|
||||
|
||||
func bestPlayable(_ streams: [Stream], maxResolution: ResolutionSetting, formatOrder: [QualityProfile.Format]) -> Stream? {
|
||||
logger.info("Starting bestPlayable function")
|
||||
logger.info("Total streams received: \(streams.count)")
|
||||
logger.info("Max resolution allowed: \(String(describing: maxResolution.value))")
|
||||
logger.info("Format order: \(formatOrder)")
|
||||
|
||||
// Filter out non-HLS streams and streams with resolution more than maxResolution
|
||||
let nonHLSStreams = streams.filter {
|
||||
let isHLS = $0.kind == .hls
|
||||
// Check if the stream's resolution is within the maximum allowed resolution
|
||||
let isWithinResolution = $0.resolution.map { $0 <= maxResolution.value } ?? false
|
||||
|
||||
logger.info("Stream ID: \($0.id) - Kind: \(String(describing: $0.kind)) - Resolution: \(String(describing: $0.resolution)) - Bitrate: \($0.bitrate ?? 0)")
|
||||
logger.info("Is HLS: \(isHLS), Is within resolution: \(isWithinResolution)")
|
||||
return !isHLS && isWithinResolution
|
||||
}
|
||||
logger.info("Non-HLS streams after filtering: \(nonHLSStreams.count)")
|
||||
|
||||
// Find max resolution and bitrate from non-HLS streams
|
||||
let bestResolutionStream = nonHLSStreams.max { $0.resolution < $1.resolution }
|
||||
let bestBitrateStream = nonHLSStreams.max { $0.bitrate ?? 0 < $1.bitrate ?? 0 }
|
||||
|
||||
logger.info("Best resolution stream: \(String(describing: bestResolutionStream?.id)) with resolution: \(String(describing: bestResolutionStream?.resolution))")
|
||||
logger.info("Best bitrate stream: \(String(describing: bestBitrateStream?.id)) with bitrate: \(String(describing: bestBitrateStream?.bitrate))")
|
||||
|
||||
let bestResolution = bestResolutionStream?.resolution ?? maxResolution.value
|
||||
let bestBitrate = bestBitrateStream?.bitrate ?? bestResolutionStream?.resolution.bitrate ?? maxResolution.value.bitrate
|
||||
|
||||
logger.info("Final best resolution selected: \(String(describing: bestResolution))")
|
||||
logger.info("Final best bitrate selected: \(bestBitrate)")
|
||||
|
||||
let adjustedStreams = streams.map { stream in
|
||||
if stream.kind == .hls {
|
||||
logger.info("Adjusting HLS stream ID: \(stream.id)")
|
||||
stream.resolution = bestResolution
|
||||
stream.bitrate = bestBitrate
|
||||
stream.format = .hls
|
||||
} else if stream.kind == .stream {
|
||||
logger.info("Adjusting non-HLS stream ID: \(stream.id)")
|
||||
stream.format = .stream
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
let filteredStreams = adjustedStreams.filter { stream in
|
||||
// Check if the stream's resolution is within the maximum allowed resolution
|
||||
let isWithinResolution = stream.resolution <= maxResolution.value
|
||||
logger.info("Filtered stream ID: \(stream.id) - Is within max resolution: \(isWithinResolution)")
|
||||
return isWithinResolution
|
||||
}
|
||||
|
||||
logger.info("Filtered streams count after adjustments: \(filteredStreams.count)")
|
||||
|
||||
let bestStream = filteredStreams.max { lhs, rhs in
|
||||
if lhs.resolution == rhs.resolution {
|
||||
guard let lhsFormat = QualityProfile.Format(rawValue: lhs.format.rawValue),
|
||||
let rhsFormat = QualityProfile.Format(rawValue: rhs.format.rawValue)
|
||||
else {
|
||||
logger.info("Failed to extract lhsFormat or rhsFormat for streams \(lhs.id) and \(rhs.id)")
|
||||
return false
|
||||
}
|
||||
|
||||
let lhsFormatIndex = formatOrder.firstIndex(of: lhsFormat) ?? Int.max
|
||||
let rhsFormatIndex = formatOrder.firstIndex(of: rhsFormat) ?? Int.max
|
||||
|
||||
logger.info("Comparing formats for streams \(lhs.id) and \(rhs.id) - LHS Format Index: \(lhsFormatIndex), RHS Format Index: \(rhsFormatIndex)")
|
||||
|
||||
return lhsFormatIndex > rhsFormatIndex
|
||||
}
|
||||
|
||||
logger.info("Comparing resolutions for streams \(lhs.id) and \(rhs.id) - LHS Resolution: \(String(describing: lhs.resolution)), RHS Resolution: \(String(describing: rhs.resolution))")
|
||||
|
||||
return lhs.resolution < rhs.resolution
|
||||
}
|
||||
|
||||
logger.info("Best stream selected: \(String(describing: bestStream?.id)) with resolution: \(String(describing: bestStream?.resolution)) and format: \(String(describing: bestStream?.format))")
|
||||
|
||||
return bestStream
|
||||
}
|
||||
|
||||
func updateControls(completionHandler: (() -> Void)? = nil) {
|
||||
print("updating controls")
|
||||
logger.info("updating controls")
|
||||
|
||||
guard model.presentingPlayer, !model.controls.presentingOverlays else {
|
||||
print("ignored controls update")
|
||||
logger.info("ignored controls update")
|
||||
completionHandler?()
|
||||
return
|
||||
}
|
||||
@ -143,7 +236,7 @@ extension PlayerBackend {
|
||||
DispatchQueue.main.async(qos: .userInteractive) {
|
||||
#if !os(macOS)
|
||||
guard UIApplication.shared.applicationState != .background else {
|
||||
print("not performing controls updates in background")
|
||||
logger.info("not performing controls updates in background")
|
||||
completionHandler?()
|
||||
return
|
||||
}
|
||||
|
@ -14,7 +14,7 @@ final class PlayerControlsModel: ObservableObject {
|
||||
var timer: Timer?
|
||||
|
||||
#if os(tvOS)
|
||||
private(set) var reporter = PassthroughSubject<String, Never>()
|
||||
private(set) var reporter = PassthroughSubject<String, Never>() // swiftlint:disable:this private_subject
|
||||
#endif
|
||||
|
||||
var player: PlayerModel! { .shared }
|
||||
@ -106,7 +106,11 @@ final class PlayerControlsModel: ObservableObject {
|
||||
}
|
||||
|
||||
func toggle() {
|
||||
presentingControls ? hide() : show()
|
||||
if presentingControls {
|
||||
hide()
|
||||
} else {
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
func resetTimer() {
|
||||
|
@ -47,7 +47,7 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
static var shared = PlayerModel()
|
||||
|
||||
let logger = Logger(label: "stream.yattee.app")
|
||||
let logger = Logger(label: "stream.yattee.player.model")
|
||||
|
||||
var playerItem: AVPlayerItem?
|
||||
|
||||
@ -76,6 +76,8 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
var previousActiveBackend: PlayerBackendType?
|
||||
|
||||
lazy var playerBackendView = PlayerBackendView()
|
||||
|
||||
@Published var playerSize: CGSize = .zero { didSet {
|
||||
@ -88,7 +90,7 @@ final class PlayerModel: ObservableObject {
|
||||
}}
|
||||
@Published var aspectRatio = VideoPlayerView.defaultAspectRatio
|
||||
@Published var stream: Stream?
|
||||
@Published var currentRate: Double = 1.0 { didSet { handleCurrentRateChange() } }
|
||||
@Published var currentRate = 1.0 { didSet { handleCurrentRateChange() } }
|
||||
|
||||
@Published var qualityProfileSelection: QualityProfile? { didSet { handleQualityProfileChange() } }
|
||||
|
||||
@ -128,9 +130,19 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
#if os(iOS)
|
||||
@Published var lockedOrientation: UIInterfaceOrientationMask?
|
||||
@Default(.rotateToLandscapeOnEnterFullScreen) private var rotateToLandscapeOnEnterFullScreen
|
||||
@Published var isOrientationLocked: Bool {
|
||||
didSet {
|
||||
Defaults[.isOrientationLocked] = isOrientationLocked
|
||||
}
|
||||
}
|
||||
|
||||
@Default(.rotateToLandscapeOnEnterFullScreen) var rotateToLandscapeOnEnterFullScreen
|
||||
@Default(.lockPortraitWhenBrowsing) var lockPortraitWhenBrowsing
|
||||
var fullscreenInitiatedByButton = false
|
||||
#endif
|
||||
|
||||
@Published var currentChapterIndex: Int?
|
||||
|
||||
var accounts: AccountsModel { .shared }
|
||||
var comments: CommentsModel { .shared }
|
||||
var controls: PlayerControlsModel { .shared }
|
||||
@ -175,6 +187,11 @@ final class PlayerModel: ObservableObject {
|
||||
@Default(.playerRate) var playerRate
|
||||
@Default(.systemControlsSeekDuration) var systemControlsSeekDuration
|
||||
|
||||
#if os(macOS)
|
||||
@Default(.buttonBackwardSeekDuration) private var buttonBackwardSeekDuration
|
||||
@Default(.buttonForwardSeekDuration) private var buttonForwardSeekDuration
|
||||
#endif
|
||||
|
||||
#if !os(macOS)
|
||||
@Default(.closePiPAndOpenPlayerOnEnteringForeground) var closePiPAndOpenPlayerOnEnteringForeground
|
||||
#endif
|
||||
@ -186,10 +203,43 @@ final class PlayerModel: ObservableObject {
|
||||
var rateToRestore: Float?
|
||||
private var remoteCommandCenterConfigured = false
|
||||
|
||||
// Used in the PlayerModel extension in PlayerQueue
|
||||
var retryAttempts = [String: Int]()
|
||||
|
||||
#if os(macOS)
|
||||
var keyPressMonitor: Any?
|
||||
#endif
|
||||
|
||||
init() {
|
||||
#if os(iOS)
|
||||
isOrientationLocked = Defaults[.isOrientationLocked]
|
||||
|
||||
if isOrientationLocked, lockPortraitWhenBrowsing {
|
||||
lockedOrientation = UIInterfaceOrientationMask.portrait
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
} else if isOrientationLocked {
|
||||
lockOrientationAction()
|
||||
}
|
||||
#endif
|
||||
#if !os(macOS)
|
||||
mpvBackend.controller = mpvController
|
||||
mpvBackend.client = mpvController.client
|
||||
|
||||
// Register for audio session interruption notifications
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleAudioSessionInterruption(_:)),
|
||||
name: AVAudioSession.interruptionNotification,
|
||||
object: nil
|
||||
)
|
||||
|
||||
// Register for audio session route change notifications
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleRouteChange(_:)),
|
||||
name: AVAudioSession.routeChangeNotification,
|
||||
object: AVAudioSession.sharedInstance()
|
||||
)
|
||||
#endif
|
||||
|
||||
playbackMode = Defaults[.playbackMode]
|
||||
@ -206,10 +256,25 @@ final class PlayerModel: ObservableObject {
|
||||
currentRate = playerRate
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(
|
||||
self, name: AVAudioSession.interruptionNotification, object: nil
|
||||
)
|
||||
|
||||
NotificationCenter.default.removeObserver(
|
||||
self,
|
||||
name: AVAudioSession.routeChangeNotification,
|
||||
object: AVAudioSession.sharedInstance()
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
func show() {
|
||||
#if os(macOS)
|
||||
if presentingPlayer {
|
||||
Windows.player.focus()
|
||||
assignKeyPressMonitor()
|
||||
return
|
||||
}
|
||||
#endif
|
||||
@ -225,6 +290,7 @@ final class PlayerModel: ObservableObject {
|
||||
#if os(macOS)
|
||||
Windows.player.open()
|
||||
Windows.player.focus()
|
||||
assignKeyPressMonitor()
|
||||
#endif
|
||||
}
|
||||
|
||||
@ -244,6 +310,7 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
destroyKeyPressMonitor()
|
||||
Windows.player.hide()
|
||||
#endif
|
||||
}
|
||||
@ -282,6 +349,14 @@ final class PlayerModel: ObservableObject {
|
||||
backend.isPlaying
|
||||
}
|
||||
|
||||
var isPaused: Bool {
|
||||
backend.isPaused
|
||||
}
|
||||
|
||||
var hasStarted: Bool {
|
||||
backend.hasStarted
|
||||
}
|
||||
|
||||
var playerItemDuration: CMTime? {
|
||||
guard !currentItem.isNil else {
|
||||
return nil
|
||||
@ -478,7 +553,10 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func handlePresentationChange() {
|
||||
backend.setNeedsDrawing(presentingPlayer)
|
||||
#if os(macOS)
|
||||
// TODO: Check whether this is needed on macOS
|
||||
backend.setNeedsDrawing(presentingPlayer)
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
if presentingPlayer, activeBackend == .appleAVPlayer, avPlayerUsesSystemControls, Constants.isIPhone {
|
||||
@ -507,18 +585,16 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
if !presentingPlayer {
|
||||
#if os(iOS)
|
||||
if Defaults[.lockPortraitWhenBrowsing] {
|
||||
if lockPortraitWhenBrowsing {
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
} else {
|
||||
Orientation.lockOrientation(.allButUpsideDown)
|
||||
Orientation.lockOrientation(.all)
|
||||
}
|
||||
|
||||
OrientationModel.shared.stopOrientationUpdates()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func changeActiveBackend(from: PlayerBackendType, to: PlayerBackendType, changingStream: Bool = true) {
|
||||
func changeActiveBackend(from: PlayerBackendType, to: PlayerBackendType, changingStream: Bool = true, isInClosePip: Bool = false) {
|
||||
guard activeBackend != to else {
|
||||
return
|
||||
}
|
||||
@ -527,7 +603,7 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
let wasPlaying = isPlaying
|
||||
|
||||
if to == .mpv {
|
||||
if to == .mpv && !isInClosePip {
|
||||
closePiP()
|
||||
}
|
||||
|
||||
@ -620,60 +696,65 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func closeCurrentItem(finished: Bool = false) {
|
||||
pause()
|
||||
videoBeingOpened = nil
|
||||
advancing = false
|
||||
forceBackendOnPlay = nil
|
||||
|
||||
guard !closing else { return }
|
||||
closing = true
|
||||
controls.presentingControls = false
|
||||
|
||||
self.prepareCurrentItemForHistory(finished: finished)
|
||||
if playingFullScreen { exitFullScreen() }
|
||||
|
||||
self.hide()
|
||||
|
||||
Delay.by(0.8) { [weak self] in
|
||||
Delay.by(0.3) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.closePiP()
|
||||
pause()
|
||||
videoBeingOpened = nil
|
||||
advancing = false
|
||||
forceBackendOnPlay = nil
|
||||
|
||||
withAnimation {
|
||||
self.currentItem = nil
|
||||
controls.presentingControls = false
|
||||
|
||||
self.prepareCurrentItemForHistory(finished: finished)
|
||||
self.hide()
|
||||
|
||||
Delay.by(0.7) { [weak self] in
|
||||
guard let self else { return }
|
||||
if playingInPictureInPicture { self.closePiP() }
|
||||
|
||||
withAnimation {
|
||||
self.currentItem = nil
|
||||
}
|
||||
|
||||
self.updateNowPlayingInfo()
|
||||
self.backend.closeItem()
|
||||
self.aspectRatio = VideoPlayerView.defaultAspectRatio
|
||||
self.resetAutoplay()
|
||||
self.closing = false
|
||||
}
|
||||
self.updateNowPlayingInfo()
|
||||
|
||||
self.backend.closeItem()
|
||||
self.aspectRatio = VideoPlayerView.defaultAspectRatio
|
||||
self.resetAutoplay()
|
||||
self.closing = false
|
||||
self.playingFullScreen = false
|
||||
}
|
||||
}
|
||||
|
||||
func startPiP() {
|
||||
previousActiveBackend = activeBackend
|
||||
avPlayerBackend.startPictureInPictureOnPlay = false
|
||||
avPlayerBackend.startPictureInPictureOnSwitch = false
|
||||
|
||||
if activeBackend == .appleAVPlayer {
|
||||
guard activeBackend != .appleAVPlayer else {
|
||||
avPlayerBackend.tryStartingPictureInPicture()
|
||||
return
|
||||
}
|
||||
|
||||
guard let video = currentVideo else { return }
|
||||
guard let stream = avPlayerBackend.bestPlayable(availableStreams, maxResolution: .hd720p30) else { return }
|
||||
avPlayerBackend.startPictureInPictureOnSwitch = true
|
||||
|
||||
exitFullScreen()
|
||||
|
||||
if avPlayerBackend.video == video {
|
||||
if activeBackend != .appleAVPlayer {
|
||||
avPlayerBackend.startPictureInPictureOnSwitch = true
|
||||
saveTime {
|
||||
self.changeActiveBackend(from: .mpv, to: .appleAVPlayer)
|
||||
_ = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in
|
||||
if let pipController = self?.pipController, pipController.isPictureInPictureActive, self?.avPlayerBackend.isPlaying == true {
|
||||
self?.exitFullScreen()
|
||||
self?.controls.objectWillChange.send()
|
||||
timer.invalidate()
|
||||
} else if self?.activeBackend == .appleAVPlayer, self?.avPlayerBackend.startPictureInPictureOnSwitch == false {
|
||||
self?.avPlayerBackend.startPictureInPictureOnSwitch = true
|
||||
self?.avPlayerBackend.tryStartingPictureInPicture()
|
||||
}
|
||||
}
|
||||
changeActiveBackend(from: activeBackend, to: .appleAVPlayer)
|
||||
} else {
|
||||
avPlayerBackend.startPictureInPictureOnPlay = true
|
||||
playStream(stream, of: video, preservingTime: true, upgrading: true, withBackend: avPlayerBackend)
|
||||
}
|
||||
|
||||
controls.objectWillChange.send()
|
||||
}
|
||||
|
||||
var transitioningToPiP: Bool {
|
||||
@ -701,7 +782,28 @@ final class PlayerModel: ObservableObject {
|
||||
show()
|
||||
#endif
|
||||
|
||||
backend.closePiP()
|
||||
avPlayerBackend.closePiP()
|
||||
_ = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in
|
||||
if self?.activeBackend == .appleAVPlayer, self?.avPlayerBackend.isPlaying == true, self?.playingInPictureInPicture == false {
|
||||
timer.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
guard previousActiveBackend == .mpv else { return }
|
||||
|
||||
saveTime {
|
||||
self.changeActiveBackend(from: .appleAVPlayer, to: .mpv, isInClosePip: true)
|
||||
_ = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in
|
||||
if self?.activeBackend == .mpv, self?.mpvBackend.isPlaying == true {
|
||||
timer.invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We need to remove the itme from the player, if not it will be displayed when next video goe to PiP.
|
||||
Delay.by(1.0) {
|
||||
self.avPlayerBackend.closeItem()
|
||||
}
|
||||
}
|
||||
|
||||
var pipImage: String {
|
||||
@ -713,29 +815,34 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func toggleFullScreenAction() {
|
||||
toggleFullscreen(playingFullScreen, showControls: false)
|
||||
toggleFullscreen(playingFullScreen, showControls: false, initiatedByButton: true)
|
||||
}
|
||||
|
||||
func togglePiPAction() {
|
||||
(pipController?.isPictureInPictureActive ?? false) ? closePiP() : startPiP()
|
||||
if pipController?.isPictureInPictureActive ?? false {
|
||||
closePiP()
|
||||
} else {
|
||||
startPiP()
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
var lockOrientationImage: String {
|
||||
lockedOrientation.isNil ? "lock.rotation.open" : "lock.rotation"
|
||||
isOrientationLocked ? "lock.rotation" : "lock.rotation.open"
|
||||
}
|
||||
|
||||
func lockOrientationAction() {
|
||||
if lockedOrientation.isNil {
|
||||
// This makes toggling orientation lock more robust
|
||||
if lockedOrientation.isNil || !isOrientationLocked {
|
||||
isOrientationLocked = true
|
||||
let orientationMask = OrientationTracker.shared.currentInterfaceOrientationMask
|
||||
lockedOrientation = orientationMask
|
||||
let orientation = OrientationTracker.shared.currentInterfaceOrientation
|
||||
Orientation.lockOrientation(orientationMask, andRotateTo: .landscapeLeft)
|
||||
// iOS 16 workaround
|
||||
Orientation.lockOrientation(orientationMask, andRotateTo: orientation)
|
||||
Orientation.lockOrientation(orientationMask, andRotateTo: playingFullScreen ? nil : orientation)
|
||||
} else {
|
||||
isOrientationLocked = false
|
||||
lockedOrientation = nil
|
||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: OrientationTracker.shared.currentInterfaceOrientation)
|
||||
Orientation.lockOrientation(.all)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@ -753,10 +860,12 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
func handleCurrentItemChange() {
|
||||
if currentItem == nil {
|
||||
captions = nil
|
||||
FeedModel.shared.calculateUnwatchedFeed()
|
||||
}
|
||||
|
||||
// Captions need to be set to nil on item change, to clear the previus values.
|
||||
captions = nil
|
||||
|
||||
#if os(macOS)
|
||||
Windows.player.window?.title = windowTitle
|
||||
#endif
|
||||
@ -812,37 +921,40 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.playerAPI(item.video)?.loadDetails(item, completionHandler: { newItem in
|
||||
self.playerAPI(item.video)?.loadDetails(item, failureHandler: nil) { newItem in
|
||||
guard newItem.videoID == self.autoplayItem?.videoID else { return }
|
||||
self.autoplayItem = newItem
|
||||
self.updateRemoteCommandCenter()
|
||||
self.controls.objectWillChange.send()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateRemoteCommandCenter() {
|
||||
let skipForwardCommand = MPRemoteCommandCenter.shared().skipForwardCommand
|
||||
let skipBackwardCommand = MPRemoteCommandCenter.shared().skipBackwardCommand
|
||||
let previousTrackCommand = MPRemoteCommandCenter.shared().previousTrackCommand
|
||||
let nextTrackCommand = MPRemoteCommandCenter.shared().nextTrackCommand
|
||||
let commandCenter = MPRemoteCommandCenter.shared()
|
||||
let skipForwardCommand = commandCenter.skipForwardCommand
|
||||
let skipBackwardCommand = commandCenter.skipBackwardCommand
|
||||
let previousTrackCommand = commandCenter.previousTrackCommand
|
||||
let nextTrackCommand = commandCenter.nextTrackCommand
|
||||
|
||||
if !remoteCommandCenterConfigured {
|
||||
remoteCommandCenterConfigured = true
|
||||
|
||||
#if !os(macOS)
|
||||
try? AVAudioSession.sharedInstance().setCategory(
|
||||
.playback,
|
||||
mode: .moviePlayback
|
||||
)
|
||||
|
||||
UIApplication.shared.beginReceivingRemoteControlEvents()
|
||||
#endif
|
||||
|
||||
let interval = TimeInterval(systemControlsSeekDuration) ?? 10
|
||||
let preferredIntervals = [NSNumber(value: interval)]
|
||||
|
||||
// Remove existing targets to avoid duplicates
|
||||
skipForwardCommand.removeTarget(nil)
|
||||
skipBackwardCommand.removeTarget(nil)
|
||||
previousTrackCommand.removeTarget(nil)
|
||||
nextTrackCommand.removeTarget(nil)
|
||||
commandCenter.playCommand.removeTarget(nil)
|
||||
commandCenter.pauseCommand.removeTarget(nil)
|
||||
commandCenter.togglePlayPauseCommand.removeTarget(nil)
|
||||
commandCenter.changePlaybackPositionCommand.removeTarget(nil)
|
||||
|
||||
// Re-add targets for handling commands
|
||||
skipForwardCommand.preferredIntervals = preferredIntervals
|
||||
skipBackwardCommand.preferredIntervals = preferredIntervals
|
||||
|
||||
@ -866,22 +978,22 @@ final class PlayerModel: ObservableObject {
|
||||
return .success
|
||||
}
|
||||
|
||||
MPRemoteCommandCenter.shared().playCommand.addTarget { [weak self] _ in
|
||||
commandCenter.playCommand.addTarget { [weak self] _ in
|
||||
self?.play()
|
||||
return .success
|
||||
}
|
||||
|
||||
MPRemoteCommandCenter.shared().pauseCommand.addTarget { [weak self] _ in
|
||||
commandCenter.pauseCommand.addTarget { [weak self] _ in
|
||||
self?.pause()
|
||||
return .success
|
||||
}
|
||||
|
||||
MPRemoteCommandCenter.shared().togglePlayPauseCommand.addTarget { [weak self] _ in
|
||||
commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
|
||||
self?.togglePlay()
|
||||
return .success
|
||||
}
|
||||
|
||||
MPRemoteCommandCenter.shared().changePlaybackPositionCommand.addTarget { [weak self] remoteEvent in
|
||||
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] remoteEvent in
|
||||
guard let event = remoteEvent as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
|
||||
|
||||
self?.backend.seek(to: event.positionTime, seekType: .userInteracted)
|
||||
@ -916,22 +1028,43 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
#else
|
||||
func handleEnterForeground() {
|
||||
setNeedsDrawing(presentingPlayer)
|
||||
avPlayerBackend.bindPlayerToLayer()
|
||||
DispatchQueue.global(qos: .userInteractive).async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
if !self.musicMode, self.activeBackend == .mpv {
|
||||
self.mpvBackend.addVideoTrackFromStream()
|
||||
self.mpvBackend.setVideoToAuto()
|
||||
self.mpvBackend.controls.resetTimer()
|
||||
} else if !self.musicMode, self.activeBackend == .appleAVPlayer {
|
||||
self.avPlayerBackend.bindPlayerToLayer()
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
OrientationTracker.shared.startDeviceOrientationTracking()
|
||||
#endif
|
||||
|
||||
guard closePiPAndOpenPlayerOnEnteringForeground, playingInPictureInPicture else {
|
||||
return
|
||||
}
|
||||
|
||||
show()
|
||||
closePiP()
|
||||
// Needs to be delayed a bit, otherwise the PiP windows stays open
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
self?.closePiP()
|
||||
}
|
||||
}
|
||||
|
||||
func handleEnterBackground() {
|
||||
#if os(iOS)
|
||||
OrientationTracker.shared.stopDeviceOrientationTracking()
|
||||
#endif
|
||||
|
||||
if Defaults[.pauseOnEnteringBackground], !playingInPictureInPicture, !musicMode {
|
||||
pause()
|
||||
} else if !playingInPictureInPicture {
|
||||
} else if !playingInPictureInPicture, activeBackend == .appleAVPlayer {
|
||||
avPlayerBackend.removePlayerFromLayer()
|
||||
} else if activeBackend == .mpv, !musicMode {
|
||||
mpvBackend.setVideoToNo()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@ -941,6 +1074,7 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
logger.info("entering fullscreen")
|
||||
toggleFullscreen(false, showControls: showControls)
|
||||
self.playingFullScreen = true
|
||||
}
|
||||
|
||||
func exitFullScreen(showControls: Bool = true) {
|
||||
@ -948,6 +1082,7 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
logger.info("exiting fullscreen")
|
||||
toggleFullscreen(true, showControls: showControls)
|
||||
self.playingFullScreen = false
|
||||
}
|
||||
|
||||
func updateNowPlayingInfo() {
|
||||
@ -955,18 +1090,22 @@ final class PlayerModel: ObservableObject {
|
||||
guard activeBackend == .mpv else { return }
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
if activeBackend == .appleAVPlayer, avPlayerUsesSystemControls {
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
guard let video = currentItem?.video else {
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = .none
|
||||
return
|
||||
}
|
||||
|
||||
let currentTime = (backend.currentTime?.seconds.isFinite ?? false) ? backend.currentTime!.seconds : 0
|
||||
|
||||
// Determine the media type based on musicMode
|
||||
let mediaType: NSNumber
|
||||
if musicMode {
|
||||
mediaType = MPMediaType.anyAudio.rawValue as NSNumber
|
||||
} else {
|
||||
mediaType = MPMediaType.anyVideo.rawValue as NSNumber
|
||||
}
|
||||
|
||||
// Prepare the Now Playing info dictionary
|
||||
var nowPlayingInfo: [String: AnyObject] = [
|
||||
MPMediaItemPropertyTitle: video.displayTitle as AnyObject,
|
||||
MPMediaItemPropertyArtist: video.displayAuthor as AnyObject,
|
||||
@ -974,7 +1113,7 @@ final class PlayerModel: ObservableObject {
|
||||
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime as AnyObject,
|
||||
MPNowPlayingInfoPropertyPlaybackQueueCount: queue.count as AnyObject,
|
||||
MPNowPlayingInfoPropertyPlaybackQueueIndex: 1 as AnyObject,
|
||||
MPMediaItemPropertyMediaType: MPMediaType.anyVideo.rawValue as AnyObject
|
||||
MPMediaItemPropertyMediaType: mediaType
|
||||
]
|
||||
|
||||
if !currentArtwork.isNil {
|
||||
@ -995,7 +1134,7 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
func updateCurrentArtwork() {
|
||||
guard let video = currentVideo,
|
||||
let thumbnailURL = video.thumbnailURL(quality: .medium)
|
||||
let thumbnailURL = video.thumbnailURL(quality: Constants.isIPhone ? .medium : .maxres)
|
||||
else {
|
||||
return
|
||||
}
|
||||
@ -1017,7 +1156,7 @@ final class PlayerModel: ObservableObject {
|
||||
task.resume()
|
||||
}
|
||||
|
||||
func toggleFullscreen(_ isFullScreen: Bool, showControls: Bool = true) {
|
||||
func toggleFullscreen(_ isFullScreen: Bool, showControls: Bool = true, initiatedByButton: Bool = false) {
|
||||
controls.presentingControls = showControls && isFullScreen
|
||||
|
||||
#if os(macOS)
|
||||
@ -1029,18 +1168,27 @@ final class PlayerModel: ObservableObject {
|
||||
#if os(iOS)
|
||||
if playingFullScreen {
|
||||
if activeBackend == .appleAVPlayer, avPlayerUsesSystemControls {
|
||||
fullscreenInitiatedByButton = initiatedByButton
|
||||
avPlayerBackend.controller.enterFullScreen(animated: true)
|
||||
return
|
||||
}
|
||||
guard rotateToLandscapeOnEnterFullScreen.isRotating else { return }
|
||||
let lockOrientation = rotateToLandscapeOnEnterFullScreen.interfaceOrientation
|
||||
if currentVideoIsLandscape {
|
||||
let delay = activeBackend == .appleAVPlayer && avPlayerUsesSystemControls ? 0.8 : 0
|
||||
// not sure why but first rotation call is ignore so doing rotate to same orientation first
|
||||
Delay.by(delay) {
|
||||
let orientation = OrientationTracker.shared.currentDeviceOrientation.isLandscape ? OrientationTracker.shared.currentInterfaceOrientation : self.rotateToLandscapeOnEnterFullScreen.interaceOrientation
|
||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: OrientationTracker.shared.currentInterfaceOrientation)
|
||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: orientation)
|
||||
if initiatedByButton {
|
||||
Orientation.lockOrientation(isOrientationLocked
|
||||
? (lockOrientation == .landscapeRight ? .landscapeRight : .landscapeLeft)
|
||||
: .landscape)
|
||||
}
|
||||
let orientation = OrientationTracker.shared.currentDeviceOrientation.isLandscape
|
||||
? OrientationTracker.shared.currentInterfaceOrientation
|
||||
: rotateToLandscapeOnEnterFullScreen.interfaceOrientation
|
||||
|
||||
Orientation.lockOrientation(
|
||||
isOrientationLocked
|
||||
? (lockOrientation == .landscapeRight ? .landscapeRight : .landscapeLeft)
|
||||
: .all,
|
||||
andRotateTo: orientation
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if activeBackend == .appleAVPlayer, avPlayerUsesSystemControls {
|
||||
@ -1048,10 +1196,12 @@ final class PlayerModel: ObservableObject {
|
||||
avPlayerBackend.controller.dismiss(animated: true)
|
||||
return
|
||||
}
|
||||
let rotationOrientation = Constants.isIPhone ? UIInterfaceOrientation.portrait : nil
|
||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: rotationOrientation)
|
||||
if lockPortraitWhenBrowsing {
|
||||
lockedOrientation = UIInterfaceOrientationMask.portrait
|
||||
}
|
||||
let rotationOrientation = lockPortraitWhenBrowsing ? UIInterfaceOrientation.portrait : nil
|
||||
Orientation.lockOrientation(lockPortraitWhenBrowsing ? .portrait : .all, andRotateTo: rotationOrientation)
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
|
||||
@ -1108,4 +1258,213 @@ final class PlayerModel: ObservableObject {
|
||||
onPlayStream.forEach { $0(stream) }
|
||||
onPlayStream.removeAll()
|
||||
}
|
||||
|
||||
func updateTime(_ cmTime: CMTime) {
|
||||
let time = CMTimeGetSeconds(cmTime)
|
||||
let newChapterIndex = chapterForTime(time)
|
||||
if currentChapterIndex != newChapterIndex {
|
||||
DispatchQueue.main.async {
|
||||
self.currentChapterIndex = newChapterIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func chapterForTime(_ time: Double) -> Int? {
|
||||
guard let chapters = self.videoForDisplay?.chapters else {
|
||||
return nil
|
||||
}
|
||||
|
||||
for (index, chapter) in chapters.enumerated() {
|
||||
let nextChapterStartTime = index < (chapters.count - 1) ? chapters[index + 1].start : nil
|
||||
|
||||
if let nextChapterStart = nextChapterStartTime {
|
||||
if time >= chapter.start, time < nextChapterStart {
|
||||
return index
|
||||
}
|
||||
} else {
|
||||
if time >= chapter.start {
|
||||
return index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
#if !os(macOS)
|
||||
func setAudioSessionActive(_ setActive: Bool) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setActive(setActive)
|
||||
} catch {
|
||||
self.logger.error("Error setting up audio session: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func handleAudioSessionInterruption(_ notification: Notification) {
|
||||
logger.info("Audio session interruption received.")
|
||||
logger.info("Notification object: \(String(describing: notification.object))")
|
||||
|
||||
guard let info = notification.userInfo else {
|
||||
logger.info("userInfo is missing in the notification.")
|
||||
return
|
||||
}
|
||||
|
||||
// Extract the interruption type
|
||||
guard let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
||||
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
|
||||
else {
|
||||
logger.info("AVAudioSessionInterruptionTypeKey is missing or not a UInt in userInfo.")
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("Interruption type received: \(type)")
|
||||
|
||||
// Check availability for iOS 14.5 or newer to handle interruption reason
|
||||
// Currently only for debugging purpose
|
||||
#if os(iOS)
|
||||
if #available(iOS 14.5, *) {
|
||||
// Extract the interruption reason, if available
|
||||
if let reasonValue = info[AVAudioSessionInterruptionReasonKey] as? UInt,
|
||||
let reason = AVAudioSession.InterruptionReason(rawValue: reasonValue)
|
||||
{
|
||||
logger.info("Interruption reason received: \(reason)")
|
||||
switch reason {
|
||||
case .default:
|
||||
logger.info("Interruption reason: Default or unspecified interruption occurred.")
|
||||
case .appWasSuspended:
|
||||
logger.info("Interruption reason: The app was suspended during the interruption.")
|
||||
@unknown default:
|
||||
logger.info("Unknown interruption reason received.")
|
||||
}
|
||||
} else {
|
||||
logger.info("AVAudioSessionInterruptionReasonKey is missing or not a UInt in userInfo.")
|
||||
}
|
||||
} else {
|
||||
logger.info("Interruption reason handling is not available on this iOS version.")
|
||||
}
|
||||
#endif
|
||||
|
||||
// Handle the specific interruption type
|
||||
switch type {
|
||||
case .began:
|
||||
pause()
|
||||
logger.info("Audio session interrupted (began).")
|
||||
case .ended:
|
||||
// Extract any interruption options, if available
|
||||
if let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt {
|
||||
logger.info("Interruption options received: \(optionsValue)")
|
||||
if optionsValue & AVAudioSession.InterruptionOptions.shouldResume.rawValue != 0 {
|
||||
play()
|
||||
logger.info("Interruption option indicates playback should resume automatically.")
|
||||
} else {
|
||||
logger.info("Interruption option indicates playback should not resume automatically.")
|
||||
}
|
||||
} else {
|
||||
logger.info("AVAudioSessionInterruptionOptionKey is missing or not a UInt in userInfo.")
|
||||
}
|
||||
logger.info("Audio session interruption ended.")
|
||||
// Check if audio was resumed or if there's any indication of ducking
|
||||
let currentVolume = AVAudioSession.sharedInstance().outputVolume
|
||||
logger.info("Current output volume: \(currentVolume)")
|
||||
default:
|
||||
logger.info("Unknown interruption type received.")
|
||||
}
|
||||
}
|
||||
|
||||
@objc func handleRouteChange(_ notification: Notification) {
|
||||
logger.info("Audio route change received.")
|
||||
|
||||
guard let info = notification.userInfo else {
|
||||
logger.info("userInfo is missing in the notification.")
|
||||
return
|
||||
}
|
||||
|
||||
guard let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
||||
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue)
|
||||
else {
|
||||
logger.info("AVAudioSessionRouteChangeReasonKey is missing or not a UInt in userInfo.")
|
||||
return
|
||||
}
|
||||
|
||||
logger.info("Route change reason received: \(reason)")
|
||||
|
||||
let currentCategory = AVAudioSession.sharedInstance().category
|
||||
logger.info("Current audio session category before change: \(currentCategory)")
|
||||
|
||||
switch reason {
|
||||
case .categoryChange:
|
||||
logger.info("Audio session category changed.")
|
||||
let newCategory = AVAudioSession.sharedInstance().category
|
||||
logger.info("New audio session category: \(newCategory)")
|
||||
case .oldDeviceUnavailable, .newDeviceAvailable:
|
||||
logger.info("Audio route change may indicate ducking or device change.")
|
||||
let currentRoute = AVAudioSession.sharedInstance().currentRoute
|
||||
logger.info("Current audio route: \(currentRoute)")
|
||||
|
||||
for output in currentRoute.outputs {
|
||||
logger.info("Output port type: \(output.portType), UID: \(output.uid)")
|
||||
switch output.portType {
|
||||
case .headphones, .bluetoothA2DP:
|
||||
logger.info("Detected port type \(output.portType). Executing play().")
|
||||
play()
|
||||
default:
|
||||
logger.info("Detected port type \(output.portType). Executing pause().")
|
||||
pause()
|
||||
}
|
||||
}
|
||||
case .noSuitableRouteForCategory:
|
||||
logger.info("No suitable route for the current category.")
|
||||
default:
|
||||
logger.info("Unhandled route change reason: \(reason)")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
private func assignKeyPressMonitor() {
|
||||
keyPressMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] keyEvent -> NSEvent? in
|
||||
// Check if the player window is the key window
|
||||
guard let self, let window = Windows.playerWindow, window.isKeyWindow else { return keyEvent }
|
||||
|
||||
switch keyEvent.keyCode {
|
||||
case 124:
|
||||
if !self.liveStreamInAVPlayer {
|
||||
let interval = TimeInterval(self.buttonForwardSeekDuration) ?? 10
|
||||
self.backend.seek(
|
||||
relative: .secondsInDefaultTimescale(interval),
|
||||
seekType: .userInteracted
|
||||
)
|
||||
}
|
||||
case 123:
|
||||
if !self.liveStreamInAVPlayer {
|
||||
let interval = TimeInterval(self.buttonBackwardSeekDuration) ?? 10
|
||||
self.backend.seek(
|
||||
relative: .secondsInDefaultTimescale(-interval),
|
||||
seekType: .userInteracted
|
||||
)
|
||||
}
|
||||
case 3:
|
||||
self.toggleFullscreen(
|
||||
self.playingFullScreen,
|
||||
showControls: false
|
||||
)
|
||||
case 49:
|
||||
if !self.controls.isLoadingVideo {
|
||||
self.backend.togglePlay()
|
||||
}
|
||||
default:
|
||||
return keyEvent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func destroyKeyPressMonitor() {
|
||||
if let keyPressMonitor {
|
||||
NSEvent.removeMonitor(keyPressMonitor)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
@ -94,7 +94,9 @@ extension PlayerModel {
|
||||
}
|
||||
} else {
|
||||
self.videoBeingOpened = nil
|
||||
self.availableStreams = self.streamsWithInstance(instance: playerInstance, streams: video.streams)
|
||||
self.streamsWithInstance(instance: playerInstance, streams: video.streams) { processedStreams in
|
||||
self.availableStreams = processedStreams
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -105,6 +107,7 @@ extension PlayerModel {
|
||||
|
||||
func playerAPI(_ video: Video) -> VideosAPI? {
|
||||
guard let url = video.instanceURL else { return accounts.api }
|
||||
if accounts.current?.url == url { return accounts.api }
|
||||
switch video.app {
|
||||
case .local:
|
||||
return nil
|
||||
@ -124,14 +127,32 @@ extension PlayerModel {
|
||||
var streamByQualityProfile: Stream? {
|
||||
let profile = qualityProfile ?? .defaultProfile
|
||||
|
||||
// First attempt: Filter by both `canPlay` and `isPreferred`
|
||||
if let streamPreferredForProfile = backend.bestPlayable(
|
||||
availableStreams.filter { backend.canPlay($0) && profile.isPreferred($0) },
|
||||
maxResolution: profile.resolution
|
||||
maxResolution: profile.resolution, formatOrder: profile.formats
|
||||
) {
|
||||
return streamPreferredForProfile
|
||||
}
|
||||
|
||||
return backend.bestPlayable(availableStreams.filter { backend.canPlay($0) }, maxResolution: profile.resolution)
|
||||
// Fallback: Filter by `canPlay` only
|
||||
let fallbackStream = backend.bestPlayable(
|
||||
availableStreams.filter { backend.canPlay($0) },
|
||||
maxResolution: profile.resolution, formatOrder: profile.formats
|
||||
)
|
||||
|
||||
// If no stream is found, trigger the error handler
|
||||
guard let finalStream = fallbackStream else {
|
||||
let error = RequestError(
|
||||
userMessage: "No supported streams available.",
|
||||
cause: NSError(domain: "stream.yatte.app", code: -1, userInfo: [NSLocalizedDescriptionKey: "No supported streams available"])
|
||||
)
|
||||
videoLoadFailureHandler(error, video: currentVideo)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Return the found stream
|
||||
return finalStream
|
||||
}
|
||||
|
||||
func advanceToNextItem() {
|
||||
@ -328,16 +349,41 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
playerAPI(video)?
|
||||
.loadDetails(item, completionHandler: { [weak self] newItem in
|
||||
.loadDetails(item, failureHandler: nil) { [weak self] newItem in
|
||||
guard let self else { return }
|
||||
|
||||
replaceQueueItem(newItem)
|
||||
|
||||
self.logger.info("LOADED queue details: \(videoID)")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func videoLoadFailureHandler(_ error: RequestError, video: Video? = nil) {
|
||||
guard let video else {
|
||||
presentErrorAlert(error)
|
||||
return
|
||||
}
|
||||
|
||||
let videoID = video.videoID
|
||||
let currentRetry = retryAttempts[videoID] ?? 0
|
||||
|
||||
if currentRetry < Defaults[.videoLoadingRetryCount] {
|
||||
retryAttempts[videoID] = currentRetry + 1
|
||||
|
||||
logger.info("Retry attempt \(currentRetry + 1) for video \(videoID) due to error: \(error)")
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
guard let self else { return }
|
||||
self.enqueueVideo(video, play: true, prepending: true, loadDetails: true)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
retryAttempts[videoID] = 0
|
||||
presentErrorAlert(error, video: video)
|
||||
}
|
||||
|
||||
private func presentErrorAlert(_ error: RequestError, video: Video? = nil) {
|
||||
var message = error.userMessage
|
||||
if let errorDictionary = error.json.dictionaryObject,
|
||||
let errorMessage = errorDictionary["message"] ?? errorDictionary["error"],
|
||||
|
@ -44,22 +44,6 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
private func skip(_ segment: Segment, at time: CMTime) {
|
||||
if let duration = playerItemDuration, segment.endTime.seconds >= duration.seconds - 3 {
|
||||
logger.error("segment end time is: \(segment.end) when player item duration is: \(duration.seconds)")
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.pause()
|
||||
|
||||
self.backend.eofPlaybackModeAction()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
backend.seek(to: segment.endTime, seekType: .segmentSkip(segment.category))
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
@ -69,6 +53,14 @@ extension PlayerModel {
|
||||
self?.segmentRestorationTime = time
|
||||
}
|
||||
logger.info("SponsorBlock skipping to: \(segment.end)")
|
||||
|
||||
if let duration = playerItemDuration, segment.endTime.seconds >= duration.seconds - 3 {
|
||||
logger.error("Segment end time is: \(segment.end) when player item duration is: \(duration.seconds)")
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.backend.eofPlaybackModeAction()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldSkip(_ segment: Segment, at time: CMTime) -> Bool {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import Siesta
|
||||
import SwiftUI
|
||||
@ -41,7 +42,9 @@ extension PlayerModel {
|
||||
self.logger.info("ignoring loaded streams from \(instance.description) as current video has changed")
|
||||
return
|
||||
}
|
||||
self.availableStreams = self.streamsWithInstance(instance: instance, streams: video.streams)
|
||||
self.streamsWithInstance(instance: instance, streams: video.streams) { processedStreams in
|
||||
self.availableStreams = processedStreams
|
||||
}
|
||||
} else {
|
||||
self.logger.critical("no streams available from \(instance.description)")
|
||||
}
|
||||
@ -53,28 +56,172 @@ extension PlayerModel {
|
||||
}
|
||||
}
|
||||
|
||||
func streamsWithInstance(instance: Instance, streams: [Stream]) -> [Stream] {
|
||||
streams.map { stream in
|
||||
stream.instance = instance
|
||||
func streamsWithInstance(instance: Instance, streams: [Stream], completion: @escaping ([Stream]) -> Void) {
|
||||
// Queue for stream processing
|
||||
let streamProcessingQueue = DispatchQueue(label: "stream.yattee.streamProcessing.Queue")
|
||||
// Queue for accessing the processedStreams array
|
||||
let processedStreamsQueue = DispatchQueue(label: "stream.yattee.processedStreams.Queue")
|
||||
// DispatchGroup for managing multiple tasks
|
||||
let streamProcessingGroup = DispatchGroup()
|
||||
|
||||
if instance.app == .invidious, instance.proxiesVideos {
|
||||
if let audio = stream.audioAsset {
|
||||
stream.audioAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: audio)
|
||||
var processedStreams = [Stream]()
|
||||
let instance = instance
|
||||
|
||||
var hasForbiddenAsset = false
|
||||
var hasAllowedAsset = false
|
||||
|
||||
for stream in streams {
|
||||
streamProcessingQueue.async(group: streamProcessingGroup) {
|
||||
let forbiddenAssetTestGroup = DispatchGroup()
|
||||
if !hasAllowedAsset, !hasForbiddenAsset, !instance.proxiesVideos, stream.format != Stream.Format.unknown {
|
||||
let (nonHLSAssets, hlsURLs) = self.getAssets(from: [stream])
|
||||
if let firstStream = nonHLSAssets.first {
|
||||
let asset = firstStream.0
|
||||
let url = firstStream.1
|
||||
let requestRange = firstStream.2
|
||||
|
||||
if instance.app == .invidious {
|
||||
self.testAsset(url: url, range: requestRange, isHLS: false, forbiddenAssetTestGroup: forbiddenAssetTestGroup) { status in
|
||||
switch status {
|
||||
case HTTPStatus.Forbidden:
|
||||
hasForbiddenAsset = true
|
||||
case HTTPStatus.PartialContent:
|
||||
hasAllowedAsset = true
|
||||
case HTTPStatus.OK:
|
||||
hasAllowedAsset = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if instance.app == .piped {
|
||||
self.testPipedAssets(asset: asset!, requestRange: requestRange, isHLS: false, forbiddenAssetTestGroup: forbiddenAssetTestGroup) { status in
|
||||
switch status {
|
||||
case HTTPStatus.Forbidden:
|
||||
hasForbiddenAsset = true
|
||||
case HTTPStatus.PartialContent:
|
||||
hasAllowedAsset = true
|
||||
case HTTPStatus.OK:
|
||||
hasAllowedAsset = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let firstHLS = hlsURLs.first {
|
||||
let asset = AVURLAsset(url: firstHLS)
|
||||
if instance.app == .piped {
|
||||
self.testPipedAssets(asset: asset, requestRange: nil, isHLS: true, forbiddenAssetTestGroup: forbiddenAssetTestGroup) { status in
|
||||
switch status {
|
||||
case HTTPStatus.Forbidden:
|
||||
hasForbiddenAsset = true
|
||||
case HTTPStatus.PartialContent:
|
||||
hasAllowedAsset = true
|
||||
case HTTPStatus.OK:
|
||||
hasAllowedAsset = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let video = stream.videoAsset {
|
||||
stream.videoAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: video)
|
||||
forbiddenAssetTestGroup.wait()
|
||||
|
||||
// Post-processing code
|
||||
if instance.app == .invidious, hasForbiddenAsset || instance.proxiesVideos {
|
||||
if let audio = stream.audioAsset {
|
||||
stream.audioAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: audio)
|
||||
}
|
||||
if let video = stream.videoAsset {
|
||||
stream.videoAsset = InvidiousAPI.proxiedAsset(instance: instance, asset: video)
|
||||
}
|
||||
} else if instance.app == .piped, !instance.proxiesVideos, !hasForbiddenAsset {
|
||||
if let hlsURL = stream.hlsURL {
|
||||
PipedAPI.nonProxiedAsset(url: hlsURL) { possibleNonProxiedURL in
|
||||
if let nonProxiedURL = possibleNonProxiedURL {
|
||||
stream.hlsURL = nonProxiedURL.url
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let audio = stream.audioAsset {
|
||||
PipedAPI.nonProxiedAsset(asset: audio) { nonProxiedAudioAsset in
|
||||
stream.audioAsset = nonProxiedAudioAsset
|
||||
}
|
||||
}
|
||||
if let video = stream.videoAsset {
|
||||
PipedAPI.nonProxiedAsset(asset: video) { nonProxiedVideoAsset in
|
||||
stream.videoAsset = nonProxiedVideoAsset
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Append to processedStreams within the processedStreamsQueue
|
||||
processedStreamsQueue.sync {
|
||||
processedStreams.append(stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stream
|
||||
streamProcessingGroup.notify(queue: .main) {
|
||||
// Access and pass processedStreams within the processedStreamsQueue block
|
||||
processedStreamsQueue.sync {
|
||||
completion(processedStreams)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func streamsSorter(_ lhs: Stream, _ rhs: Stream) -> Bool {
|
||||
if lhs.resolution.isNil || rhs.resolution.isNil {
|
||||
private func getAssets(from streams: [Stream]) -> (nonHLSAssets: [(AVURLAsset?, URL, String?)], hlsURLs: [URL]) {
|
||||
var nonHLSAssets = [(AVURLAsset?, URL, String?)]()
|
||||
var hlsURLs = [URL]()
|
||||
|
||||
for stream in streams {
|
||||
if stream.isHLS {
|
||||
if let url = stream.hlsURL?.url {
|
||||
hlsURLs.append(url)
|
||||
}
|
||||
} else {
|
||||
if let asset = stream.audioAsset {
|
||||
nonHLSAssets.append((asset, asset.url, stream.requestRange))
|
||||
}
|
||||
if let asset = stream.videoAsset {
|
||||
nonHLSAssets.append((asset, asset.url, stream.requestRange))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (nonHLSAssets, hlsURLs)
|
||||
}
|
||||
|
||||
private func testAsset(url: URL, range: String?, isHLS: Bool, forbiddenAssetTestGroup: DispatchGroup, completion: @escaping (Int) -> Void) {
|
||||
// In case the range is nil, generate a random one.
|
||||
let randomEnd = Int.random(in: 200 ... 800)
|
||||
let requestRange = range ?? "0-\(randomEnd)"
|
||||
|
||||
forbiddenAssetTestGroup.enter()
|
||||
URLTester.testURLResponse(url: url, range: requestRange, isHLS: isHLS) { statusCode in
|
||||
completion(statusCode)
|
||||
forbiddenAssetTestGroup.leave()
|
||||
}
|
||||
}
|
||||
|
||||
private func testPipedAssets(asset: AVURLAsset, requestRange: String?, isHLS: Bool, forbiddenAssetTestGroup: DispatchGroup, completion: @escaping (Int) -> Void) {
|
||||
PipedAPI.nonProxiedAsset(asset: asset) { possibleNonProxiedAsset in
|
||||
if let nonProxiedAsset = possibleNonProxiedAsset {
|
||||
self.testAsset(url: nonProxiedAsset.url, range: requestRange, isHLS: isHLS, forbiddenAssetTestGroup: forbiddenAssetTestGroup, completion: completion)
|
||||
} else {
|
||||
completion(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func streamsSorter(lhs: Stream, rhs: Stream) -> Bool {
|
||||
// Use optional chaining to simplify nil handling
|
||||
guard let lhsRes = lhs.resolution?.height, let rhsRes = rhs.resolution?.height else {
|
||||
return lhs.kind < rhs.kind
|
||||
}
|
||||
|
||||
return lhs.kind == rhs.kind ? (lhs.resolution.height > rhs.resolution.height) : (lhs.kind < rhs.kind)
|
||||
// Compare either kind or resolution based on conditions
|
||||
return lhs.kind == rhs.kind ? (lhsRes > rhsRes) : (lhs.kind < rhs.kind)
|
||||
}
|
||||
}
|
||||
|
@ -16,10 +16,12 @@ struct ScreenSaverManager {
|
||||
return false
|
||||
}
|
||||
|
||||
noSleepReturn = IOPMAssertionCreateWithName(kIOPMAssertionTypeNoDisplaySleep as CFString,
|
||||
IOPMAssertionLevel(kIOPMAssertionLevelOn),
|
||||
reason as CFString,
|
||||
&noSleepAssertion)
|
||||
noSleepReturn = IOPMAssertionCreateWithName(
|
||||
kIOPMAssertionTypeNoDisplaySleep as CFString,
|
||||
IOPMAssertionLevel(kIOPMAssertionLevelOn),
|
||||
reason as CFString,
|
||||
&noSleepAssertion
|
||||
)
|
||||
return noSleepReturn == kIOReturnSuccess
|
||||
}
|
||||
|
||||
|
@ -60,7 +60,7 @@ struct Playlist: Identifiable, Equatable, Hashable {
|
||||
)
|
||||
}
|
||||
|
||||
static func == (lhs: Playlist, rhs: Playlist) -> Bool {
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.id == rhs.id && lhs.updated == rhs.updated
|
||||
}
|
||||
|
||||
|
@ -69,7 +69,10 @@ final class PlaylistsModel: ObservableObject {
|
||||
.onSuccess { resource in
|
||||
self.error = nil
|
||||
if let playlists: [Playlist] = resource.typedContent() {
|
||||
self.playlists = playlists
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.playlists = playlists
|
||||
}
|
||||
PlaylistsCacheModel.shared.storePlaylist(account: account, playlists: playlists)
|
||||
onSuccess()
|
||||
}
|
||||
|
@ -3,15 +3,15 @@ import Foundation
|
||||
|
||||
struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
|
||||
static var bridge = QualityProfileBridge()
|
||||
static var defaultProfile = Self(id: "default", backend: .mpv, resolution: .hd720p60, formats: [.stream])
|
||||
static var defaultProfile = Self(id: "default", backend: .mpv, resolution: .hd720p60, formats: [.stream], order: Array(Format.allCases.indices))
|
||||
|
||||
enum Format: String, CaseIterable, Identifiable, Defaults.Serializable {
|
||||
case hls
|
||||
case stream
|
||||
case mp4
|
||||
case avc1
|
||||
case av1
|
||||
case stream
|
||||
case webm
|
||||
case mp4
|
||||
case av1
|
||||
case hls
|
||||
|
||||
var id: String {
|
||||
rawValue
|
||||
@ -23,7 +23,6 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
|
||||
return "Stream"
|
||||
case .webm:
|
||||
return "WebM"
|
||||
|
||||
default:
|
||||
return rawValue.uppercased()
|
||||
}
|
||||
@ -31,18 +30,18 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
|
||||
|
||||
var streamFormat: Stream.Format? {
|
||||
switch self {
|
||||
case .hls:
|
||||
return nil
|
||||
case .stream:
|
||||
return nil
|
||||
case .mp4:
|
||||
return .mp4
|
||||
case .webm:
|
||||
return .webm
|
||||
case .avc1:
|
||||
return .avc1
|
||||
case .stream:
|
||||
return nil
|
||||
case .webm:
|
||||
return .webm
|
||||
case .mp4:
|
||||
return .mp4
|
||||
case .av1:
|
||||
return .av1
|
||||
case .hls:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -53,20 +52,23 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
|
||||
var backend: PlayerBackendType
|
||||
var resolution: ResolutionSetting
|
||||
var formats: [Format]
|
||||
|
||||
var order: [Int]
|
||||
var description: String {
|
||||
if let name, !name.isEmpty { return name }
|
||||
return "\(backend.label) - \(resolution.description) - \(formatsDescription)"
|
||||
}
|
||||
|
||||
var formatsDescription: String {
|
||||
if formats.count == Format.allCases.count {
|
||||
switch formats.count {
|
||||
case Format.allCases.count:
|
||||
return "Any format".localized()
|
||||
} else if formats.count <= 3 {
|
||||
case 0:
|
||||
return "No format selected".localized()
|
||||
case 1 ... 3:
|
||||
return formats.map(\.description).joined(separator: ", ")
|
||||
default:
|
||||
return String(format: "%@ formats".localized(), String(formats.count))
|
||||
}
|
||||
|
||||
return String(format: "%@ formats".localized(), String(formats.count))
|
||||
}
|
||||
|
||||
func isPreferred(_ stream: Stream) -> Bool {
|
||||
@ -74,7 +76,8 @@ struct QualityProfile: Hashable, Identifiable, Defaults.Serializable {
|
||||
return true
|
||||
}
|
||||
|
||||
let resolutionMatch = !stream.resolution.isNil && resolution.value >= stream.resolution
|
||||
let defaultResolution = Stream.Resolution.custom(height: 720, refreshRate: 30)
|
||||
let resolutionMatch = resolution.value ?? defaultResolution >= stream.resolution
|
||||
|
||||
if resolutionMatch, formats.contains(.stream), stream.kind == .stream {
|
||||
return true
|
||||
@ -100,7 +103,8 @@ struct QualityProfileBridge: Defaults.Bridge {
|
||||
"name": value.name ?? "",
|
||||
"backend": value.backend.rawValue,
|
||||
"resolution": value.resolution.rawValue,
|
||||
"formats": value.formats.map { $0.rawValue }.joined(separator: Self.formatsSeparator)
|
||||
"formats": value.formats.map(\.rawValue).joined(separator: Self.formatsSeparator),
|
||||
"order": value.order.map { String($0) }.joined(separator: Self.formatsSeparator) // New line
|
||||
]
|
||||
}
|
||||
|
||||
@ -115,7 +119,8 @@ struct QualityProfileBridge: Defaults.Bridge {
|
||||
|
||||
let name = object["name"]
|
||||
let formats = (object["formats"] ?? "").components(separatedBy: Self.formatsSeparator).compactMap { QualityProfile.Format(rawValue: $0) }
|
||||
let order = (object["order"] ?? "").components(separatedBy: Self.formatsSeparator).compactMap { Int($0) }
|
||||
|
||||
return .init(id: id, name: name, backend: backend, resolution: resolution, formats: formats)
|
||||
return .init(id: id, name: name, backend: backend, resolution: resolution, formats: formats, order: order)
|
||||
}
|
||||
}
|
||||
|
@ -62,6 +62,12 @@ final class RecentsModel: ObservableObject {
|
||||
return nil
|
||||
}
|
||||
|
||||
var presentedItem: RecentItem? {
|
||||
guard let recent = items.last else { return nil }
|
||||
|
||||
return recent
|
||||
}
|
||||
|
||||
static func symbolSystemImage(_ name: String) -> String {
|
||||
let firstLetter = name.first?.lowercased()
|
||||
let regex = #"^[a-z0-9]$"#
|
||||
|
@ -18,6 +18,14 @@ final class SearchModel: ObservableObject {
|
||||
|
||||
@Published var focused = false
|
||||
|
||||
@Default(.showSearchSuggestions) private var showSearchSuggestions
|
||||
|
||||
#if os(iOS)
|
||||
var textField: UITextField!
|
||||
#elseif os(macOS)
|
||||
var textField: NSTextField!
|
||||
#endif
|
||||
|
||||
var accounts: AccountsModel { .shared }
|
||||
private var resource: Resource!
|
||||
|
||||
@ -96,7 +104,7 @@ final class SearchModel: ObservableObject {
|
||||
}}
|
||||
|
||||
func loadSuggestions(_ query: String) {
|
||||
guard accounts.app.supportsSearchSuggestions else {
|
||||
guard accounts.app.supportsSearchSuggestions, showSearchSuggestions else {
|
||||
querySuggestions.removeAll()
|
||||
return
|
||||
}
|
||||
|
@ -71,13 +71,13 @@ final class SeekModel: ObservableObject {
|
||||
func showOSD() {
|
||||
guard !presentingOSD else { return }
|
||||
|
||||
withAnimation(.easeIn(duration: 0.1)) { self.presentingOSD = true }
|
||||
presentingOSD = true
|
||||
}
|
||||
|
||||
func hideOSD() {
|
||||
guard presentingOSD else { return }
|
||||
|
||||
withAnimation(.easeIn(duration: 0.1)) { self.presentingOSD = false }
|
||||
presentingOSD = false
|
||||
}
|
||||
|
||||
func hideOSDWithDelay() {
|
||||
|
@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
enum SeekType: Equatable {
|
||||
case chapterSkip(String)
|
||||
case segmentSkip(String)
|
||||
case segmentRestore
|
||||
case userInteracted
|
||||
|
@ -7,6 +7,9 @@ final class SettingsModel: ObservableObject {
|
||||
@Published var presentingAlert = false
|
||||
@Published var alert = Alert(title: Text("Error"))
|
||||
|
||||
@Published var presentingSettingsImportSheet = false
|
||||
@Published var settingsImportURL: URL?
|
||||
|
||||
func presentAlert(title: String, message: String? = nil) {
|
||||
let message = message.isNil ? nil : Text(message!)
|
||||
alert = Alert(title: Text(title), message: message)
|
||||
@ -17,4 +20,9 @@ final class SettingsModel: ObservableObject {
|
||||
self.alert = alert
|
||||
presentingAlert = true
|
||||
}
|
||||
|
||||
func presentSettingsImportSheet(_ url: URL) {
|
||||
settingsImportURL = url
|
||||
presentingSettingsImportSheet = true
|
||||
}
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import Logging
|
||||
import SwiftyJSON
|
||||
|
||||
final class SponsorBlockAPI: ObservableObject {
|
||||
static let categories = ["sponsor", "selfpromo", "intro", "outro", "interaction", "music_offtopic"]
|
||||
static let categories = ["sponsor", "selfpromo", "interaction", "intro", "outro", "preview", "filler", "music_offtopic"]
|
||||
|
||||
let logger = Logger(label: "stream.yattee.app.sb")
|
||||
|
||||
@ -13,7 +13,7 @@ final class SponsorBlockAPI: ObservableObject {
|
||||
@Published var segments = [Segment]()
|
||||
|
||||
static func categoryDescription(_ name: String) -> String? {
|
||||
guard Self.categories.contains(name) else {
|
||||
guard categories.contains(name) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -21,22 +21,26 @@ final class SponsorBlockAPI: ObservableObject {
|
||||
case "sponsor":
|
||||
return "Sponsor".localized()
|
||||
case "selfpromo":
|
||||
return "Self-promotion".localized()
|
||||
case "intro":
|
||||
return "Intro".localized()
|
||||
case "outro":
|
||||
return "Outro".localized()
|
||||
return "Unpaid/Self Promotion".localized()
|
||||
case "interaction":
|
||||
return "Interaction".localized()
|
||||
return "Interaction Reminder (Subscribe)".localized()
|
||||
case "intro":
|
||||
return "Intermission/Intro Animation".localized()
|
||||
case "outro":
|
||||
return "Endcards/Credits".localized()
|
||||
case "preview":
|
||||
return "Preview/Recap/Hook".localized()
|
||||
case "filler":
|
||||
return "Filler Tangent/Jokes".localized()
|
||||
case "music_offtopic":
|
||||
return "Offtopic in Music Videos".localized()
|
||||
return "Music: Non-Music Section".localized()
|
||||
default:
|
||||
return name.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
static func categoryDetails(_ name: String) -> String? {
|
||||
guard Self.categories.contains(name) else {
|
||||
guard categories.contains(name) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -46,9 +50,14 @@ final class SponsorBlockAPI: ObservableObject {
|
||||
"The creator will receive payment or compensation in the form of money or free products.").localized()
|
||||
|
||||
case "selfpromo":
|
||||
return ("Promoting a product or service that is directly related to the creator themselves. " +
|
||||
return ("The creator will not receive any payment in exchange for this promotion. " +
|
||||
"This includes charity drives or free shout outs for products or other people they like.\n\n" +
|
||||
"Promoting a product or service that is directly related to the creator themselves. " +
|
||||
"This usually includes merchandise or promotion of monetized platforms.").localized()
|
||||
|
||||
case "interaction":
|
||||
return "Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video).".localized()
|
||||
|
||||
case "intro":
|
||||
return ("Segments typically found at the start of a video that include an animation, " +
|
||||
"still frame or clip which are also seen in other videos by the same creator.").localized()
|
||||
@ -56,8 +65,11 @@ final class SponsorBlockAPI: ObservableObject {
|
||||
case "outro":
|
||||
return "Typically near or at the end of the video when the credits pop up and/or endcards are shown.".localized()
|
||||
|
||||
case "interaction":
|
||||
return "Explicit reminders to like, subscribe or interact with them on any paid or free platform(s) (e.g. click on a video).".localized()
|
||||
case "preview":
|
||||
return "Collection of clips that show what is coming up in in this video or other videos in a series where all information is repeated later in the video".localized()
|
||||
|
||||
case "filler":
|
||||
return "Filler Tangent/ Jokes is only for tangential scenes added only for filler or humor that are not required to understand the main content of the video.".localized()
|
||||
|
||||
case "music_offtopic":
|
||||
return "For videos which feature music as the primary content.".localized()
|
||||
@ -100,8 +112,8 @@ final class SponsorBlockAPI: ObservableObject {
|
||||
self.segments = JSON(value).arrayValue.map(SponsorBlockSegment.init).sorted { $0.end < $1.end }
|
||||
|
||||
self.logger.info("loaded \(self.segments.count) SponsorBlock segments")
|
||||
self.segments.forEach {
|
||||
self.logger.info("\($0.start) -> \($0.end)")
|
||||
for segment in self.segments {
|
||||
self.logger.info("\(segment.start) -> \(segment.end)")
|
||||
}
|
||||
case let .failure(error):
|
||||
self.segments = []
|
||||
|
@ -4,7 +4,7 @@ import Siesta
|
||||
final class Store<Data>: ResourceObserver, ObservableObject {
|
||||
@Published private var all: Data?
|
||||
|
||||
var collection: Data { all ?? ([] as! Data) }
|
||||
var collection: Data { all ?? ([item].compactMap { $0 } as! Data) }
|
||||
var item: Data? { all }
|
||||
|
||||
init(_ data: Data? = nil) {
|
||||
|
@ -4,67 +4,130 @@ import Foundation
|
||||
|
||||
// swiftlint:disable:next final_class
|
||||
class Stream: Equatable, Hashable, Identifiable {
|
||||
enum Resolution: String, CaseIterable, Comparable, Defaults.Serializable {
|
||||
case hd2160p60
|
||||
case hd2160p50
|
||||
case hd2160p48
|
||||
case hd2160p30
|
||||
case hd1440p60
|
||||
case hd1440p50
|
||||
case hd1440p48
|
||||
case hd1440p30
|
||||
case hd1080p60
|
||||
case hd1080p50
|
||||
case hd1080p48
|
||||
case hd1080p30
|
||||
case hd720p60
|
||||
case hd720p50
|
||||
case hd720p48
|
||||
case hd720p30
|
||||
case sd480p30
|
||||
case sd360p30
|
||||
case sd240p30
|
||||
case sd144p30
|
||||
case unknown
|
||||
enum Resolution: Comparable, Codable, Defaults.Serializable {
|
||||
case predefined(PredefinedResolution)
|
||||
case custom(height: Int, refreshRate: Int)
|
||||
|
||||
enum PredefinedResolution: String, CaseIterable, Codable {
|
||||
// 8K UHD (16:9) Resolutions
|
||||
case hd4320p60, hd4320p30
|
||||
|
||||
// 4K UHD (16:9) Resolutions
|
||||
case hd2160p60, hd2160p30
|
||||
|
||||
// 1440p (16:9) Resolutions
|
||||
case hd1440p60, hd1440p30
|
||||
|
||||
// 1080p (Full HD, 16:9) Resolutions
|
||||
case hd1080p60, hd1080p30
|
||||
|
||||
// 720p (HD, 16:9) Resolutions
|
||||
case hd720p60, hd720p30
|
||||
|
||||
// Standard Definition (SD) Resolutions
|
||||
case sd480p30
|
||||
case sd360p30
|
||||
case sd240p30
|
||||
case sd144p30
|
||||
}
|
||||
|
||||
var name: String {
|
||||
"\(height)p\(refreshRate != -1 && refreshRate != 30 ? ", \(refreshRate) fps" : "")"
|
||||
switch self {
|
||||
case let .predefined(predefined):
|
||||
return predefined.rawValue
|
||||
case let .custom(height, refreshRate):
|
||||
return "\(height)p\(refreshRate != 30 ? ", \(refreshRate) fps" : "")"
|
||||
}
|
||||
}
|
||||
|
||||
var height: Int {
|
||||
if self == .unknown {
|
||||
return -1
|
||||
switch self {
|
||||
case let .predefined(predefined):
|
||||
return predefined.height
|
||||
case let .custom(height, _):
|
||||
return height
|
||||
}
|
||||
|
||||
let resolutionPart = rawValue.components(separatedBy: "p").first!
|
||||
return Int(resolutionPart.components(separatedBy: CharacterSet.decimalDigits.inverted).joined())!
|
||||
}
|
||||
|
||||
var refreshRate: Int {
|
||||
if self == .unknown {
|
||||
return -1
|
||||
switch self {
|
||||
case let .predefined(predefined):
|
||||
return predefined.refreshRate
|
||||
case let .custom(_, refreshRate):
|
||||
return refreshRate
|
||||
}
|
||||
|
||||
let refreshRatePart = rawValue.components(separatedBy: "p")[1]
|
||||
|
||||
if refreshRatePart.isEmpty {
|
||||
return 30
|
||||
}
|
||||
|
||||
return Int(refreshRatePart.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? -1
|
||||
}
|
||||
|
||||
static func from(resolution: String, fps: Int? = nil) -> Resolution {
|
||||
allCases.first { $0.rawValue.contains(resolution) && $0.refreshRate == (fps ?? 30) } ?? .unknown
|
||||
var bitrate: Int {
|
||||
switch self {
|
||||
case let .predefined(predefined):
|
||||
return predefined.bitrate
|
||||
case let .custom(height, refreshRate):
|
||||
// Find the closest predefined resolution based on height and refresh rate
|
||||
let closestPredefined = Stream.Resolution.PredefinedResolution.allCases.min {
|
||||
abs($0.height - height) + abs($0.refreshRate - refreshRate) <
|
||||
abs($1.height - height) + abs($1.refreshRate - refreshRate)
|
||||
}
|
||||
// Return the bitrate of the closest predefined resolution or a default bitrate if no close match is found
|
||||
return closestPredefined?.bitrate ?? 5_000_000
|
||||
}
|
||||
}
|
||||
|
||||
static func < (lhs: Resolution, rhs: Resolution) -> Bool {
|
||||
static func from(resolution: String, fps: Int? = nil) -> Self {
|
||||
if let predefined = PredefinedResolution(rawValue: resolution) {
|
||||
return .predefined(predefined)
|
||||
}
|
||||
|
||||
// Attempt to parse height and refresh rate
|
||||
if let height = Int(resolution.components(separatedBy: "p").first ?? ""), height > 0 {
|
||||
let refreshRate = fps ?? 30
|
||||
return .custom(height: height, refreshRate: refreshRate)
|
||||
}
|
||||
|
||||
// Default behavior if parsing fails
|
||||
return .custom(height: 720, refreshRate: 30)
|
||||
}
|
||||
|
||||
static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.height == rhs.height ? (lhs.refreshRate < rhs.refreshRate) : (lhs.height < rhs.height)
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case predefined
|
||||
case custom
|
||||
case height
|
||||
case refreshRate
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
if let predefinedValue = try? container.decode(PredefinedResolution.self, forKey: .predefined) {
|
||||
self = .predefined(predefinedValue)
|
||||
} else if let height = try? container.decode(Int.self, forKey: .height),
|
||||
let refreshRate = try? container.decode(Int.self, forKey: .refreshRate)
|
||||
{
|
||||
self = .custom(height: height, refreshRate: refreshRate)
|
||||
} else {
|
||||
// Set default resolution to 720p 30 if decoding fails
|
||||
self = .custom(height: 720, refreshRate: 30)
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
switch self {
|
||||
case let .predefined(predefinedValue):
|
||||
try container.encode(predefinedValue, forKey: .predefined)
|
||||
case let .custom(height, refreshRate):
|
||||
try container.encode(height, forKey: .height)
|
||||
try container.encode(refreshRate, forKey: .refreshRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Kind: String, Comparable {
|
||||
case stream, adaptive, hls
|
||||
case hls, adaptive, stream
|
||||
|
||||
private var sortOrder: Int {
|
||||
switch self {
|
||||
@ -77,42 +140,28 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
static func < (lhs: Kind, rhs: Kind) -> Bool {
|
||||
static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.sortOrder < rhs.sortOrder
|
||||
}
|
||||
}
|
||||
|
||||
enum Format: String, Comparable {
|
||||
case webm
|
||||
enum Format: String {
|
||||
case avc1
|
||||
case av1
|
||||
case mp4
|
||||
case av1
|
||||
case webm
|
||||
case hls
|
||||
case stream
|
||||
case unknown
|
||||
|
||||
private var sortOrder: Int {
|
||||
switch self {
|
||||
case .mp4:
|
||||
return 0
|
||||
case .avc1:
|
||||
return 1
|
||||
case .av1:
|
||||
return 2
|
||||
case .webm:
|
||||
return 3
|
||||
case .unknown:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.sortOrder < rhs.sortOrder
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .webm:
|
||||
return "WebM"
|
||||
|
||||
case .hls:
|
||||
return "adaptive (HLS)"
|
||||
case .stream:
|
||||
return "Stream"
|
||||
default:
|
||||
return rawValue.uppercased()
|
||||
}
|
||||
@ -121,17 +170,25 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
static func from(_ string: String) -> Self {
|
||||
let lowercased = string.lowercased()
|
||||
|
||||
if lowercased.contains("avc1") {
|
||||
return .avc1
|
||||
}
|
||||
if lowercased.contains("mpeg_4") || lowercased.contains("mp4") {
|
||||
return .mp4
|
||||
}
|
||||
if lowercased.contains("av01") {
|
||||
return .av1
|
||||
}
|
||||
if lowercased.contains("webm") {
|
||||
return .webm
|
||||
} else if lowercased.contains("avc1") {
|
||||
return .avc1
|
||||
} else if lowercased.contains("av01") {
|
||||
return .av1
|
||||
} else if lowercased.contains("mpeg_4") || lowercased.contains("mp4") {
|
||||
return .mp4
|
||||
} else {
|
||||
return .unknown
|
||||
}
|
||||
if lowercased.contains("stream") {
|
||||
return .stream
|
||||
}
|
||||
if lowercased.contains("hls") {
|
||||
return .hls
|
||||
}
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
|
||||
@ -149,6 +206,8 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
|
||||
var encoding: String?
|
||||
var videoFormat: String?
|
||||
var bitrate: Int?
|
||||
var requestRange: String?
|
||||
|
||||
init(
|
||||
instance: Instance? = nil,
|
||||
@ -159,7 +218,9 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
resolution: Resolution? = nil,
|
||||
kind: Kind = .hls,
|
||||
encoding: String? = nil,
|
||||
videoFormat: String? = nil
|
||||
videoFormat: String? = nil,
|
||||
bitrate: Int? = nil,
|
||||
requestRange: String? = nil
|
||||
) {
|
||||
self.instance = instance
|
||||
self.audioAsset = audioAsset
|
||||
@ -170,6 +231,8 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
self.kind = kind
|
||||
self.encoding = encoding
|
||||
format = .from(videoFormat ?? "")
|
||||
self.bitrate = bitrate
|
||||
self.requestRange = requestRange
|
||||
}
|
||||
|
||||
var isLocal: Bool {
|
||||
@ -182,23 +245,31 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
|
||||
var quality: String {
|
||||
guard localURL.isNil else { return "Opened File" }
|
||||
return kind == .hls ? "adaptive (HLS)" : "\(resolution.name)\(kind == .stream ? " (\(kind.rawValue))" : "")"
|
||||
|
||||
if kind == .hls {
|
||||
return "adaptive (HLS)"
|
||||
}
|
||||
|
||||
return resolution.name
|
||||
}
|
||||
|
||||
var shortQuality: String {
|
||||
guard localURL.isNil else { return "File" }
|
||||
|
||||
if kind == .hls {
|
||||
return "HLS"
|
||||
} else {
|
||||
return resolution?.name ?? "?"
|
||||
return "adaptive (HLS)"
|
||||
}
|
||||
|
||||
if kind == .stream {
|
||||
return resolution.name
|
||||
}
|
||||
return resolutionAndFormat
|
||||
}
|
||||
|
||||
var description: String {
|
||||
guard localURL.isNil else { return resolutionAndFormat }
|
||||
let instanceString = instance.isNil ? "" : " - (\(instance!.description))"
|
||||
return "\(resolutionAndFormat)\(instanceString)"
|
||||
return format != .hls ? "\(resolutionAndFormat)\(instanceString)" : "adaptive (HLS)\(instanceString)"
|
||||
}
|
||||
|
||||
var resolutionAndFormat: String {
|
||||
@ -221,7 +292,8 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
|
||||
if kind == .hls {
|
||||
return hlsURL
|
||||
} else if videoAssetContainsAudio {
|
||||
}
|
||||
if videoAssetContainsAudio {
|
||||
return videoAsset.url
|
||||
}
|
||||
|
||||
@ -244,3 +316,97 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Stream.Resolution.PredefinedResolution {
|
||||
var height: Int {
|
||||
switch self {
|
||||
// 8K UHD (16:9) Resolutions
|
||||
case .hd4320p60, .hd4320p30:
|
||||
return 4320
|
||||
|
||||
// 4K UHD (16:9) Resolutions
|
||||
case .hd2160p60, .hd2160p30:
|
||||
return 2160
|
||||
|
||||
// 1440p (16:9) Resolutions
|
||||
case .hd1440p60, .hd1440p30:
|
||||
return 1440
|
||||
|
||||
// 1080p (Full HD, 16:9) Resolutions
|
||||
case .hd1080p60, .hd1080p30:
|
||||
return 1080
|
||||
|
||||
// 720p (HD, 16:9) Resolutions
|
||||
case .hd720p60, .hd720p30:
|
||||
return 720
|
||||
|
||||
// Standard Definition (SD) Resolutions
|
||||
case .sd480p30:
|
||||
return 480
|
||||
|
||||
case .sd360p30:
|
||||
return 360
|
||||
|
||||
case .sd240p30:
|
||||
return 240
|
||||
|
||||
case .sd144p30:
|
||||
return 144
|
||||
}
|
||||
}
|
||||
|
||||
var refreshRate: Int {
|
||||
switch self {
|
||||
// 60 fps Resolutions
|
||||
case .hd4320p60, .hd2160p60, .hd1440p60, .hd1080p60, .hd720p60:
|
||||
return 60
|
||||
|
||||
// 30 fps Resolutions
|
||||
case .hd4320p30, .hd2160p30, .hd1440p30, .hd1080p30, .hd720p30,
|
||||
.sd480p30, .sd360p30, .sd240p30, .sd144p30:
|
||||
return 30
|
||||
}
|
||||
}
|
||||
|
||||
// These values are an approximation.
|
||||
// https://support.google.com/youtube/answer/1722171?hl=en#zippy=%2Cbitrate
|
||||
|
||||
var bitrate: Int {
|
||||
switch self {
|
||||
// 8K UHD (16:9) Resolutions
|
||||
case .hd4320p60:
|
||||
return 180_000_000 // Midpoint between 120 Mbps and 240 Mbps
|
||||
case .hd4320p30:
|
||||
return 120_000_000 // Midpoint between 80 Mbps and 160 Mbps
|
||||
// 4K UHD (16:9) Resolutions
|
||||
case .hd2160p60:
|
||||
return 60_500_000 // Midpoint between 53 Mbps and 68 Mbps
|
||||
case .hd2160p30:
|
||||
return 40_000_000 // Midpoint between 35 Mbps and 45 Mbps
|
||||
// 1440p (2K) Resolutions
|
||||
case .hd1440p60:
|
||||
return 24_000_000 // 24 Mbps
|
||||
case .hd1440p30:
|
||||
return 16_000_000 // 16 Mbps
|
||||
// 1080p (Full HD, 16:9) Resolutions
|
||||
case .hd1080p60:
|
||||
return 12_000_000 // 12 Mbps
|
||||
case .hd1080p30:
|
||||
return 8_000_000 // 8 Mbps
|
||||
// 720p (HD, 16:9) Resolutions
|
||||
case .hd720p60:
|
||||
return 7_500_000 // 7.5 Mbps
|
||||
case .hd720p30:
|
||||
return 5_000_000 // 5 Mbps
|
||||
// Standard Definition (SD) Resolutions
|
||||
case .sd480p30:
|
||||
return 2_500_000 // 2.5 Mbps
|
||||
case .sd360p30:
|
||||
return 1_000_000 // 1 Mbps
|
||||
case .sd240p30:
|
||||
return 1_000_000 // 1 Mbps
|
||||
case .sd144p30:
|
||||
return 600_000 // 0.6 Mbps
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -5,10 +5,27 @@ final class ThumbnailsModel: ObservableObject {
|
||||
static var shared = ThumbnailsModel()
|
||||
|
||||
@Published var unloadable = Set<URL>()
|
||||
private var retryCounts = [URL: Int]()
|
||||
private let maxRetries = 3
|
||||
private let retryDelay: TimeInterval = 1.0
|
||||
|
||||
func insertUnloadable(_ url: URL) {
|
||||
DispatchQueue.main.async {
|
||||
self.unloadable.insert(url)
|
||||
let retries = (retryCounts[url] ?? 0) + 1
|
||||
|
||||
if retries >= maxRetries {
|
||||
DispatchQueue.main.async {
|
||||
self.unloadable.insert(url)
|
||||
self.retryCounts.removeValue(forKey: url)
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
self.retryCounts[url] = retries
|
||||
}
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + retryDelay) {
|
||||
DispatchQueue.main.async {
|
||||
self.retryCounts[url] = retries
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,21 +37,23 @@ final class ThumbnailsModel: ObservableObject {
|
||||
return unloadable.contains(url)
|
||||
}
|
||||
|
||||
func best(_ video: Video) -> URL? {
|
||||
func best(_ video: Video) -> (url: URL?, quality: Thumbnail.Quality?) {
|
||||
for quality in availableQualitites {
|
||||
let url = video.thumbnailURL(quality: quality)
|
||||
if !isUnloadable(url) {
|
||||
return url
|
||||
return (url, quality)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return (nil, nil)
|
||||
}
|
||||
|
||||
private var availableQualitites: [Thumbnail.Quality] {
|
||||
switch Defaults[.thumbnailsQuality] {
|
||||
case .highest:
|
||||
return [.maxresdefault, .medium, .default]
|
||||
return [.maxres, .high, .medium, .default]
|
||||
case .high:
|
||||
return [.high, .medium, .default]
|
||||
case .medium:
|
||||
return [.medium, .default]
|
||||
case .low:
|
||||
|
@ -45,7 +45,7 @@ struct URLBookmarkModel {
|
||||
func saveBookmark(_ url: NSURL) {
|
||||
guard url.isFileURL else {
|
||||
logger.error("trying to save bookmark for something that is not a file")
|
||||
logger.error("not a file: \(url.absoluteString)")
|
||||
logger.error("not a file: \(url.absoluteString ?? "unknown")")
|
||||
return
|
||||
}
|
||||
|
||||
@ -114,7 +114,7 @@ struct URLBookmarkModel {
|
||||
func refreshAll() {
|
||||
logger.info("refreshing all bookmarks")
|
||||
|
||||
allURLs.forEach { url in
|
||||
for url in allURLs {
|
||||
if loadBookmark(url) != nil {
|
||||
logger.info("bookmark for \(url) exists")
|
||||
} else {
|
||||
|
@ -53,7 +53,7 @@ struct Video: Identifiable, Equatable, Hashable {
|
||||
|
||||
var channel: Channel
|
||||
|
||||
var related = [Video]()
|
||||
var related = [Self]()
|
||||
var chapters = [Chapter]()
|
||||
|
||||
var captions = [Captions]()
|
||||
@ -83,7 +83,7 @@ struct Video: Identifiable, Equatable, Hashable {
|
||||
dislikes: Int? = nil,
|
||||
keywords: [String] = [],
|
||||
streams: [Stream] = [],
|
||||
related: [Video] = [],
|
||||
related: [Self] = [],
|
||||
chapters: [Chapter] = [],
|
||||
captions: [Captions] = []
|
||||
) {
|
||||
@ -116,7 +116,7 @@ struct Video: Identifiable, Equatable, Hashable {
|
||||
self.captions = captions
|
||||
}
|
||||
|
||||
static func local(_ url: URL) -> Video {
|
||||
static func local(_ url: URL) -> Self {
|
||||
Self(
|
||||
app: .local,
|
||||
videoID: url.absoluteString,
|
||||
@ -249,7 +249,7 @@ struct Video: Identifiable, Equatable, Hashable {
|
||||
thumbnails.first { $0.quality == quality }?.url
|
||||
}
|
||||
|
||||
static func == (lhs: Video, rhs: Video) -> Bool {
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
let videoIDIsEqual = lhs.videoID == rhs.videoID
|
||||
|
||||
if !lhs.indexID.isNil, !rhs.indexID.isNil {
|
||||
@ -290,8 +290,8 @@ struct Video: Identifiable, Equatable, Hashable {
|
||||
}
|
||||
|
||||
var localStreamIsFile: Bool {
|
||||
guard let localStream else { return false }
|
||||
return localStream.localURL.isFileURL
|
||||
guard let url = localStream?.localURL else { return false }
|
||||
return url.isFileURL
|
||||
}
|
||||
|
||||
var localStreamIsRemoteURL: Bool {
|
||||
|
@ -39,6 +39,7 @@ final class URLParserTests: XCTestCase {
|
||||
"https://www.youtube.com/playlist?list=PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU": "PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU",
|
||||
"https://www.youtube.com/watch?v=playlist&list=PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU": "PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU",
|
||||
"youtube.com/watch?v=playlist&list=PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU": "PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU",
|
||||
"https://www.youtube.com/watch?v=ZyhrYis509A&list=PL7DA3D097D6FDBC02": "PL7DA3D097D6FDBC02",
|
||||
"/watch?v=playlist&list=PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU": "PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU",
|
||||
"watch?v=playlist&list=PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU": "PLDIoUOhQQPlXr63I_vwF9GD8sAKh77dWU",
|
||||
"playlist?list=ABCDE": "ABCDE"
|
||||
@ -56,7 +57,7 @@ final class URLParserTests: XCTestCase {
|
||||
]
|
||||
|
||||
func testUrlsParsing() throws {
|
||||
Self.urls.forEach { urlString in
|
||||
for urlString in Self.urls {
|
||||
let url = URL(string: urlString)!
|
||||
let parser = URLParser(url: url)
|
||||
XCTAssertEqual(parser.destination, .fileURL)
|
||||
@ -65,7 +66,7 @@ final class URLParserTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testVideosParsing() throws {
|
||||
Self.videos.forEach { url, id in
|
||||
for (url, id) in Self.videos {
|
||||
let parser = URLParser(url: URL(string: url)!)
|
||||
XCTAssertEqual(parser.destination, .video)
|
||||
XCTAssertEqual(parser.videoID, id)
|
||||
@ -73,7 +74,7 @@ final class URLParserTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testChannelsByNameParsing() throws {
|
||||
Self.channelsByName.forEach { url, name in
|
||||
for (url, name) in Self.channelsByName {
|
||||
let parser = URLParser(url: URL(string: url)!)
|
||||
XCTAssertEqual(parser.destination, .channel)
|
||||
XCTAssertEqual(parser.channelName, name)
|
||||
@ -82,7 +83,7 @@ final class URLParserTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testChannelsByIdParsing() throws {
|
||||
Self.channelsByID.forEach { url, id in
|
||||
for (url, id) in Self.channelsByID {
|
||||
let parser = URLParser(url: URL(string: url)!)
|
||||
XCTAssertEqual(parser.destination, .channel)
|
||||
XCTAssertEqual(parser.channelID, id)
|
||||
@ -91,7 +92,7 @@ final class URLParserTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testUsersParsing() throws {
|
||||
Self.users.forEach { url, user in
|
||||
for (url, user) in Self.users {
|
||||
let parser = URLParser(url: URL(string: url)!)
|
||||
XCTAssertEqual(parser.destination, .channel)
|
||||
XCTAssertNil(parser.channelID)
|
||||
@ -101,7 +102,7 @@ final class URLParserTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testPlaylistsParsing() throws {
|
||||
Self.playlists.forEach { url, id in
|
||||
for (url, id) in Self.playlists {
|
||||
let parser = URLParser(url: URL(string: url)!)
|
||||
XCTAssertEqual(parser.destination, .playlist)
|
||||
XCTAssertEqual(parser.playlistID, id)
|
||||
@ -109,7 +110,7 @@ final class URLParserTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testSearchesParsing() throws {
|
||||
Self.searches.forEach { url, query in
|
||||
for (url, query) in Self.searches {
|
||||
let parser = URLParser(url: URL(string: url)!)
|
||||
XCTAssertEqual(parser.destination, .search)
|
||||
XCTAssertEqual(parser.searchQuery, query)
|
||||
@ -126,7 +127,7 @@ final class URLParserTests: XCTestCase {
|
||||
"watch?v=IUTGFQpKaPU&t=30s": 30
|
||||
]
|
||||
|
||||
samples.forEach { url, time in
|
||||
for (url, time) in samples {
|
||||
XCTAssertEqual(
|
||||
URLParser(url: URL(string: url)!).time,
|
||||
time
|
||||
|
@ -1,15 +1,17 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Invidious.svg",
|
||||
"filename" : "Invidious_512x512@1x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "Invidious_512x512@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "Invidious_512x512@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
|
@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="512pt" height="512pt" version="1.0" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><g><rect x="-.0072516" y=".00056299" width="512.01" height="512.02" fill="#575757" stroke-width=".063019"/><path d="m247.17 455.95c-19.792-0.78921-38.719-4.2564-57.154-10.47-60.968-20.55-108.68-68.579-127-127.86-7.8955-25.538-10.062-53.943-6.2586-82.067 3.7105-27.439 13.603-53.515 29.342-77.344 12.069-18.273 29.138-36.277 47.228-49.816 36.891-27.61 85.944-42.49 132.38-40.157 25.88 1.3001 49.939 6.765 73.106 16.606 8.1948 3.481 20.024 9.6845 27.696 14.525 14.15 8.9272 22.367 15.498 34.482 27.573 13.254 13.211 22.128 24.276 30.398 37.906 7.2081 11.879 14.099 27.15 18.229 40.397 1.5996 5.1305 4.442 16.456 5.6852 22.653 2.3908 11.917 2.6998 15.722 2.7049 33.312 6e-3 18.515-0.46256 24.413-2.9166 36.758-9.3274 46.92-35.58 88.167-74.872 117.64-22.814 17.112-50.027 29.535-78.547 35.858-16.714 3.7059-35.421 5.2453-54.498 4.4846zm-35.1-78.786c-5.3e-4 -0.52647-0.0741-2.0564-0.16311-3.3999l-0.16178-2.4427-4.7018-0.26271c-4.0477-0.22614-4.7968-0.33363-5.3847-0.77253-2.0235-1.5108-1.4679-6.0695 2.2494-18.457 0.8637-2.8781 3.3371-11.321 5.4966-18.762 2.1594-7.4409 5.2002-17.836 6.7573-23.101 1.5571-5.2648 4.1948-14.282 5.8615-20.038 1.6667-5.7562 3.6145-12.4 4.3284-14.764 0.71391-2.3641 3.2583-11.037 5.6542-19.272 4.9475-17.007 8.1626-27.723 8.9438-29.811 0.51852-1.3858 0.54785-1.4139 0.99761-0.95317 0.25486 0.26106 3.8462 7.3667 7.9807 15.79 4.1345 8.4236 13.089 26.573 19.898 40.331 17.188 34.73 37.849 76.578 43.261 87.622l4.5356 9.257 11.359-0.0895c6.2475-0.0492 11.615-0.19623 11.929-0.32672 0.5614-0.23385 0.54167-0.2959-1.3723-4.3176-1.068-2.2442-8.1436-16.601-15.724-31.904-48.687-98.293-61.22-123.86-67.889-138.48-4.7022-10.309-6.9031-14.807-7.7139-15.762-0.82931-0.97742-1.6319-1.0638-2.3704-0.25525-1.1993 1.313-4.1046 10.063-9.3869 28.27-2.0569 7.0899-6.5372 22.425-9.9562 34.077-6.6396 22.629-8.5182 29.037-14.33 48.883-2.0354 6.9495-4.7977 16.369-6.1385 20.931-1.3408 4.5628-4.033 13.81-5.9826 20.549-4.304 14.877-6.136 20.889-7.3886 24.25-2.1371 5.7334-2.5723 6.3292-4.9216 6.7384-0.88855 0.15472-2.4102 0.28196-3.3815 0.28275-2.1993 3e-3 -3.5494 0.36339-4.0558 1.0863-0.42176 0.60215-0.56421 4.8802-0.18251 5.4812 0.20573 0.32388 2.4672 0.37414 23.34 0.51873l8.6151 0.0597-7e-4 -0.95723zm36.751-205.59c4.3282-0.92335 8.4607-4.943 9.4374-9.1796 0.36569-1.5862 0.32543-4.9758-0.077-6.4799-0.85108-3.1813-3.2688-6.291-6.039-7.7675-3.8111-2.0313-9.456-2.0295-13.272 5e-3 -5.9828 3.1888-8.1556 11.089-4.7878 17.408 2.6995 5.0648 8.3611 7.3754 14.738 6.015z" fill="#f0f0f0" stroke-width=".025526"/></g><g transform="matrix(.069892 0 0 -.069892 44.236 474.48)"><path d="m2787 4669c-124-65-123-255 3-319 86-44 196-16 247 62 58 87 26 211-67 258-51 26-132 26-183-1z" fill="#00b6f0" stroke="#00b6f0" stroke-width="4.25"/><path d="m2882 4108c-12-16-63-166-102-303-30-104-101-350-165-565-20-69-58-199-85-290-26-91-64-221-85-290-20-69-58-199-85-290-26-91-64-221-85-290-20-69-57-195-81-280-59-207-93-299-115-310-10-6-35-10-56-10-73 0-84-8-81-54l3-41 228-3 228-2-3 47-3 48-73 3c-66 3-74 5-84 27-13 28 0 104 37 225 13 41 47 156 75 255s66 230 85 290c18 61 56 191 85 290 28 99 66 230 85 290 18 61 56 191 85 290 85 297 123 419 131 429 5 5 17-11 28-35 10-24 192-393 403-819s447-902 523-1058l139-282h168c92 0 168 4 168 8s-75 158-166 342c-588 1183-969 1958-1033 2100-29 63-69 151-89 195-44 95-58 110-80 83z" fill="#575757"/></g></svg>
|
Before Width: | Height: | Size: 3.4 KiB |
BIN
Shared/Assets.xcassets/Invidious.imageset/Invidious_512x512@1x.png
vendored
Normal file
BIN
Shared/Assets.xcassets/Invidious.imageset/Invidious_512x512@1x.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 37 KiB |
BIN
Shared/Assets.xcassets/Invidious.imageset/Invidious_512x512@2x.png
vendored
Normal file
BIN
Shared/Assets.xcassets/Invidious.imageset/Invidious_512x512@2x.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 85 KiB |
BIN
Shared/Assets.xcassets/Invidious.imageset/Invidious_512x512@3x.png
vendored
Normal file
BIN
Shared/Assets.xcassets/Invidious.imageset/Invidious_512x512@3x.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 146 KiB |
@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "1.000",
|
||||
"green" : "1.000",
|
||||
"red" : "1.000"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.110",
|
||||
"green" : "0.110",
|
||||
"red" : "0.118"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
@ -9,28 +9,34 @@ struct ChannelAvatarView: View {
|
||||
@ObservedObject private var accounts = AccountsModel.shared
|
||||
@ObservedObject private var subscribedChannels = SubscribedChannelsModel.shared
|
||||
|
||||
@State private var url: URL?
|
||||
@State private var loaded = false
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
Group {
|
||||
Group {
|
||||
if let url = channel?.thumbnailURLOrCached {
|
||||
if let url {
|
||||
ThumbnailView(url: url)
|
||||
} else {
|
||||
ZStack {
|
||||
Color(white: 0.6)
|
||||
.opacity(0.5)
|
||||
|
||||
Group {
|
||||
if let video, video.isLocal {
|
||||
Image(systemName: video.localStreamImageSystemName)
|
||||
} else {
|
||||
Image(systemName: "play.rectangle")
|
||||
}
|
||||
if loaded {
|
||||
Image(systemName: "person.circle")
|
||||
.imageScale(.large)
|
||||
.foregroundColor(.accentColor)
|
||||
} else {
|
||||
Color("PlaceholderColor")
|
||||
}
|
||||
|
||||
if let video, video.isLocal {
|
||||
Image(systemName: video.localStreamImageSystemName)
|
||||
.foregroundColor(.accentColor)
|
||||
.font(.system(size: 20))
|
||||
.contentShape(Rectangle())
|
||||
.imageScale(.small)
|
||||
}
|
||||
.foregroundColor(.accentColor)
|
||||
.font(.system(size: 20))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.onAppear(perform: updateURL)
|
||||
}
|
||||
}
|
||||
.clipShape(Circle())
|
||||
@ -49,10 +55,21 @@ struct ChannelAvatarView: View {
|
||||
#endif
|
||||
.clipShape(Circle())
|
||||
.foregroundColor(.secondary)
|
||||
.imageScale(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.imageScale(.small)
|
||||
}
|
||||
|
||||
func updateURL() {
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
if let url = channel?.thumbnailURLOrCached {
|
||||
DispatchQueue.main.async {
|
||||
self.url = url
|
||||
}
|
||||
}
|
||||
self.loaded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -61,7 +61,8 @@ struct ChannelListItem: View {
|
||||
private var label: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack {
|
||||
ChannelAvatarView(channel: channel)
|
||||
ChannelAvatarView(channel: channel, subscribedBadge: false)
|
||||
.id("channel-avatar-\(channel.id)")
|
||||
#if os(tvOS)
|
||||
.frame(width: 90, height: 90)
|
||||
#else
|
||||
|
@ -53,7 +53,6 @@ struct ChannelPlaylistCell: View {
|
||||
|
||||
Text("\(playlist.videosCount ?? playlist.videos.count) videos")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
.frame(height: 20)
|
||||
}
|
||||
}
|
||||
|
@ -212,6 +212,7 @@ struct ChannelVideosView: View {
|
||||
|
||||
var thumbnail: some View {
|
||||
ChannelAvatarView(channel: store.item?.channel)
|
||||
.id("channel-avatar-\(store.item?.channel?.id ?? "")")
|
||||
#if os(tvOS)
|
||||
.frame(width: 80, height: 80, alignment: .trailing)
|
||||
#else
|
||||
@ -338,7 +339,8 @@ struct ChannelVideosView: View {
|
||||
private var resource: Resource? {
|
||||
guard let channel = presentedChannel else { return nil }
|
||||
|
||||
let data = contentType != .videos ? channel.tabs.first(where: { $0.contentType == contentType })?.data : nil
|
||||
let tabData = channel.tabs.first { $0.contentType == contentType }?.data
|
||||
let data = contentType != .videos ? tabData : nil
|
||||
let resource = accounts.api.channel(channel.id, contentType: contentType, data: data)
|
||||
|
||||
if contentType == .videos {
|
||||
@ -362,6 +364,7 @@ struct ChannelVideosView: View {
|
||||
}
|
||||
} label: {
|
||||
Label("Unsubscribe", systemImage: "xmark.circle")
|
||||
.help("Unsubscribe")
|
||||
#if os(iOS)
|
||||
.labelStyle(.automatic)
|
||||
#else
|
||||
@ -377,7 +380,8 @@ struct ChannelVideosView: View {
|
||||
navigation.sidebarSectionChanged.toggle()
|
||||
}
|
||||
} label: {
|
||||
Label("Subscribe", systemImage: "circle")
|
||||
Label("Subscribe", systemImage: "star.circle")
|
||||
.help("Subscribe")
|
||||
#if os(iOS)
|
||||
.labelStyle(.automatic)
|
||||
#else
|
||||
@ -411,6 +415,7 @@ struct ChannelVideosView: View {
|
||||
feed.markChannelAsWatched(channel.id)
|
||||
} label: {
|
||||
Label("Mark channel feed as watched", systemImage: "checkmark.circle.fill")
|
||||
.help("Mark channel feed as watched")
|
||||
}
|
||||
.disabled(!feed.canMarkAllFeedAsWatched)
|
||||
}
|
||||
@ -421,6 +426,7 @@ struct ChannelVideosView: View {
|
||||
feed.markChannelAsUnwatched(channel.id)
|
||||
} label: {
|
||||
Label("Mark channel feed as unwatched", systemImage: "checkmark.circle")
|
||||
.help("Mark channel feed as unwatched")
|
||||
}
|
||||
}
|
||||
|
||||
@ -451,7 +457,8 @@ struct ChannelVideosView: View {
|
||||
next = next ?? ""
|
||||
}
|
||||
|
||||
let data = contentType != .videos ? channel.tabs.first(where: { $0.contentType == contentType })?.data : nil
|
||||
let tabData = channel.tabs.first { $0.contentType == contentType }?.data
|
||||
let data = contentType != .videos ? tabData : nil
|
||||
accounts.api.channel(channel.id, contentType: contentType, data: data, page: next).load().onSuccess { response in
|
||||
if let page: ChannelPage = response.typedContent() {
|
||||
self.page = page
|
||||
|
@ -2,9 +2,27 @@ import Defaults
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct Constants {
|
||||
static let yatteeProtocol = "yattee://"
|
||||
enum Constants {
|
||||
static let overlayAnimation = Animation.linear(duration: 0.2)
|
||||
static let aspectRatio16x9 = 16.0 / 9.0
|
||||
static let aspectRatio4x3 = 4.0 / 3.0
|
||||
|
||||
static var isAppleTV: Bool {
|
||||
#if os(iOS)
|
||||
UIDevice.current.userInterfaceIdiom == .tv
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
static var isMac: Bool {
|
||||
#if os(iOS)
|
||||
UIDevice.current.userInterfaceIdiom == .mac
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
static var isIPhone: Bool {
|
||||
#if os(iOS)
|
||||
UIDevice.current.userInterfaceIdiom == .phone
|
||||
@ -21,6 +39,38 @@ struct Constants {
|
||||
#endif
|
||||
}
|
||||
|
||||
static var isTvOS: Bool {
|
||||
#if os(tvOS)
|
||||
true
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
static var isMacOS: Bool {
|
||||
#if os(macOS)
|
||||
true
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
static var isIOS: Bool {
|
||||
#if os(iOS)
|
||||
true
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
static var detailsVisibility: Bool {
|
||||
#if os(iOS)
|
||||
false
|
||||
#else
|
||||
true
|
||||
#endif
|
||||
}
|
||||
|
||||
static var progressViewScale: Double {
|
||||
#if os(macOS)
|
||||
0.4
|
||||
@ -53,11 +103,44 @@ struct Constants {
|
||||
#endif
|
||||
}
|
||||
|
||||
static var detailsVisibility: Bool {
|
||||
#if os(iOS)
|
||||
false
|
||||
static var contentViewMinWidth: Double {
|
||||
#if os(macOS)
|
||||
835
|
||||
#else
|
||||
true
|
||||
0
|
||||
#endif
|
||||
}
|
||||
|
||||
static var deviceName: String {
|
||||
#if os(macOS)
|
||||
Host().localizedName ?? "Mac"
|
||||
#else
|
||||
UIDevice.current.name
|
||||
#endif
|
||||
}
|
||||
|
||||
static var platform: String {
|
||||
#if os(macOS)
|
||||
"macOS"
|
||||
#elseif os(iOS)
|
||||
"iOS"
|
||||
#elseif os(tvOS)
|
||||
"tvOS"
|
||||
#else
|
||||
"unknown"
|
||||
#endif
|
||||
}
|
||||
|
||||
static var defaultNavigationStyle: NavigationStyle {
|
||||
#if os(macOS)
|
||||
return .sidebar
|
||||
#elseif os(iOS)
|
||||
if isIPad {
|
||||
return .sidebar
|
||||
}
|
||||
return .tab
|
||||
#else
|
||||
return .tab
|
||||
#endif
|
||||
}
|
||||
|
||||
|
@ -6,122 +6,123 @@ import SwiftUI
|
||||
#endif
|
||||
|
||||
extension Defaults.Keys {
|
||||
static let instancesManifest = Key<String>("instancesManifest", default: "")
|
||||
static let countryOfPublicInstances = Key<String?>("countryOfPublicInstances")
|
||||
|
||||
static let instances = Key<[Instance]>("instances", default: [])
|
||||
static let accounts = Key<[Account]>("accounts", default: [])
|
||||
static let lastAccountID = Key<Account.ID?>("lastAccountID")
|
||||
static let lastInstanceID = Key<Instance.ID?>("lastInstanceID")
|
||||
static let lastUsedPlaylistID = Key<Playlist.ID?>("lastPlaylistID")
|
||||
static let lastAccountIsPublic = Key<Bool>("lastAccountIsPublic", default: false)
|
||||
|
||||
static let sponsorBlockInstance = Key<String>("sponsorBlockInstance", default: "https://sponsor.ajay.app")
|
||||
static let sponsorBlockCategories = Key<Set<String>>("sponsorBlockCategories", default: Set(SponsorBlockAPI.categories))
|
||||
|
||||
static let enableReturnYouTubeDislike = Key<Bool>("enableReturnYouTubeDislike", default: false)
|
||||
// MARK: GROUP - Browsing
|
||||
|
||||
static let showHome = Key<Bool>("showHome", default: true)
|
||||
static let showOpenActionsInHome = Key<Bool>("showOpenActionsInHome", default: true)
|
||||
static let showQueueInHome = Key<Bool>("showQueueInHome", default: true)
|
||||
static let showOpenActionsToolbarItem = Key<Bool>("showOpenActionsToolbarItem", default: false)
|
||||
static let showFavoritesInHome = Key<Bool>("showFavoritesInHome", default: true)
|
||||
static let favorites = Key<[FavoriteItem]>("favorites", default: [])
|
||||
static let widgetsSettings = Key<[WidgetSettings]>("widgetsSettings", default: [])
|
||||
static let startupSection = Key<StartupSection>("startupSection", default: .home)
|
||||
static let showSearchSuggestions = Key<Bool>("showSearchSuggestions", default: true)
|
||||
static let visibleSections = Key<Set<VisibleSection>>("visibleSections", default: [.subscriptions, .trending, .playlists])
|
||||
|
||||
static let showOpenActionsToolbarItem = Key<Bool>("showOpenActionsToolbarItem", default: false)
|
||||
#if os(iOS)
|
||||
static let showDocuments = Key<Bool>("showDocuments", default: false)
|
||||
static let lockPortraitWhenBrowsing = Key<Bool>("lockPortraitWhenBrowsing", default: Constants.isIPhone)
|
||||
#endif
|
||||
static let homeHistoryItems = Key<Int>("homeHistoryItems", default: 10)
|
||||
static let favorites = Key<[FavoriteItem]>("favorites", default: [])
|
||||
|
||||
static let playerButtonSingleTapGesture = Key<PlayerTapGestureAction>("playerButtonSingleTapGesture", default: .togglePlayer)
|
||||
static let playerButtonDoubleTapGesture = Key<PlayerTapGestureAction>("playerButtonDoubleTapGesture", default: .nothing)
|
||||
static let playerButtonShowsControlButtonsWhenMinimized = Key<Bool>("playerButtonShowsControlButtonsWhenMinimized", default: false)
|
||||
static let playerButtonIsExpanded = Key<Bool>("playerButtonIsExpanded", default: false)
|
||||
static let playerBarMaxWidth = Key<String>("playerBarMaxWidth", default: "600")
|
||||
|
||||
#if !os(tvOS)
|
||||
#if os(macOS)
|
||||
static let accountPickerDisplaysUsernameDefault = true
|
||||
#else
|
||||
static let accountPickerDisplaysUsernameDefault = UIDevice.current.userInterfaceIdiom == .pad
|
||||
static let accountPickerDisplaysUsernameDefault = Constants.isIPad
|
||||
#endif
|
||||
static let accountPickerDisplaysUsername = Key<Bool>("accountPickerDisplaysUsername", default: accountPickerDisplaysUsernameDefault)
|
||||
#endif
|
||||
|
||||
static let accountPickerDisplaysAnonymousAccounts = Key<Bool>("accountPickerDisplaysAnonymousAccounts", default: true)
|
||||
#if os(iOS)
|
||||
static let lockPortraitWhenBrowsing = Key<Bool>("lockPortraitWhenBrowsing", default: UIDevice.current.userInterfaceIdiom == .phone)
|
||||
#endif
|
||||
static let showUnwatchedFeedBadges = Key<Bool>("showUnwatchedFeedBadges", default: false)
|
||||
static let keepChannelsWithUnwatchedFeedOnTop = Key<Bool>("keepChannelsWithUnwatchedFeedOnTop", default: true)
|
||||
static let showToggleWatchedStatusButton = Key<Bool>("showToggleWatchedStatusButton", default: false)
|
||||
static let expandChannelDescription = Key<Bool>("expandChannelDescription", default: false)
|
||||
|
||||
static let keepChannelsWithUnwatchedFeedOnTop = Key<Bool>("keepChannelsWithUnwatchedFeedOnTop", default: true)
|
||||
static let showChannelAvatarInChannelsLists = Key<Bool>("showChannelAvatarInChannelsLists", default: true)
|
||||
static let showChannelAvatarInVideosListing = Key<Bool>("showChannelAvatarInVideosListing", default: true)
|
||||
|
||||
static let playerButtonSingleTapGesture = Key<PlayerTapGestureAction>("playerButtonSingleTapGesture", default: .togglePlayer)
|
||||
static let playerButtonDoubleTapGesture = Key<PlayerTapGestureAction>("playerButtonDoubleTapGesture", default: .togglePlayerVisibility)
|
||||
static let playerButtonShowsControlButtonsWhenMinimized = Key<Bool>("playerButtonShowsControlButtonsWhenMinimized", default: true)
|
||||
static let playerButtonIsExpanded = Key<Bool>("playerButtonIsExpanded", default: true)
|
||||
static let playerBarMaxWidth = Key<String>("playerBarMaxWidth", default: "600")
|
||||
static let channelOnThumbnail = Key<Bool>("channelOnThumbnail", default: false)
|
||||
static let timeOnThumbnail = Key<Bool>("timeOnThumbnail", default: true)
|
||||
static let roundedThumbnails = Key<Bool>("roundedThumbnails", default: true)
|
||||
static let thumbnailsQuality = Key<ThumbnailsQuality>("thumbnailsQuality", default: .highest)
|
||||
|
||||
static let captionsLanguageCode = Key<String?>("captionsLanguageCode")
|
||||
static let activeBackend = Key<PlayerBackendType>("activeBackend", default: .mpv)
|
||||
// MARK: GROUP - Player
|
||||
|
||||
static let hd2160pMPVProfile = QualityProfile(id: "hd2160pMPVProfile", backend: .mpv, resolution: .hd2160p60, formats: QualityProfile.Format.allCases)
|
||||
static let hd1080pMPVProfile = QualityProfile(id: "hd1080pMPVProfile", backend: .mpv, resolution: .hd1080p60, formats: QualityProfile.Format.allCases)
|
||||
static let hd720pMPVProfile = QualityProfile(id: "hd720pMPVProfile", backend: .mpv, resolution: .hd720p60, formats: QualityProfile.Format.allCases)
|
||||
static let hd720pAVPlayerProfile = QualityProfile(id: "hd720pAVPlayerProfile", backend: .appleAVPlayer, resolution: .hd720p60, formats: [.hls, .stream])
|
||||
static let sd360pAVPlayerProfile = QualityProfile(id: "sd360pAVPlayerProfile", backend: .appleAVPlayer, resolution: .sd360p30, formats: [.hls, .stream])
|
||||
|
||||
#if os(iOS)
|
||||
static let qualityProfilesDefault = UIDevice.current.userInterfaceIdiom == .pad ? [
|
||||
hd2160pMPVProfile,
|
||||
hd1080pMPVProfile,
|
||||
hd720pMPVProfile,
|
||||
hd720pAVPlayerProfile,
|
||||
sd360pAVPlayerProfile
|
||||
] : [
|
||||
hd1080pMPVProfile,
|
||||
hd720pMPVProfile,
|
||||
hd720pAVPlayerProfile,
|
||||
sd360pAVPlayerProfile
|
||||
]
|
||||
|
||||
static let batteryCellularProfileDefault = hd720pAVPlayerProfile.id
|
||||
static let batteryNonCellularProfileDefault = hd720pAVPlayerProfile.id
|
||||
static let chargingCellularProfileDefault = hd720pAVPlayerProfile.id
|
||||
static let chargingNonCellularProfileDefault = hd1080pMPVProfile.id
|
||||
#elseif os(tvOS)
|
||||
static let qualityProfilesDefault = [
|
||||
hd2160pMPVProfile,
|
||||
hd1080pMPVProfile,
|
||||
hd720pMPVProfile,
|
||||
hd720pAVPlayerProfile
|
||||
]
|
||||
static let batteryCellularProfileDefault = hd1080pMPVProfile.id
|
||||
static let batteryNonCellularProfileDefault = hd1080pMPVProfile.id
|
||||
static let chargingCellularProfileDefault = hd1080pMPVProfile.id
|
||||
static let chargingNonCellularProfileDefault = hd1080pMPVProfile.id
|
||||
#else
|
||||
static let qualityProfilesDefault = [
|
||||
hd2160pMPVProfile,
|
||||
hd1080pMPVProfile,
|
||||
hd720pMPVProfile,
|
||||
hd720pAVPlayerProfile
|
||||
]
|
||||
static let batteryCellularProfileDefault = hd1080pMPVProfile.id
|
||||
static let batteryNonCellularProfileDefault = hd1080pMPVProfile.id
|
||||
static let chargingCellularProfileDefault = hd1080pMPVProfile.id
|
||||
static let chargingNonCellularProfileDefault = hd1080pMPVProfile.id
|
||||
#endif
|
||||
static let playerRate = Key<Double>("playerRate", default: 1.0)
|
||||
static let qualityProfiles = Key<[QualityProfile]>("qualityProfiles", default: qualityProfilesDefault)
|
||||
static let batteryCellularProfile = Key<QualityProfile.ID>("batteryCellularProfile", default: batteryCellularProfileDefault)
|
||||
static let batteryNonCellularProfile = Key<QualityProfile.ID>("batteryNonCellularProfile", default: batteryNonCellularProfileDefault)
|
||||
static let chargingCellularProfile = Key<QualityProfile.ID>("chargingCellularProfile", default: chargingCellularProfileDefault)
|
||||
static let chargingNonCellularProfile = Key<QualityProfile.ID>("chargingNonCellularProfile", default: chargingNonCellularProfileDefault)
|
||||
static let forceAVPlayerForLiveStreams = Key<Bool>("forceAVPlayerForLiveStreams", default: true)
|
||||
static let playerSidebar = Key<PlayerSidebarSetting>("playerSidebar", default: .defaultValue)
|
||||
static let playerInstanceID = Key<Instance.ID?>("playerInstance")
|
||||
|
||||
#if os(tvOS)
|
||||
static let pauseOnHidingPlayerDefault = true
|
||||
#else
|
||||
static let pauseOnHidingPlayerDefault = false
|
||||
#endif
|
||||
static let pauseOnHidingPlayer = Key<Bool>("pauseOnHidingPlayer", default: pauseOnHidingPlayerDefault)
|
||||
|
||||
static let closeVideoOnEOF = Key<Bool>("closeVideoOnEOF", default: false)
|
||||
|
||||
#if !os(macOS)
|
||||
static let pauseOnEnteringBackground = Key<Bool>("pauseOnEnteringBackground", default: false)
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
static let playerControlsLayoutDefault = UIDevice.current.userInterfaceIdiom == .pad ? PlayerControlsLayout.medium : .small
|
||||
static let fullScreenPlayerControlsLayoutDefault = UIDevice.current.userInterfaceIdiom == .pad ? PlayerControlsLayout.medium : .small
|
||||
static let expandVideoDescriptionDefault = Constants.isIPad
|
||||
#else
|
||||
static let expandVideoDescriptionDefault = true
|
||||
#endif
|
||||
static let expandVideoDescription = Key<Bool>("expandVideoDescription", default: expandVideoDescriptionDefault)
|
||||
|
||||
static let collapsedLinesDescription = Key<Int>("collapsedLinesDescription", default: 5)
|
||||
static let exitFullscreenOnEOF = Key<Bool>("exitFullscreenOnEOF", default: true)
|
||||
|
||||
static let showChapters = Key<Bool>("showChapters", default: true)
|
||||
static let showChapterThumbnails = Key<Bool>("showChapterThumbnails", default: true)
|
||||
static let showChapterThumbnailsOnlyWhenDifferent = Key<Bool>("showChapterThumbnailsOnlyWhenDifferent", default: false)
|
||||
static let expandChapters = Key<Bool>("expandChapters", default: true)
|
||||
static let showRelated = Key<Bool>("showRelated", default: true)
|
||||
static let showInspector = Key<ShowInspectorSetting>("showInspector", default: .onlyLocal)
|
||||
|
||||
static let playerSidebar = Key<PlayerSidebarSetting>("playerSidebar", default: .defaultValue)
|
||||
static let showKeywords = Key<Bool>("showKeywords", default: false)
|
||||
static let showComments = Key<Bool>("showComments", default: true)
|
||||
#if !os(tvOS)
|
||||
static let showScrollToTopInComments = Key<Bool>("showScrollToTopInComments", default: true)
|
||||
#endif
|
||||
static let enableReturnYouTubeDislike = Key<Bool>("enableReturnYouTubeDislike", default: false)
|
||||
|
||||
#if os(iOS)
|
||||
static let isOrientationLocked = Key<Bool>("isOrientationLocked", default: Constants.isIPhone)
|
||||
static let enterFullscreenInLandscape = Key<Bool>("enterFullscreenInLandscape", default: Constants.isIPhone)
|
||||
static let rotateToLandscapeOnEnterFullScreen = Key<FullScreenRotationSetting>("rotateToLandscapeOnEnterFullScreen", default: .landscapeRight)
|
||||
#endif
|
||||
|
||||
static let closePiPOnNavigation = Key<Bool>("closePiPOnNavigation", default: false)
|
||||
static let closePiPOnOpeningPlayer = Key<Bool>("closePiPOnOpeningPlayer", default: false)
|
||||
static let closePlayerOnOpeningPiP = Key<Bool>("closePlayerOnOpeningPiP", default: false)
|
||||
#if !os(macOS)
|
||||
static let closePiPAndOpenPlayerOnEnteringForeground = Key<Bool>("closePiPAndOpenPlayerOnEnteringForeground", default: false)
|
||||
#endif
|
||||
|
||||
static let captionsAutoShow = Key<Bool>("captionsAutoShow", default: false)
|
||||
static let captionsDefaultLanguageCode = Key<String>("captionsDefaultLanguageCode", default: LanguageCodes.English.rawValue)
|
||||
static let captionsFallbackLanguageCode = Key<String>("captionsDefaultFallbackCode", default: LanguageCodes.English.rawValue)
|
||||
static let captionsFontScaleSize = Key<String>("captionsFontScale", default: "1.0")
|
||||
static let captionsFontColor = Key<String>("captionsFontColor", default: "#FFFFFF")
|
||||
|
||||
// MARK: GROUP - Controls
|
||||
|
||||
static let avPlayerUsesSystemControls = Key<Bool>("avPlayerUsesSystemControls", default: Constants.isTvOS)
|
||||
static let horizontalPlayerGestureEnabled = Key<Bool>("horizontalPlayerGestureEnabled", default: true)
|
||||
static let fullscreenPlayerGestureEnabled = Key<Bool>("fullscreenPlayerGestureEnabled", default: true)
|
||||
static let seekGestureSensitivity = Key<Double>("seekGestureSensitivity", default: 30.0)
|
||||
static let seekGestureSpeed = Key<Double>("seekGestureSpeed", default: 0.5)
|
||||
|
||||
#if os(iOS)
|
||||
static let playerControlsLayoutDefault = Constants.isIPad ? PlayerControlsLayout.medium : .small
|
||||
static let fullScreenPlayerControlsLayoutDefault = Constants.isIPad ? PlayerControlsLayout.medium : .small
|
||||
#elseif os(tvOS)
|
||||
static let playerControlsLayoutDefault = PlayerControlsLayout.tvRegular
|
||||
static let fullScreenPlayerControlsLayoutDefault = PlayerControlsLayout.tvRegular
|
||||
@ -132,80 +133,7 @@ extension Defaults.Keys {
|
||||
|
||||
static let playerControlsLayout = Key<PlayerControlsLayout>("playerControlsLayout", default: playerControlsLayoutDefault)
|
||||
static let fullScreenPlayerControlsLayout = Key<PlayerControlsLayout>("fullScreenPlayerControlsLayout", default: fullScreenPlayerControlsLayoutDefault)
|
||||
static let avPlayerUsesSystemControls = Key<Bool>("avPlayerUsesSystemControls", default: true)
|
||||
static let horizontalPlayerGestureEnabled = Key<Bool>("horizontalPlayerGestureEnabled", default: true)
|
||||
static let seekGestureSpeed = Key<Double>("seekGestureSpeed", default: 0.5)
|
||||
static let seekGestureSensitivity = Key<Double>("seekGestureSensitivity", default: 30.0)
|
||||
static let showKeywords = Key<Bool>("showKeywords", default: false)
|
||||
#if !os(tvOS)
|
||||
static let showScrollToTopInComments = Key<Bool>("showScrollToTopInComments", default: true)
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
static let expandVideoDescriptionDefault = Constants.isIPad
|
||||
#else
|
||||
static let expandVideoDescriptionDefault = true
|
||||
#endif
|
||||
static let expandVideoDescription = Key<Bool>("expandVideoDescription", default: expandVideoDescriptionDefault)
|
||||
|
||||
#if os(tvOS)
|
||||
static let pauseOnHidingPlayerDefault = true
|
||||
#else
|
||||
static let pauseOnHidingPlayerDefault = false
|
||||
#endif
|
||||
static let pauseOnHidingPlayer = Key<Bool>("pauseOnHidingPlayer", default: pauseOnHidingPlayerDefault)
|
||||
|
||||
#if !os(macOS)
|
||||
static let pauseOnEnteringBackground = Key<Bool>("pauseOnEnteringBackground", default: true)
|
||||
#endif
|
||||
static let closeVideoOnEOF = Key<Bool>("closeVideoOnEOF", default: false)
|
||||
static let closePiPOnNavigation = Key<Bool>("closePiPOnNavigation", default: false)
|
||||
static let closePiPOnOpeningPlayer = Key<Bool>("closePiPOnOpeningPlayer", default: false)
|
||||
#if !os(macOS)
|
||||
static let closePiPAndOpenPlayerOnEnteringForeground = Key<Bool>("closePiPAndOpenPlayerOnEnteringForeground", default: false)
|
||||
#endif
|
||||
static let closePlayerOnOpeningPiP = Key<Bool>("closePlayerOnOpeningPiP", default: false)
|
||||
|
||||
static let recentlyOpened = Key<[RecentItem]>("recentlyOpened", default: [])
|
||||
|
||||
static let queue = Key<[PlayerQueueItem]>("queue", default: [])
|
||||
static let saveLastPlayed = Key<Bool>("saveLastPlayed", default: false)
|
||||
static let lastPlayed = Key<PlayerQueueItem?>("lastPlayed")
|
||||
static let playbackMode = Key<PlayerModel.PlaybackMode>("playbackMode", default: .queue)
|
||||
|
||||
static let saveHistory = Key<Bool>("saveHistory", default: true)
|
||||
static let showWatchingProgress = Key<Bool>("showWatchingProgress", default: true)
|
||||
static let watchedThreshold = Key<Int>("watchedThreshold", default: 90)
|
||||
static let watchedVideoStyle = Key<WatchedVideoStyle>("watchedVideoStyle", default: .badge)
|
||||
static let watchedVideoBadgeColor = Key<WatchedVideoBadgeColor>("WatchedVideoBadgeColor", default: .red)
|
||||
static let watchedVideoPlayNowBehavior = Key<WatchedVideoPlayNowBehavior>("watchedVideoPlayNowBehavior", default: .continue)
|
||||
static let resetWatchedStatusOnPlaying = Key<Bool>("resetWatchedStatusOnPlaying", default: false)
|
||||
static let saveRecents = Key<Bool>("saveRecents", default: true)
|
||||
|
||||
static let trendingCategory = Key<TrendingCategory>("trendingCategory", default: .default)
|
||||
static let trendingCountry = Key<Country>("trendingCountry", default: .us)
|
||||
|
||||
static let visibleSections = Key<Set<VisibleSection>>("visibleSections", default: [.subscriptions, .trending, .playlists])
|
||||
static let startupSection = Key<StartupSection>("startupSection", default: .home)
|
||||
|
||||
#if os(iOS)
|
||||
static let honorSystemOrientationLock = Key<Bool>("honorSystemOrientationLock", default: true)
|
||||
static let enterFullscreenInLandscape = Key<Bool>("enterFullscreenInLandscape", default: UIDevice.current.userInterfaceIdiom == .phone)
|
||||
static let rotateToLandscapeOnEnterFullScreen = Key<FullScreenRotationSetting>(
|
||||
"rotateToLandscapeOnEnterFullScreen",
|
||||
default: UIDevice.current.userInterfaceIdiom == .phone ? .landscapeRight : .disabled
|
||||
)
|
||||
#endif
|
||||
|
||||
static let showMPVPlaybackStats = Key<Bool>("showMPVPlaybackStats", default: false)
|
||||
static let showPlayNowInBackendContextMenu = Key<Bool>("showPlayNowInBackendContextMenu", default: false)
|
||||
|
||||
#if os(macOS)
|
||||
static let playerDetailsPageButtonLabelStyleDefault = ButtonLabelStyle.iconAndText
|
||||
#else
|
||||
static let playerDetailsPageButtonLabelStyleDefault = UIDevice.current.userInterfaceIdiom == .phone ? ButtonLabelStyle.iconOnly : .iconAndText
|
||||
#endif
|
||||
static let playerActionsButtonLabelStyle = Key<ButtonLabelStyle>("playerActionsButtonLabelStyle", default: .iconAndText)
|
||||
static let playerControlsBackgroundOpacity = Key<Double>("playerControlsBackgroundOpacity", default: 0.2)
|
||||
|
||||
static let systemControlsCommands = Key<SystemControlsCommands>("systemControlsCommands", default: .restartAndAdvanceToNext)
|
||||
|
||||
@ -214,18 +142,6 @@ extension Defaults.Keys {
|
||||
static let gestureBackwardSeekDuration = Key<String>("gestureBackwardSeekDuration", default: "10")
|
||||
static let gestureForwardSeekDuration = Key<String>("gestureForwardSeekDuration", default: "10")
|
||||
static let systemControlsSeekDuration = Key<String>("systemControlsBackwardSeekDuration", default: "10")
|
||||
static let actionButtonShareEnabled = Key<Bool>("actionButtonShareEnabled", default: true)
|
||||
static let actionButtonAddToPlaylistEnabled = Key<Bool>("actionButtonAddToPlaylistEnabled", default: true)
|
||||
static let actionButtonSubscribeEnabled = Key<Bool>("actionButtonSubscribeEnabled", default: false)
|
||||
static let actionButtonSettingsEnabled = Key<Bool>("actionButtonSettingsEnabled", default: true)
|
||||
static let actionButtonHideEnabled = Key<Bool>("actionButtonHideEnabled", default: false)
|
||||
static let actionButtonCloseEnabled = Key<Bool>("actionButtonCloseEnabled", default: true)
|
||||
static let actionButtonFullScreenEnabled = Key<Bool>("actionButtonFullScreenEnabled", default: false)
|
||||
static let actionButtonPipEnabled = Key<Bool>("actionButtonPipEnabled", default: false)
|
||||
static let actionButtonLockOrientationEnabled = Key<Bool>("actionButtonLockOrientationEnabled", default: false)
|
||||
static let actionButtonRestartEnabled = Key<Bool>("actionButtonRestartEnabled", default: false)
|
||||
static let actionButtonAdvanceToNextItemEnabled = Key<Bool>("actionButtonAdvanceToNextItemEnabled", default: false)
|
||||
static let actionButtonMusicModeEnabled = Key<Bool>("actionButtonMusicModeEnabled", default: true)
|
||||
|
||||
#if os(iOS)
|
||||
static let playerControlsLockOrientationEnabled = Key<Bool>("playerControlsLockOrientationEnabled", default: true)
|
||||
@ -242,13 +158,235 @@ extension Defaults.Keys {
|
||||
static let playerControlsPlaybackModeEnabled = Key<Bool>("playerControlsPlaybackModeEnabled", default: false)
|
||||
static let playerControlsMusicModeEnabled = Key<Bool>("playerControlsMusicModeEnabled", default: false)
|
||||
|
||||
static let playerActionsButtonLabelStyle = Key<ButtonLabelStyle>("playerActionsButtonLabelStyle", default: .iconAndText)
|
||||
|
||||
static let actionButtonShareEnabled = Key<Bool>("actionButtonShareEnabled", default: true)
|
||||
static let actionButtonAddToPlaylistEnabled = Key<Bool>("actionButtonAddToPlaylistEnabled", default: true)
|
||||
static let actionButtonSubscribeEnabled = Key<Bool>("actionButtonSubscribeEnabled", default: false)
|
||||
static let actionButtonSettingsEnabled = Key<Bool>("actionButtonSettingsEnabled", default: true)
|
||||
static let actionButtonHideEnabled = Key<Bool>("actionButtonHideEnabled", default: false)
|
||||
static let actionButtonCloseEnabled = Key<Bool>("actionButtonCloseEnabled", default: true)
|
||||
static let actionButtonFullScreenEnabled = Key<Bool>("actionButtonFullScreenEnabled", default: false)
|
||||
static let actionButtonPipEnabled = Key<Bool>("actionButtonPipEnabled", default: false)
|
||||
static let actionButtonLockOrientationEnabled = Key<Bool>("actionButtonLockOrientationEnabled", default: false)
|
||||
static let actionButtonRestartEnabled = Key<Bool>("actionButtonRestartEnabled", default: false)
|
||||
static let actionButtonAdvanceToNextItemEnabled = Key<Bool>("actionButtonAdvanceToNextItemEnabled", default: false)
|
||||
static let actionButtonMusicModeEnabled = Key<Bool>("actionButtonMusicModeEnabled", default: true)
|
||||
|
||||
// MARK: GROUP - Quality
|
||||
|
||||
static let hd2160p60MPVProfile = QualityProfile(id: "hd2160p60MPVProfile", backend: .mpv, resolution: .hd2160p60, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
|
||||
static let hd1080p60MPVProfile = QualityProfile(id: "hd1080p60MPVProfile", backend: .mpv, resolution: .hd1080p60, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
|
||||
static let hd1080pMPVProfile = QualityProfile(id: "hd1080pMPVProfile", backend: .mpv, resolution: .hd1080p30, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
|
||||
static let hd720p60MPVProfile = QualityProfile(id: "hd720p60MPVProfile", backend: .mpv, resolution: .hd720p60, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
|
||||
static let hd720pMPVProfile = QualityProfile(id: "hd720pMPVProfile", backend: .mpv, resolution: .hd720p30, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
|
||||
static let sd360pMPVProfile = QualityProfile(id: "sd360pMPVProfile", backend: .mpv, resolution: .sd360p30, formats: QualityProfile.Format.allCases, order: Array(QualityProfile.Format.allCases.indices))
|
||||
static let hd720pAVPlayerProfile = QualityProfile(id: "hd720pAVPlayerProfile", backend: .appleAVPlayer, resolution: .hd720p30, formats: [.stream, .hls], order: Array(QualityProfile.Format.allCases.indices))
|
||||
static let sd360pAVPlayerProfile = QualityProfile(id: "sd360pAVPlayerProfile", backend: .appleAVPlayer, resolution: .sd360p30, formats: [.stream, .hls], order: Array(QualityProfile.Format.allCases.indices))
|
||||
|
||||
#if os(iOS)
|
||||
enum QualityProfiles {
|
||||
// iPad-specific settings
|
||||
enum iPad {
|
||||
static let qualityProfilesDefault = [
|
||||
hd1080p60MPVProfile,
|
||||
hd1080pMPVProfile,
|
||||
hd720p60MPVProfile,
|
||||
hd720pMPVProfile
|
||||
]
|
||||
|
||||
static let batteryCellularProfileDefault = hd720pMPVProfile.id
|
||||
static let batteryNonCellularProfileDefault = hd720p60MPVProfile.id
|
||||
static let chargingCellularProfileDefault = hd1080pMPVProfile.id
|
||||
static let chargingNonCellularProfileDefault = hd1080p60MPVProfile.id
|
||||
}
|
||||
|
||||
// iPhone-specific settings
|
||||
enum iPhone {
|
||||
static let qualityProfilesDefault = [
|
||||
hd1080p60MPVProfile,
|
||||
hd1080pMPVProfile,
|
||||
hd720p60MPVProfile,
|
||||
hd720pMPVProfile,
|
||||
sd360pMPVProfile
|
||||
]
|
||||
|
||||
static let batteryCellularProfileDefault = sd360pMPVProfile.id
|
||||
static let batteryNonCellularProfileDefault = hd720p60MPVProfile.id
|
||||
static let chargingCellularProfileDefault = hd720pMPVProfile.id
|
||||
static let chargingNonCellularProfileDefault = hd1080p60MPVProfile.id
|
||||
}
|
||||
|
||||
// Access the correct profile based on device type
|
||||
static var currentProfile: (qualityProfilesDefault: [QualityProfile], batteryCellularProfileDefault: String, batteryNonCellularProfileDefault: String, chargingCellularProfileDefault: String, chargingNonCellularProfileDefault: String) {
|
||||
if Constants.isIPad {
|
||||
return (
|
||||
qualityProfilesDefault: iPad.qualityProfilesDefault,
|
||||
batteryCellularProfileDefault: iPad.batteryCellularProfileDefault,
|
||||
batteryNonCellularProfileDefault: iPad.batteryNonCellularProfileDefault,
|
||||
chargingCellularProfileDefault: iPad.chargingCellularProfileDefault,
|
||||
chargingNonCellularProfileDefault: iPad.chargingNonCellularProfileDefault
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
qualityProfilesDefault: iPhone.qualityProfilesDefault,
|
||||
batteryCellularProfileDefault: iPhone.batteryCellularProfileDefault,
|
||||
batteryNonCellularProfileDefault: iPhone.batteryNonCellularProfileDefault,
|
||||
chargingCellularProfileDefault: iPhone.chargingCellularProfileDefault,
|
||||
chargingNonCellularProfileDefault: iPhone.chargingNonCellularProfileDefault
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#elseif os(tvOS)
|
||||
enum QualityProfiles {
|
||||
// tvOS-specific settings
|
||||
enum tvOS {
|
||||
static let qualityProfilesDefault = [
|
||||
hd2160p60MPVProfile,
|
||||
hd1080p60MPVProfile,
|
||||
hd720p60MPVProfile,
|
||||
hd720pAVPlayerProfile
|
||||
]
|
||||
|
||||
static let batteryCellularProfileDefault = hd1080p60MPVProfile.id
|
||||
static let batteryNonCellularProfileDefault = hd1080p60MPVProfile.id
|
||||
static let chargingCellularProfileDefault = hd1080p60MPVProfile.id
|
||||
static let chargingNonCellularProfileDefault = hd1080p60MPVProfile.id
|
||||
}
|
||||
|
||||
// Access the correct profile based on device type
|
||||
static var currentProfile: (qualityProfilesDefault: [QualityProfile], batteryCellularProfileDefault: String, batteryNonCellularProfileDefault: String, chargingCellularProfileDefault: String, chargingNonCellularProfileDefault: String) {
|
||||
(
|
||||
qualityProfilesDefault: tvOS.qualityProfilesDefault,
|
||||
batteryCellularProfileDefault: tvOS.batteryCellularProfileDefault,
|
||||
batteryNonCellularProfileDefault: tvOS.batteryNonCellularProfileDefault,
|
||||
chargingCellularProfileDefault: tvOS.chargingCellularProfileDefault,
|
||||
chargingNonCellularProfileDefault: tvOS.chargingNonCellularProfileDefault
|
||||
)
|
||||
}
|
||||
}
|
||||
#else
|
||||
enum QualityProfiles {
|
||||
// macOS-specific settings
|
||||
enum macOS {
|
||||
static let qualityProfilesDefault = [
|
||||
hd2160p60MPVProfile,
|
||||
hd1080p60MPVProfile,
|
||||
hd1080pMPVProfile,
|
||||
hd720p60MPVProfile
|
||||
]
|
||||
|
||||
static let batteryCellularProfileDefault = hd1080p60MPVProfile.id
|
||||
static let batteryNonCellularProfileDefault = hd1080p60MPVProfile.id
|
||||
static let chargingCellularProfileDefault = hd1080p60MPVProfile.id
|
||||
static let chargingNonCellularProfileDefault = hd1080p60MPVProfile.id
|
||||
}
|
||||
|
||||
// Access the correct profile for other platforms
|
||||
static var currentProfile: (qualityProfilesDefault: [QualityProfile], batteryCellularProfileDefault: String, batteryNonCellularProfileDefault: String, chargingCellularProfileDefault: String, chargingNonCellularProfileDefault: String) {
|
||||
(
|
||||
qualityProfilesDefault: macOS.qualityProfilesDefault,
|
||||
batteryCellularProfileDefault: macOS.batteryCellularProfileDefault,
|
||||
batteryNonCellularProfileDefault: macOS.batteryNonCellularProfileDefault,
|
||||
chargingCellularProfileDefault: macOS.chargingCellularProfileDefault,
|
||||
chargingNonCellularProfileDefault: macOS.chargingNonCellularProfileDefault
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
static let batteryCellularProfile = Key<QualityProfile.ID>(
|
||||
"batteryCellularProfile",
|
||||
default: QualityProfiles.currentProfile.batteryCellularProfileDefault
|
||||
)
|
||||
static let batteryNonCellularProfile = Key<QualityProfile.ID>(
|
||||
"batteryNonCellularProfile",
|
||||
default: QualityProfiles.currentProfile.batteryNonCellularProfileDefault
|
||||
)
|
||||
static let chargingCellularProfile = Key<QualityProfile.ID>(
|
||||
"chargingCellularProfile",
|
||||
default: QualityProfiles.currentProfile.chargingCellularProfileDefault
|
||||
)
|
||||
static let chargingNonCellularProfile = Key<QualityProfile.ID>(
|
||||
"chargingNonCellularProfile",
|
||||
default: QualityProfiles.currentProfile.chargingNonCellularProfileDefault
|
||||
)
|
||||
static let forceAVPlayerForLiveStreams = Key<Bool>(
|
||||
"forceAVPlayerForLiveStreams",
|
||||
default: true
|
||||
)
|
||||
static let qualityProfiles = Key<[QualityProfile]>(
|
||||
"qualityProfiles",
|
||||
default: QualityProfiles.currentProfile.qualityProfilesDefault
|
||||
)
|
||||
|
||||
// MARK: GROUP - History
|
||||
|
||||
static let saveRecents = Key<Bool>("saveRecents", default: true)
|
||||
static let saveHistory = Key<Bool>("saveHistory", default: true)
|
||||
static let showRecents = Key<Bool>("showRecents", default: true)
|
||||
static let limitRecents = Key<Bool>("limitRecents", default: false)
|
||||
static let limitRecentsAmount = Key<Int>("limitRecentsAmount", default: 10)
|
||||
static let showWatchingProgress = Key<Bool>("showWatchingProgress", default: true)
|
||||
static let saveLastPlayed = Key<Bool>("saveLastPlayed", default: false)
|
||||
|
||||
static let watchedVideoPlayNowBehavior = Key<WatchedVideoPlayNowBehavior>("watchedVideoPlayNowBehavior", default: .continue)
|
||||
static let watchedThreshold = Key<Int>("watchedThreshold", default: 90)
|
||||
static let resetWatchedStatusOnPlaying = Key<Bool>("resetWatchedStatusOnPlaying", default: false)
|
||||
|
||||
static let watchedVideoStyle = Key<WatchedVideoStyle>("watchedVideoStyle", default: .badge)
|
||||
static let watchedVideoBadgeColor = Key<WatchedVideoBadgeColor>("WatchedVideoBadgeColor", default: .red)
|
||||
static let showToggleWatchedStatusButton = Key<Bool>("showToggleWatchedStatusButton", default: false)
|
||||
|
||||
// MARK: GROUP - SponsorBlock
|
||||
|
||||
static let sponsorBlockInstance = Key<String>("sponsorBlockInstance", default: "https://sponsor.ajay.app")
|
||||
static let sponsorBlockCategories = Key<Set<String>>("sponsorBlockCategories", default: Set(SponsorBlockAPI.categories))
|
||||
static let sponsorBlockColors = Key<[String: String]>("sponsorBlockColors", default: SponsorBlockColors.dictionary)
|
||||
static let sponsorBlockShowTimeWithSkipsRemoved = Key<Bool>("sponsorBlockShowTimeWithSkipsRemoved", default: false)
|
||||
static let sponsorBlockShowCategoriesInTimeline = Key<Bool>("sponsorBlockShowCategoriesInTimeline", default: true)
|
||||
static let sponsorBlockShowNoticeAfterSkip = Key<Bool>("sponsorBlockShowNoticeAfterSkip", default: true)
|
||||
|
||||
// MARK: GROUP - Locations
|
||||
|
||||
static let instancesManifest = Key<String>("instancesManifest", default: "")
|
||||
static let countryOfPublicInstances = Key<String?>("countryOfPublicInstances")
|
||||
|
||||
static let instances = Key<[Instance]>("instances", default: [])
|
||||
static let accounts = Key<[Account]>("accounts", default: [])
|
||||
|
||||
// MARK: Group - Advanced
|
||||
|
||||
static let showPlayNowInBackendContextMenu = Key<Bool>("showPlayNowInBackendContextMenu", default: false)
|
||||
static let videoLoadingRetryCount = Key<Int>("videoLoadingRetryCount", default: 10)
|
||||
|
||||
static let showMPVPlaybackStats = Key<Bool>("showMPVPlaybackStats", default: false)
|
||||
static let mpvEnableLogging = Key<Bool>("mpvEnableLogging", default: false)
|
||||
static let mpvCacheSecs = Key<String>("mpvCacheSecs", default: "120")
|
||||
static let mpvCachePauseWait = Key<String>("mpvCachePauseWait", default: "3")
|
||||
static let mpvEnableLogging = Key<Bool>("mpvEnableLogging", default: false)
|
||||
static let mpvCachePauseInital = Key<Bool>("mpvCachePauseInitial", default: false)
|
||||
static let mpvDeinterlace = Key<Bool>("mpvDeinterlace", default: false)
|
||||
static let mpvHWdec = Key<String>("hwdec", default: "auto-safe")
|
||||
static let mpvDemuxerLavfProbeInfo = Key<String>("mpvDemuxerLavfProbeInfo", default: "no")
|
||||
static let mpvInitialAudioSync = Key<Bool>("mpvInitialAudioSync", default: true)
|
||||
static let mpvSetRefreshToContentFPS = Key<Bool>("mpvSetRefreshToContentFPS", default: false)
|
||||
|
||||
static let showCacheStatus = Key<Bool>("showCacheStatus", default: false)
|
||||
static let feedCacheSize = Key<String>("feedCacheSize", default: "50")
|
||||
|
||||
// MARK: GROUP - Other exportable
|
||||
|
||||
static let lastAccountID = Key<Account.ID?>("lastAccountID")
|
||||
static let lastInstanceID = Key<Instance.ID?>("lastInstanceID")
|
||||
|
||||
static let playerRate = Key<Double>("playerRate", default: 1.0)
|
||||
static let recentlyOpened = Key<[RecentItem]>("recentlyOpened", default: [])
|
||||
|
||||
static let trendingCategory = Key<TrendingCategory>("trendingCategory", default: .default)
|
||||
static let trendingCountry = Key<Country>("trendingCountry", default: .us)
|
||||
|
||||
static let subscriptionsViewPage = Key<SubscriptionsView.Page>("subscriptionsViewPage", default: .feed)
|
||||
|
||||
static let subscriptionsListingStyle = Key<ListingStyle>("subscriptionsListingStyle", default: .cells)
|
||||
@ -259,8 +397,21 @@ extension Defaults.Keys {
|
||||
static let searchListingStyle = Key<ListingStyle>("searchListingStyle", default: .cells)
|
||||
static let hideShorts = Key<Bool>("hideShorts", default: false)
|
||||
static let hideWatched = Key<Bool>("hideWatched", default: false)
|
||||
static let showInspector = Key<ShowInspectorSetting>("showInspector", default: .onlyLocal)
|
||||
static let widgetsSettings = Key<[WidgetSettings]>("widgetsSettings", default: [])
|
||||
|
||||
// MARK: GROUP - Not exportable
|
||||
|
||||
static let queue = Key<[PlayerQueueItem]>("queue", default: [])
|
||||
static let playbackMode = Key<PlayerModel.PlaybackMode>("playbackMode", default: .queue)
|
||||
static let lastPlayed = Key<PlayerQueueItem?>("lastPlayed")
|
||||
|
||||
static let activeBackend = Key<PlayerBackendType>("activeBackend", default: .mpv)
|
||||
static let captionsLanguageCode = Key<String?>("captionsLanguageCode")
|
||||
static let lastUsedPlaylistID = Key<Playlist.ID?>("lastPlaylistID")
|
||||
static let lastAccountIsPublic = Key<Bool>("lastAccountIsPublic", default: false)
|
||||
|
||||
// MARK: LEGACY
|
||||
|
||||
static let homeHistoryItems = Key<Int>("homeHistoryItems", default: 10)
|
||||
}
|
||||
|
||||
enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
|
||||
@ -277,18 +428,34 @@ enum ResolutionSetting: String, CaseIterable, Defaults.Serializable {
|
||||
case sd240p30
|
||||
case sd144p30
|
||||
|
||||
var value: Stream.Resolution! {
|
||||
.init(rawValue: rawValue)
|
||||
var value: Stream.Resolution {
|
||||
if let predefined = Stream.Resolution.PredefinedResolution(rawValue: rawValue) {
|
||||
return .predefined(predefined)
|
||||
}
|
||||
// Provide a default value of 720p 30
|
||||
return .custom(height: 720, refreshRate: 30)
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .hd2160p60:
|
||||
return "4K, 60fps"
|
||||
case .hd2160p30:
|
||||
return "4K"
|
||||
let resolution = value
|
||||
let height = resolution.height
|
||||
let refreshRate = resolution.refreshRate
|
||||
|
||||
// Superscript labels
|
||||
let superscript4K = "⁴ᴷ"
|
||||
let superscriptHD = "ᴴᴰ"
|
||||
|
||||
// Special handling for specific resolutions
|
||||
switch height {
|
||||
case 2160:
|
||||
// 4K superscript after the refresh rate
|
||||
return refreshRate == 30 ? "2160p \(superscript4K)" : "2160p\(refreshRate) \(superscript4K)"
|
||||
case 1440, 1080:
|
||||
// HD superscript after the refresh rate
|
||||
return refreshRate == 30 ? "\(height)p \(superscriptHD)" : "\(height)p\(refreshRate) \(superscriptHD)"
|
||||
default:
|
||||
return value.name
|
||||
// Default formatting for other resolutions
|
||||
return refreshRate == 30 ? "\(height)p" : "\(height)p\(refreshRate)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -394,15 +561,26 @@ enum ButtonLabelStyle: String, CaseIterable, Defaults.Serializable {
|
||||
var text: Bool {
|
||||
self == .iconAndText
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .iconOnly:
|
||||
return "Icon only".localized()
|
||||
case .iconAndText:
|
||||
return "Icon and text".localized()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ThumbnailsQuality: String, CaseIterable, Defaults.Serializable {
|
||||
case highest, medium, low
|
||||
case highest, high, medium, low
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .highest:
|
||||
return "Highest quality".localized()
|
||||
return "Best quality".localized()
|
||||
case .high:
|
||||
return "High quality".localized()
|
||||
case .medium:
|
||||
return "Medium quality".localized()
|
||||
case .low:
|
||||
@ -452,26 +630,19 @@ enum PlayerTapGestureAction: String, CaseIterable, Defaults.Serializable {
|
||||
}
|
||||
|
||||
enum FullScreenRotationSetting: String, CaseIterable, Defaults.Serializable {
|
||||
case disabled
|
||||
case landscapeLeft
|
||||
case landscapeRight
|
||||
|
||||
#if os(iOS)
|
||||
var interaceOrientation: UIInterfaceOrientation {
|
||||
var interfaceOrientation: UIInterfaceOrientation {
|
||||
switch self {
|
||||
case .landscapeLeft:
|
||||
return .landscapeLeft
|
||||
case .landscapeRight:
|
||||
return .landscapeRight
|
||||
default:
|
||||
return .portrait
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
var isRotating: Bool {
|
||||
self != .disabled
|
||||
}
|
||||
}
|
||||
|
||||
struct WidgetSettings: Defaults.Serializable {
|
||||
@ -492,7 +663,7 @@ struct WidgetSettings: Defaults.Serializable {
|
||||
}
|
||||
|
||||
static func maxLimit(_ style: WidgetListingStyle) -> Int {
|
||||
Self.maxLimit[style] ?? Self.defaultLimit
|
||||
maxLimit[style] ?? defaultLimit
|
||||
}
|
||||
}
|
||||
|
||||
@ -530,3 +701,26 @@ enum WidgetListingStyle: String, CaseIterable, Defaults.Serializable {
|
||||
case horizontalCells
|
||||
case list
|
||||
}
|
||||
|
||||
enum SponsorBlockColors: String {
|
||||
case sponsor = "#00D400" // Green
|
||||
case selfpromo = "#FFFF00" // Yellow
|
||||
case interaction = "#CC00FF" // Purple
|
||||
case intro = "#00FFFF" // Cyan
|
||||
case outro = "#0202ED" // Dark Blue
|
||||
case preview = "#008FD6" // Light Blue
|
||||
case filler = "#7300FF" // Violet
|
||||
case music_offtopic = "#FF9900" // Orange
|
||||
|
||||
// Define all cases, can be used to iterate over the colors
|
||||
static let allCases: [SponsorBlockColors] = [Self.sponsor, Self.selfpromo, Self.interaction, Self.intro, Self.outro, Self.preview, Self.filler, Self.music_offtopic]
|
||||
|
||||
// Create a dictionary with the category names as keys and colors as values
|
||||
static let dictionary: [String: String] = {
|
||||
var dict = [String: String]()
|
||||
for item in allCases {
|
||||
dict[String(describing: item)] = item.rawValue
|
||||
}
|
||||
return dict
|
||||
}()
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct Delay {
|
||||
enum Delay {
|
||||
@discardableResult static func by(_ interval: TimeInterval, block: @escaping () -> Void) -> Timer {
|
||||
Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in block() }
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user