mirror of
https://github.com/yattee/yattee.git
synced 2025-04-27 15:30:33 +05:30
Compare commits
901 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 | ||
|
16b25df3bc | ||
|
a66e59a282 | ||
|
aeeedf3d63 | ||
|
3d35a60c7a | ||
|
8ffdd4d51f | ||
|
f871c7aaf5 | ||
|
32af2b385b | ||
|
f78545baf9 | ||
|
d95bcc4065 | ||
|
1efd9e2b90 | ||
|
59e5fcb37d | ||
|
47d68d7948 | ||
|
3e22c1ebde | ||
|
ede5d85693 | ||
|
4d5390ce2d | ||
|
91290d4736 | ||
|
7e7225c59f | ||
|
7b9bbd8974 | ||
|
c36dc67a72 | ||
|
71b25afa28 | ||
|
2c5eb18bc9 | ||
|
56c2e552f7 | ||
|
5ee869c02c | ||
|
6f91eedf4c | ||
|
0328656a44 | ||
|
8d11a92f97 | ||
|
f3a8a0977c | ||
|
3bbc2df431 | ||
|
5faa5f0a48 | ||
|
ca8586ce7f | ||
|
8efa68e65c | ||
|
04c15ed59a | ||
|
75772533fd | ||
|
3ca0f4cf2d | ||
|
5f745afecb | ||
|
e8c8c6b5b4 | ||
|
0585120bd8 | ||
|
1f43e11b8e | ||
|
792a2c1c6c | ||
|
12ef9e091c | ||
|
101ecb6892 | ||
|
be2e4acedd | ||
|
c6cff4dee4 | ||
|
eaeaa45422 | ||
|
6ddf1113bf | ||
|
15f3e11a78 | ||
|
713570dfd6 | ||
|
50eb0be1d7 | ||
|
3adbea1897 | ||
|
80a644eb7a | ||
|
b1637c5ef1 | ||
|
166482601d | ||
|
1d61dec8eb | ||
|
ca7195caba | ||
|
947f216fac | ||
|
a054d343a9 | ||
|
48263ae7db | ||
|
562df2d9ba | ||
|
e5f137a2d2 | ||
|
6856506834 | ||
|
a604382a3d | ||
|
0bbbf0907d | ||
|
921987be5d | ||
|
b47156ba5e | ||
|
5ab06d0e09 | ||
|
af632d7943 | ||
|
56993de1c2 | ||
|
ca61f0d8e5 | ||
|
6e89538623 | ||
|
d167ff575d | ||
|
c7d6253739 | ||
|
24241d3485 | ||
|
5cfcffc885 | ||
|
9d2e6f117d | ||
|
7c24a86a6a | ||
|
4d70c8a3c3 | ||
|
59f48c739a | ||
|
ae144ea82f | ||
|
2583be9401 | ||
|
0061bd8c20 | ||
|
12afb31c03 | ||
|
35867ba14a | ||
|
50e1491990 | ||
|
1e23809359 | ||
|
eed9330c0c | ||
|
1e2d6cf72f | ||
|
ac7dad2ab8 | ||
|
e1f03bc025 | ||
|
0dee8310ce | ||
|
b16eae3d88 | ||
|
02a29e5d07 | ||
|
22bbf731e9 | ||
|
c0053cf837 | ||
|
16fb7087e3 | ||
|
8f48da93d8 | ||
|
f3eac03b58 | ||
|
86b8c7384c | ||
|
1502d02184 | ||
|
a0dc280f22 | ||
|
91ec2f39b0 | ||
|
1d85f92087 | ||
|
ce2f9cee99 | ||
|
63fe52695c | ||
|
9524991c5f | ||
|
78f2c681a7 | ||
|
8f8abe7bb1 | ||
|
0a6beabae8 | ||
|
873bbf90bb | ||
|
450a4b42f7 | ||
|
db4b3115b1 | ||
|
fba465a22a | ||
|
d996069a20 | ||
|
d7a2564617 | ||
|
d5f8e35430 | ||
|
8f9de6d1be | ||
|
0fc6f7fdb7 | ||
|
d5f88a73f8 | ||
|
97af5a6e0c | ||
|
4c5ef920b4 | ||
|
a55683e6bf | ||
|
739ca007e8 | ||
|
7d7bd40a89 | ||
|
c6798be167 | ||
|
2b7ccc4b03 | ||
|
08ce572b9e | ||
|
1cc66fdc10 | ||
|
34a05433d5 | ||
|
5383cf0e90 | ||
|
a4fdd50388 | ||
|
f67b1d4feb | ||
|
b53b5eac56 | ||
|
6c5b8ef3ec | ||
|
226da4d2be | ||
|
49ffffae53 | ||
|
848a43ce7f | ||
|
6ca0e82feb | ||
|
f7f53c6417 | ||
|
55a0b2dee6 | ||
|
9f0700d2bf | ||
|
ab0c2e7b84 | ||
|
d603ef7431 | ||
|
281e0510cd | ||
|
837c9a3f75 | ||
|
bcec9d09ab | ||
|
82a09d1584 | ||
|
dae667fa8a | ||
|
0f802684f2 | ||
|
59b49c2e2f | ||
|
3a8d6aed76 | ||
|
2952e10359 | ||
|
59f84ec129 | ||
|
a76dae6656 | ||
|
a208ef9147 | ||
|
7a998d2d69 | ||
|
f3659904dc | ||
|
72246448f1 | ||
|
6617ad5fc6 | ||
|
65b3eb60d9 | ||
|
0174d2f8a0 | ||
|
8f340586a6 | ||
|
23e07baa7a |
22
.github/workflows/release.yml
vendored
22
.github/workflows/release.yml
vendored
@ -23,11 +23,13 @@ jobs:
|
||||
testflight:
|
||||
strategy:
|
||||
matrix:
|
||||
lane: ['mac beta', 'ios beta', 'tvos beta']
|
||||
# disabled mac beta lane
|
||||
# lane: ['mac beta', 'ios beta', 'tvos beta']
|
||||
lane: ['ios beta', 'tvos beta']
|
||||
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'
|
||||
@ -36,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
|
||||
@ -48,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'
|
||||
@ -57,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
|
||||
@ -68,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 }}
|
||||
@ -78,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
|
||||
|
21
Backports/ToolbarBackground+Backport.swift
Normal file
21
Backports/ToolbarBackground+Backport.swift
Normal file
@ -0,0 +1,21 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func toolbarBackground(_ color: Color) -> some View {
|
||||
if #available(iOS 16, *) {
|
||||
content
|
||||
.toolbarBackground(color, for: .navigationBar)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func toolbarBackgroundVisibility(_ visible: Bool) -> some View {
|
||||
if #available(iOS 16, *) {
|
||||
content
|
||||
.toolbarBackground(visible ? .visible : .hidden, for: .navigationBar)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
12
Backports/ToolbarColorScheme+Backport.swift
Normal file
12
Backports/ToolbarColorScheme+Backport.swift
Normal file
@ -0,0 +1,12 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Backport where Content: View {
|
||||
@ViewBuilder func toolbarColorScheme(_ colorScheme: ColorScheme) -> some View {
|
||||
if #available(iOS 16, *) {
|
||||
content
|
||||
.toolbarColorScheme(colorScheme, for: .navigationBar)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
130
CHANGELOG.md
130
CHANGELOG.md
@ -1,9 +1,123 @@
|
||||
## Build 146
|
||||
* Fixed playback settings sheet height
|
||||
* Localizations updates fixes
|
||||
* Other minor changes and fixes
|
||||
## Build 199
|
||||
|
||||
## Previous Builds
|
||||
* Added button to scroll to top in comments and setting to toggle it
|
||||
* Switch seek duration +/- buttons in controls settings
|
||||
* Other minor changes and fixes
|
||||
## 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
|
||||
|
||||
## 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
|
||||
|
||||
**Big thanks to the current, past and future project contributors!**
|
||||
|
11
Extensions/AVPlayerViewController+FullScreen.swift
Normal file
11
Extensions/AVPlayerViewController+FullScreen.swift
Normal file
@ -0,0 +1,11 @@
|
||||
import AVKit
|
||||
|
||||
extension AVPlayerViewController {
|
||||
func enterFullScreen(animated: Bool) {
|
||||
perform(NSSelectorFromString("enterFullScreenAnimated:completionHandler:"), with: animated, with: nil)
|
||||
}
|
||||
|
||||
func exitFullScreen(animated: Bool) {
|
||||
perform(NSSelectorFromString("exitFullScreenAnimated:completionHandler:"), with: animated, with: nil)
|
||||
}
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
import CoreData
|
||||
|
||||
extension NSManagedObjectContext {
|
||||
/// Executes the given `NSBatchDeleteRequest` and directly merges the changes to bring the given managed object context up to date.
|
||||
///
|
||||
/// - Parameter batchDeleteRequest: The `NSBatchDeleteRequest` to execute.
|
||||
/// - Throws: An error if anything went wrong executing the batch deletion.
|
||||
func executeAndMergeChanges(_ batchDeleteRequest: NSBatchDeleteRequest) throws {
|
||||
batchDeleteRequest.resultType = .resultTypeObjectIDs
|
||||
let result = try execute(batchDeleteRequest) as? NSBatchDeleteResult
|
||||
let changes: [AnyHashable: Any] = [NSDeletedObjectsKey: result?.result as? [NSManagedObjectID] ?? []]
|
||||
NSManagedObjectContext.mergeChanges(fromRemoteContextSave: changes, into: [self])
|
||||
}
|
||||
}
|
@ -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
|
||||
|
||||
|
14
Extensions/String+ReplacingHTMLEntities.swift
Normal file
14
Extensions/String+ReplacingHTMLEntities.swift
Normal file
@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
var replacingHTMLEntities: String {
|
||||
do {
|
||||
return try NSAttributedString(data: Data(utf8), options: [
|
||||
.documentType: NSAttributedString.DocumentType.html,
|
||||
.characterEncoding: String.Encoding.utf8.rawValue
|
||||
], documentAttributes: nil).string
|
||||
} catch {
|
||||
return self
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
165
Gemfile.lock
165
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.752.0)
|
||||
aws-sdk-core (3.171.0)
|
||||
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.63.0)
|
||||
aws-sdk-core (~> 3, >= 3.165.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.121.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.6)
|
||||
fastlane (2.212.2)
|
||||
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)
|
||||
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.39.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.0.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
|
||||
|
@ -53,13 +53,17 @@ struct Account: Defaults.Serializable, Hashable, Identifiable {
|
||||
}
|
||||
|
||||
var instance: Instance! {
|
||||
Defaults[.instances].first { $0.id == instanceID } ?? Instance(app: app ?? .invidious, name: urlString, apiURLString: urlString)
|
||||
InstancesModel.shared.find(instanceID) ?? Instance(app: app ?? .invidious, name: urlString, apiURLString: urlString)
|
||||
}
|
||||
|
||||
var isPublic: Bool {
|
||||
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(String.init)
|
||||
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)
|
||||
}
|
||||
@ -236,7 +238,7 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI {
|
||||
|
||||
func trending(country: Country, category: TrendingCategory?) -> Resource {
|
||||
resource(baseURL: account.url, path: "\(Self.basePath)/trending")
|
||||
.withParam("type", category?.name)
|
||||
.withParam("type", category?.type)
|
||||
.withParam("region", country.rawValue)
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -23,6 +24,7 @@ final class SubscribedChannelsModel: ObservableObject, CacheModel {
|
||||
@Published var error: RequestError?
|
||||
|
||||
var accounts: AccountsModel { .shared }
|
||||
var unwatchedFeedCount: UnwatchedFeedCountModel { .shared }
|
||||
|
||||
var resource: Resource? {
|
||||
accounts.api.subscriptions
|
||||
@ -32,6 +34,19 @@ final class SubscribedChannelsModel: ObservableObject, CacheModel {
|
||||
channels.sorted { $0.name.lowercased() < $1.name.lowercased() }
|
||||
}
|
||||
|
||||
var allByUnwatchedCount: [Channel] {
|
||||
if let account = accounts.current {
|
||||
return all.sorted { c1, c2 in
|
||||
let c1HasUnwatched = (unwatchedFeedCount.unwatchedByChannel[account]?[c1.id] ?? -1) > 0
|
||||
let c2HasUnwatched = (unwatchedFeedCount.unwatchedByChannel[account]?[c2.id] ?? -1) > 0
|
||||
let nameIncreasing = c1.name.lowercased() < c2.name.lowercased()
|
||||
|
||||
return c1HasUnwatched ? (c2HasUnwatched ? nameIncreasing : true) : (c2HasUnwatched ? false : nameIncreasing)
|
||||
}
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func subscribe(_ channelID: String, onSuccess: @escaping () -> Void = {}) {
|
||||
accounts.api.subscribe(channelID) {
|
||||
self.scheduleLoad(onSuccess: onSuccess)
|
||||
@ -54,15 +69,14 @@ final class SubscribedChannelsModel: ObservableObject, CacheModel {
|
||||
return
|
||||
}
|
||||
|
||||
loadCachedChannels(account)
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
let request = force ? self.resource?.load() : self.resource?.loadIfNeeded()
|
||||
guard request != nil else { return }
|
||||
|
||||
if request != nil {
|
||||
self.isLoading = true
|
||||
}
|
||||
self.loadCachedChannels(account)
|
||||
|
||||
self.isLoading = true
|
||||
|
||||
request?
|
||||
.onCompletion { [weak self] _ in
|
||||
@ -72,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()
|
||||
@ -92,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
|
||||
@ -69,6 +65,7 @@ final class CommentsModel: ObservableObject {
|
||||
}
|
||||
|
||||
func loadNextPage() {
|
||||
guard nextPageAvailable else { return }
|
||||
load(page: nextPage)
|
||||
}
|
||||
|
||||
|
@ -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 } }
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ import Foundation
|
||||
|
||||
struct FavoriteItem: Codable, Equatable, Identifiable, Defaults.Serializable {
|
||||
enum Section: Codable, Equatable, Defaults.Serializable {
|
||||
case history
|
||||
case subscriptions
|
||||
case popular
|
||||
case trending(String, String?)
|
||||
@ -13,6 +14,8 @@ struct FavoriteItem: Codable, Equatable, Identifiable, Defaults.Serializable {
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .history:
|
||||
return "History"
|
||||
case .subscriptions:
|
||||
return "Subscriptions"
|
||||
case .popular:
|
||||
@ -44,10 +47,14 @@ 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
|
||||
}
|
||||
|
||||
var id = UUID().uuidString
|
||||
var section: Section
|
||||
|
||||
var widgetSettingsKey: String {
|
||||
"favorites-\(id)"
|
||||
}
|
||||
}
|
134
Model/Favorites/FavoritesModel.swift
Normal file
134
Model/Favorites/FavoritesModel.swift
Normal file
@ -0,0 +1,134 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
struct FavoritesModel {
|
||||
static let shared = Self()
|
||||
|
||||
@Default(.showFavoritesInHome) var showFavoritesInHome
|
||||
@Default(.favorites) var all
|
||||
@Default(.widgetsSettings) var widgetsSettings
|
||||
|
||||
var isEnabled: Bool {
|
||||
showFavoritesInHome
|
||||
}
|
||||
|
||||
func contains(_ item: FavoriteItem) -> Bool {
|
||||
all.contains { $0 == item }
|
||||
}
|
||||
|
||||
func toggle(_ item: FavoriteItem) {
|
||||
if contains(item) {
|
||||
remove(item)
|
||||
} else {
|
||||
add(item)
|
||||
}
|
||||
}
|
||||
|
||||
func add(_ item: FavoriteItem) {
|
||||
if contains(item) { return }
|
||||
all.append(item)
|
||||
}
|
||||
|
||||
func remove(_ item: FavoriteItem) {
|
||||
if let index = all.firstIndex(where: { $0 == item }) {
|
||||
all.remove(at: index)
|
||||
}
|
||||
}
|
||||
|
||||
func canMoveUp(_ item: FavoriteItem) -> Bool {
|
||||
if let index = all.firstIndex(where: { $0 == item }) {
|
||||
return index > all.startIndex
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func canMoveDown(_ item: FavoriteItem) -> Bool {
|
||||
if let index = all.firstIndex(where: { $0 == item }) {
|
||||
return index < all.endIndex - 1
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func moveUp(_ item: FavoriteItem) {
|
||||
guard canMoveUp(item) else {
|
||||
return
|
||||
}
|
||||
|
||||
if let from = all.firstIndex(where: { $0 == item }) {
|
||||
all.move(
|
||||
fromOffsets: IndexSet(integer: from),
|
||||
toOffset: from - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func moveDown(_ item: FavoriteItem) {
|
||||
guard canMoveDown(item) else {
|
||||
return
|
||||
}
|
||||
|
||||
if let from = all.firstIndex(where: { $0 == item }) {
|
||||
all.move(
|
||||
fromOffsets: IndexSet(integer: from),
|
||||
toOffset: from + 2
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func addableItems() -> [FavoriteItem] {
|
||||
let allItems = [
|
||||
FavoriteItem(section: .subscriptions),
|
||||
FavoriteItem(section: .popular),
|
||||
FavoriteItem(section: .history)
|
||||
]
|
||||
|
||||
return allItems.filter { item in !all.contains { $0.section == item.section } }
|
||||
}
|
||||
|
||||
func listingStyle(_ item: FavoriteItem) -> WidgetListingStyle {
|
||||
widgetSettings(item).listingStyle
|
||||
}
|
||||
|
||||
func limit(_ item: FavoriteItem) -> Int {
|
||||
min(WidgetSettings.maxLimit(listingStyle(item)), widgetSettings(item).limit)
|
||||
}
|
||||
|
||||
func setListingStyle(_ style: WidgetListingStyle, _ item: FavoriteItem) {
|
||||
if let index = widgetsSettings.firstIndex(where: { $0.id == item.widgetSettingsKey }) {
|
||||
var settings = widgetsSettings[index]
|
||||
settings.listingStyle = style
|
||||
widgetsSettings[index] = settings
|
||||
} else {
|
||||
let settings = WidgetSettings(id: item.widgetSettingsKey, listingStyle: style)
|
||||
widgetsSettings.append(settings)
|
||||
}
|
||||
}
|
||||
|
||||
func setLimit(_ limit: Int, _ item: FavoriteItem) {
|
||||
if let index = widgetsSettings.firstIndex(where: { $0.id == item.widgetSettingsKey }) {
|
||||
var settings = widgetsSettings[index]
|
||||
let limit = min(max(1, limit), WidgetSettings.maxLimit(settings.listingStyle))
|
||||
settings.limit = limit
|
||||
widgetsSettings[index] = settings
|
||||
} else {
|
||||
var settings = WidgetSettings(id: item.widgetSettingsKey, limit: limit)
|
||||
let limit = min(max(1, limit), WidgetSettings.maxLimit(settings.listingStyle))
|
||||
settings.limit = limit
|
||||
widgetsSettings.append(settings)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,82 +0,0 @@
|
||||
import Defaults
|
||||
import Foundation
|
||||
|
||||
struct FavoritesModel {
|
||||
static let shared = Self()
|
||||
|
||||
@Default(.showFavoritesInHome) var showFavoritesInHome
|
||||
@Default(.favorites) var all
|
||||
|
||||
var isEnabled: Bool {
|
||||
showFavoritesInHome
|
||||
}
|
||||
|
||||
func contains(_ item: FavoriteItem) -> Bool {
|
||||
all.contains { $0 == item }
|
||||
}
|
||||
|
||||
func toggle(_ item: FavoriteItem) {
|
||||
contains(item) ? remove(item) : add(item)
|
||||
}
|
||||
|
||||
func add(_ item: FavoriteItem) {
|
||||
all.append(item)
|
||||
}
|
||||
|
||||
func remove(_ item: FavoriteItem) {
|
||||
if let index = all.firstIndex(where: { $0 == item }) {
|
||||
all.remove(at: index)
|
||||
}
|
||||
}
|
||||
|
||||
func canMoveUp(_ item: FavoriteItem) -> Bool {
|
||||
if let index = all.firstIndex(where: { $0 == item }) {
|
||||
return index > all.startIndex
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func canMoveDown(_ item: FavoriteItem) -> Bool {
|
||||
if let index = all.firstIndex(where: { $0 == item }) {
|
||||
return index < all.endIndex - 1
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func moveUp(_ item: FavoriteItem) {
|
||||
guard canMoveUp(item) else {
|
||||
return
|
||||
}
|
||||
|
||||
if let from = all.firstIndex(where: { $0 == item }) {
|
||||
all.move(
|
||||
fromOffsets: IndexSet(integer: from),
|
||||
toOffset: from - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func moveDown(_ item: FavoriteItem) {
|
||||
guard canMoveDown(item) else {
|
||||
return
|
||||
}
|
||||
|
||||
if let from = all.firstIndex(where: { $0 == item }) {
|
||||
all.move(
|
||||
fromOffsets: IndexSet(integer: from),
|
||||
toOffset: from + 2
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func addableItems() -> [FavoriteItem] {
|
||||
let allItems = [
|
||||
FavoriteItem(section: .subscriptions),
|
||||
FavoriteItem(section: .popular)
|
||||
]
|
||||
|
||||
return allItems.filter { item in !all.contains { $0.section == item.section } }
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
||||
@ -223,6 +223,7 @@ final class FeedModel: ObservableObject, CacheModel {
|
||||
try? self.backgroundContext.save()
|
||||
|
||||
self.calculateUnwatchedFeed()
|
||||
WatchModel.shared.watchesChanged()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -10,18 +10,21 @@ extension PlayerModel {
|
||||
historyVideos.first { $0.videoID == id }
|
||||
}
|
||||
|
||||
func loadHistoryVideoDetails(_ watch: Watch) {
|
||||
func loadHistoryVideoDetails(_ watch: Watch, onCompletion: @escaping () -> Void = {}) {
|
||||
guard historyVideo(watch.videoID).isNil else {
|
||||
onCompletion()
|
||||
return
|
||||
}
|
||||
|
||||
if !Video.VideoID.isValid(watch.videoID), let url = URL(string: watch.videoID) {
|
||||
historyVideos.append(.local(url))
|
||||
onCompletion()
|
||||
return
|
||||
}
|
||||
|
||||
if let video = VideosCacheModel.shared.retrieveVideo(watch.video.cacheKey) {
|
||||
historyVideos.append(video)
|
||||
onCompletion()
|
||||
return
|
||||
}
|
||||
|
||||
@ -35,6 +38,7 @@ extension PlayerModel {
|
||||
if let video: Video = response.typedContent() {
|
||||
VideosCacheModel.shared.storeVideo(video)
|
||||
self.historyVideos.append(video)
|
||||
onCompletion()
|
||||
}
|
||||
}
|
||||
.onCompletion { _ in
|
||||
@ -42,13 +46,12 @@ extension PlayerModel {
|
||||
}
|
||||
}
|
||||
|
||||
func updateWatch(finished: Bool = false) {
|
||||
guard let currentVideo, saveHistory else { return }
|
||||
func updateWatch(finished: Bool = false, time: CMTime? = nil) {
|
||||
guard let currentVideo, saveHistory, isPlaying else { return }
|
||||
|
||||
let id = currentVideo.videoID
|
||||
let time = backend.currentTime
|
||||
let time = time ?? backend.currentTime
|
||||
let seconds = time?.seconds ?? 0
|
||||
let duration = playerTime.duration.seconds
|
||||
if seconds < 3 {
|
||||
return
|
||||
}
|
||||
@ -59,13 +62,13 @@ extension PlayerModel {
|
||||
let results = try? backgroundContext.fetch(watchFetchRequest)
|
||||
|
||||
backgroundContext.perform { [weak self] in
|
||||
guard let self, finished || self.backend.isPlaying else {
|
||||
guard let self, finished || time != nil || self.backend.isPlaying else {
|
||||
return
|
||||
}
|
||||
|
||||
let watch: Watch!
|
||||
|
||||
let duration = self.playerTime.duration.seconds
|
||||
let duration = self.activeBackend == .mpv ? self.playerTime.duration.seconds : self.avPlayerBackend.playerItemDuration?.seconds ?? 0
|
||||
|
||||
if results?.isEmpty ?? true {
|
||||
watch = Watch(context: self.backgroundContext)
|
||||
@ -107,13 +110,19 @@ extension PlayerModel {
|
||||
try? self.context.save()
|
||||
|
||||
FeedModel.shared.calculateUnwatchedFeed()
|
||||
WatchModel.shared.watchesChanged()
|
||||
}
|
||||
}
|
||||
|
||||
func removeAllWatches() {
|
||||
let watchesFetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "Watch")
|
||||
let deleteRequest = NSBatchDeleteRequest(fetchRequest: watchesFetchRequest)
|
||||
_ = try? context.execute(deleteRequest)
|
||||
_ = try? context.save()
|
||||
|
||||
do {
|
||||
try context.executeAndMergeChanges(deleteRequest)
|
||||
try context.save()
|
||||
} catch let error as NSError {
|
||||
logger.info(.init(stringLiteral: error.localizedDescription))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -63,7 +63,14 @@ final class NavigationModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
@Published var tabSelection: TabSelection!
|
||||
@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
|
||||
@Published var videoToAddToPlaylist: Video!
|
||||
@ -83,6 +90,7 @@ final class NavigationModel: ObservableObject {
|
||||
@Published var presentingSettings = false
|
||||
@Published var presentingAccounts = false
|
||||
@Published var presentingWelcomeScreen = false
|
||||
@Published var presentingHomeSettings = false
|
||||
|
||||
@Published var presentingChannelSheet = false
|
||||
@Published var channelPresentedInSheet: Channel!
|
||||
@ -99,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
|
||||
@ -136,6 +148,10 @@ final class NavigationModel: ObservableObject {
|
||||
} else {
|
||||
navigateToChannel()
|
||||
}
|
||||
#elseif os(tvOS)
|
||||
Delay.by(0.01) {
|
||||
navigateToChannel()
|
||||
}
|
||||
#else
|
||||
navigateToChannel()
|
||||
#endif
|
||||
@ -257,6 +273,8 @@ final class NavigationModel: ObservableObject {
|
||||
presentingChannel = false
|
||||
presentingPlaylist = false
|
||||
presentingOpenVideos = false
|
||||
presentingFileImporter = false
|
||||
presentingSettingsImportSheet = false
|
||||
}
|
||||
|
||||
func hideKeyboard() {
|
||||
@ -267,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) {
|
||||
@ -277,6 +296,11 @@ final class NavigationModel: ObservableObject {
|
||||
}
|
||||
|
||||
func presentAlert(_ alert: Alert) {
|
||||
guard !presentingSettings else {
|
||||
SettingsModel.shared.presentAlert(alert)
|
||||
return
|
||||
}
|
||||
|
||||
self.alert = alert
|
||||
presentingAlert = true
|
||||
}
|
||||
@ -290,6 +314,25 @@ final class NavigationModel: ObservableObject {
|
||||
channelPresentedInSheet = channel
|
||||
presentingChannelSheet = true
|
||||
}
|
||||
|
||||
func multipleTapHandler() {
|
||||
switch tabSelection {
|
||||
case .search:
|
||||
self.search.focused = true
|
||||
default:
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import AVFoundation
|
||||
import AVKit
|
||||
import Defaults
|
||||
import Foundation
|
||||
import Logging
|
||||
@ -6,6 +6,7 @@ import MediaPlayer
|
||||
#if !os(macOS)
|
||||
import UIKit
|
||||
#endif
|
||||
import SwiftUI
|
||||
|
||||
final class AVPlayerBackend: PlayerBackend {
|
||||
static let assetKeysToLoad = ["tracks", "playable", "duration"]
|
||||
@ -37,8 +38,11 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
!avPlayer.currentItem.isNil
|
||||
}
|
||||
|
||||
var isLoadingVideo: Bool {
|
||||
model.currentItem == nil || model.time == nil || !model.time!.isValid
|
||||
var isLoadingVideo = false
|
||||
|
||||
var hasStarted = false
|
||||
var isPaused: Bool {
|
||||
avPlayer.timeControlStatus == .paused
|
||||
}
|
||||
|
||||
var isPlaying: Bool {
|
||||
@ -84,6 +88,10 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
private(set) var playerLayer = AVPlayerLayer()
|
||||
#if os(tvOS)
|
||||
var controller: AppleAVPlayerViewController?
|
||||
#elseif os(iOS)
|
||||
var controller = AVPlayerViewController() { didSet {
|
||||
controller.player = avPlayer
|
||||
}}
|
||||
#endif
|
||||
var startPictureInPictureOnPlay = false
|
||||
var startPictureInPictureOnSwitch = false
|
||||
@ -94,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()
|
||||
@ -108,28 +116,43 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
addPlayerTimeControlStatusObserver()
|
||||
|
||||
playerLayer.player = avPlayer
|
||||
#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
|
||||
|
||||
if let url = stream.singleAssetURL {
|
||||
model.logger.info("playing stream with one asset\(stream.kind == .hls ? " (HLS)" : ""): \(url)")
|
||||
|
||||
@ -137,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)")
|
||||
@ -152,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()
|
||||
}
|
||||
|
||||
@ -160,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() {
|
||||
@ -207,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
|
||||
@ -291,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
|
||||
}
|
||||
@ -313,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)
|
||||
@ -325,6 +383,10 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
return
|
||||
}
|
||||
|
||||
if self.model.musicMode {
|
||||
self.startMusicMode()
|
||||
}
|
||||
|
||||
if !preservingTime,
|
||||
!self.model.transitioningToPiP,
|
||||
let segment = self.model.sponsorBlock.segments.first,
|
||||
@ -341,9 +403,11 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
self.model.lastSkipped = segment
|
||||
self.model.handleOnPlayStream(stream)
|
||||
self.model.play()
|
||||
}
|
||||
} else {
|
||||
self.model.handleOnPlayStream(stream)
|
||||
self.model.play()
|
||||
}
|
||||
}
|
||||
@ -369,7 +433,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
if preservingTime {
|
||||
if model.preservedTime.isNil {
|
||||
if model.preservedTime.isNil || upgrading {
|
||||
model.saveTime {
|
||||
replaceItemAndSeek()
|
||||
startPlaying()
|
||||
@ -400,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() {
|
||||
@ -467,16 +530,16 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
return
|
||||
}
|
||||
|
||||
self.isLoadingVideo = false
|
||||
|
||||
switch playerItem.status {
|
||||
case .readyToPlay:
|
||||
if self.model.playingInPictureInPicture {
|
||||
self.startPictureInPictureOnSwitch = false
|
||||
self.startPictureInPictureOnPlay = false
|
||||
}
|
||||
if self.model.activeBackend == .appleAVPlayer,
|
||||
self.isAutoplaying(playerItem)
|
||||
{
|
||||
self.model.updateAspectRatio()
|
||||
if self.model.aspectRatio != self.aspectRatio {
|
||||
self.model.updateAspectRatio()
|
||||
}
|
||||
|
||||
if self.startPictureInPictureOnPlay,
|
||||
let controller = self.model.pipController,
|
||||
@ -487,21 +550,26 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
self.model.play()
|
||||
}
|
||||
} else if self.startPictureInPictureOnPlay {
|
||||
self.startPictureInPictureOnPlay = false
|
||||
self.model.stream = self.stream
|
||||
self.model.streamSelection = self.stream
|
||||
|
||||
if self.model.activeBackend != .appleAVPlayer {
|
||||
self.startPictureInPictureOnSwitch = true
|
||||
let seconds = self.model.mpvBackend.currentTime?.seconds ?? 0
|
||||
self.seek(to: seconds, seekType: .backendSync) { _ in
|
||||
self.seek(to: seconds, seekType: .backendSync) { finished in
|
||||
guard finished else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.model.pause()
|
||||
self.model.changeActiveBackend(from: .mpv, to: .appleAVPlayer, changingStream: false)
|
||||
|
||||
Delay.by(3) {
|
||||
self.startPictureInPictureOnPlay = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .failed:
|
||||
DispatchQueue.main.async {
|
||||
self.model.playerError = item.error
|
||||
@ -575,6 +643,8 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
if self.controlsUpdates {
|
||||
self.updateControls()
|
||||
}
|
||||
|
||||
self.model.updateTime(self.currentTime!)
|
||||
}
|
||||
}
|
||||
|
||||
@ -594,7 +664,7 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
self.timeObserverThrottle.execute {
|
||||
self.model.updateWatch()
|
||||
self.model.updateWatch(time: self.currentTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -624,8 +694,18 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
|
||||
if player.timeControlStatus == .playing {
|
||||
self.model.objectWillChange.send()
|
||||
if player.rate != Float(self.model.currentRate) {
|
||||
player.rate = Float(self.model.currentRate)
|
||||
|
||||
if let rate = self.model.rateToRestore, player.rate != rate {
|
||||
player.rate = rate
|
||||
self.model.rateToRestore = nil
|
||||
}
|
||||
|
||||
if player.rate > 0, player.rate != Float(self.model.currentRate) {
|
||||
if self.model.avPlayerUsesSystemControls {
|
||||
self.model.currentRate = Double(player.rate)
|
||||
} else {
|
||||
player.rate = Float(self.model.currentRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -635,10 +715,14 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
} else {
|
||||
ScreenSaverManager.shared.enable()
|
||||
}
|
||||
#else
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.isIdleTimerDisabled = self.model.presentingPlayer && self.isPlaying
|
||||
}
|
||||
#endif
|
||||
|
||||
self.timeObserverThrottle.execute {
|
||||
self.model.updateWatch()
|
||||
self.model.updateWatch(time: self.currentTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -688,13 +772,27 @@ final class AVPlayerBackend: PlayerBackend {
|
||||
|
||||
func didChangeTo() {
|
||||
if startPictureInPictureOnSwitch {
|
||||
startPictureInPictureOnSwitch = false
|
||||
tryStartingPictureInPicture()
|
||||
} else if model.musicMode {
|
||||
startMusicMode()
|
||||
} else {
|
||||
stopMusicMode()
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
if model.playingFullScreen {
|
||||
ControlOverlaysModel.shared.hide()
|
||||
model.navigation.presentingPlaybackSettings = false
|
||||
|
||||
model.onPlayStream.append { _ in
|
||||
self.controller.enterFullScreen(animated: true)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var isStartingPiP: Bool {
|
||||
startPictureInPictureOnPlay || startPictureInPictureOnSwitch
|
||||
}
|
||||
|
||||
func tryStartingPictureInPicture() {
|
||||
@ -708,10 +806,36 @@ 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Delay.by(5) {
|
||||
self.startPictureInPictureOnSwitch = false
|
||||
}
|
||||
}
|
||||
|
||||
func setPlayerInLayer(_ playerIsPresented: Bool) {
|
||||
if playerIsPresented {
|
||||
bindPlayerToLayer()
|
||||
} else {
|
||||
removePlayerFromLayer()
|
||||
}
|
||||
}
|
||||
|
||||
func removePlayerFromLayer() {
|
||||
playerLayer.player = nil
|
||||
#if os(iOS)
|
||||
controller.player = nil
|
||||
#endif
|
||||
}
|
||||
|
||||
func bindPlayerToLayer() {
|
||||
playerLayer.player = avPlayer
|
||||
#if os(iOS)
|
||||
controller.player = avPlayer
|
||||
#endif
|
||||
}
|
||||
|
||||
func getTimeUpdates() {}
|
||||
|
@ -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,
|
||||
@ -267,9 +277,11 @@ final class MPVBackend: PlayerBackend {
|
||||
|
||||
self.model.lastSkipped = segment
|
||||
self.play()
|
||||
self.model.handleOnPlayStream(stream)
|
||||
}
|
||||
} else {
|
||||
self.play()
|
||||
self.model.handleOnPlayStream(stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -298,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 {
|
||||
@ -310,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()
|
||||
}
|
||||
@ -319,7 +331,7 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
if preservingTime {
|
||||
if model.preservedTime.isNil {
|
||||
if model.preservedTime.isNil || upgrading {
|
||||
model.saveTime {
|
||||
replaceItem(self.model.preservedTime)
|
||||
}
|
||||
@ -333,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()
|
||||
@ -343,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() {
|
||||
@ -362,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)?) {
|
||||
@ -378,8 +433,8 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
func closeItem() {
|
||||
client?.pause()
|
||||
client?.stop()
|
||||
pause()
|
||||
stop()
|
||||
self.video = nil
|
||||
self.stream = nil
|
||||
}
|
||||
@ -423,8 +478,10 @@ final class MPVBackend: PlayerBackend {
|
||||
}
|
||||
|
||||
timeObserverThrottle.execute {
|
||||
self.model.updateWatch()
|
||||
self.model.updateWatch(time: self.currentTime)
|
||||
}
|
||||
|
||||
self.model.updateTime(self.currentTime!)
|
||||
}
|
||||
|
||||
private func stopClientUpdates() {
|
||||
@ -438,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)))"))
|
||||
|
||||
@ -457,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
|
||||
@ -465,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()
|
||||
|
||||
@ -506,8 +605,6 @@ final class MPVBackend: PlayerBackend {
|
||||
guard client.eofReached else {
|
||||
return
|
||||
}
|
||||
|
||||
getTimeUpdates()
|
||||
eofPlaybackModeAction()
|
||||
}
|
||||
|
||||
@ -524,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() {
|
||||
@ -588,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,17 +413,17 @@ 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
|
||||
let height = [model.playerSize.height, model.playerSize.width / aspectRatio].min()!
|
||||
var insets = 0.0
|
||||
#if os(iOS)
|
||||
insets = OrientationTracker.shared.currentInterfaceOrientation.isPortrait ? SafeAreaModel.shared.safeArea.bottom : 0
|
||||
#endif
|
||||
let offsetY = max(0, model.playingFullScreen ? ((model.playerSize.height / 2.0) - ((height + insets) / 2)) : 0)
|
||||
UIView.animate(withDuration: 0.2, animations: {
|
||||
let aspectRatio = self.aspectRatio > 0 && self.aspectRatio < VideoPlayerView.defaultAspectRatio ? self.aspectRatio : VideoPlayerView.defaultAspectRatio
|
||||
let height = [model.playerSize.height, model.playerSize.width / aspectRatio].min()!
|
||||
var insets = 0.0
|
||||
#if os(iOS)
|
||||
insets = OrientationTracker.shared.currentInterfaceOrientation.isPortrait ? SafeArea.insets.bottom : 0
|
||||
#endif
|
||||
let offsetY = model.playingFullScreen ? ((model.playerSize.height / 2.0) - ((height + insets) / 2)) : 0
|
||||
self.glView?.frame = CGRect(x: 0, y: offsetY, width: roundedWidth, height: height)
|
||||
}) { completion in
|
||||
if completion {
|
||||
@ -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)
|
||||
@ -111,12 +117,21 @@ extension PlayerBackend {
|
||||
|
||||
if model.queue.isEmpty {
|
||||
#if os(tvOS)
|
||||
if model.activeBackend == .appleAVPlayer {
|
||||
model.avPlayerBackend.controller?.dismiss(animated: false)
|
||||
if Defaults[.closeVideoOnEOF] {
|
||||
if model.activeBackend == .appleAVPlayer {
|
||||
model.avPlayerBackend.controller?.dismiss(animated: false)
|
||||
}
|
||||
model.resetQueue()
|
||||
model.hide()
|
||||
}
|
||||
#else
|
||||
if Defaults[.closeVideoOnEOF] {
|
||||
model.resetQueue()
|
||||
model.hide()
|
||||
} else if Defaults[.exitFullscreenOnEOF], model.playingFullScreen {
|
||||
model.exitFullScreen()
|
||||
}
|
||||
#endif
|
||||
model.resetQueue()
|
||||
model.hide()
|
||||
} else {
|
||||
model.advanceToNextItem()
|
||||
}
|
||||
@ -129,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
|
||||
}
|
||||
@ -141,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
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import Foundation
|
||||
import SwiftUI
|
||||
|
||||
final class PiPDelegate: NSObject, AVPictureInPictureControllerDelegate {
|
||||
var player: PlayerModel!
|
||||
var player: PlayerModel { .shared }
|
||||
|
||||
func pictureInPictureController(
|
||||
_: AVPictureInPictureController,
|
||||
@ -16,29 +16,31 @@ final class PiPDelegate: NSObject, AVPictureInPictureControllerDelegate {
|
||||
func pictureInPictureControllerWillStartPictureInPicture(_: AVPictureInPictureController) {}
|
||||
|
||||
func pictureInPictureControllerDidStartPictureInPicture(_: AVPictureInPictureController) {
|
||||
guard let player else { return }
|
||||
player.play()
|
||||
|
||||
player.playingInPictureInPicture = true
|
||||
player.avPlayerBackend.startPictureInPictureOnPlay = false
|
||||
player.avPlayerBackend.startPictureInPictureOnSwitch = false
|
||||
player.controls.objectWillChange.send()
|
||||
|
||||
if Defaults[.closePlayerOnOpeningPiP] { Delay.by(0.1) { player.hide() } }
|
||||
if Defaults[.closePlayerOnOpeningPiP] { Delay.by(0.1) { self.player.hide() } }
|
||||
}
|
||||
|
||||
func pictureInPictureControllerDidStopPictureInPicture(_: AVPictureInPictureController) {
|
||||
guard let player else { return }
|
||||
|
||||
player.playingInPictureInPicture = false
|
||||
player.controls.objectWillChange.send()
|
||||
}
|
||||
|
||||
func pictureInPictureControllerWillStopPictureInPicture(_: AVPictureInPictureController) {}
|
||||
func pictureInPictureControllerWillStopPictureInPicture(_: AVPictureInPictureController) {
|
||||
player.show()
|
||||
}
|
||||
|
||||
func pictureInPictureController(
|
||||
_: AVPictureInPictureController,
|
||||
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
|
||||
) {
|
||||
let wasPlaying = player.isPlaying
|
||||
|
||||
var delay = 0.0
|
||||
#if os(iOS)
|
||||
if !player.presentingPlayer {
|
||||
@ -50,7 +52,7 @@ final class PiPDelegate: NSObject, AVPictureInPictureControllerDelegate {
|
||||
#endif
|
||||
|
||||
if !player.currentItem.isNil, !player.musicMode {
|
||||
player?.show()
|
||||
player.show()
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
@ -58,6 +60,11 @@ final class PiPDelegate: NSObject, AVPictureInPictureControllerDelegate {
|
||||
self?.player.playingInPictureInPicture = false
|
||||
}
|
||||
|
||||
if wasPlaying {
|
||||
Delay.by(1) {
|
||||
self?.player.play()
|
||||
}
|
||||
}
|
||||
completionHandler(true)
|
||||
}
|
||||
}
|
||||
|
@ -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,15 +47,15 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
static var shared = PlayerModel()
|
||||
|
||||
let logger = Logger(label: "stream.yattee.app")
|
||||
let logger = Logger(label: "stream.yattee.player.model")
|
||||
|
||||
var avPlayerView = AppleAVPlayerView()
|
||||
var playerItem: AVPlayerItem?
|
||||
|
||||
var mpvPlayerView = MPVPlayerView()
|
||||
|
||||
@Published var presentingPlayer = false { didSet { handlePresentationChange() } }
|
||||
@Published var activeBackend = PlayerBackendType.mpv
|
||||
@Published var forceBackendOnPlay: PlayerBackendType?
|
||||
|
||||
var avPlayerBackend = AVPlayerBackend()
|
||||
var mpvBackend = MPVBackend()
|
||||
@ -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(.rotateToPortraitOnExitFullScreen) private var rotateToPortraitOnExitFullScreen
|
||||
@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 }
|
||||
@ -152,6 +164,9 @@ final class PlayerModel: ObservableObject {
|
||||
@Published var playingInPictureInPicture = false
|
||||
var pipController: AVPictureInPictureController?
|
||||
var pipDelegate = PiPDelegate()
|
||||
#if !os(macOS)
|
||||
var appleAVPlayerViewControllerDelegate = AppleAVPlayerViewControllerDelegate()
|
||||
#endif
|
||||
|
||||
var playerError: Error? { didSet {
|
||||
if let error = playerError {
|
||||
@ -163,6 +178,7 @@ final class PlayerModel: ObservableObject {
|
||||
@Default(.saveLastPlayed) var saveLastPlayed
|
||||
@Default(.lastPlayed) var lastPlayed
|
||||
@Default(.qualityProfiles) var qualityProfiles
|
||||
@Default(.avPlayerUsesSystemControls) var avPlayerUsesSystemControls
|
||||
@Default(.forceAVPlayerForLiveStreams) var forceAVPlayerForLiveStreams
|
||||
@Default(.pauseOnHidingPlayer) private var pauseOnHidingPlayer
|
||||
@Default(.closePiPOnNavigation) var closePiPOnNavigation
|
||||
@ -171,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
|
||||
@ -178,31 +199,82 @@ final class PlayerModel: ObservableObject {
|
||||
private var currentArtwork: MPMediaItemArtwork?
|
||||
|
||||
var onPresentPlayer = [() -> Void]()
|
||||
var onPlayStream = [(Stream) -> Void]()
|
||||
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
|
||||
|
||||
Defaults[.activeBackend] = .mpv
|
||||
playbackMode = Defaults[.playbackMode]
|
||||
|
||||
guard pipController.isNil else { return }
|
||||
pipController = .init(playerLayer: avPlayerBackend.playerLayer)
|
||||
let pipDelegate = PiPDelegate()
|
||||
pipDelegate.player = self
|
||||
|
||||
self.pipDelegate = pipDelegate
|
||||
pipController = .init(playerLayer: avPlayerBackend.playerLayer)
|
||||
pipController?.delegate = pipDelegate
|
||||
#if os(iOS)
|
||||
if #available(iOS 14.2, *) {
|
||||
pipController?.canStartPictureInPictureAutomaticallyFromInline = true
|
||||
}
|
||||
#endif
|
||||
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
|
||||
@ -218,6 +290,7 @@ final class PlayerModel: ObservableObject {
|
||||
#if os(macOS)
|
||||
Windows.player.open()
|
||||
Windows.player.focus()
|
||||
assignKeyPressMonitor()
|
||||
#endif
|
||||
}
|
||||
|
||||
@ -231,17 +304,13 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.exitFullScreen(showControls: false)
|
||||
Delay.by(0.3) {
|
||||
self?.exitFullScreen(showControls: false)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
if Defaults[.lockPortraitWhenBrowsing] {
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
} else {
|
||||
Orientation.lockOrientation(.allButUpsideDown)
|
||||
}
|
||||
#endif
|
||||
#if os(macOS)
|
||||
destroyKeyPressMonitor()
|
||||
Windows.player.hide()
|
||||
#endif
|
||||
}
|
||||
@ -280,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
|
||||
@ -410,6 +487,10 @@ final class PlayerModel: ObservableObject {
|
||||
upgrading: upgrading
|
||||
)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.forceBackendOnPlay = nil
|
||||
}
|
||||
|
||||
if !upgrading {
|
||||
updateCurrentArtwork()
|
||||
}
|
||||
@ -444,7 +525,7 @@ final class PlayerModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
if let backend = (live && forceAVPlayerForLiveStreams) ? PlayerBackendType.appleAVPlayer : qualityProfile?.backend,
|
||||
if let backend = forceBackendOnPlay ?? ((live && forceAVPlayerForLiveStreams) ? PlayerBackendType.appleAVPlayer : qualityProfile?.backend),
|
||||
backend != activeBackend,
|
||||
backend == .appleAVPlayer || !(avPlayerBackend.startPictureInPictureOnPlay || playingInPictureInPicture)
|
||||
{
|
||||
@ -472,9 +553,19 @@ 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 {
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
}
|
||||
#endif
|
||||
|
||||
controls.hide()
|
||||
controls.hideOverlays()
|
||||
|
||||
#if !os(macOS)
|
||||
UIApplication.shared.isIdleTimerDisabled = presentingPlayer
|
||||
@ -491,9 +582,19 @@ final class PlayerModel: ObservableObject {
|
||||
self?.pause()
|
||||
}
|
||||
}
|
||||
|
||||
if !presentingPlayer {
|
||||
#if os(iOS)
|
||||
if lockPortraitWhenBrowsing {
|
||||
Orientation.lockOrientation(.portrait, andRotateTo: .portrait)
|
||||
} else {
|
||||
Orientation.lockOrientation(.all)
|
||||
}
|
||||
#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
|
||||
}
|
||||
@ -502,7 +603,7 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
let wasPlaying = isPlaying
|
||||
|
||||
if to == .mpv {
|
||||
if to == .mpv && !isInClosePip {
|
||||
closePiP()
|
||||
}
|
||||
|
||||
@ -518,6 +619,9 @@ final class PlayerModel: ObservableObject {
|
||||
if !self.backend.canPlayAtRate(currentRate) {
|
||||
currentRate = self.backend.suggestedPlaybackRates.last { $0 < currentRate } ?? 1.0
|
||||
}
|
||||
|
||||
self.rateToRestore = Float(currentRate)
|
||||
|
||||
self.backend.didChangeTo()
|
||||
|
||||
if wasPlaying {
|
||||
@ -541,10 +645,15 @@ final class PlayerModel: ObservableObject {
|
||||
self.stream = stream
|
||||
streamSelection = stream
|
||||
|
||||
self.upgradeToStream(stream, force: true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !backend.canPlay(stream) || (to == .mpv && !stream.hlsURL.isNil) {
|
||||
if !backend.canPlay(stream) ||
|
||||
(to == .mpv && stream.isHLS) ||
|
||||
(to == .appleAVPlayer && !stream.isHLS)
|
||||
{
|
||||
guard let preferredStream = streamByQualityProfile else {
|
||||
return
|
||||
}
|
||||
@ -587,57 +696,65 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func closeCurrentItem(finished: Bool = false) {
|
||||
pause()
|
||||
videoBeingOpened = nil
|
||||
|
||||
guard !closing else { return }
|
||||
closing = true
|
||||
controls.presentingControls = false
|
||||
|
||||
self.hide()
|
||||
if playingFullScreen { exitFullScreen() }
|
||||
|
||||
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
|
||||
|
||||
controls.presentingControls = false
|
||||
|
||||
self.prepareCurrentItemForHistory(finished: finished)
|
||||
withAnimation {
|
||||
self.currentItem = nil
|
||||
}
|
||||
self.updateNowPlayingInfo()
|
||||
self.hide()
|
||||
|
||||
self.backend.closeItem()
|
||||
self.aspectRatio = VideoPlayerView.defaultAspectRatio
|
||||
self.resetAutoplay()
|
||||
self.closing = false
|
||||
self.playingFullScreen = false
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
changeActiveBackend(from: activeBackend, to: .appleAVPlayer)
|
||||
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()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
avPlayerBackend.startPictureInPictureOnPlay = true
|
||||
playStream(stream, of: video, preservingTime: true, upgrading: true, withBackend: avPlayerBackend)
|
||||
}
|
||||
|
||||
controls.objectWillChange.send()
|
||||
}
|
||||
|
||||
var transitioningToPiP: Bool {
|
||||
@ -665,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 {
|
||||
@ -677,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
|
||||
@ -717,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
|
||||
@ -776,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
|
||||
|
||||
@ -830,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)
|
||||
@ -880,22 +1028,43 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
#else
|
||||
func handleEnterForeground() {
|
||||
setNeedsDrawing(presentingPlayer)
|
||||
avPlayerBackend.playerLayer.player = avPlayerBackend.avPlayer
|
||||
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 {
|
||||
avPlayerBackend.playerLayer.player = nil
|
||||
} else if !playingInPictureInPicture, activeBackend == .appleAVPlayer {
|
||||
avPlayerBackend.removePlayerFromLayer()
|
||||
} else if activeBackend == .mpv, !musicMode {
|
||||
mpvBackend.setVideoToNo()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@ -905,6 +1074,7 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
logger.info("entering fullscreen")
|
||||
toggleFullscreen(false, showControls: showControls)
|
||||
self.playingFullScreen = true
|
||||
}
|
||||
|
||||
func exitFullScreen(showControls: Bool = true) {
|
||||
@ -912,18 +1082,30 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
logger.info("exiting fullscreen")
|
||||
toggleFullscreen(true, showControls: showControls)
|
||||
self.playingFullScreen = false
|
||||
}
|
||||
|
||||
func updateNowPlayingInfo() {
|
||||
#if os(tvOS)
|
||||
guard activeBackend == .mpv else { 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,
|
||||
@ -931,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 {
|
||||
@ -952,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
|
||||
}
|
||||
@ -974,31 +1156,52 @@ 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)
|
||||
Windows.player.toggleFullScreen()
|
||||
#endif
|
||||
|
||||
playingFullScreen = !isFullScreen
|
||||
|
||||
#if os(iOS)
|
||||
if !playingFullScreen {
|
||||
playingFullScreen = true
|
||||
Orientation.lockOrientation(.allButUpsideDown)
|
||||
} else {
|
||||
let rotationOrientation = rotateToPortraitOnExitFullScreen ? UIInterfaceOrientation.portrait : nil
|
||||
Orientation.lockOrientation(.allButUpsideDown, andRotateTo: rotationOrientation)
|
||||
// TODO: rework to move view before rotating
|
||||
if SafeArea.insets.left > 0 {
|
||||
Delay.by(0.15) {
|
||||
self.playingFullScreen = false
|
||||
}
|
||||
} else {
|
||||
self.playingFullScreen = false
|
||||
if playingFullScreen {
|
||||
if activeBackend == .appleAVPlayer, avPlayerUsesSystemControls {
|
||||
fullscreenInitiatedByButton = initiatedByButton
|
||||
avPlayerBackend.controller.enterFullScreen(animated: true)
|
||||
return
|
||||
}
|
||||
let lockOrientation = rotateToLandscapeOnEnterFullScreen.interfaceOrientation
|
||||
if currentVideoIsLandscape {
|
||||
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 {
|
||||
avPlayerBackend.controller.exitFullScreen(animated: true)
|
||||
avPlayerBackend.controller.dismiss(animated: true)
|
||||
return
|
||||
}
|
||||
if lockPortraitWhenBrowsing {
|
||||
lockedOrientation = UIInterfaceOrientationMask.portrait
|
||||
}
|
||||
let rotationOrientation = lockPortraitWhenBrowsing ? UIInterfaceOrientation.portrait : nil
|
||||
Orientation.lockOrientation(lockPortraitWhenBrowsing ? .portrait : .all, andRotateTo: rotationOrientation)
|
||||
}
|
||||
#else
|
||||
playingFullScreen = !isFullScreen
|
||||
#endif
|
||||
}
|
||||
|
||||
@ -1031,13 +1234,237 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.aspectRatio = self.backend.aspectRatio
|
||||
withAnimation {
|
||||
self.aspectRatio = self.backend.aspectRatio
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
var currentVideoIsLandscape: Bool {
|
||||
guard currentVideo != nil else { return false }
|
||||
|
||||
return aspectRatio > 1
|
||||
}
|
||||
|
||||
var formattedSize: String {
|
||||
guard let videoWidth = backend?.videoWidth, let videoHeight = backend?.videoHeight else { return "unknown" }
|
||||
return "\(String(format: "%.2f", videoWidth))\u{d7}\(String(format: "%.2f", videoHeight))"
|
||||
}
|
||||
|
||||
func handleOnPlayStream(_ stream: Stream) {
|
||||
backend.setRate(currentRate)
|
||||
|
||||
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
|
||||
}
|
||||
|
@ -88,13 +88,15 @@ extension PlayerModel {
|
||||
guard let playerInstance = self.playerInstance else { return }
|
||||
let streamsInstance = video.streams.compactMap(\.instance).first
|
||||
|
||||
if video.streams.isEmpty || streamsInstance != playerInstance {
|
||||
if video.streams.isEmpty || streamsInstance.isNil || streamsInstance!.apiURLString != playerInstance.apiURLString {
|
||||
self.loadAvailableStreams(video) { [weak self] _ in
|
||||
self?.videoBeingOpened = nil
|
||||
}
|
||||
} 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() {
|
||||
@ -257,7 +278,7 @@ extension PlayerModel {
|
||||
if let video = currentVideo, !historyVideos.contains(where: { $0 == video }) {
|
||||
historyVideos.append(video)
|
||||
}
|
||||
updateWatch(finished: finished)
|
||||
updateWatch(finished: finished, time: backend.currentTime)
|
||||
}
|
||||
|
||||
if let video = currentItem.video,
|
||||
@ -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"],
|
||||
@ -364,10 +410,7 @@ extension PlayerModel {
|
||||
message: Text(message),
|
||||
primaryButton: .cancel { [weak self] in
|
||||
guard let self else { return }
|
||||
self.advancing = false
|
||||
self.videoBeingOpened = nil
|
||||
self.currentItem = nil
|
||||
self.hide()
|
||||
self.closeCurrentItem()
|
||||
},
|
||||
secondaryButton: retryButton
|
||||
)
|
||||
|
@ -32,23 +32,18 @@ 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
|
||||
var playerItemEndTimeWithSegments: CMTime? {
|
||||
if let duration = playerItemDuration,
|
||||
let segment = sponsorBlock.segments.last,
|
||||
segment.endTime.seconds >= duration.seconds - 3
|
||||
{
|
||||
return segment.endTime
|
||||
}
|
||||
|
||||
return playerItemDuration
|
||||
}
|
||||
|
||||
private func skip(_ segment: Segment, at time: CMTime) {
|
||||
backend.seek(to: segment.endTime, seekType: .segmentSkip(segment.category))
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
@ -58,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)
|
||||
}
|
||||
}
|
||||
|
@ -39,7 +39,9 @@ final class RecentsModel: ObservableObject {
|
||||
|
||||
func addQuery(_ query: String) {
|
||||
if !query.isEmpty {
|
||||
NavigationModel.shared.tabSelection = .search
|
||||
if NavigationModel.shared.tabSelection != .search {
|
||||
NavigationModel.shared.tabSelection = .search
|
||||
}
|
||||
add(.init(from: query))
|
||||
}
|
||||
}
|
||||
@ -60,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]$"#
|
||||
|
@ -16,9 +16,31 @@ final class SearchModel: ObservableObject {
|
||||
@Published var querySuggestions = [String]()
|
||||
private var suggestionsDebouncer = Debouncer(.milliseconds(200))
|
||||
|
||||
@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!
|
||||
|
||||
init() {
|
||||
#if os(iOS)
|
||||
addKeyboardDidHideNotificationObserver()
|
||||
#endif
|
||||
}
|
||||
|
||||
deinit {
|
||||
#if os(iOS)
|
||||
removeKeyboardDidHideNotificationObserver()
|
||||
#endif
|
||||
}
|
||||
|
||||
var isLoading: Bool {
|
||||
resource?.isLoading ?? false
|
||||
}
|
||||
@ -82,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
|
||||
}
|
||||
@ -136,4 +158,18 @@ final class SearchModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private func addKeyboardDidHideNotificationObserver() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(onKeyboardDidHide), name: UIResponder.keyboardDidHideNotification, object: nil)
|
||||
}
|
||||
|
||||
@objc func onKeyboardDidHide() {
|
||||
focused = false
|
||||
}
|
||||
|
||||
private func removeKeyboardDidHideNotificationObserver() {
|
||||
NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardDidHideNotification, object: nil)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
@ -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,31 +231,45 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
self.kind = kind
|
||||
self.encoding = encoding
|
||||
format = .from(videoFormat ?? "")
|
||||
self.bitrate = bitrate
|
||||
self.requestRange = requestRange
|
||||
}
|
||||
|
||||
var isLocal: Bool {
|
||||
localURL != nil
|
||||
}
|
||||
|
||||
var isHLS: Bool {
|
||||
hlsURL != nil
|
||||
}
|
||||
|
||||
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 {
|
||||
@ -217,7 +292,8 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
|
||||
if kind == .hls {
|
||||
return hlsURL
|
||||
} else if videoAssetContainsAudio {
|
||||
}
|
||||
if videoAssetContainsAudio {
|
||||
return videoAsset.url
|
||||
}
|
||||
|
||||
@ -229,8 +305,108 @@ class Stream: Equatable, Hashable, Identifiable {
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(videoAsset?.url)
|
||||
hasher.combine(audioAsset?.url)
|
||||
hasher.combine(hlsURL)
|
||||
if let url = videoAsset?.url {
|
||||
hasher.combine(url)
|
||||
}
|
||||
if let url = audioAsset?.url {
|
||||
hasher.combine(url)
|
||||
}
|
||||
if let url = hlsURL {
|
||||
hasher.combine(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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:
|
||||
|
@ -41,4 +41,8 @@ enum TrendingCategory: String, CaseIterable, Identifiable, Defaults.Serializable
|
||||
var controlLabel: String {
|
||||
id == "default" ? "All".localized() : title
|
||||
}
|
||||
|
||||
var type: String {
|
||||
rawValue.capitalized
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -7,6 +7,7 @@ import Foundation
|
||||
final class Watch: NSManagedObject, Identifiable {
|
||||
@Default(.watchedThreshold) private var watchedThreshold
|
||||
@Default(.saveHistory) private var saveHistory
|
||||
@Default(.showWatchingProgress) private var showWatchingProgress
|
||||
}
|
||||
|
||||
extension Watch {
|
||||
@ -102,4 +103,8 @@ extension Watch {
|
||||
|
||||
return Video(app: app ?? AccountsModel.shared.current?.app ?? .local, instanceURL: instanceURL, videoID: videoID)
|
||||
}
|
||||
|
||||
var isShowingProgress: Bool {
|
||||
saveHistory && showWatchingProgress && (finished || progress > 0)
|
||||
}
|
||||
}
|
||||
|
11
Model/WatchModel.swift
Normal file
11
Model/WatchModel.swift
Normal file
@ -0,0 +1,11 @@
|
||||
import SwiftUI
|
||||
|
||||
final class WatchModel: ObservableObject {
|
||||
static let shared = WatchModel()
|
||||
|
||||
@Published var historyToken = UUID()
|
||||
|
||||
func watchesChanged() {
|
||||
historyToken = UUID()
|
||||
}
|
||||
}
|
@ -42,7 +42,7 @@ final class ShareViewController: SLComposeServiceViewController {
|
||||
self.open(url: url)
|
||||
}
|
||||
|
||||
self.extensionContext!.completeRequest(returningItems: nil, completionHandler: nil)
|
||||
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 |
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