Compare commits
653 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cab50750f | ||
|
|
d795e82581 | ||
|
|
e7161bc9ab | ||
|
|
8e74363f32 | ||
|
|
1cb28788d6 | ||
|
|
ff9f855d4c | ||
|
|
13df2d1077 | ||
|
|
8389404975 | ||
|
|
cd920e2d84 | ||
|
|
92a11c18e0 | ||
|
|
e05f10fe42 | ||
|
|
2540ae22ce | ||
|
|
f490957091 | ||
|
|
a146fc8810 | ||
|
|
100d7e0830 | ||
|
|
ebcdd5bbf7 | ||
|
|
18b33884e6 | ||
|
|
9410239c48 | ||
|
|
4fed25a3ab | ||
|
|
a8810cae8a | ||
|
|
aff009de92 | ||
|
|
1924efbef2 | ||
|
|
3b53d76a18 | ||
|
|
b7221e5599 | ||
|
|
5384c34b27 | ||
|
|
ca92f61900 | ||
|
|
4fba558c33 | ||
|
|
d82767f5df | ||
|
|
e56fc93b14 | ||
|
|
1e399297bd | ||
|
|
feaf82fa3f | ||
|
|
781d199546 | ||
|
|
3013251285 | ||
|
|
0e1ed71dc1 | ||
|
|
5a781ba62c | ||
|
|
0cea614423 | ||
|
|
24d006742b | ||
|
|
c7f0c2ec83 | ||
|
|
c34c7fbe83 | ||
|
|
57bbb59874 | ||
|
|
e90d2e2244 | ||
|
|
917dabc4be | ||
|
|
bc2defc8ef | ||
|
|
3ce1480e10 | ||
|
|
9597b40726 | ||
|
|
1e6408d5be | ||
|
|
c2f6897f47 | ||
|
|
eaf3682384 | ||
|
|
f3c7b636a8 | ||
|
|
64d34a9354 | ||
|
|
2a2ecf0526 | ||
|
|
a77c7e8e3c | ||
|
|
88791eccf9 | ||
|
|
515f7ea26d | ||
|
|
e83bbf3121 | ||
|
|
89b34eddc1 | ||
|
|
89fd7f0e34 | ||
|
|
ab9ae5b620 | ||
|
|
a9c519971e | ||
|
|
e51b7351f8 | ||
|
|
e0f9d6ea1c | ||
|
|
1817c5dbd2 | ||
|
|
0619c8c9c4 | ||
|
|
d6ed318eb8 | ||
|
|
5f39622ad6 | ||
|
|
3b2a6bd40a | ||
|
|
8d3e165edf | ||
|
|
f3a9fc9d1c | ||
|
|
820af06419 | ||
|
|
80192e65c4 | ||
|
|
ff930e2ad2 | ||
|
|
fafc2e65ac | ||
|
|
61c783fb55 | ||
|
|
59df18621b | ||
|
|
0021b94e00 | ||
|
|
53b43edc2a | ||
|
|
64e8514985 | ||
|
|
a3d9207bca | ||
|
|
ef0880695e | ||
|
|
073110fac9 | ||
|
|
2d58157cf7 | ||
|
|
571be9840f | ||
|
|
a2cbc722c7 | ||
|
|
bc7c612cca | ||
|
|
fe8f07336a | ||
|
|
305b06f781 | ||
|
|
7d57cf1a69 | ||
|
|
3c56544a24 | ||
|
|
d6696cc84e | ||
|
|
bf97e419ae | ||
|
|
1e8fe46f17 | ||
|
|
73317e9781 | ||
|
|
eba0bbc9cf | ||
|
|
c69ec61656 | ||
|
|
de12e2b0a2 | ||
|
|
87dc57a576 | ||
|
|
52c8b99dd5 | ||
|
|
7beabe4702 | ||
|
|
415d7d6e9a | ||
|
|
51b47971e2 | ||
|
|
90b0d413bc | ||
|
|
a18bcae0fb | ||
|
|
4ccffad3e7 | ||
|
|
46b08007a4 | ||
|
|
7b05fe43cf | ||
|
|
8f7749160e | ||
|
|
d4c51697d4 | ||
|
|
7091502667 | ||
|
|
d6c7246cd1 | ||
|
|
973d226c49 | ||
|
|
dd849b532b | ||
|
|
1a58df27d2 | ||
|
|
68b5fe3599 | ||
|
|
67f73bfa39 | ||
|
|
5de7cab285 | ||
|
|
67d39c39ea | ||
|
|
9d8e227609 | ||
|
|
962323a75c | ||
|
|
fc23201b4f | ||
|
|
f0519ea88d | ||
|
|
f9f21606ff | ||
|
|
c4d026f4d8 | ||
|
|
577827303e | ||
|
|
3e0a1af9fa | ||
|
|
63bc806a06 | ||
|
|
f05496a458 | ||
|
|
d3660b45b1 | ||
|
|
1b812ebed5 | ||
|
|
6703299da9 | ||
|
|
80d63c0219 | ||
|
|
c2f8145e74 | ||
|
|
ce00aeb5f1 | ||
|
|
5899cc8625 | ||
|
|
90217bb495 | ||
|
|
16e88cca8c | ||
|
|
e8e62061ae | ||
|
|
3adc4d2a21 | ||
|
|
185524c06c | ||
|
|
6a208ee201 | ||
|
|
99938ddf5a | ||
|
|
963a54a36c | ||
|
|
e939c9b933 | ||
|
|
2ffd569bba | ||
|
|
c8ea494d6f | ||
|
|
577a61a452 | ||
|
|
a731c4eebd | ||
|
|
8a664757b8 | ||
|
|
655a78900d | ||
|
|
87a33af8d1 | ||
|
|
36b1c48fdd | ||
|
|
0454ba9f29 | ||
|
|
b55ed6349c | ||
|
|
0c34add45a | ||
|
|
1c1345a3b7 | ||
|
|
9f706a348e | ||
|
|
f4750e781d | ||
|
|
0b574cc047 | ||
|
|
4a816470d1 | ||
|
|
0d43b57f55 | ||
|
|
31f662a582 | ||
|
|
23e0ec9774 | ||
|
|
d6ac8569a8 | ||
|
|
2ce04b3fd3 | ||
|
|
bf5203348b | ||
|
|
16fb1a52ca | ||
|
|
d8be7b2463 | ||
|
|
ec37b5ab2c | ||
|
|
29eb072e5d | ||
|
|
2a4a7f5f2d | ||
|
|
8b3f950bc5 | ||
|
|
db527311d6 | ||
|
|
b76e834be1 | ||
|
|
c9905d9d88 | ||
|
|
b9bb109f4a | ||
|
|
16b834cf71 | ||
|
|
f6baf490fb | ||
|
|
3201499397 | ||
|
|
6555251c2e | ||
|
|
71c15f3651 | ||
|
|
25da30d6e2 | ||
|
|
1394eae01e | ||
|
|
205715ae29 | ||
|
|
587d419502 | ||
|
|
bc081b535e | ||
|
|
62d2d1f7ca | ||
|
|
66aab5b771 | ||
|
|
5c89100afd | ||
|
|
ffbaaa81a8 | ||
|
|
f1a3b48017 | ||
|
|
8ab72b1262 | ||
|
|
b9c02618d5 | ||
|
|
0b22f28bb6 | ||
|
|
2932a7b324 | ||
|
|
8e8ae32287 | ||
|
|
2189b3d3dd | ||
|
|
f770cf174b | ||
|
|
f7e771123f | ||
|
|
5757b1c010 | ||
|
|
92513e234f | ||
|
|
2688e1b981 | ||
|
|
54423a1267 | ||
|
|
defe87debb | ||
|
|
a1b2248f16 | ||
|
|
cd42a86d40 | ||
|
|
76661c7599 | ||
|
|
6de829c16d | ||
|
|
9b0ba285b3 | ||
|
|
cbcb160bdd | ||
|
|
10bfa95060 | ||
|
|
7201be6f02 | ||
|
|
9f17f13175 | ||
|
|
c0e9f29c04 | ||
|
|
ab5df3c9ef | ||
|
|
7768939767 | ||
|
|
8b72bde4a9 | ||
|
|
c29b2cb8da | ||
|
|
96e3362f43 | ||
|
|
7cdf0e5355 | ||
|
|
ef355b1f04 | ||
|
|
81535894e1 | ||
|
|
887f30e739 | ||
|
|
3d7889e19a | ||
|
|
4b8e8cddb5 | ||
|
|
d33baf07d3 | ||
|
|
66f61c3c38 | ||
|
|
88efb09317 | ||
|
|
5df021a836 | ||
|
|
89eb0d7796 | ||
|
|
ba9178a0f6 | ||
|
|
27cd73efab | ||
|
|
e15b19deb3 | ||
|
|
baccc931a2 | ||
|
|
79a2873975 | ||
|
|
e397be4b2e | ||
|
|
4dddc0f926 | ||
|
|
ebcb414b89 | ||
|
|
77dba04289 | ||
|
|
12ceef02cd | ||
|
|
fe3b652b4f | ||
|
|
9c9785ba9e | ||
|
|
bce9ed2690 | ||
|
|
ec914133d6 | ||
|
|
2d1b03e403 | ||
|
|
7f07260177 | ||
|
|
e1314077e2 | ||
|
|
09e9462ac0 | ||
|
|
dd65505f7f | ||
|
|
951158bcd3 | ||
|
|
9b1dd0923a | ||
|
|
bd908516b5 | ||
|
|
8cb10d1062 | ||
|
|
446439c2e0 | ||
|
|
a5463d783d | ||
|
|
640db35456 | ||
|
|
caa4b765c1 | ||
|
|
9c6aebe66a | ||
|
|
ef42510383 | ||
|
|
5273dfd22b | ||
|
|
00bc4232fb | ||
|
|
35c9258062 | ||
|
|
89bf51c3cc | ||
|
|
f64c5a02db | ||
|
|
cf284eb3d8 | ||
|
|
b581a077e1 | ||
|
|
e651b975b7 | ||
|
|
1c550b1b77 | ||
|
|
5bcae81538 | ||
|
|
c951725222 | ||
|
|
0b966d7c04 | ||
|
|
8e0e35afe3 | ||
|
|
daf7f35196 | ||
|
|
d5ac30b6d8 | ||
|
|
81b91bbb97 | ||
|
|
af2bd030e9 | ||
|
|
5590c2f784 | ||
|
|
6cc70dd123 | ||
|
|
fae588b0f0 | ||
|
|
bd2aeb2234 | ||
|
|
cca0bbf42c | ||
|
|
06e0eb5c4e | ||
|
|
b478fbb6bf | ||
|
|
b98a7b0634 | ||
|
|
ce38024a3f | ||
|
|
04dce9265b | ||
|
|
5b8418cd82 | ||
|
|
b0c5255bd7 | ||
|
|
73dd171987 | ||
|
|
ff35559687 | ||
|
|
5aadd50946 | ||
|
|
63b5ba2112 | ||
|
|
8b955578a2 | ||
|
|
1e5c021c93 | ||
|
|
0b86f56486 | ||
|
|
728b93f4e5 | ||
|
|
2fc483b24e | ||
|
|
fc901bc01e | ||
|
|
2b0884b154 | ||
|
|
307d20e538 | ||
|
|
a2f03908f6 | ||
|
|
77aef8877e | ||
|
|
0cf930d6e1 | ||
|
|
4b0b949541 | ||
|
|
14b717f985 | ||
|
|
cfbac538f8 | ||
|
|
1ac6b7e3df | ||
|
|
c9f6e8676b | ||
|
|
5aab1450cd | ||
|
|
1e7080a136 | ||
|
|
993cec4138 | ||
|
|
6c524499f9 | ||
|
|
b3463ffdfc | ||
|
|
50942b44f1 | ||
|
|
f602f8919f | ||
|
|
0e86d8a00f | ||
|
|
56b1e1977c | ||
|
|
30e23b9079 | ||
|
|
d83ecb881b | ||
|
|
4c14c08b35 | ||
|
|
ecb9b90163 | ||
|
|
33a2be24f4 | ||
|
|
e8b0d52515 | ||
|
|
9faa0de2d6 | ||
|
|
221155d002 | ||
|
|
4a37e17324 | ||
|
|
52b2a3418e | ||
|
|
2753b243e5 | ||
|
|
f22b356b7c | ||
|
|
d8ba5af8d9 | ||
|
|
505ef39ee7 | ||
|
|
e71d5cc176 | ||
|
|
74e57bbd88 | ||
|
|
76eaeb9820 | ||
|
|
9a70f98dd5 | ||
|
|
f28f1d8736 | ||
|
|
e0f03ccb93 | ||
|
|
34d1dbb20e | ||
|
|
e3e2db659d | ||
|
|
528b4ad7ac | ||
|
|
d29501386b | ||
|
|
6688469b6c | ||
|
|
ae9c30aa6d | ||
|
|
364d2e8a51 | ||
|
|
6cc90b46b3 | ||
|
|
33adea2819 | ||
|
|
9f41861dcf | ||
|
|
2b2d23e574 | ||
|
|
f6e2bcb120 | ||
|
|
314cd62bee | ||
|
|
41e7123d1c | ||
|
|
2af42b39f5 | ||
|
|
0a06b336c8 | ||
|
|
028c9159f3 | ||
|
|
dee4fa07e3 | ||
|
|
2764f1736a | ||
|
|
d3d1a7bcde | ||
|
|
7fcd598fa1 | ||
|
|
0fc1506b11 | ||
|
|
e0aa7ea0df | ||
|
|
25f77645f8 | ||
|
|
1c81091e8b | ||
|
|
94502b558d | ||
|
|
a7d7d00eb3 | ||
|
|
3b5e07c1d2 | ||
|
|
db10369fb5 | ||
|
|
32da5918c7 | ||
|
|
dc542021b5 | ||
|
|
bfad157a28 | ||
|
|
a71a646743 | ||
|
|
366bc0137e | ||
|
|
3eb60840e6 | ||
|
|
65c4a1340d | ||
|
|
3e90447dd4 | ||
|
|
bd0768797e | ||
|
|
730ef4616f | ||
|
|
c4d4475aa9 | ||
|
|
d1eb40f2a9 | ||
|
|
77518d774e | ||
|
|
a6fb7b956d | ||
|
|
034ff3f478 | ||
|
|
98ca4e7a6d | ||
|
|
461a276a20 | ||
|
|
3975473da9 | ||
|
|
d34b86297a | ||
|
|
c4a83e283f | ||
|
|
dac471f0a6 | ||
|
|
252fc4387b | ||
|
|
3e299e2136 | ||
|
|
01cab2277e | ||
|
|
e4f4e861e0 | ||
|
|
4d665013f0 | ||
|
|
9aa4ea4a2e | ||
|
|
93ae03f55c | ||
|
|
b311ac98a7 | ||
|
|
83d425b2fb | ||
|
|
007fbdd0a3 | ||
|
|
37df999db5 | ||
|
|
72b9675df4 | ||
|
|
7a30a63335 | ||
|
|
0ff0fab3f4 | ||
|
|
d9d9b0ee00 | ||
|
|
fdaa69a787 | ||
|
|
ed5403e597 | ||
|
|
e6f290b85f | ||
|
|
aa20d9c701 | ||
|
|
e7128afb32 | ||
|
|
a24b126539 | ||
|
|
e1fe20db86 | ||
|
|
cd9f6aa8bd | ||
|
|
747bd1b416 | ||
|
|
364ce46fe5 | ||
|
|
5703279b46 | ||
|
|
4022ccb213 | ||
|
|
3a836462f5 | ||
|
|
8a5f24002f | ||
|
|
c30f9860ee | ||
|
|
94c170e3d2 | ||
|
|
cd8aba32d8 | ||
|
|
15f3ddf612 | ||
|
|
90f20f6e46 | ||
|
|
ea1f45bbaf | ||
|
|
7e62c9bc9a | ||
|
|
23f9e9dfae | ||
|
|
580e12b605 | ||
|
|
ff4c5f28af | ||
|
|
1b931ea348 | ||
|
|
49c0437f81 | ||
|
|
d81ae94ce8 | ||
|
|
7c77c70024 | ||
|
|
b28c4a56f3 | ||
|
|
2495a318eb | ||
|
|
7832ea4d0a | ||
|
|
4a0a51ef1d | ||
|
|
8cc551d906 | ||
|
|
c8da365a00 | ||
|
|
74b7cbc530 | ||
|
|
a14063a736 | ||
|
|
a3307a90a3 | ||
|
|
a2145fd7e8 | ||
|
|
cab5e4d980 | ||
|
|
ab603e6997 | ||
|
|
957348fe19 | ||
|
|
444bd040b0 | ||
|
|
d0ae63235d | ||
|
|
1727125ea7 | ||
|
|
dc498d62d8 | ||
|
|
455bf08213 | ||
|
|
0f974ef2a3 | ||
|
|
2d9aaccfe0 | ||
|
|
2c6823eb53 | ||
|
|
9dfcc01f17 | ||
|
|
38aad9610b | ||
|
|
54af64abef | ||
|
|
e1720a00da | ||
|
|
882d0ea188 | ||
|
|
f3b539232f | ||
|
|
33ea657a5c | ||
|
|
75820adcbc | ||
|
|
76cdb2b3f8 | ||
|
|
0a2ea33635 | ||
|
|
3cd8e41000 | ||
|
|
aad6093852 | ||
|
|
c553cff9d1 | ||
|
|
dcd458bd3d | ||
|
|
05dc61d17d | ||
|
|
e4de11127f | ||
|
|
2dc49735f4 | ||
|
|
0ebacd4bd3 | ||
|
|
14c8c1aaed | ||
|
|
2da774272d | ||
|
|
dd08826931 | ||
|
|
b681025389 | ||
|
|
ef42207174 | ||
|
|
efa5638b12 | ||
|
|
65549428bf | ||
|
|
c63cea891d | ||
|
|
4e80f58823 | ||
|
|
cda3b64a2b | ||
|
|
cfe39d504c | ||
|
|
cf43d1a657 | ||
|
|
cbe3b18226 | ||
|
|
b637a0f7d2 | ||
|
|
a0ce7cc6d0 | ||
|
|
a640df30bc | ||
|
|
062e6e6c23 | ||
|
|
d709e3b13e | ||
|
|
b232bebd73 | ||
|
|
90ef8ef6f9 | ||
|
|
0df6b8e2a0 | ||
|
|
f48b26076d | ||
|
|
c86a8438e5 | ||
|
|
373d4ca3b1 | ||
|
|
8bc360d554 | ||
|
|
3fae21d559 | ||
|
|
74ce9d7eea | ||
|
|
5055a700c9 | ||
|
|
faa2baae68 | ||
|
|
ed42371353 | ||
|
|
ab33693dd9 | ||
|
|
24277135a8 | ||
|
|
23d9cd36d1 | ||
|
|
b243524a7d | ||
|
|
8288682e68 | ||
|
|
58ec915699 | ||
|
|
480abb216d | ||
|
|
249109a94e | ||
|
|
eb7fa93f9b | ||
|
|
e8fd322d30 | ||
|
|
cad03a3566 | ||
|
|
9baa4063bd | ||
|
|
41db34ed8e | ||
|
|
5aba66ce05 | ||
|
|
79407ccd70 | ||
|
|
9a93b3b3bb | ||
|
|
2b846a1aca | ||
|
|
55d61172f4 | ||
|
|
57173a62dc | ||
|
|
78f65be09d | ||
|
|
293a9517a5 | ||
|
|
38b6215046 | ||
|
|
fc4a11d916 | ||
|
|
cf2beb8299 | ||
|
|
49d157a95a | ||
|
|
9692c173ae | ||
|
|
a297ac4843 | ||
|
|
6a4621c377 | ||
|
|
a061f9f480 | ||
|
|
0fb6f2fb30 | ||
|
|
0773f773ba | ||
|
|
39bb3a9370 | ||
|
|
b79e534692 | ||
|
|
e9336e9a67 | ||
|
|
adfde1a7cd | ||
|
|
cab6257fb2 | ||
|
|
3f0f0090af | ||
|
|
b278632581 | ||
|
|
5ee1a9cabb | ||
|
|
2169bea031 | ||
|
|
2fb19f601b | ||
|
|
a602c35a8f | ||
|
|
46ac4a2cc7 | ||
|
|
962f65874e | ||
|
|
95cf252349 | ||
|
|
8470cbe8d5 | ||
|
|
636a27246f | ||
|
|
a488c68633 | ||
|
|
7342b7eb92 | ||
|
|
8370519758 | ||
|
|
85e21edbf1 | ||
|
|
8d4115f5a0 | ||
|
|
c5d7a6729b | ||
|
|
db4046267f | ||
|
|
1e869a2c2f | ||
|
|
b6502c042a | ||
|
|
b506871c46 | ||
|
|
734678b1d5 | ||
|
|
68e98bbb94 | ||
|
|
d84ed558f3 | ||
|
|
ad39e8e10a | ||
|
|
29bba04fdd | ||
|
|
5a24957e88 | ||
|
|
39f2735756 | ||
|
|
53ea1cc899 | ||
|
|
5dc86d4765 | ||
|
|
d13731c28f | ||
|
|
459ca3245b | ||
|
|
0d1fb87284 | ||
|
|
7f0446b85f | ||
|
|
11fbe19f80 | ||
|
|
495742c52c | ||
|
|
5c97b85492 | ||
|
|
894305e126 | ||
|
|
e60cec69f8 | ||
|
|
7bc1c22770 | ||
|
|
e86dab5613 | ||
|
|
eeb803223c | ||
|
|
1a43f7ef1b | ||
|
|
f4624bdc25 | ||
|
|
3c5f2b4079 | ||
|
|
955190a9cc | ||
|
|
e1e4f4833c | ||
|
|
3b987646a6 | ||
|
|
ed993d07ce | ||
|
|
0e574ea18d | ||
|
|
dc9008f31c | ||
|
|
1a5fcdcb10 | ||
|
|
62b00837ec | ||
|
|
0fc48497d0 | ||
|
|
7e12136211 | ||
|
|
7639de153b | ||
|
|
ea3cc18b3c | ||
|
|
c9fb52086e | ||
|
|
878edc6909 | ||
|
|
74f0aca517 | ||
|
|
60bb3b905d | ||
|
|
fdde5fb56c | ||
|
|
49ae9c6f57 | ||
|
|
2254adb8d6 | ||
|
|
d4c722aeac | ||
|
|
eefcfb8be5 | ||
|
|
4af2712cc0 | ||
|
|
958b870bf0 | ||
|
|
ce7e1b255f | ||
|
|
acae4b4544 | ||
|
|
f7bbb20c38 | ||
|
|
2c655b9482 | ||
|
|
b8dbce6bf2 | ||
|
|
730823c520 | ||
|
|
77f14a7d5b | ||
|
|
07c7cb7ab5 | ||
|
|
5333d53d61 | ||
|
|
82e50b9ba3 | ||
|
|
663605b9e8 | ||
|
|
00847c8d3d | ||
|
|
f20ad67186 | ||
|
|
e23387a384 | ||
|
|
bb141cad57 | ||
|
|
e833b4bc68 | ||
|
|
34fc26ed18 | ||
|
|
91527b83dd | ||
|
|
14138151a3 | ||
|
|
6c2bfe2a45 | ||
|
|
996cd36a9e | ||
|
|
6aa2e00d93 | ||
|
|
344e0932dc | ||
|
|
eaffffb2f0 | ||
|
|
f6c0513d2d | ||
|
|
013f064280 | ||
|
|
cd2c3f359e | ||
|
|
123c6bba05 | ||
|
|
a1ea926342 | ||
|
|
6a17ac02af | ||
|
|
815be2a175 | ||
|
|
ece3bc001f | ||
|
|
27609e7789 | ||
|
|
40b8410390 | ||
|
|
347f196a6a | ||
|
|
468f58e531 | ||
|
|
a994868be4 | ||
|
|
723233381c | ||
|
|
602de34824 | ||
|
|
9b1f2a98e5 | ||
|
|
946de97580 | ||
|
|
f2eadabf6a | ||
|
|
373d83a0d5 | ||
|
|
2c0ba18b49 | ||
|
|
3e8e8e1163 | ||
|
|
fe9c73a8f0 | ||
|
|
4f62391027 | ||
|
|
53b5fdda87 | ||
|
|
c0b71eb73d | ||
|
|
9b4590c876 | ||
|
|
4b18bad3bc | ||
|
|
752cb1cdc6 |
14
.claude/settings.local.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(find:*)",
|
||||||
|
"Bash(bun install:*)",
|
||||||
|
"Bash(bunx expo prebuild:*)",
|
||||||
|
"Bash(bunx expo run:*)",
|
||||||
|
"Bash(npx expo prebuild:*)",
|
||||||
|
"Bash(npx expo run:*)",
|
||||||
|
"Bash(xcodebuild:*)"
|
||||||
|
],
|
||||||
|
"deny": []
|
||||||
|
}
|
||||||
|
}
|
||||||
7
.cursor/rules/no-custom-ios-folder-logic.mdc
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
description: Don't write code directly in the ios folder.
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
We never write code directly in the ios folder. This code is generated by expo plugins.
|
||||||
1
.env.development
Normal file
@@ -0,0 +1 @@
|
|||||||
|
EXPO_PUBLIC_WRITE_DEBUG=1
|
||||||
1
.env.production
Normal file
@@ -0,0 +1 @@
|
|||||||
|
EXPO_PUBLIC_WRITE_DEBUG=0
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": ["next/core-web-vitals"]
|
|
||||||
}
|
|
||||||
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.modules/vlc-player/Frameworks/*.xcframework filter=lfs diff=lfs merge=lfs -text
|
||||||
11
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -4,9 +4,7 @@ title: "[Bug]: "
|
|||||||
labels:
|
labels:
|
||||||
- ["❌ bug"]
|
- ["❌ bug"]
|
||||||
projects:
|
projects:
|
||||||
- ["fredrikburmester/5"]
|
- ["streamyfin/3"]
|
||||||
assignees:
|
|
||||||
- fredrikburmester
|
|
||||||
|
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
@@ -45,6 +43,13 @@ body:
|
|||||||
label: Version
|
label: Version
|
||||||
description: What version of Streamyfin are you running?
|
description: What version of Streamyfin are you running?
|
||||||
options:
|
options:
|
||||||
|
- 0.29.0
|
||||||
|
- 0.28.0
|
||||||
|
- 0.27.0
|
||||||
|
- 0.26.1
|
||||||
|
- 0.26.0
|
||||||
|
- 0.25.0
|
||||||
|
- 0.24.0
|
||||||
- 0.23.0
|
- 0.23.0
|
||||||
- 0.22.0
|
- 0.22.0
|
||||||
- 0.21.0
|
- 0.21.0
|
||||||
|
|||||||
3
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -4,7 +4,8 @@ about: Suggest an idea for this project
|
|||||||
title: ''
|
title: ''
|
||||||
labels: '✨ enhancement'
|
labels: '✨ enhancement'
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
projects:
|
||||||
|
- streamyfin/3
|
||||||
---
|
---
|
||||||
|
|
||||||
**Describe the solution you'd like**
|
**Describe the solution you'd like**
|
||||||
|
|||||||
102
.github/workflows/build-android.yml
vendored
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
name: 🤖 Android APK Build (Phone + TV)
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
branches: [develop, master]
|
||||||
|
push:
|
||||||
|
branches: [develop, master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-android:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
name: 🏗️ Build Android APK
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
target: [phone, tv]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout code
|
||||||
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
show-progress: false
|
||||||
|
|
||||||
|
- name: 🍞 Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: 💾 Cache Bun dependencies
|
||||||
|
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||||
|
with:
|
||||||
|
path: ~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-${{ runner.arch }}-bun-develop-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-bun-develop
|
||||||
|
${{ runner.os }}-bun-develop
|
||||||
|
|
||||||
|
- name: 💾 Cache node_modules
|
||||||
|
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: ${{ runner.os }}-${{ runner.arch }}-modules-latest-develop-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-modules-latest-develop
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-modules-develop
|
||||||
|
${{ runner.os }}-modules-develop
|
||||||
|
|
||||||
|
- name: 📦 Install dependencies and reload submodules
|
||||||
|
run: |
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
bun run submodule-reload
|
||||||
|
|
||||||
|
- name: 💾 Cache Gradle global
|
||||||
|
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/caches
|
||||||
|
~/.gradle/wrapper
|
||||||
|
key: ${{ runner.os }}-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
|
restore-keys: ${{ runner.os }}-gradle-develop
|
||||||
|
|
||||||
|
- name: 🛠️ Generate project files
|
||||||
|
run: |
|
||||||
|
if [ "${{ matrix.target }}" = "tv" ]; then
|
||||||
|
bun run prebuild:tv
|
||||||
|
else
|
||||||
|
bun run prebuild
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 💾 Cache project Gradle (.gradle)
|
||||||
|
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||||
|
with:
|
||||||
|
path: android/.gradle
|
||||||
|
key: ${{ runner.os }}-android-gradle-develop-${{ hashFiles('android/**/build.gradle', 'android/gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
|
restore-keys: ${{ runner.os }}-android-gradle-develop
|
||||||
|
|
||||||
|
- name: 🚀 Build APK
|
||||||
|
env:
|
||||||
|
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
|
||||||
|
run: bun run build:android:local
|
||||||
|
|
||||||
|
- name: 📅 Set date tag
|
||||||
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: 📤 Upload APK artifact
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
with:
|
||||||
|
name: streamyfin-android-${{ matrix.target }}-apk-${{ env.DATE_TAG }}
|
||||||
|
path: |
|
||||||
|
android/app/build/outputs/apk/release/*.apk
|
||||||
|
retention-days: 7
|
||||||
89
.github/workflows/build-ios.yml
vendored
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
name: 🤖 iOS IPA Build (Phone + TV)
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
pull_request:
|
||||||
|
branches: [develop, master]
|
||||||
|
push:
|
||||||
|
branches: [develop, master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-ios:
|
||||||
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'streamyfin/streamyfin'
|
||||||
|
runs-on: macos-15
|
||||||
|
name: 🏗️ Build iOS IPA
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
target: [phone, tv]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout code
|
||||||
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
show-progress: false
|
||||||
|
|
||||||
|
- name: 🍞 Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: 💾 Cache Bun dependencies
|
||||||
|
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||||
|
with:
|
||||||
|
path: ~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-bun-cache-${{ hashFiles('bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-cache
|
||||||
|
|
||||||
|
- name: 📦 Install dependencies and reload submodules
|
||||||
|
run: |
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
bun run submodule-reload
|
||||||
|
|
||||||
|
- name: 🛠️ Generate project files
|
||||||
|
run: |
|
||||||
|
if [ "${{ matrix.target }}" = "tv" ]; then
|
||||||
|
bun run prebuild:tv
|
||||||
|
else
|
||||||
|
bun run prebuild
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 🏗️ Setup EAS
|
||||||
|
uses: expo/expo-github-action@main
|
||||||
|
with:
|
||||||
|
eas-version: latest
|
||||||
|
token: ${{ secrets.EXPO_TOKEN }}
|
||||||
|
|
||||||
|
- name: ⚙️ Ensure iOS/tvOS SDKs installed
|
||||||
|
run: |
|
||||||
|
if [ "${{ matrix.target }}" = "tv" ]; then
|
||||||
|
xcodebuild -downloadPlatform tvOS
|
||||||
|
else
|
||||||
|
xcodebuild -downloadPlatform iOS
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: 🚀 Build iOS app
|
||||||
|
env:
|
||||||
|
EXPO_TV: ${{ matrix.target == 'tv' && 1 || 0 }}
|
||||||
|
run: eas build -p ios --local --non-interactive
|
||||||
|
|
||||||
|
- name: 📅 Set date tag
|
||||||
|
run: echo "DATE_TAG=$(date +%d-%m-%Y_%H-%M-%S)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: 📤 Upload IPA artifact
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
with:
|
||||||
|
name: streamyfin-ios-${{ matrix.target }}-ipa-${{ env.DATE_TAG }}
|
||||||
|
path: build-*.ipa
|
||||||
|
retention-days: 7
|
||||||
46
.github/workflows/check-lockfile.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
name: 🔒 Lockfile Consistency Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [develop, master]
|
||||||
|
push:
|
||||||
|
branches: [develop, master]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-lockfile:
|
||||||
|
name: 🔍 Check bun.lock and package.json consistency
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout repository
|
||||||
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
show-progress: false
|
||||||
|
submodules: recursive
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: 🍞 Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: 💾 Cache Bun dependencies
|
||||||
|
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
|
||||||
|
|
||||||
|
- name: 🛡️ Verify lockfile consistency
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
echo "➡️ Checking for discrepancies between bun.lock and package.json..."
|
||||||
|
bun install --frozen-lockfile --dry-run --ignore-scripts
|
||||||
|
echo "✅ Lockfile is consistent with package.json!"
|
||||||
43
.github/workflows/ci-codeql.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: 🛡️ CodeQL Analysis
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master, develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [master, develop]
|
||||||
|
schedule:
|
||||||
|
- cron: '24 2 * * *'
|
||||||
|
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: 🔎 Analyze with CodeQL
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
language: [ 'javascript-typescript', 'actions' ]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout repository
|
||||||
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
show-progress: false
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: 🏁 Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
queries: +security-extended,security-and-quality
|
||||||
|
|
||||||
|
- name: 🛠️ Autobuild
|
||||||
|
uses: github/codeql-action/autobuild@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10
|
||||||
|
|
||||||
|
- name: 🧪 Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@96f518a34f7a870018057716cc4d7a5c014bd61c # v3.29.10
|
||||||
24
.github/workflows/conflict.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
name: 🏷️🔀Merge Conflict Labeler
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [develop]
|
||||||
|
pull_request_target:
|
||||||
|
branches: [develop]
|
||||||
|
types: [synchronize]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
label:
|
||||||
|
name: 🏷️ Labeling Merge Conflicts
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
if: ${{ github.repository == 'streamyfin/streamyfin' }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
steps:
|
||||||
|
- name: 🚩 Apply merge conflict label
|
||||||
|
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
||||||
|
with:
|
||||||
|
dirtyLabel: 'merge-conflict'
|
||||||
|
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||||
|
repoToken: '${{ secrets.GITHUB_TOKEN }}'
|
||||||
121
.github/workflows/linting.yml
vendored
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
name: 🚦 Security & Quality Gate
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, edited, synchronize, reopened]
|
||||||
|
branches: [develop, master]
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [develop]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate_pr_title:
|
||||||
|
name: "📝 Validate PR Title"
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: amannn/action-semantic-pull-request@7f33ba792281b034f64e96f4c0b5496782dd3b37 # v6.1.0
|
||||||
|
id: lint_pr_title
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||||
|
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||||
|
with:
|
||||||
|
header: pr-title-lint-error
|
||||||
|
message: |
|
||||||
|
Hey there and thank you for opening this pull request! 👋🏼
|
||||||
|
We require pull request titles to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/).
|
||||||
|
|
||||||
|
**Error details:**
|
||||||
|
```
|
||||||
|
${{ steps.lint_pr_title.outputs.error_message }}
|
||||||
|
```
|
||||||
|
|
||||||
|
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||||
|
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||||
|
with:
|
||||||
|
header: pr-title-lint-error
|
||||||
|
delete: true
|
||||||
|
|
||||||
|
dependency-review:
|
||||||
|
name: 🔍 Vulnerable Dependencies
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Dependency Review
|
||||||
|
uses: actions/dependency-review-action@bc41886e18ea39df68b1b1245f4184881938e050 # v4.7.2
|
||||||
|
with:
|
||||||
|
fail-on-severity: high
|
||||||
|
deny-licenses: GPL-3.0, AGPL-3.0
|
||||||
|
base-ref: ${{ github.event.pull_request.base.sha || 'develop' }}
|
||||||
|
head-ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
||||||
|
|
||||||
|
expo-doctor:
|
||||||
|
name: 🚑 Expo Doctor Check
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- name: 🛒 Checkout repository
|
||||||
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
submodules: recursive
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: 🍞 Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: 📦 Install dependencies (bun)
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: 🚑 Run Expo Doctor
|
||||||
|
run: bun expo-doctor
|
||||||
|
|
||||||
|
code_quality:
|
||||||
|
name: "🔍 Lint & Test (${{ matrix.command }})"
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
command:
|
||||||
|
- "lint"
|
||||||
|
- "check"
|
||||||
|
- "format"
|
||||||
|
steps:
|
||||||
|
- name: "📥 Checkout PR code"
|
||||||
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
submodules: recursive
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: "🟢 Setup Node.js"
|
||||||
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: '24.x'
|
||||||
|
|
||||||
|
- name: "🍞 Setup Bun"
|
||||||
|
uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 # v2.0.2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: "📦 Install dependencies"
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: "🚨 Run ${{ matrix.command }}"
|
||||||
|
run: bun run ${{ matrix.command }}
|
||||||
23
.github/workflows/notification.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
name: 🛎️ Discord Pull Request Notification
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, reopened]
|
||||||
|
branches: [develop]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
notify:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- name: 🛎️ Notify Discord
|
||||||
|
uses: Ilshidur/action-discord@d2594079a10f1d6739ee50a2471f0ca57418b554 # 0.4.0
|
||||||
|
env:
|
||||||
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
DISCORD_AVATAR: https://avatars.githubusercontent.com/u/193271640
|
||||||
|
with:
|
||||||
|
args: |
|
||||||
|
📢 New Pull Request in **${{ github.repository }}**
|
||||||
|
**Title:** ${{ github.event.pull_request.title }}
|
||||||
|
**By:** ${{ github.event.pull_request.user.login }}
|
||||||
|
**Branch:** ${{ github.event.pull_request.head.ref }}
|
||||||
|
🔗 ${{ github.event.pull_request.html_url }}
|
||||||
49
.github/workflows/stale.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
name: 🕒 Handle Stale Issues
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Runs daily at 1:30 AM UTC (3:30 AM CEST - France time)
|
||||||
|
- cron: "30 1 * * *"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
stale-issues:
|
||||||
|
name: 🗑️ Cleanup Stale Issues
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 🔄 Mark/Close Stale Issues
|
||||||
|
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
||||||
|
with:
|
||||||
|
# Global settings
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
operations-per-run: 500 # Increase if you have >1000 issues
|
||||||
|
log-level: debug
|
||||||
|
|
||||||
|
# Issue configuration
|
||||||
|
days-before-issue-stale: 90
|
||||||
|
days-before-issue-close: 7
|
||||||
|
stale-issue-label: "stale"
|
||||||
|
exempt-issue-labels: "Roadmap v1,help needed,enhancement"
|
||||||
|
|
||||||
|
# Notifications messages
|
||||||
|
stale-issue-message: |
|
||||||
|
⏳ This issue has been automatically marked as **stale** because it has had no activity for 90 days.
|
||||||
|
|
||||||
|
**Next steps:**
|
||||||
|
- If this is still relevant, add a comment to keep it open
|
||||||
|
- Otherwise, it will be closed in 7 days
|
||||||
|
|
||||||
|
Thank you for your contributions! 🙌
|
||||||
|
|
||||||
|
close-issue-message: |
|
||||||
|
🚮 This issue has been automatically closed due to inactivity (7 days since being marked stale).
|
||||||
|
|
||||||
|
**Need to reopen?**
|
||||||
|
Click "Reopen" and add a comment explaining why this should stay open.
|
||||||
|
|
||||||
|
# Disable PR handling
|
||||||
|
days-before-pr-stale: -1
|
||||||
|
days-before-pr-close: -1
|
||||||
16
.gitignore
vendored
@@ -18,14 +18,16 @@ expo-env.d.ts
|
|||||||
|
|
||||||
Streamyfin.app
|
Streamyfin.app
|
||||||
|
|
||||||
build-*
|
|
||||||
*.mp4
|
*.mp4
|
||||||
build-*
|
|
||||||
Streamyfin.app
|
Streamyfin.app
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
/ios
|
/ios
|
||||||
/android
|
/android
|
||||||
|
/iostv
|
||||||
|
/iosmobile
|
||||||
|
/androidmobile
|
||||||
|
/androidtv
|
||||||
|
|
||||||
modules/player/android
|
modules/player/android
|
||||||
|
|
||||||
@@ -35,4 +37,12 @@ credentials.json
|
|||||||
*.ipa
|
*.ipa
|
||||||
.continuerc.json
|
.continuerc.json
|
||||||
|
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.ruby-lsp
|
||||||
|
modules/hls-downloader/android/build
|
||||||
|
streamyfin-4fec1-firebase-adminsdk.json
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.aab
|
||||||
|
/version-backup-*
|
||||||
|
|||||||
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
lint-staged
|
||||||
3
.idea/.gitignore
generated
vendored
@@ -1,3 +0,0 @@
|
|||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
||||||
329
.idea/caches/deviceStreaming.xml
generated
@@ -1,329 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="DeviceStreaming">
|
|
||||||
<option name="deviceSelectionList">
|
|
||||||
<list>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="27" />
|
|
||||||
<option name="brand" value="DOCOMO" />
|
|
||||||
<option name="codename" value="F01L" />
|
|
||||||
<option name="id" value="F01L" />
|
|
||||||
<option name="manufacturer" value="FUJITSU" />
|
|
||||||
<option name="name" value="F-01L" />
|
|
||||||
<option name="screenDensity" value="360" />
|
|
||||||
<option name="screenX" value="720" />
|
|
||||||
<option name="screenY" value="1280" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="28" />
|
|
||||||
<option name="brand" value="DOCOMO" />
|
|
||||||
<option name="codename" value="SH-01L" />
|
|
||||||
<option name="id" value="SH-01L" />
|
|
||||||
<option name="manufacturer" value="SHARP" />
|
|
||||||
<option name="name" value="AQUOS sense2 SH-01L" />
|
|
||||||
<option name="screenDensity" value="480" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2160" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="Lenovo" />
|
|
||||||
<option name="codename" value="TB370FU" />
|
|
||||||
<option name="id" value="TB370FU" />
|
|
||||||
<option name="manufacturer" value="Lenovo" />
|
|
||||||
<option name="name" value="Tab P12" />
|
|
||||||
<option name="screenDensity" value="340" />
|
|
||||||
<option name="screenX" value="1840" />
|
|
||||||
<option name="screenY" value="2944" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="31" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="a51" />
|
|
||||||
<option name="id" value="a51" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy A51" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="akita" />
|
|
||||||
<option name="id" value="akita" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 8a" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="b0q" />
|
|
||||||
<option name="id" value="b0q" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy S22 Ultra" />
|
|
||||||
<option name="screenDensity" value="600" />
|
|
||||||
<option name="screenX" value="1440" />
|
|
||||||
<option name="screenY" value="3088" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="32" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="bluejay" />
|
|
||||||
<option name="id" value="bluejay" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 6a" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="caiman" />
|
|
||||||
<option name="id" value="caiman" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 9 Pro" />
|
|
||||||
<option name="screenDensity" value="360" />
|
|
||||||
<option name="screenX" value="960" />
|
|
||||||
<option name="screenY" value="2142" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="comet" />
|
|
||||||
<option name="id" value="comet" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 9 Pro Fold" />
|
|
||||||
<option name="screenDensity" value="390" />
|
|
||||||
<option name="screenX" value="2076" />
|
|
||||||
<option name="screenY" value="2152" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="29" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="crownqlteue" />
|
|
||||||
<option name="id" value="crownqlteue" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy Note9" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="2220" />
|
|
||||||
<option name="screenY" value="1080" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="dm3q" />
|
|
||||||
<option name="id" value="dm3q" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy S23 Ultra" />
|
|
||||||
<option name="screenDensity" value="600" />
|
|
||||||
<option name="screenX" value="1440" />
|
|
||||||
<option name="screenY" value="3088" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="e1q" />
|
|
||||||
<option name="id" value="e1q" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy S24" />
|
|
||||||
<option name="screenDensity" value="480" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2340" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="felix" />
|
|
||||||
<option name="id" value="felix" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel Fold" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="2208" />
|
|
||||||
<option name="screenY" value="1840" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="felix" />
|
|
||||||
<option name="id" value="felix" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel Fold" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="2208" />
|
|
||||||
<option name="screenY" value="1840" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="felix_camera" />
|
|
||||||
<option name="id" value="felix_camera" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel Fold (Camera-enabled)" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="2208" />
|
|
||||||
<option name="screenY" value="1840" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="gts8uwifi" />
|
|
||||||
<option name="id" value="gts8uwifi" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy Tab S8 Ultra" />
|
|
||||||
<option name="screenDensity" value="320" />
|
|
||||||
<option name="screenX" value="1848" />
|
|
||||||
<option name="screenY" value="2960" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="husky" />
|
|
||||||
<option name="id" value="husky" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 8 Pro" />
|
|
||||||
<option name="screenDensity" value="390" />
|
|
||||||
<option name="screenX" value="1008" />
|
|
||||||
<option name="screenY" value="2244" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="30" />
|
|
||||||
<option name="brand" value="motorola" />
|
|
||||||
<option name="codename" value="java" />
|
|
||||||
<option name="id" value="java" />
|
|
||||||
<option name="manufacturer" value="Motorola" />
|
|
||||||
<option name="name" value="G20" />
|
|
||||||
<option name="screenDensity" value="280" />
|
|
||||||
<option name="screenX" value="720" />
|
|
||||||
<option name="screenY" value="1600" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="komodo" />
|
|
||||||
<option name="id" value="komodo" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 9 Pro XL" />
|
|
||||||
<option name="screenDensity" value="360" />
|
|
||||||
<option name="screenX" value="1008" />
|
|
||||||
<option name="screenY" value="2244" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="lynx" />
|
|
||||||
<option name="id" value="lynx" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 7a" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="31" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="oriole" />
|
|
||||||
<option name="id" value="oriole" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 6" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="panther" />
|
|
||||||
<option name="id" value="panther" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 7" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="q5q" />
|
|
||||||
<option name="id" value="q5q" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy Z Fold5" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1812" />
|
|
||||||
<option name="screenY" value="2176" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="samsung" />
|
|
||||||
<option name="codename" value="q6q" />
|
|
||||||
<option name="id" value="q6q" />
|
|
||||||
<option name="manufacturer" value="Samsung" />
|
|
||||||
<option name="name" value="Galaxy Z Fold6" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1856" />
|
|
||||||
<option name="screenY" value="2160" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="30" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="r11" />
|
|
||||||
<option name="id" value="r11" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel Watch" />
|
|
||||||
<option name="screenDensity" value="320" />
|
|
||||||
<option name="screenX" value="384" />
|
|
||||||
<option name="screenY" value="384" />
|
|
||||||
<option name="type" value="WEAR_OS" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="30" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="redfin" />
|
|
||||||
<option name="id" value="redfin" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 5" />
|
|
||||||
<option name="screenDensity" value="440" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2340" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="shiba" />
|
|
||||||
<option name="id" value="shiba" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 8" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2400" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="33" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="tangorpro" />
|
|
||||||
<option name="id" value="tangorpro" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel Tablet" />
|
|
||||||
<option name="screenDensity" value="320" />
|
|
||||||
<option name="screenX" value="1600" />
|
|
||||||
<option name="screenY" value="2560" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
<PersistentDeviceSelectionData>
|
|
||||||
<option name="api" value="34" />
|
|
||||||
<option name="brand" value="google" />
|
|
||||||
<option name="codename" value="tokay" />
|
|
||||||
<option name="id" value="tokay" />
|
|
||||||
<option name="manufacturer" value="Google" />
|
|
||||||
<option name="name" value="Pixel 9" />
|
|
||||||
<option name="screenDensity" value="420" />
|
|
||||||
<option name="screenX" value="1080" />
|
|
||||||
<option name="screenY" value="2424" />
|
|
||||||
</PersistentDeviceSelectionData>
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/misc.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectRootManager">
|
|
||||||
<output url="file://$PROJECT_DIR$/out" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
8
.idea/modules.xml
generated
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectModuleManager">
|
|
||||||
<modules>
|
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/streamyfin.iml" filepath="$PROJECT_DIR$/.idea/streamyfin.iml" />
|
|
||||||
</modules>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
9
.idea/streamyfin.iml
generated
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<module type="JAVA_MODULE" version="4">
|
|
||||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
|
||||||
<exclude-output />
|
|
||||||
<content url="file://$MODULE_DIR$" />
|
|
||||||
<orderEntry type="inheritedJdk" />
|
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
|
||||||
</component>
|
|
||||||
</module>
|
|
||||||
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
17
.vscode/settings.json
vendored
@@ -1,15 +1,24 @@
|
|||||||
{
|
{
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
|
||||||
"editor.formatOnSave": true,
|
|
||||||
"[javascript]": {
|
"[javascript]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true
|
||||||
},
|
},
|
||||||
"[typescriptreact]": {
|
"[typescriptreact]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true
|
||||||
},
|
},
|
||||||
|
"prettier.printWidth": 120,
|
||||||
"[swift]": {
|
"[swift]": {
|
||||||
"editor.defaultFormatter": "sswg.swift-lang"
|
"editor.defaultFormatter": "sswg.swift-lang"
|
||||||
|
},
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
},
|
||||||
|
"[javascriptreact]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome",
|
||||||
|
"editor.formatOnSave": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
Makefile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
e2e:
|
||||||
|
maestro start-device --platform android
|
||||||
|
maestro test login.yaml
|
||||||
|
|
||||||
|
e2e-setup:
|
||||||
|
curl -fsSL "https://get.maestro.mobile.dev" | bash
|
||||||
210
README.md
@@ -1,78 +1,83 @@
|
|||||||
# 📺 Streamyfin
|
|
||||||
|
|
||||||
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
<a href="https://www.buymeacoffee.com/fredrikbur3" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 41px !important;width: 174px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
|
||||||
|
|
||||||
Welcome to Streamyfin, a simple and user-friendly Jellyfin client built with Expo. If you're looking for an alternative to other Jellyfin clients, we hope you'll find Streamyfin to be a useful addition to your media streaming toolbox.
|
|
||||||
|
|
||||||
<div style="display: flex; flex-direction: row; gap: 8px">
|
<p align="center">
|
||||||
<img width=150 src="./assets/images/screenshots/screenshot1.png" />
|
<img src="https://raw.githubusercontent.com/streamyfin/.github/refs/heads/main/streamyfin-github-banner.png" alt="Streamyfin" width="100%">
|
||||||
<img width=150 src="./assets/images/screenshots/screenshot3.png" />
|
</p>
|
||||||
<img width=150 src="./assets/images/screenshots/screenshot2.png" />
|
|
||||||
|
**Streamyfin is a simple, user-friendly Jellyfin video streaming client built with Expo. Designed as an alternative to other Jellyfin clients, it aims to offer a smooth and reliable streaming experience. We hope you'll find it a valuable addition to your media streaming toolbox.**
|
||||||
</div>
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="./assets/images/screenshots/screenshot1.png" width="22%">
|
||||||
|
|
||||||
|
<img src="./assets/images/screenshots/screenshot3.png" width="22%">
|
||||||
|
|
||||||
|
<img src="./assets/images/screenshots/screenshot2.png" width="22%">
|
||||||
|
|
||||||
|
<img src="./assets/images/jellyseerr.PNG" width="23%">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
## 🌟 Features
|
## 🌟 Features
|
||||||
|
|
||||||
- 🚀 **Skp intro / credits support**
|
- 🚀 **Skip Intro / Credits Support**
|
||||||
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
- 🖼️ **Trickplay images**: The new golden standard for chapter previews when seeking.
|
||||||
- 🔊 **Background audio**: Stream music in the background, even when locking the phone.
|
|
||||||
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
- 📥 **Download media** (Experimental): Save your media locally and watch it offline.
|
||||||
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
- 📡 **Chromecast** (Experimental): Cast your media to any Chromecast-enabled device.
|
||||||
|
- 📡 **Settings management** (Experimental): Manage app settings for all your users with a JF plugin.
|
||||||
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
- 🤖 **Jellyseerr integration**: Request media directly in the app.
|
||||||
|
- 👁️ **Sessions View:** View all active sessions currently streaming on your server.
|
||||||
|
|
||||||
## 🧪 Experimental Features
|
## 🧪 Experimental Features
|
||||||
|
|
||||||
Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These are still in development, and we appreciate your patience and feedback as we work to improve them.
|
Streamyfin includes some exciting experimental features like media downloading and Chromecast support. These features are still in development, and your patience and feedback are much appreciated as we work to improve them.
|
||||||
|
|
||||||
### Downloading
|
### 📥 Downloading
|
||||||
|
|
||||||
Downloading works by using ffmpeg to convert an HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
|
Downloading works by using ffmpeg to convert an HLS stream into a video file on the device. This means that you can download and view any file you can stream! The file is converted by Jellyfin on the server in real time as it is downloaded. This means a **bit longer download times** but supports any file that your server can transcode.
|
||||||
|
|
||||||
### Chromecast
|
### 🎥 Chromecast
|
||||||
|
|
||||||
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos and audio, but we're working on adding support for subtitles and other features.
|
Chromecast support is still in development, and we're working on improving it. Currently, it supports casting videos, but we're working on adding support for subtitles and other features.
|
||||||
|
|
||||||
## Plugins
|
### 🧩 Streamyfin Plugin
|
||||||
|
|
||||||
In Streamyfin we have built-in support for a few plugins. These plugins are not required to use Streamyfin, but they add some extra functionality.
|
The Jellyfin Plugin for Streamyfin is a plugin you install into Jellyfin that holds all settings for the client Streamyfin. This allows you to synchronize settings across all your users, like for example:
|
||||||
|
|
||||||
### Collection rows
|
- Auto log in to Jellyseerr without the user having to do anything
|
||||||
|
- Choose the default languages
|
||||||
|
- Set download method and search provider
|
||||||
|
- Customize home screen
|
||||||
|
- And much more...
|
||||||
|
|
||||||
Jellyfin collections can be shown as rows or carousel on the home screen.
|
[Streamyfin Plugin](https://github.com/streamyfin/jellyfin-plugin-streamyfin)
|
||||||
The following tags can be added to a collection to provide this functionality.
|
|
||||||
|
|
||||||
Available tags:
|
### 🔍 Jellysearch
|
||||||
|
|
||||||
- sf_promoted: will make the collection a row at home
|
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin!
|
||||||
- sf_carousel: will make the collection a carousel on home.
|
|
||||||
|
|
||||||
A plugin exists to create collections based on external sources like mdblist. This make the automatic process of managing collections such as trending, most watched, etc.
|
|
||||||
See [Collection Import Plugin](https://github.com/lostb1t/jellyfin-plugin-collection-import) for more info.
|
|
||||||
|
|
||||||
### Jellysearch
|
|
||||||
|
|
||||||
[Jellysearch](https://gitlab.com/DomiStyle/jellysearch) now works with Streamyfin! 🚀
|
|
||||||
|
|
||||||
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
|
> A fast full-text search proxy for Jellyfin. Integrates seamlessly with most Jellyfin clients.
|
||||||
|
|
||||||
## Roadmap for V1
|
## 🛣️ Roadmap for V1
|
||||||
|
|
||||||
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) to see what we're working on next. We are always open for feedback and suggestions, so please let us know if you have any ideas or feature requests.
|
Check out our [Roadmap](https://github.com/users/fredrikburmester/projects/5) To see what we're working on next, we are always open to feedback and suggestions. Please let us know if you have any ideas or feature requests.
|
||||||
|
|
||||||
## Get it now
|
## 📥 Get it now
|
||||||
|
|
||||||
<div style="display: flex; gap: 5px;">
|
<div style="display: flex; gap: 5px;">
|
||||||
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
<a href="https://apps.apple.com/app/streamyfin/id6593660679?l=en-GB"><img height=50 alt="Get Streamyfin on App Store" src="./assets/Download_on_the_App_Store_Badge.png"/></a>
|
||||||
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
<a href="https://play.google.com/store/apps/details?id=com.fredrikburmester.streamyfin"><img height=50 alt="Get the beta on Google Play" src="./assets/Google_Play_Store_badge_EN.svg"/></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Or download the APKs [here on GitHub](https://github.com/fredrikburmester/streamyfin/releases) for Android.
|
Or download the APKs [here on GitHub](https://github.com/streamyfin/streamyfin/releases) for Android.
|
||||||
|
|
||||||
### Beta testing
|
### 🧪 Beta testing
|
||||||
|
|
||||||
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and i'll know that you have subscribed. This is where i'll post APKs and IPAs. This won't give automatic access to the TestFlight however, so you need to send me a DM with the email you use for Apple so that i can manually add you.
|
To access the Streamyfin beta, you need to subscribe to the Member tier (or higher) on [Patreon](https://www.patreon.com/streamyfin). This will give you immediate access to the 🧪-public-beta channel on Discord and I'll know that you have subscribed. This is where I post APKs and IPAs. This won't give automatic access to the TestFlight, however, so you need to send me a DM with the email you use for Apple so that I can manually add you.
|
||||||
|
|
||||||
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
**Note**: Everyone who is actively contributing to the source code of Streamyfin will have automatic access to the betas.
|
||||||
|
|
||||||
## 🚀 Getting Started
|
## 🚀 Getting Started
|
||||||
|
|
||||||
@@ -85,12 +90,19 @@ To access the Streamyfin beta, you need to subscribe to the Member tier (or high
|
|||||||
|
|
||||||
We welcome any help to make Streamyfin better. If you'd like to contribute, please fork the repository and submit a pull request. For major changes, it's best to open an issue first to discuss your ideas.
|
We welcome any help to make Streamyfin better. If you'd like to contribute, please fork the repository and submit a pull request. For major changes, it's best to open an issue first to discuss your ideas.
|
||||||
|
|
||||||
### Development info
|
### 👨💻 Development info
|
||||||
|
|
||||||
1. Use node `>20`
|
1. Use node `>20`
|
||||||
2. Install dependencies `bun i && bun run submodule-reload`
|
2. Install dependencies `bun i && bun run submodule-reload`
|
||||||
3. Make sure you have xcode and/or android studio installed.
|
3. Make sure you have xcode and/or android studio installed. (follow the guides for expo: https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||||
4. Create an expo dev build by running `npx expo run:ios` or `npx expo run:android`. This will open a simulator on you computer and run the app.
|
4. Install BiomeJS extension in VSCode/Your IDE (https://biomejs.dev/)
|
||||||
|
4. run `npm run prebuild`
|
||||||
|
5. Create an expo dev build by running `npm run ios` or `npm run android`. This will open a simulator on your computer and run the app.
|
||||||
|
|
||||||
|
For the TV version suffix the npm commands with `:tv`.
|
||||||
|
|
||||||
|
`npm run prebuild:tv`
|
||||||
|
`npm run ios:tv or npm run android:tv`
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
@@ -104,30 +116,134 @@ Key points of the MPL-2.0:
|
|||||||
- You must disclose your source code for any modifications to the covered files
|
- You must disclose your source code for any modifications to the covered files
|
||||||
- Larger works may combine MPL code with code under other licenses
|
- Larger works may combine MPL code with code under other licenses
|
||||||
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license
|
- MPL-licensed components must remain under the MPL, but the larger work can be under a different license
|
||||||
- For the full text of the license, please see the LICENSE file in this repository.
|
- For the full text of the license, please see the LICENSE file in this repository
|
||||||
|
|
||||||
## 🌐 Connect with Us
|
## 🌐 Connect with Us
|
||||||
|
|
||||||
Join our Discord: [https://discord.gg/BuGG9ZNhaE](https://discord.gg/BuGG9ZNhaE)
|
Join our Discord: [](https://discord.gg/BuGG9ZNhaE)
|
||||||
|
|
||||||
If you have questions or need support, feel free to reach out:
|
Need support or have questions:
|
||||||
|
|
||||||
- GitHub Issues: Report bugs or request features here.
|
- GitHub Issues: Report bugs or request features here.
|
||||||
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
|
- Email: [fredrik.burmester@gmail.com](mailto:fredrik.burmester@gmail.com)
|
||||||
|
|
||||||
|
## ❓ FAQ
|
||||||
|
|
||||||
|
1. Q: Why can't I see my libraries in Streamyfin?
|
||||||
|
A: Make sure your server is running one of the latest versions and that you have at least one library that isn't audio only.
|
||||||
|
2. Q: Why can't I see my music library?
|
||||||
|
A: We don't currently support music and are unlikely to support music in the near future.
|
||||||
|
|
||||||
## 📝 Credits
|
## 📝 Credits
|
||||||
|
|
||||||
Streamyfin is developed by Fredrik Burmester and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
|
Streamyfin is developed by [Fredrik Burmester](https://github.com/fredrikburmester) and is not affiliated with Jellyfin. The app is built with Expo, React Native, and other open-source libraries.
|
||||||
|
|
||||||
## ✨ Acknowledgements
|
## ✨ Acknowledgements
|
||||||
|
|
||||||
I'd like to thank the following people and projects for their contributions to Streamyfin:
|
We would like to thank the Jellyfin team for their great software and awesome support on discord.
|
||||||
|
|
||||||
|
Special shoutout to the JF official clients for being an inspiration to ours.
|
||||||
|
|
||||||
|
### 🏆 Core Developers
|
||||||
|
|
||||||
|
Thanks to the following contributors for their significant contributions:
|
||||||
|
|
||||||
|
<div align="left">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/Alexk2309">
|
||||||
|
<img src="https://github.com/Alexk2309.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@Alexk2309</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/herrrta">
|
||||||
|
<img src="https://github.com/herrrta.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@herrrta</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/lostb1t">
|
||||||
|
<img src="https://github.com/lostb1t.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@lostb1t</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/Simon-Eklundh">
|
||||||
|
<img src="https://github.com/Simon-Eklundh.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@Simon-Eklundh</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/topiga">
|
||||||
|
<img src="https://github.com/topiga.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@topiga</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/lancechant">
|
||||||
|
<img src="https://github.com/lancechant.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@lancechant</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/simoncaron">
|
||||||
|
<img src="https://github.com/simoncaron.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@simoncaron</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/jakequade">
|
||||||
|
<img src="https://github.com/jakequade.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@jakequade</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/Ryan0204">
|
||||||
|
<img src="https://github.com/Ryan0204.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@Ryan0204</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/retardgerman">
|
||||||
|
<img src="https://github.com/retardgerman.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@retardgerman</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/whoopsi-daisy">
|
||||||
|
<img src="https://github.com/whoopsi-daisy.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@whoopsi-daisy</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/Gauvino">
|
||||||
|
<img src="https://github.com/Gauvino.png?size=55" width="55" style="border-radius: 50%;" />
|
||||||
|
<br /><sub><b>@Gauvino</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
And all other developers who have contributed to Streamyfin, thank you for your contributions.
|
||||||
|
|
||||||
|
I'd also like to thank the following people and projects for their contributions to Streamyfin:
|
||||||
|
|
||||||
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
|
- [Reiverr](https://github.com/aleksilassila/reiverr) for great help with understanding the Jellyfin API.
|
||||||
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
|
- [Jellyfin TS SDK](https://github.com/jellyfin/jellyfin-sdk-typescript) for the TypeScript SDK.
|
||||||
- [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project.
|
- [Jellyseerr](https://github.com/Fallenbagel/jellyseerr) for enabling API integration with their project.
|
||||||
- The Jellyfin devs for always being helpful in the Discord.
|
- The Jellyfin devs for always being helpful in the Discord.
|
||||||
|
|
||||||
## Star History
|
## ⭐ Star History
|
||||||
|
|
||||||
[](https://star-history.com/#fredrikburmester/streamyfin&Date)
|
[](https://star-history.com/#streamyfin/streamyfin&Date)
|
||||||
|
|
||||||
|
## ⚠️ Disclaimer
|
||||||
|
Streamyfin does not promote, support, or condone piracy in any form. The app is intended solely for streaming media that you personally own and control. It does not provide or include any media content. Any discussions or support requests related to piracy are strictly prohibited across all our channels.
|
||||||
|
|
||||||
|
## 🤝 Sponsorship
|
||||||
|
VPS hosting generously provided by [Hexabyte](https://hexabyte.se/en/vps/?currency=eur) and [SweHosting](https://swehosting.se/en/#tj%C3%A4nster)
|
||||||
|
|||||||
17
app.config.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
module.exports = ({ config }) => {
|
||||||
|
if (process.env.EXPO_TV !== "1") {
|
||||||
|
config.plugins.push([
|
||||||
|
"react-native-google-cast",
|
||||||
|
{ useDefaultExpandedMediaControls: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add the background downloader plugin only for non-TV builds
|
||||||
|
config.plugins.push("./plugins/withRNBackgroundDownloader.js");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
android: {
|
||||||
|
googleServicesFile: process.env.GOOGLE_SERVICES_JSON,
|
||||||
|
},
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
};
|
||||||
97
app.json
@@ -2,16 +2,11 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Streamyfin",
|
"name": "Streamyfin",
|
||||||
"slug": "streamyfin",
|
"slug": "streamyfin",
|
||||||
"version": "0.23.0",
|
"version": "0.32.1",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "streamyfin",
|
"scheme": "streamyfin",
|
||||||
"userInterfaceStyle": "dark",
|
"userInterfaceStyle": "dark",
|
||||||
"splash": {
|
|
||||||
"image": "./assets/images/splash.png",
|
|
||||||
"resizeMode": "contain",
|
|
||||||
"backgroundColor": "#2E2E2E"
|
|
||||||
},
|
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"assetBundlePatterns": ["**/*"],
|
"assetBundlePatterns": ["**/*"],
|
||||||
"ios": {
|
"ios": {
|
||||||
@@ -32,26 +27,45 @@
|
|||||||
"usesNonExemptEncryption": false
|
"usesNonExemptEncryption": false
|
||||||
},
|
},
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "com.fredrikburmester.streamyfin"
|
"bundleIdentifier": "com.fredrikburmester.streamyfin",
|
||||||
|
"icon": {
|
||||||
|
"dark": "./assets/images/icon-ios-plain.png",
|
||||||
|
"light": "./assets/images/icon-ios-light.png",
|
||||||
|
"tinted": "./assets/images/icon-ios-tinted.png"
|
||||||
|
},
|
||||||
|
"appleTeamId": "MWD5K362T8"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"jsEngine": "hermes",
|
"jsEngine": "hermes",
|
||||||
"versionCode": 49,
|
"versionCode": 62,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/images/adaptive_icon.png"
|
"foregroundImage": "./assets/images/icon-android-plain.png",
|
||||||
|
"monochromeImage": "./assets/images/icon-android-themed.png",
|
||||||
|
"backgroundColor": "#2E2E2E"
|
||||||
},
|
},
|
||||||
"package": "com.fredrikburmester.streamyfin",
|
"package": "com.fredrikburmester.streamyfin",
|
||||||
"permissions": []
|
"permissions": [
|
||||||
|
"android.permission.FOREGROUND_SERVICE",
|
||||||
|
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
||||||
|
"android.permission.WRITE_SETTINGS"
|
||||||
|
],
|
||||||
|
"googleServicesFile": "./google-services.json"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
"@react-native-tvos/config-tv",
|
||||||
"expo-router",
|
"expo-router",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"@config-plugins/ffmpeg-kit-react-native",
|
|
||||||
[
|
[
|
||||||
"react-native-video",
|
"react-native-video",
|
||||||
{
|
{
|
||||||
"enableNotificationControls": true,
|
"enableNotificationControls": true,
|
||||||
"enableBackgroundAudio": true
|
"enableBackgroundAudio": true,
|
||||||
|
"androidExtensions": {
|
||||||
|
"useExoplayerRtsp": false,
|
||||||
|
"useExoplayerSmoothStreaming": false,
|
||||||
|
"useExoplayerHls": true,
|
||||||
|
"useExoplayerDash": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -60,19 +74,67 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"deploymentTarget": "15.6",
|
"deploymentTarget": "15.6",
|
||||||
"useFrameworks": "static"
|
"useFrameworks": "static"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"compileSdkVersion": 35,
|
||||||
|
"targetSdkVersion": 35,
|
||||||
|
"buildToolsVersion": "35.0.0",
|
||||||
|
"kotlinVersion": "2.0.21",
|
||||||
|
"minSdkVersion": 24,
|
||||||
|
"usesCleartextTraffic": true,
|
||||||
|
"packagingOptions": {
|
||||||
|
"jniLibs": {
|
||||||
|
"useLegacyPackaging": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"useAndroidX": true,
|
||||||
|
"enableJetifier": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"expo-screen-orientation",
|
||||||
|
{
|
||||||
|
"initialOrientation": "DEFAULT"
|
||||||
|
}
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"expo-sensors",
|
"expo-sensors",
|
||||||
{
|
{
|
||||||
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
|
"motionPermission": "Allow Streamyfin to access your device motion for landscape video watching."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"expo-localization",
|
||||||
"expo-asset",
|
"expo-asset",
|
||||||
["react-native-edge-to-edge"],
|
[
|
||||||
["react-native-bottom-tabs"],
|
"react-native-edge-to-edge",
|
||||||
["@react-native-tvos/config-tv"]
|
{
|
||||||
|
"android": {
|
||||||
|
"parentTheme": "Material3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
["./plugins/withChangeNativeAndroidTextToWhite.js"],
|
||||||
|
["./plugins/withAndroidManifest.js"],
|
||||||
|
["./plugins/withTrustLocalCerts.js"],
|
||||||
|
["./plugins/withGradleProperties.js"],
|
||||||
|
[
|
||||||
|
"expo-splash-screen",
|
||||||
|
{
|
||||||
|
"backgroundColor": "#2e2e2e",
|
||||||
|
"image": "./assets/images/icon-ios-plain.png",
|
||||||
|
"imageWidth": 100
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-notifications",
|
||||||
|
{
|
||||||
|
"icon": "./assets/images/notification.png",
|
||||||
|
"color": "#9333EA"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"./plugins/with-runtime-framework-headers.js",
|
||||||
|
"react-native-bottom-tabs"
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
@@ -85,12 +147,13 @@
|
|||||||
"projectId": "e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
"projectId": "e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"owner": "fredrikburmester",
|
"owner": "streamyfin",
|
||||||
"runtimeVersion": {
|
"runtimeVersion": {
|
||||||
"policy": "appVersion"
|
"policy": "appVersion"
|
||||||
},
|
},
|
||||||
"updates": {
|
"updates": {
|
||||||
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
"url": "https://u.expo.dev/e79219d1-797f-4fbe-9fa1-cfd360690a68"
|
||||||
}
|
},
|
||||||
|
"newArchEnabled": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
app/(auth)/(tabs)/(custom-links)/_layout.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
export default function CustomMenuLayout() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name='index'
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
headerLargeTitle: true,
|
||||||
|
headerTitle: t("tabs.custom_links"),
|
||||||
|
headerBlurEffect: "prominent",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
app/(auth)/(tabs)/(custom-links)/index.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import Ionicons from "@expo/vector-icons/Ionicons";
|
||||||
|
import { useAtom } from "jotai/index";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FlatList, Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
|
const WebBrowser = !Platform.isTV ? require("expo-web-browser") : null;
|
||||||
|
|
||||||
|
export interface MenuLink {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function menuLinks() {
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [menuLinks, setMenuLinks] = useState<MenuLink[]>([]);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const getMenuLinks = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await api?.axiosInstance.get(
|
||||||
|
`${api?.basePath}/web/config.json`,
|
||||||
|
);
|
||||||
|
const config = response?.data;
|
||||||
|
|
||||||
|
if (!config && !Object.hasOwn(config, "menuLinks")) {
|
||||||
|
console.error("Menu links not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMenuLinks(config?.menuLinks as MenuLink[]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to retrieve config:", error);
|
||||||
|
}
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getMenuLinks();
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: 10,
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
data={menuLinks}
|
||||||
|
renderItem={({ item }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
if (!Platform.isTV) {
|
||||||
|
WebBrowser.openBrowserAsync(item.url);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItem
|
||||||
|
title={item.name}
|
||||||
|
iconAfter={<Ionicons name='link' size={24} color='white' />}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
ItemSeparatorComponent={() => (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
ListEmptyComponent={
|
||||||
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
|
{t("custom_links.no_links")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
app/(auth)/(tabs)/(favorites)/_layout.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
|
|
||||||
|
export default function SearchLayout() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Stack>
|
||||||
|
<Stack.Screen
|
||||||
|
name='index'
|
||||||
|
options={{
|
||||||
|
headerShown: !Platform.isTV,
|
||||||
|
headerLargeTitle: true,
|
||||||
|
headerTitle: t("tabs.favorites"),
|
||||||
|
headerLargeStyle: {
|
||||||
|
backgroundColor: "black",
|
||||||
|
},
|
||||||
|
headerBlurEffect: "prominent",
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
|
headerShadowVisible: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
app/(auth)/(tabs)/(favorites)/index.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { RefreshControl, ScrollView, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Favorites } from "@/components/home/Favorites";
|
||||||
|
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
||||||
|
|
||||||
|
export default function favorites() {
|
||||||
|
const invalidateCache = useInvalidatePlaybackProgressCache();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const refetch = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await invalidateCache();
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
nestedScrollEnabled
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
||||||
|
}
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className='my-4'>
|
||||||
|
<Favorites />
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,53 +1,151 @@
|
|||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
import { Feather } from "@expo/vector-icons";
|
|
||||||
import { Stack, useRouter } from "expo-router";
|
import { Stack, useRouter } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, TouchableOpacity, View } from "react-native";
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
|
|
||||||
|
const Chromecast = Platform.isTV ? null : require("@/components/Chromecast");
|
||||||
|
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||||
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
const router = useRouter();
|
const _router = useRouter();
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="index"
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: "Home",
|
headerTitle: t("tabs.home"),
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerLargeStyle: {
|
||||||
|
backgroundColor: "black",
|
||||||
|
},
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<View className="flex flex-row items-center space-x-2">
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
<TouchableOpacity
|
{!Platform.isTV && (
|
||||||
onPress={() => {
|
<>
|
||||||
router.push("/(auth)/settings");
|
<Chromecast.Chromecast />
|
||||||
}}
|
{user?.Policy?.IsAdministrator && <SessionsButton />}
|
||||||
>
|
<SettingsButton />
|
||||||
<Feather name="settings" color={"white"} size={22} />
|
</>
|
||||||
</TouchableOpacity>
|
)}
|
||||||
</View>
|
</View>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="settings"
|
name='downloads/index'
|
||||||
options={{
|
options={{
|
||||||
title: "Settings",
|
title: t("home.downloads.downloads_title"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='downloads/[seriesId]'
|
||||||
|
options={{
|
||||||
|
title: t("home.downloads.tvseries"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='sessions/index'
|
||||||
|
options={{
|
||||||
|
title: t("home.sessions.title"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='settings'
|
||||||
|
options={{
|
||||||
|
title: t("home.settings.settings_title"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='settings/marlin-search/page'
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='settings/jellyseerr/page'
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='settings/hide-libraries/page'
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='settings/logs/page'
|
||||||
|
options={{
|
||||||
|
title: "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='intro/page'
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
title: "",
|
||||||
|
presentation: "modal",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
{Object.entries(nestedTabPageScreenOptions).map(([name, options]) => (
|
||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="collections/[collectionId]"
|
name='collections/[collectionId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: true,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SettingsButton = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/(auth)/settings");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Feather name='settings' color={"white"} size={22} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SessionsButton = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { sessions = [] } = useSessions({} as useSessionsProps);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/(auth)/sessions");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View className='mr-4'>
|
||||||
|
<Ionicons
|
||||||
|
name='play-circle'
|
||||||
|
color={sessions.length === 0 ? "white" : "#9333ea"}
|
||||||
|
size={25}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
150
app/(auth)/(tabs)/(home)/downloads/[seriesId].tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { EpisodeCard } from "@/components/downloads/EpisodeCard";
|
||||||
|
import {
|
||||||
|
SeasonDropdown,
|
||||||
|
type SeasonIndexState,
|
||||||
|
} from "@/components/series/SeasonDropdown";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const local = useLocalSearchParams();
|
||||||
|
const { seriesId, episodeSeasonIndex } = local as {
|
||||||
|
seriesId: string;
|
||||||
|
episodeSeasonIndex: number | string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [seasonIndexState, setSeasonIndexState] = useState<SeasonIndexState>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const { getDownloadedItems, deleteItems } = useDownload();
|
||||||
|
|
||||||
|
const series = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
getDownloadedItems()
|
||||||
|
?.filter((f) => f.item.SeriesId === seriesId)
|
||||||
|
?.sort(
|
||||||
|
(a, b) => a?.item.ParentIndexNumber! - b.item.ParentIndexNumber!,
|
||||||
|
) || []
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [getDownloadedItems]);
|
||||||
|
|
||||||
|
// Group episodes by season in a single pass
|
||||||
|
const seasonGroups = useMemo(() => {
|
||||||
|
const groups: Record<number, BaseItemDto[]> = {};
|
||||||
|
|
||||||
|
series.forEach((episode) => {
|
||||||
|
const seasonNumber = episode.item.ParentIndexNumber;
|
||||||
|
if (seasonNumber !== undefined && seasonNumber !== null) {
|
||||||
|
if (!groups[seasonNumber]) {
|
||||||
|
groups[seasonNumber] = [];
|
||||||
|
}
|
||||||
|
groups[seasonNumber].push(episode.item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort episodes within each season
|
||||||
|
Object.values(groups).forEach((episodes) => {
|
||||||
|
episodes.sort((a, b) => (a.IndexNumber || 0) - (b.IndexNumber || 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}, [series]);
|
||||||
|
|
||||||
|
// Get unique seasons (just the season numbers, sorted)
|
||||||
|
const uniqueSeasons = useMemo(() => {
|
||||||
|
const seasonNumbers = Object.keys(seasonGroups)
|
||||||
|
.map(Number)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
return seasonNumbers.map((seasonNum) => seasonGroups[seasonNum][0]); // First episode of each season
|
||||||
|
}, [seasonGroups]);
|
||||||
|
|
||||||
|
const seasonIndex =
|
||||||
|
seasonIndexState[series?.[0]?.item?.ParentId ?? ""] ||
|
||||||
|
episodeSeasonIndex ||
|
||||||
|
"";
|
||||||
|
|
||||||
|
const groupBySeason = useMemo<BaseItemDto[]>(() => {
|
||||||
|
return seasonGroups[Number(seasonIndex)] ?? [];
|
||||||
|
}, [seasonGroups, seasonIndex]);
|
||||||
|
|
||||||
|
const initialSeasonIndex = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.values(groupBySeason)?.[0]?.ParentIndexNumber ??
|
||||||
|
series?.[0]?.item?.ParentIndexNumber,
|
||||||
|
[groupBySeason],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (series.length > 0) {
|
||||||
|
navigation.setOptions({
|
||||||
|
title: series[0].item.SeriesName,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
storage.delete(seriesId);
|
||||||
|
router.back();
|
||||||
|
}
|
||||||
|
}, [series]);
|
||||||
|
|
||||||
|
const deleteSeries = useCallback(() => {
|
||||||
|
Alert.alert(
|
||||||
|
"Delete season",
|
||||||
|
"Are you sure you want to delete the entire season?",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: "Cancel",
|
||||||
|
style: "cancel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
onPress: () => deleteItems(groupBySeason),
|
||||||
|
style: "destructive",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}, [groupBySeason]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex-1'>
|
||||||
|
{series.length > 0 && (
|
||||||
|
<View className='flex flex-row items-center justify-start my-2 px-4'>
|
||||||
|
<SeasonDropdown
|
||||||
|
item={series[0].item}
|
||||||
|
seasons={uniqueSeasons}
|
||||||
|
state={seasonIndexState}
|
||||||
|
initialSeasonIndex={initialSeasonIndex!}
|
||||||
|
onSelect={(season) => {
|
||||||
|
setSeasonIndexState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[series[0].item.ParentId ?? ""]: season.ParentIndexNumber,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center ml-2'>
|
||||||
|
<Text className='text-xs font-bold'>{groupBySeason.length}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='bg-neutral-800/80 rounded-full h-9 w-9 flex items-center justify-center ml-auto'>
|
||||||
|
<TouchableOpacity onPress={deleteSeries}>
|
||||||
|
<Ionicons name='trash' size={20} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<ScrollView key={seasonIndex} className='px-4'>
|
||||||
|
{groupBySeason.map((episode, index) => (
|
||||||
|
<EpisodeCard key={index} item={episode} />
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
284
app/(auth)/(tabs)/(home)/downloads/index.tsx
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Alert, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { ActiveDownloads } from "@/components/downloads/ActiveDownloads";
|
||||||
|
import { DownloadSize } from "@/components/downloads/DownloadSize";
|
||||||
|
import { MovieCard } from "@/components/downloads/MovieCard";
|
||||||
|
import { SeriesCard } from "@/components/downloads/SeriesCard";
|
||||||
|
import { useDownload } from "@/providers/DownloadProvider";
|
||||||
|
import { type DownloadedItem } from "@/providers/Downloads/types";
|
||||||
|
import { queueAtom } from "@/utils/atoms/queue";
|
||||||
|
import { writeToLog } from "@/utils/log";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [queue, setQueue] = useAtom(queueAtom);
|
||||||
|
const {
|
||||||
|
removeProcess,
|
||||||
|
getDownloadedItems,
|
||||||
|
deleteFileByType,
|
||||||
|
deleteAllFiles,
|
||||||
|
} = useDownload();
|
||||||
|
const router = useRouter();
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
|
const [showMigration, setShowMigration] = useState(false);
|
||||||
|
|
||||||
|
const migration_20241124 = () => {
|
||||||
|
Alert.alert(
|
||||||
|
t("home.downloads.new_app_version_requires_re_download"),
|
||||||
|
t("home.downloads.new_app_version_requires_re_download_description"),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: t("home.downloads.back"),
|
||||||
|
onPress: () => {
|
||||||
|
setShowMigration(false);
|
||||||
|
router.back();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("home.downloads.delete"),
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
await deleteAllFiles();
|
||||||
|
setShowMigration(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadedFiles = getDownloadedItems();
|
||||||
|
|
||||||
|
const movies = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return downloadedFiles?.filter((f) => f.item.Type === "Movie") || [];
|
||||||
|
} catch {
|
||||||
|
setShowMigration(true);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [downloadedFiles]);
|
||||||
|
|
||||||
|
const groupedBySeries = useMemo(() => {
|
||||||
|
try {
|
||||||
|
const episodes = downloadedFiles?.filter(
|
||||||
|
(f) => f.item.Type === "Episode",
|
||||||
|
);
|
||||||
|
const series: { [key: string]: DownloadedItem[] } = {};
|
||||||
|
episodes?.forEach((e) => {
|
||||||
|
if (!series[e.item.SeriesName!]) series[e.item.SeriesName!] = [];
|
||||||
|
series[e.item.SeriesName!].push(e);
|
||||||
|
});
|
||||||
|
return Object.values(series);
|
||||||
|
} catch {
|
||||||
|
setShowMigration(true);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [downloadedFiles]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity onPress={bottomSheetModalRef.current?.present}>
|
||||||
|
<DownloadSize items={downloadedFiles?.map((f) => f.item) || []} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [downloadedFiles]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showMigration) {
|
||||||
|
migration_20241124();
|
||||||
|
}
|
||||||
|
}, [showMigration]);
|
||||||
|
|
||||||
|
const deleteMovies = () =>
|
||||||
|
deleteFileByType("Movie")
|
||||||
|
.then(() =>
|
||||||
|
toast.success(
|
||||||
|
t("home.downloads.toasts.deleted_all_movies_successfully"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.catch((reason) => {
|
||||||
|
writeToLog("ERROR", reason);
|
||||||
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_movies"));
|
||||||
|
});
|
||||||
|
const deleteShows = () =>
|
||||||
|
deleteFileByType("Episode")
|
||||||
|
.then(() =>
|
||||||
|
toast.success(
|
||||||
|
t("home.downloads.toasts.deleted_all_tvseries_successfully"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.catch((reason) => {
|
||||||
|
writeToLog("ERROR", reason);
|
||||||
|
toast.error(t("home.downloads.toasts.failed_to_delete_all_tvseries"));
|
||||||
|
});
|
||||||
|
const deleteAllMedia = async () =>
|
||||||
|
await Promise.all([deleteMovies(), deleteShows()]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<ScrollView showsVerticalScrollIndicator={false} className='flex-1'>
|
||||||
|
<View className='py-4'>
|
||||||
|
<View className='mb-4 flex flex-col space-y-4 px-4'>
|
||||||
|
<View className='bg-neutral-900 p-4 rounded-2xl'>
|
||||||
|
<Text className='text-lg font-bold'>
|
||||||
|
{t("home.downloads.queue")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-xs opacity-70 text-red-600'>
|
||||||
|
{t("home.downloads.queue_hint")}
|
||||||
|
</Text>
|
||||||
|
<View className='flex flex-col space-y-2 mt-2'>
|
||||||
|
{queue.map((q, index) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() =>
|
||||||
|
router.push(`/(auth)/items/page?id=${q.item.Id}`)
|
||||||
|
}
|
||||||
|
className='relative bg-neutral-900 border border-neutral-800 p-4 rounded-2xl overflow-hidden flex flex-row items-center justify-between'
|
||||||
|
key={index}
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<Text className='font-semibold'>{q.item.Name}</Text>
|
||||||
|
<Text className='text-xs opacity-50'>
|
||||||
|
{q.item.Type}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
removeProcess(q.id);
|
||||||
|
setQueue((prev) => {
|
||||||
|
if (!prev) return [];
|
||||||
|
return [...prev.filter((i) => i.id !== q.id)];
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='close' size={24} color='red' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{queue.length === 0 && (
|
||||||
|
<Text className='opacity-50'>
|
||||||
|
{t("home.downloads.no_items_in_queue")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ActiveDownloads />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{movies.length > 0 && (
|
||||||
|
<View className='mb-4'>
|
||||||
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
|
<Text className='text-lg font-bold'>
|
||||||
|
{t("home.downloads.movies")}
|
||||||
|
</Text>
|
||||||
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
|
<Text className='text-xs font-bold'>{movies?.length}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<View className='px-4 flex flex-row'>
|
||||||
|
{movies?.map((item) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item.item}
|
||||||
|
isOffline
|
||||||
|
key={item.item.Id}
|
||||||
|
>
|
||||||
|
<MovieCard item={item.item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{groupedBySeries.length > 0 && (
|
||||||
|
<View className='mb-4'>
|
||||||
|
<View className='flex flex-row items-center justify-between mb-2 px-4'>
|
||||||
|
<Text className='text-lg font-bold'>
|
||||||
|
{t("home.downloads.tvseries")}
|
||||||
|
</Text>
|
||||||
|
<View className='bg-purple-600 rounded-full h-6 w-6 flex items-center justify-center'>
|
||||||
|
<Text className='text-xs font-bold'>
|
||||||
|
{groupedBySeries?.length}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||||
|
<View className='px-4 flex flex-row'>
|
||||||
|
{groupedBySeries?.map((items) => (
|
||||||
|
<View
|
||||||
|
className='mb-2 last:mb-0'
|
||||||
|
key={items[0].item.SeriesId}
|
||||||
|
>
|
||||||
|
<SeriesCard
|
||||||
|
items={items.map((i) => i.item)}
|
||||||
|
key={items[0].item.SeriesId}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{downloadedFiles?.length === 0 && (
|
||||||
|
<View className='flex px-4'>
|
||||||
|
<Text className='opacity-50'>
|
||||||
|
{t("home.downloads.no_downloaded_items")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
enableDynamicSizing
|
||||||
|
handleIndicatorStyle={{
|
||||||
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
backgroundStyle={{
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
}}
|
||||||
|
backdropComponent={(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BottomSheetView>
|
||||||
|
<View className='p-4 space-y-4 mb-4'>
|
||||||
|
<Button color='purple' onPress={deleteMovies}>
|
||||||
|
{t("home.downloads.delete_all_movies_button")}
|
||||||
|
</Button>
|
||||||
|
<Button color='purple' onPress={deleteShows}>
|
||||||
|
{t("home.downloads.delete_all_tvseries_button")}
|
||||||
|
</Button>
|
||||||
|
<Button color='red' onPress={deleteAllMedia}>
|
||||||
|
{t("home.downloads.delete_all_button")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,371 +1,5 @@
|
|||||||
import { Button } from "@/components/Button";
|
import { HomeIndex } from "@/components/settings/HomeIndex";
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { LargeMovieCarousel } from "@/components/home/LargeMovieCarousel";
|
|
||||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { MediaListSection } from "@/components/medialists/MediaListSection";
|
|
||||||
import { Colors } from "@/constants/Colors";
|
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Feather, Ionicons } from "@expo/vector-icons";
|
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import {
|
|
||||||
BaseItemDto,
|
|
||||||
BaseItemKind,
|
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import {
|
|
||||||
getItemsApi,
|
|
||||||
getSuggestionsApi,
|
|
||||||
getTvShowsApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
getUserViewsApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import NetInfo from "@react-native-community/netinfo";
|
|
||||||
import { QueryFunction, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useNavigation, useRouter } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
ActivityIndicator,
|
|
||||||
RefreshControl,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
type ScrollingCollectionListSection = {
|
export default function page() {
|
||||||
type: "ScrollingCollectionList";
|
return <HomeIndex />;
|
||||||
title?: string;
|
|
||||||
queryKey: (string | undefined | null)[];
|
|
||||||
queryFn: QueryFunction<BaseItemDto[]>;
|
|
||||||
orientation?: "horizontal" | "vertical";
|
|
||||||
};
|
|
||||||
|
|
||||||
type MediaListSection = {
|
|
||||||
type: "MediaListSection";
|
|
||||||
queryKey: (string | undefined)[];
|
|
||||||
queryFn: QueryFunction<BaseItemDto>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Section = ScrollingCollectionListSection | MediaListSection;
|
|
||||||
|
|
||||||
export default function index() {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [settings, _] = useSettings();
|
|
||||||
|
|
||||||
const [isConnected, setIsConnected] = useState<boolean | null>(null);
|
|
||||||
const [loadingRetry, setLoadingRetry] = useState(false);
|
|
||||||
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
const checkConnection = useCallback(async () => {
|
|
||||||
setLoadingRetry(true);
|
|
||||||
const state = await NetInfo.fetch();
|
|
||||||
setIsConnected(state.isConnected);
|
|
||||||
setLoadingRetry(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = NetInfo.addEventListener((state) => {
|
|
||||||
if (state.isConnected == false || state.isInternetReachable === false)
|
|
||||||
setIsConnected(false);
|
|
||||||
else setIsConnected(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
NetInfo.fetch().then((state) => {
|
|
||||||
setIsConnected(state.isConnected);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
unsubscribe();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: userViews,
|
|
||||||
isError: e1,
|
|
||||||
isLoading: l1,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["home", "userViews", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await getUserViewsApi(api).getUserViews({
|
|
||||||
userId: user.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data.Items || null;
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: mediaListCollections,
|
|
||||||
isError: e2,
|
|
||||||
isLoading: l2,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["home", "sf_promoted", user?.Id, settings?.usePopularPlugin],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user.Id,
|
|
||||||
tags: ["sf_promoted"],
|
|
||||||
recursive: true,
|
|
||||||
fields: ["Tags"],
|
|
||||||
includeItemTypes: ["BoxSet"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data.Items || [];
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && settings?.usePopularPlugin === true,
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const collections = useMemo(() => {
|
|
||||||
const allow = ["movies", "tvshows"];
|
|
||||||
return (
|
|
||||||
userViews?.filter(
|
|
||||||
(c) => c.CollectionType && allow.includes(c.CollectionType)
|
|
||||||
) || []
|
|
||||||
);
|
|
||||||
}, [userViews]);
|
|
||||||
|
|
||||||
const invalidateCache = useInvalidatePlaybackProgressCache();
|
|
||||||
|
|
||||||
const refetch = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
await invalidateCache();
|
|
||||||
setLoading(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const createCollectionConfig = useCallback(
|
|
||||||
(
|
|
||||||
title: string,
|
|
||||||
queryKey: string[],
|
|
||||||
includeItemTypes: BaseItemKind[],
|
|
||||||
parentId: string | undefined
|
|
||||||
): ScrollingCollectionListSection => ({
|
|
||||||
title,
|
|
||||||
queryKey,
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return [];
|
|
||||||
return (
|
|
||||||
(
|
|
||||||
await getUserLibraryApi(api).getLatestMedia({
|
|
||||||
userId: user?.Id,
|
|
||||||
limit: 20,
|
|
||||||
fields: ["PrimaryImageAspectRatio", "Path"],
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
includeItemTypes,
|
|
||||||
parentId,
|
|
||||||
})
|
|
||||||
).data || []
|
|
||||||
);
|
|
||||||
},
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
}),
|
|
||||||
[api, user?.Id]
|
|
||||||
);
|
|
||||||
|
|
||||||
const sections = useMemo(() => {
|
|
||||||
if (!api || !user?.Id) return [];
|
|
||||||
|
|
||||||
const latestMediaViews = collections.map((c) => {
|
|
||||||
const includeItemTypes: BaseItemKind[] =
|
|
||||||
c.CollectionType === "tvshows" ? ["Series"] : ["Movie"];
|
|
||||||
const title = "Recently Added in " + c.Name;
|
|
||||||
const queryKey = [
|
|
||||||
"home",
|
|
||||||
"recentlyAddedIn" + c.CollectionType,
|
|
||||||
user?.Id!,
|
|
||||||
c.Id!,
|
|
||||||
];
|
|
||||||
return createCollectionConfig(
|
|
||||||
title || "",
|
|
||||||
queryKey,
|
|
||||||
includeItemTypes,
|
|
||||||
c.Id
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const ss: Section[] = [
|
|
||||||
{
|
|
||||||
title: "Continue Watching",
|
|
||||||
queryKey: ["home", "resumeItems"],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await getItemsApi(api).getResumeItems({
|
|
||||||
userId: user.Id,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
includeItemTypes: ["Movie", "Series", "Episode"],
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Next Up",
|
|
||||||
queryKey: ["home", "nextUp-all"],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await getTvShowsApi(api).getNextUp({
|
|
||||||
userId: user?.Id,
|
|
||||||
fields: ["MediaSourceCount"],
|
|
||||||
limit: 20,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
enableResumable: false,
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
},
|
|
||||||
...latestMediaViews,
|
|
||||||
...(mediaListCollections?.map(
|
|
||||||
(ml) =>
|
|
||||||
({
|
|
||||||
title: ml.Name,
|
|
||||||
queryKey: ["home", "mediaList", ml.Id!],
|
|
||||||
queryFn: async () => ml,
|
|
||||||
type: "MediaListSection",
|
|
||||||
orientation: "vertical",
|
|
||||||
} as Section)
|
|
||||||
) || []),
|
|
||||||
{
|
|
||||||
title: "Suggested Movies",
|
|
||||||
queryKey: ["home", "suggestedMovies", user?.Id],
|
|
||||||
queryFn: async () =>
|
|
||||||
(
|
|
||||||
await getSuggestionsApi(api).getSuggestions({
|
|
||||||
userId: user?.Id,
|
|
||||||
limit: 10,
|
|
||||||
mediaType: ["Video"],
|
|
||||||
type: ["Movie"],
|
|
||||||
})
|
|
||||||
).data.Items || [],
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "vertical",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Suggested Episodes",
|
|
||||||
queryKey: ["home", "suggestedEpisodes", user?.Id],
|
|
||||||
queryFn: async () => {
|
|
||||||
try {
|
|
||||||
const suggestions = await getSuggestions(api, user.Id);
|
|
||||||
const nextUpPromises = suggestions.map((series) =>
|
|
||||||
getNextUp(api, user.Id, series.Id)
|
|
||||||
);
|
|
||||||
const nextUpResults = await Promise.all(nextUpPromises);
|
|
||||||
|
|
||||||
return nextUpResults.filter((item) => item !== null) || [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching data:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
type: "ScrollingCollectionList",
|
|
||||||
orientation: "horizontal",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
return ss;
|
|
||||||
}, [api, user?.Id, collections, mediaListCollections]);
|
|
||||||
|
|
||||||
if (e1 || e2)
|
|
||||||
return (
|
|
||||||
<View className="flex flex-col items-center justify-center h-full -mt-6">
|
|
||||||
<Text className="text-3xl font-bold mb-2">Oops!</Text>
|
|
||||||
<Text className="text-center opacity-70">
|
|
||||||
Something went wrong.{"\n"}Please log out and in again.
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (l1 || l2)
|
|
||||||
return (
|
|
||||||
<View className="justify-center items-center h-full">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollView
|
|
||||||
nestedScrollEnabled
|
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
|
||||||
refreshControl={
|
|
||||||
<RefreshControl refreshing={loading} onRefresh={refetch} />
|
|
||||||
}
|
|
||||||
contentContainerStyle={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
paddingBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col space-y-4">
|
|
||||||
<LargeMovieCarousel />
|
|
||||||
|
|
||||||
{sections.map((section, index) => {
|
|
||||||
if (section.type === "ScrollingCollectionList") {
|
|
||||||
return (
|
|
||||||
<ScrollingCollectionList
|
|
||||||
key={index}
|
|
||||||
title={section.title}
|
|
||||||
queryKey={section.queryKey}
|
|
||||||
queryFn={section.queryFn}
|
|
||||||
orientation={section.orientation}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (section.type === "MediaListSection") {
|
|
||||||
return (
|
|
||||||
<MediaListSection
|
|
||||||
key={index}
|
|
||||||
queryKey={section.queryKey}
|
|
||||||
queryFn={section.queryFn}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to get suggestions
|
|
||||||
async function getSuggestions(api: Api, userId: string | undefined) {
|
|
||||||
if (!userId) return [];
|
|
||||||
const response = await getSuggestionsApi(api).getSuggestions({
|
|
||||||
userId,
|
|
||||||
limit: 10,
|
|
||||||
mediaType: ["Unknown"],
|
|
||||||
type: ["Series"],
|
|
||||||
});
|
|
||||||
return response.data.Items ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to get the next up TV show for a series
|
|
||||||
async function getNextUp(
|
|
||||||
api: Api,
|
|
||||||
userId: string | undefined,
|
|
||||||
seriesId: string | undefined
|
|
||||||
) {
|
|
||||||
if (!userId || !seriesId) return null;
|
|
||||||
const response = await getTvShowsApi(api).getNextUp({
|
|
||||||
userId,
|
|
||||||
seriesId,
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
return response.data.Items?.[0] ?? null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
154
app/(auth)/(tabs)/(home)/intro/page.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { Feather, Ionicons } from "@expo/vector-icons";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useFocusEffect, useRouter } from "expo-router";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Linking, Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
storage.set("hasShownIntro", true);
|
||||||
|
}, []),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className={`bg-neutral-900 h-full ${Platform.isTV ? "py-5 space-y-4" : "py-16 space-y-8"} px-4`}
|
||||||
|
>
|
||||||
|
<View>
|
||||||
|
<Text className='text-3xl font-bold text-center mb-2'>
|
||||||
|
{t("home.intro.welcome_to_streamyfin")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-center'>
|
||||||
|
{t("home.intro.a_free_and_open_source_client_for_jellyfin")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View>
|
||||||
|
<Text className='text-lg font-bold'>
|
||||||
|
{t("home.intro.features_title")}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-xs'>{t("home.intro.features_description")}</Text>
|
||||||
|
<View className='flex flex-row items-center mt-4'>
|
||||||
|
<Image
|
||||||
|
source={require("@/assets/icons/jellyseerr-logo.svg")}
|
||||||
|
style={{
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<View className='shrink ml-2'>
|
||||||
|
<Text className='font-bold mb-1'>Jellyseerr</Text>
|
||||||
|
<Text className='shrink text-xs'>
|
||||||
|
{t("home.intro.jellyseerr_feature_description")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{!Platform.isTV && (
|
||||||
|
<>
|
||||||
|
<View className='flex flex-row items-center mt-4'>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
}}
|
||||||
|
className='flex items-center justify-center'
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='cloud-download-outline'
|
||||||
|
size={32}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className='shrink ml-2'>
|
||||||
|
<Text className='font-bold mb-1'>
|
||||||
|
{t("home.intro.downloads_feature_title")}
|
||||||
|
</Text>
|
||||||
|
<Text className='shrink text-xs'>
|
||||||
|
{t("home.intro.downloads_feature_description")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className='flex flex-row items-center mt-4'>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
}}
|
||||||
|
className='flex items-center justify-center'
|
||||||
|
>
|
||||||
|
<Feather name='cast' size={28} color={"white"} />
|
||||||
|
</View>
|
||||||
|
<View className='shrink ml-2'>
|
||||||
|
<Text className='font-bold mb-1'>Chromecast</Text>
|
||||||
|
<Text className='shrink text-xs'>
|
||||||
|
{t("home.intro.chromecast_feature_description")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<View className='flex flex-row items-center mt-4'>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: 50,
|
||||||
|
height: 50,
|
||||||
|
}}
|
||||||
|
className='flex items-center justify-center'
|
||||||
|
>
|
||||||
|
<Feather name='settings' size={28} color={"white"} />
|
||||||
|
</View>
|
||||||
|
<View className='shrink ml-2'>
|
||||||
|
<Text className='font-bold mb-1'>
|
||||||
|
{t("home.intro.centralised_settings_plugin_title")}
|
||||||
|
</Text>
|
||||||
|
<View className='flex-row flex-wrap items-baseline'>
|
||||||
|
<Text className='shrink text-xs'>
|
||||||
|
{t("home.intro.centralised_settings_plugin_description")}{" "}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
Linking.openURL(
|
||||||
|
"https://github.com/streamyfin/jellyfin-plugin-streamyfin",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='text-xs text-purple-600 underline'>
|
||||||
|
{t("home.intro.read_more")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
router.back();
|
||||||
|
}}
|
||||||
|
className='mt-4'
|
||||||
|
>
|
||||||
|
{t("home.intro.done_button")}
|
||||||
|
</Button>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
router.back();
|
||||||
|
router.push("/settings");
|
||||||
|
}}
|
||||||
|
className='mt-4'
|
||||||
|
>
|
||||||
|
<Text className='text-purple-600 text-center'>
|
||||||
|
{t("home.intro.go_to_settings_button")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
546
app/(auth)/(tabs)/(home)/sessions/index.tsx
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
HardwareAccelerationType,
|
||||||
|
type SessionInfoDto,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client";
|
||||||
|
import {
|
||||||
|
GeneralCommandType,
|
||||||
|
PlaystateCommand,
|
||||||
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TouchableOpacity, View } from "react-native";
|
||||||
|
import { Badge } from "@/components/Badge";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import Poster from "@/components/posters/Poster";
|
||||||
|
import { useInterval } from "@/hooks/useInterval";
|
||||||
|
import { useSessions, type useSessionsProps } from "@/hooks/useSessions";
|
||||||
|
import { apiAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { formatBitrate } from "@/utils/bitrate";
|
||||||
|
import { getPrimaryImageUrl } from "@/utils/jellyfin/image/getPrimaryImageUrl";
|
||||||
|
import { formatTimeString } from "@/utils/time";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const { sessions, isLoading } = useSessions({} as useSessionsProps);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<View className='justify-center items-center h-full'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sessions || sessions.length === 0)
|
||||||
|
return (
|
||||||
|
<View className='h-full w-full flex justify-center items-center'>
|
||||||
|
<Text className='text-lg text-neutral-500'>
|
||||||
|
{t("home.sessions.no_active_sessions")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlashList
|
||||||
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
|
contentContainerStyle={{
|
||||||
|
paddingTop: 17,
|
||||||
|
paddingHorizontal: 17,
|
||||||
|
paddingBottom: 150,
|
||||||
|
}}
|
||||||
|
data={sessions}
|
||||||
|
renderItem={({ item }) => <SessionCard session={item} />}
|
||||||
|
keyExtractor={(item) => item.Id || ""}
|
||||||
|
estimatedItemSize={200}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionCardProps {
|
||||||
|
session: SessionInfoDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SessionCard = ({ session }: SessionCardProps) => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const [remainingTicks, setRemainingTicks] = useState<number>(0);
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (session.PlayState?.IsPaused) return;
|
||||||
|
setRemainingTicks(remainingTicks - 10000000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProgressPercentage = () => {
|
||||||
|
if (!session.NowPlayingItem || !session.NowPlayingItem.RunTimeTicks) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.round(
|
||||||
|
(100 / session.NowPlayingItem?.RunTimeTicks) *
|
||||||
|
(session.NowPlayingItem?.RunTimeTicks - remainingTicks),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentTime = session.PlayState?.PositionTicks;
|
||||||
|
const duration = session.NowPlayingItem?.RunTimeTicks;
|
||||||
|
if (
|
||||||
|
duration !== null &&
|
||||||
|
duration !== undefined &&
|
||||||
|
currentTime !== null &&
|
||||||
|
currentTime !== undefined
|
||||||
|
) {
|
||||||
|
const remainingTimeTicks = duration - currentTime;
|
||||||
|
setRemainingTicks(remainingTimeTicks);
|
||||||
|
}
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
const { data: ipInfo } = useQuery({
|
||||||
|
queryKey: ["ipinfo", session.RemoteEndPoint],
|
||||||
|
cacheTime: Number.POSITIVE_INFINITY,
|
||||||
|
queryFn: async () => {
|
||||||
|
const resp = await api.axiosInstance.get(
|
||||||
|
`https://freeipapi.com/api/json/${session.RemoteEndPoint}`,
|
||||||
|
);
|
||||||
|
return resp.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle session controls
|
||||||
|
const [isControlLoading, setIsControlLoading] = useState<
|
||||||
|
Record<string, boolean>
|
||||||
|
>({});
|
||||||
|
|
||||||
|
const handleSystemCommand = async (command: GeneralCommandType) => {
|
||||||
|
if (!api || !session.Id) return false;
|
||||||
|
|
||||||
|
setIsControlLoading({ ...isControlLoading, [command]: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
getSessionApi(api).sendSystemCommand({
|
||||||
|
sessionId: session.Id,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error sending ${command} command:`, error);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsControlLoading({ ...isControlLoading, [command]: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlaystateCommand = async (command: PlaystateCommand) => {
|
||||||
|
if (!api || !session.Id) return false;
|
||||||
|
|
||||||
|
setIsControlLoading({ ...isControlLoading, [command]: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
getSessionApi(api).sendPlaystateCommand({
|
||||||
|
sessionId: session.Id,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error sending playstate ${command} command:`, error);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsControlLoading({ ...isControlLoading, [command]: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlayPause = async () => {
|
||||||
|
console.log("handlePlayPause");
|
||||||
|
await handlePlaystateCommand(PlaystateCommand.PlayPause);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStop = async () => {
|
||||||
|
await handlePlaystateCommand(PlaystateCommand.Stop);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevious = async () => {
|
||||||
|
await handlePlaystateCommand(PlaystateCommand.PreviousTrack);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = async () => {
|
||||||
|
await handlePlaystateCommand(PlaystateCommand.NextTrack);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleMute = async () => {
|
||||||
|
await handleSystemCommand(GeneralCommandType.ToggleMute);
|
||||||
|
};
|
||||||
|
const handleVolumeUp = async () => {
|
||||||
|
await handleSystemCommand(GeneralCommandType.VolumeUp);
|
||||||
|
};
|
||||||
|
const handleVolumeDown = async () => {
|
||||||
|
await handleSystemCommand(GeneralCommandType.VolumeDown);
|
||||||
|
};
|
||||||
|
|
||||||
|
useInterval(tick, 1000);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex flex-col shadow-md bg-neutral-900 rounded-2xl mb-4'>
|
||||||
|
<View className='flex flex-row p-4'>
|
||||||
|
<View className='w-20 pr-4'>
|
||||||
|
<Poster
|
||||||
|
id={session.NowPlayingItem?.Id}
|
||||||
|
url={getPrimaryImageUrl({ api, item: session.NowPlayingItem })}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className='w-full flex-1'>
|
||||||
|
<View className='flex flex-row justify-between'>
|
||||||
|
<View className='flex-1 pr-4'>
|
||||||
|
{session.NowPlayingItem?.Type === "Episode" ? (
|
||||||
|
<>
|
||||||
|
<Text className='font-bold'>
|
||||||
|
{session.NowPlayingItem?.Name}
|
||||||
|
</Text>
|
||||||
|
<Text numberOfLines={1} className='text-xs opacity-50'>
|
||||||
|
{`S${session.NowPlayingItem.ParentIndexNumber?.toString()}:E${session.NowPlayingItem.IndexNumber?.toString()}`}
|
||||||
|
{" - "}
|
||||||
|
{session.NowPlayingItem.SeriesName}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text className='font-bold'>
|
||||||
|
{session.NowPlayingItem?.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-xs opacity-50'>
|
||||||
|
{session.NowPlayingItem?.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-xs opacity-50'>
|
||||||
|
{session.NowPlayingItem?.SeriesName}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text className='text-xs opacity-50 align-right text-right'>
|
||||||
|
{session.UserName}
|
||||||
|
{"\n"}
|
||||||
|
{session.Client}
|
||||||
|
{"\n"}
|
||||||
|
{session.DeviceName}
|
||||||
|
{"\n"}
|
||||||
|
{ipInfo?.cityName} {ipInfo?.countryCode}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className='flex-1' />
|
||||||
|
<View className='flex flex-col align-bottom'>
|
||||||
|
<View className='flex flex-row justify-between align-bottom mb-1'>
|
||||||
|
<Text className='-ml-0.5 text-xs opacity-50 align-left text-left'>
|
||||||
|
{!session.PlayState?.IsPaused ? (
|
||||||
|
<Ionicons name='play' size={14} color='white' />
|
||||||
|
) : (
|
||||||
|
<Ionicons name='pause' size={14} color='white' />
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text className='text-xs opacity-50 align-right text-right'>
|
||||||
|
{formatTimeString(remainingTicks, "tick")} left
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className='align-bottom bg-gray-800 h-1'>
|
||||||
|
<View
|
||||||
|
className={"bg-purple-600 h-full"}
|
||||||
|
style={{
|
||||||
|
width: `${getProgressPercentage()}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Session controls */}
|
||||||
|
<View className='flex flex-row mt-2 space-x-4 justify-center'>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePrevious}
|
||||||
|
disabled={isControlLoading[PlaystateCommand.PreviousTrack]}
|
||||||
|
style={{
|
||||||
|
opacity: isControlLoading[PlaystateCommand.PreviousTrack]
|
||||||
|
? 0.5
|
||||||
|
: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name='skip-previous'
|
||||||
|
size={24}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePlayPause}
|
||||||
|
disabled={isControlLoading[PlaystateCommand.PlayPause]}
|
||||||
|
style={{
|
||||||
|
opacity: isControlLoading[PlaystateCommand.PlayPause]
|
||||||
|
? 0.5
|
||||||
|
: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{session.PlayState?.IsPaused ? (
|
||||||
|
<Ionicons name='play' size={24} color='white' />
|
||||||
|
) : (
|
||||||
|
<Ionicons name='pause' size={24} color='white' />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleStop}
|
||||||
|
disabled={isControlLoading[PlaystateCommand.Stop]}
|
||||||
|
style={{
|
||||||
|
opacity: isControlLoading[PlaystateCommand.Stop] ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='stop' size={24} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleNext}
|
||||||
|
disabled={isControlLoading[PlaystateCommand.NextTrack]}
|
||||||
|
style={{
|
||||||
|
opacity: isControlLoading[PlaystateCommand.NextTrack]
|
||||||
|
? 0.5
|
||||||
|
: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name='skip-next'
|
||||||
|
size={24}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleVolumeDown}
|
||||||
|
disabled={isControlLoading[GeneralCommandType.VolumeDown]}
|
||||||
|
style={{
|
||||||
|
opacity: isControlLoading[GeneralCommandType.VolumeDown]
|
||||||
|
? 0.5
|
||||||
|
: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='volume-low' size={24} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleToggleMute}
|
||||||
|
disabled={isControlLoading[GeneralCommandType.ToggleMute]}
|
||||||
|
style={{
|
||||||
|
opacity: isControlLoading[GeneralCommandType.ToggleMute]
|
||||||
|
? 0.5
|
||||||
|
: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='volume-mute'
|
||||||
|
size={24}
|
||||||
|
color={session.PlayState?.IsMuted ? "red" : "white"}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleVolumeUp}
|
||||||
|
disabled={isControlLoading[GeneralCommandType.VolumeUp]}
|
||||||
|
style={{
|
||||||
|
opacity: isControlLoading[GeneralCommandType.VolumeUp]
|
||||||
|
? 0.5
|
||||||
|
: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name='volume-high' size={24} color='white' />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TranscodingView session={session} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TranscodingBadgesProps {
|
||||||
|
properties: StreamProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TranscodingBadges = ({ properties }: TranscodingBadgesProps) => {
|
||||||
|
const iconMap = {
|
||||||
|
bitrate: <Ionicons name='speedometer-outline' size={12} color='white' />,
|
||||||
|
codec: <Ionicons name='layers-outline' size={12} color='white' />,
|
||||||
|
videoRange: (
|
||||||
|
<Ionicons name='color-palette-outline' size={12} color='white' />
|
||||||
|
),
|
||||||
|
resolution: <Ionicons name='film-outline' size={12} color='white' />,
|
||||||
|
language: <Ionicons name='language-outline' size={12} color='white' />,
|
||||||
|
audioChannels: <Ionicons name='mic-outline' size={12} color='white' />,
|
||||||
|
hwType: <Ionicons name='hardware-chip-outline' size={12} color='white' />,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const icon = (val: string) => {
|
||||||
|
return (
|
||||||
|
iconMap[val as keyof typeof iconMap] ?? (
|
||||||
|
<Ionicons name='layers-outline' size={12} color='white' />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatVal = (key: string, val: any) => {
|
||||||
|
switch (key) {
|
||||||
|
case "bitrate":
|
||||||
|
return formatBitrate(val);
|
||||||
|
case "hwType":
|
||||||
|
return val === HardwareAccelerationType.None ? "sw" : "hw";
|
||||||
|
default:
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return Object.entries(properties)
|
||||||
|
.filter(([_, value]) => value !== undefined && value !== null)
|
||||||
|
.map(([key]) => (
|
||||||
|
<Badge
|
||||||
|
key={key}
|
||||||
|
variant='gray'
|
||||||
|
className='m-0 p-0 pt-0.5 mr-1'
|
||||||
|
text={formatVal(key, properties[key as keyof StreamProps])}
|
||||||
|
iconLeft={icon(key)}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StreamProps {
|
||||||
|
hwType?: HardwareAccelerationType | null | undefined;
|
||||||
|
resolution?: string | null | undefined;
|
||||||
|
language?: string | null | undefined;
|
||||||
|
codec?: string | null | undefined;
|
||||||
|
bitrate?: number | null | undefined;
|
||||||
|
videoRange?: string | null | undefined;
|
||||||
|
audioChannels?: string | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TranscodingStreamViewProps {
|
||||||
|
title: string | undefined;
|
||||||
|
value?: string;
|
||||||
|
isTranscoding: boolean;
|
||||||
|
transcodeValue?: string | undefined | null;
|
||||||
|
properties: StreamProps;
|
||||||
|
transcodeProperties?: StreamProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TranscodingStreamView = ({
|
||||||
|
title,
|
||||||
|
isTranscoding,
|
||||||
|
properties,
|
||||||
|
transcodeProperties,
|
||||||
|
}: TranscodingStreamViewProps) => {
|
||||||
|
return (
|
||||||
|
<View className='flex flex-col pt-2 first:pt-0'>
|
||||||
|
<View className='flex flex-row'>
|
||||||
|
<Text className='text-xs opacity-50 w-20 font-bold text-right pr-4'>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text className='flex-1'>
|
||||||
|
<TranscodingBadges properties={properties} />
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isTranscoding && transcodeProperties ? (
|
||||||
|
<View className='flex flex-row'>
|
||||||
|
<Text className='-mt-0 text-xs opacity-50 w-20 font-bold text-right pr-4'>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name='arrow-right-bottom'
|
||||||
|
size={14}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
</Text>
|
||||||
|
<Text className='flex-1 text-sm mt-1'>
|
||||||
|
<TranscodingBadges properties={transcodeProperties} />
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TranscodingView = ({ session }: SessionCardProps) => {
|
||||||
|
const videoStream = useMemo(() => {
|
||||||
|
return session.NowPlayingItem?.MediaStreams?.filter(
|
||||||
|
(s) => s.Type === "Video",
|
||||||
|
)[0];
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
const audioStream = useMemo(() => {
|
||||||
|
const index = session.PlayState?.AudioStreamIndex;
|
||||||
|
return index !== null && index !== undefined
|
||||||
|
? session.NowPlayingItem?.MediaStreams?.[index]
|
||||||
|
: undefined;
|
||||||
|
}, [session.PlayState?.AudioStreamIndex]);
|
||||||
|
|
||||||
|
const subtitleStream = useMemo(() => {
|
||||||
|
const index = session.PlayState?.SubtitleStreamIndex;
|
||||||
|
return index !== null && index !== undefined
|
||||||
|
? session.NowPlayingItem?.MediaStreams?.[index]
|
||||||
|
: undefined;
|
||||||
|
}, [session.PlayState?.SubtitleStreamIndex]);
|
||||||
|
|
||||||
|
const isTranscoding = useMemo(() => {
|
||||||
|
return (
|
||||||
|
session.PlayState?.PlayMethod === "Transcode" && session.TranscodingInfo
|
||||||
|
);
|
||||||
|
}, [session.PlayState?.PlayMethod, session.TranscodingInfo]);
|
||||||
|
|
||||||
|
const videoStreamTitle = () => {
|
||||||
|
return videoStream?.DisplayTitle?.split(" ")[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex flex-col bg-neutral-800 rounded-b-2xl p-4 pt-2'>
|
||||||
|
<TranscodingStreamView
|
||||||
|
title='Video'
|
||||||
|
properties={{
|
||||||
|
resolution: videoStreamTitle(),
|
||||||
|
bitrate: videoStream?.BitRate,
|
||||||
|
codec: videoStream?.Codec,
|
||||||
|
}}
|
||||||
|
transcodeProperties={{
|
||||||
|
hwType: session.TranscodingInfo?.HardwareAccelerationType,
|
||||||
|
bitrate: session.TranscodingInfo?.Bitrate,
|
||||||
|
codec: session.TranscodingInfo?.VideoCodec,
|
||||||
|
}}
|
||||||
|
isTranscoding={
|
||||||
|
!!(isTranscoding && !session.TranscodingInfo?.IsVideoDirect)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TranscodingStreamView
|
||||||
|
title='Audio'
|
||||||
|
properties={{
|
||||||
|
language: audioStream?.Language,
|
||||||
|
bitrate: audioStream?.BitRate,
|
||||||
|
codec: audioStream?.Codec,
|
||||||
|
audioChannels: audioStream?.ChannelLayout,
|
||||||
|
}}
|
||||||
|
transcodeProperties={{
|
||||||
|
codec: session.TranscodingInfo?.AudioCodec,
|
||||||
|
audioChannels: session.TranscodingInfo?.AudioChannels?.toString(),
|
||||||
|
}}
|
||||||
|
isTranscoding={
|
||||||
|
!!(isTranscoding && !session.TranscodingInfo?.IsVideoDirect)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{subtitleStream && (
|
||||||
|
<TranscodingStreamView
|
||||||
|
title='Subtitle'
|
||||||
|
isTranscoding={false}
|
||||||
|
properties={{
|
||||||
|
language: subtitleStream?.Language,
|
||||||
|
codec: subtitleStream?.Codec,
|
||||||
|
}}
|
||||||
|
transcodeValue={null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,106 +1,118 @@
|
|||||||
import { Button } from "@/components/Button";
|
import { useNavigation, useRouter } from "expo-router";
|
||||||
import { Text } from "@/components/common/Text";
|
import { t } from "i18next";
|
||||||
import { ListItem } from "@/components/ListItem";
|
|
||||||
import { SettingToggles } from "@/components/settings/SettingToggles";
|
|
||||||
import { apiAtom, useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { clearLogs, useLog } from "@/utils/log";
|
|
||||||
import { getQuickConnectApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import * as FileSystem from "expo-file-system";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { Alert, ScrollView, View } from "react-native";
|
import { useEffect } from "react";
|
||||||
import * as Progress from "react-native-progress";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { toast } from "sonner-native";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import { AppLanguageSelector } from "@/components/settings/AppLanguageSelector";
|
||||||
|
import { AudioToggles } from "@/components/settings/AudioToggles";
|
||||||
|
import { ChromecastSettings } from "@/components/settings/ChromecastSettings";
|
||||||
|
import DownloadSettings from "@/components/settings/DownloadSettings";
|
||||||
|
import { MediaProvider } from "@/components/settings/MediaContext";
|
||||||
|
import { MediaToggles } from "@/components/settings/MediaToggles";
|
||||||
|
import { OtherSettings } from "@/components/settings/OtherSettings";
|
||||||
|
import { PluginSettings } from "@/components/settings/PluginSettings";
|
||||||
|
import { QuickConnect } from "@/components/settings/QuickConnect";
|
||||||
|
import { StorageSettings } from "@/components/settings/StorageSettings";
|
||||||
|
import { SubtitleToggles } from "@/components/settings/SubtitleToggles";
|
||||||
|
import { UserInfo } from "@/components/settings/UserInfo";
|
||||||
|
import { useHaptic } from "@/hooks/useHaptic";
|
||||||
|
import { useJellyfin, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { clearLogs } from "@/utils/log";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
export default function settings() {
|
export default function settings() {
|
||||||
const { logout } = useJellyfin();
|
const router = useRouter();
|
||||||
const { logs } = useLog();
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const [_user] = useAtom(userAtom);
|
||||||
|
const { logout } = useJellyfin();
|
||||||
|
const successHapticFeedback = useHaptic("success");
|
||||||
|
|
||||||
const openQuickConnectAuthCodeInput = () => {
|
const onClearLogsClicked = async () => {
|
||||||
Alert.prompt(
|
clearLogs();
|
||||||
"Quick connect",
|
successHapticFeedback();
|
||||||
"Enter the quick connect code",
|
|
||||||
async (text) => {
|
|
||||||
if (text) {
|
|
||||||
try {
|
|
||||||
const res = await getQuickConnectApi(api!).authorizeQuickConnect({
|
|
||||||
code: text,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
if (res.status === 200) {
|
|
||||||
Alert.alert("Success", "Quick connect authorized");
|
|
||||||
} else {
|
|
||||||
Alert.alert("Error", "Invalid code");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
Alert.alert("Error", "Invalid code");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const navigation = useNavigation();
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => {
|
||||||
|
logout();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='text-red-600'>
|
||||||
|
{t("home.settings.log_out_button")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
paddingBottom: 100,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="p-4 flex flex-col gap-y-4">
|
<View className='p-4 flex flex-col gap-y-4'>
|
||||||
<View>
|
<UserInfo />
|
||||||
<Text className="font-bold text-lg mb-2">User Info</Text>
|
|
||||||
|
|
||||||
<View className="flex flex-col rounded-xl overflow-hidden border-neutral-800 divide-y-2 divide-solid divide-neutral-800 ">
|
<QuickConnect className='mb-4' />
|
||||||
<ListItem title="User" subTitle={user?.Name} />
|
|
||||||
<ListItem title="Server" subTitle={api?.basePath} />
|
<MediaProvider>
|
||||||
<ListItem title="Token" subTitle={api?.accessToken} />
|
<MediaToggles className='mb-4' />
|
||||||
</View>
|
<AudioToggles className='mb-4' />
|
||||||
<Button className="my-2.5" color="black" onPress={logout}>
|
<SubtitleToggles className='mb-4' />
|
||||||
Log out
|
</MediaProvider>
|
||||||
</Button>
|
|
||||||
|
<OtherSettings />
|
||||||
|
|
||||||
|
{!Platform.isTV && <DownloadSettings />}
|
||||||
|
|
||||||
|
<PluginSettings />
|
||||||
|
|
||||||
|
<AppLanguageSelector />
|
||||||
|
|
||||||
|
{!Platform.isTV && <ChromecastSettings />}
|
||||||
|
|
||||||
|
<ListGroup title={"Intro"}>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => {
|
||||||
|
router.push("/intro/page");
|
||||||
|
}}
|
||||||
|
title={t("home.settings.intro.show_intro")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
textColor='red'
|
||||||
|
onPress={() => {
|
||||||
|
storage.set("hasShownIntro", false);
|
||||||
|
}}
|
||||||
|
title={t("home.settings.intro.reset_intro")}
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
|
<View className='mb-4'>
|
||||||
|
<ListGroup title={t("home.settings.logs.logs_title")}>
|
||||||
|
<ListItem
|
||||||
|
onPress={() => router.push("/settings/logs/page")}
|
||||||
|
showArrow
|
||||||
|
title={t("home.settings.logs.logs_title")}
|
||||||
|
/>
|
||||||
|
<ListItem
|
||||||
|
textColor='red'
|
||||||
|
onPress={onClearLogsClicked}
|
||||||
|
title={t("home.settings.logs.delete_all_logs")}
|
||||||
|
/>
|
||||||
|
</ListGroup>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View>
|
{!Platform.isTV && <StorageSettings />}
|
||||||
<Text className="font-bold text-lg mb-2">Quick connect</Text>
|
|
||||||
<Button onPress={openQuickConnectAuthCodeInput} color="black">
|
|
||||||
Authorize
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<SettingToggles />
|
|
||||||
|
|
||||||
<View>
|
|
||||||
<Text className="font-bold text-lg mb-2">Logs</Text>
|
|
||||||
<View className="flex flex-col space-y-2">
|
|
||||||
{logs?.map((log, index) => (
|
|
||||||
<View key={index} className="bg-neutral-900 rounded-xl p-3">
|
|
||||||
<Text
|
|
||||||
className={`
|
|
||||||
mb-1
|
|
||||||
${log.level === "INFO" && "text-blue-500"}
|
|
||||||
${log.level === "ERROR" && "text-red-500"}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{log.level}
|
|
||||||
</Text>
|
|
||||||
<Text uiTextView selectable className="text-xs">
|
|
||||||
{log.message}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
{logs?.length === 0 && (
|
|
||||||
<Text className="opacity-50">No logs available</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|||||||
67
app/(auth)/(tabs)/(home)/settings/hide-libraries/page.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { getUserViewsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Switch, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
|
const user = useAtomValue(userAtom);
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["user-views", user?.Id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await getUserViewsApi(api!).getUserViews({
|
||||||
|
userId: user?.Id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.Items || null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<View className='mt-4'>
|
||||||
|
<Loader />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.hiddenLibraries?.locked === true}
|
||||||
|
className='px-4'
|
||||||
|
>
|
||||||
|
<ListGroup>
|
||||||
|
{data?.map((view) => (
|
||||||
|
<ListItem key={view.Id} title={view.Name} onPress={() => {}}>
|
||||||
|
<Switch
|
||||||
|
value={settings.hiddenLibraries?.includes(view.Id!) || false}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateSettings({
|
||||||
|
hiddenLibraries: value
|
||||||
|
? [...(settings.hiddenLibraries || []), view.Id!]
|
||||||
|
: settings.hiddenLibraries?.filter((id) => id !== view.Id),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</ListGroup>
|
||||||
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
|
{t("home.settings.other.select_liraries_you_want_to_hide")}
|
||||||
|
</Text>
|
||||||
|
</DisabledSetting>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
app/(auth)/(tabs)/(home)/settings/jellyseerr/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
import { JellyseerrSettings } from "@/components/settings/Jellyseerr";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const [_settings, _updateSettings, pluginSettings] = useSettings();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.jellyseerrServerUrl?.locked === true}
|
||||||
|
className='p-4'
|
||||||
|
>
|
||||||
|
<JellyseerrSettings />
|
||||||
|
</DisabledSetting>
|
||||||
|
);
|
||||||
|
}
|
||||||
162
app/(auth)/(tabs)/(home)/settings/logs/page.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import * as Sharing from "expo-sharing";
|
||||||
|
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
|
import Collapsible from "react-native-collapsible";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { LogLevel, useLog, writeErrorLog } from "@/utils/log";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const { logs } = useLog();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const orderFilterId = useId();
|
||||||
|
const levelsFilterId = useId();
|
||||||
|
|
||||||
|
const defaultLevels: LogLevel[] = ["INFO", "ERROR", "DEBUG", "WARN"];
|
||||||
|
const codeBlockStyle = {
|
||||||
|
backgroundColor: "#000",
|
||||||
|
padding: 10,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
maxHeight: 300,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [state, setState] = useState<Record<string, boolean>>({});
|
||||||
|
const [order, setOrder] = useState<"asc" | "desc">("desc");
|
||||||
|
const [levels, setLevels] = useState<LogLevel[]>(defaultLevels);
|
||||||
|
|
||||||
|
const _orderId = useId();
|
||||||
|
const _levelsId = useId();
|
||||||
|
|
||||||
|
const filteredLogs = useMemo(
|
||||||
|
() =>
|
||||||
|
logs
|
||||||
|
?.filter((log) => levels.includes(log.level))
|
||||||
|
?.[
|
||||||
|
// Already in asc order as they are recorded. just reverse for desc
|
||||||
|
order === "desc" ? "reverse" : "concat"
|
||||||
|
]?.(),
|
||||||
|
[logs, order, levels],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sharing it as txt while its formatted allows us to share it with many more applications
|
||||||
|
const share = useCallback(async () => {
|
||||||
|
const uri = `${FileSystem.documentDirectory}logs.txt`;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
FileSystem.writeAsStringAsync(uri, JSON.stringify(filteredLogs))
|
||||||
|
.then(() => {
|
||||||
|
setLoading(false);
|
||||||
|
Sharing.shareAsync(uri, { mimeType: "txt", UTI: "txt" });
|
||||||
|
})
|
||||||
|
.catch((e) =>
|
||||||
|
writeErrorLog("Something went wrong attempting to export", e),
|
||||||
|
)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [filteredLogs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () =>
|
||||||
|
loading ? (
|
||||||
|
<Loader />
|
||||||
|
) : (
|
||||||
|
<TouchableOpacity onPress={share}>
|
||||||
|
<Text>{t("home.settings.logs.export_logs")}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [share, loading]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View className='flex flex-row justify-end py-2 px-4 space-x-2'>
|
||||||
|
<FilterButton
|
||||||
|
id={orderFilterId}
|
||||||
|
queryKey='log'
|
||||||
|
queryFn={async () => ["asc", "desc"]}
|
||||||
|
set={(values) => setOrder(values[0])}
|
||||||
|
values={[order]}
|
||||||
|
title={t("library.filters.sort_order")}
|
||||||
|
renderItemLabel={(order) => t(`library.filters.${order}`)}
|
||||||
|
showSearch={false}
|
||||||
|
/>
|
||||||
|
<FilterButton
|
||||||
|
id={levelsFilterId}
|
||||||
|
queryKey='log'
|
||||||
|
queryFn={async () => defaultLevels}
|
||||||
|
set={setLevels}
|
||||||
|
values={levels}
|
||||||
|
title={t("home.settings.logs.level")}
|
||||||
|
renderItemLabel={(level) => level}
|
||||||
|
showSearch={false}
|
||||||
|
multiple={true}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<ScrollView className='pb-4 px-4'>
|
||||||
|
<View className='flex flex-col space-y-2'>
|
||||||
|
{filteredLogs?.map((log, index) => (
|
||||||
|
<View className='bg-neutral-900 rounded-xl p-3' key={index}>
|
||||||
|
<TouchableOpacity
|
||||||
|
disabled={!log.data}
|
||||||
|
onPress={() =>
|
||||||
|
setState((v) => ({
|
||||||
|
...v,
|
||||||
|
[log.timestamp]: !v[log.timestamp],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View className='flex flex-row justify-between'>
|
||||||
|
<Text
|
||||||
|
className={`mb-1
|
||||||
|
${log.level === "INFO" && "text-blue-500"}
|
||||||
|
${log.level === "ERROR" && "text-red-500"}
|
||||||
|
${log.level === "DEBUG" && "text-purple-500"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{log.level}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text className='text-xs'>
|
||||||
|
{new Date(log.timestamp).toLocaleString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text selectable className='text-xs'>
|
||||||
|
{log.message}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{log.data && (
|
||||||
|
<>
|
||||||
|
{!state[log.timestamp] && (
|
||||||
|
<Text className='text-xs mt-0.5'>
|
||||||
|
{t("home.settings.logs.click_for_more_info")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Collapsible collapsed={!state[log.timestamp]}>
|
||||||
|
<View className='mt-2 flex flex-col space-y-2'>
|
||||||
|
<ScrollView className='rounded-xl' style={codeBlockStyle}>
|
||||||
|
<Text>{JSON.stringify(log.data, null, 2)}</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</Collapsible>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{filteredLogs?.length === 0 && (
|
||||||
|
<Text className='opacity-50'>
|
||||||
|
{t("home.settings.logs.no_logs_available")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
app/(auth)/(tabs)/(home)/settings/marlin-search/page.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useNavigation } from "expo-router";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Linking,
|
||||||
|
Switch,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
View,
|
||||||
|
} from "react-native";
|
||||||
|
import { toast } from "sonner-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ListGroup } from "@/components/list/ListGroup";
|
||||||
|
import { ListItem } from "@/components/list/ListItem";
|
||||||
|
import DisabledSetting from "@/components/settings/DisabledSetting";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const navigation = useNavigation();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [value, setValue] = useState<string>(settings?.marlinServerUrl || "");
|
||||||
|
|
||||||
|
const onSave = (val: string) => {
|
||||||
|
updateSettings({
|
||||||
|
marlinServerUrl: !val.endsWith("/") ? val : val.slice(0, -1),
|
||||||
|
});
|
||||||
|
toast.success(t("home.settings.plugins.marlin_search.toasts.saved"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenLink = () => {
|
||||||
|
Linking.openURL("https://github.com/fredrikburmester/marlin-search");
|
||||||
|
};
|
||||||
|
|
||||||
|
const disabled = useMemo(() => {
|
||||||
|
return (
|
||||||
|
pluginSettings?.searchEngine?.locked === true &&
|
||||||
|
pluginSettings?.marlinServerUrl?.locked === true
|
||||||
|
);
|
||||||
|
}, [pluginSettings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pluginSettings?.marlinServerUrl?.locked) {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity onPress={() => onSave(value)}>
|
||||||
|
<Text className='text-blue-500'>
|
||||||
|
{t("home.settings.plugins.marlin_search.save_button")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [navigation, value]);
|
||||||
|
|
||||||
|
if (!settings) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DisabledSetting disabled={disabled} className='px-4'>
|
||||||
|
<ListGroup>
|
||||||
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.searchEngine?.locked === true}
|
||||||
|
showText={!pluginSettings?.marlinServerUrl?.locked}
|
||||||
|
>
|
||||||
|
<ListItem
|
||||||
|
title={t(
|
||||||
|
"home.settings.plugins.marlin_search.enable_marlin_search",
|
||||||
|
)}
|
||||||
|
onPress={() => {
|
||||||
|
updateSettings({ searchEngine: "Jellyfin" });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
value={settings.searchEngine === "Marlin"}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
updateSettings({ searchEngine: value ? "Marlin" : "Jellyfin" });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["search"] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
</DisabledSetting>
|
||||||
|
</ListGroup>
|
||||||
|
|
||||||
|
<DisabledSetting
|
||||||
|
disabled={pluginSettings?.marlinServerUrl?.locked === true}
|
||||||
|
showText={!pluginSettings?.searchEngine?.locked}
|
||||||
|
className='mt-2 flex flex-col rounded-xl overflow-hidden pl-4 bg-neutral-900 px-4'
|
||||||
|
>
|
||||||
|
<View className={"flex flex-row items-center bg-neutral-900 h-11 pr-4"}>
|
||||||
|
<Text className='mr-4'>
|
||||||
|
{t("home.settings.plugins.marlin_search.url")}
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
editable={settings.searchEngine === "Marlin"}
|
||||||
|
className='text-white'
|
||||||
|
placeholder={t(
|
||||||
|
"home.settings.plugins.marlin_search.server_url_placeholder",
|
||||||
|
)}
|
||||||
|
value={value}
|
||||||
|
keyboardType='url'
|
||||||
|
returnKeyType='done'
|
||||||
|
autoCapitalize='none'
|
||||||
|
textContentType='URL'
|
||||||
|
onChangeText={(text) => setValue(text)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</DisabledSetting>
|
||||||
|
<Text className='px-4 text-xs text-neutral-500 mt-1'>
|
||||||
|
{t("home.settings.plugins.marlin_search.marlin_search_hint")}{" "}
|
||||||
|
<Text className='text-blue-500' onPress={handleOpenLink}>
|
||||||
|
{t("home.settings.plugins.marlin_search.read_more_about_marlin")}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</DisabledSetting>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import { ItemImage } from "@/components/common/ItemImage";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import { SongsList } from "@/components/music/SongsList";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|
||||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const searchParams = useLocalSearchParams();
|
|
||||||
const { collectionId, artistId, albumId } = searchParams as {
|
|
||||||
collectionId: string;
|
|
||||||
artistId: string;
|
|
||||||
albumId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
const { data: album } = useQuery({
|
|
||||||
queryKey: ["album", albumId, artistId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return null;
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
ids: [albumId],
|
|
||||||
});
|
|
||||||
const data = response.data.Items?.[0];
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && !!albumId,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: songs,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
} = useQuery<{
|
|
||||||
Items: BaseItemDto[];
|
|
||||||
TotalRecordCount: number;
|
|
||||||
}>({
|
|
||||||
queryKey: ["songs", artistId, albumId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api)
|
|
||||||
return {
|
|
||||||
Items: [],
|
|
||||||
TotalRecordCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
parentId: albumId,
|
|
||||||
fields: [
|
|
||||||
"ItemCounts",
|
|
||||||
"PrimaryImageAspectRatio",
|
|
||||||
"CanDelete",
|
|
||||||
"MediaSourceCount",
|
|
||||||
],
|
|
||||||
sortBy: ["ParentIndexNumber", "IndexNumber", "SortName"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = response.data.Items;
|
|
||||||
|
|
||||||
return {
|
|
||||||
Items: data || [],
|
|
||||||
TotalRecordCount: response.data.TotalRecordCount || 0,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
if (!album) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ParallaxScrollView
|
|
||||||
headerHeight={400}
|
|
||||||
headerImage={
|
|
||||||
<ItemImage
|
|
||||||
variant={"Primary"}
|
|
||||||
item={album}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<View className="px-4 mb-8">
|
|
||||||
<Text className="font-bold text-2xl mb-2">{album?.Name}</Text>
|
|
||||||
<Text className="text-neutral-500">
|
|
||||||
{songs?.TotalRecordCount} songs
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="px-4">
|
|
||||||
<SongsList
|
|
||||||
albumId={albumId}
|
|
||||||
songs={songs?.Items}
|
|
||||||
collectionId={collectionId}
|
|
||||||
artistId={artistId}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</ParallaxScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { FlatList, ScrollView, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
import { ItemImage } from "@/components/common/ItemImage";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const searchParams = useLocalSearchParams();
|
|
||||||
const { artistId } = searchParams as {
|
|
||||||
artistId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const navigation = useNavigation();
|
|
||||||
|
|
||||||
const [startIndex, setStartIndex] = useState<number>(0);
|
|
||||||
|
|
||||||
const { data: artist } = useQuery({
|
|
||||||
queryKey: ["album", artistId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return null;
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
ids: [artistId],
|
|
||||||
});
|
|
||||||
const data = response.data.Items?.[0];
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && !!artistId,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: albums,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
} = useQuery<{
|
|
||||||
Items: BaseItemDto[];
|
|
||||||
TotalRecordCount: number;
|
|
||||||
}>({
|
|
||||||
queryKey: ["albums", artistId, startIndex],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api)
|
|
||||||
return {
|
|
||||||
Items: [],
|
|
||||||
TotalRecordCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
parentId: artistId,
|
|
||||||
sortOrder: ["Descending", "Descending", "Ascending"],
|
|
||||||
includeItemTypes: ["MusicAlbum"],
|
|
||||||
recursive: true,
|
|
||||||
fields: [
|
|
||||||
"ParentId",
|
|
||||||
"PrimaryImageAspectRatio",
|
|
||||||
"ParentId",
|
|
||||||
"PrimaryImageAspectRatio",
|
|
||||||
],
|
|
||||||
collapseBoxSetItems: false,
|
|
||||||
albumArtistIds: [artistId],
|
|
||||||
startIndex,
|
|
||||||
limit: 100,
|
|
||||||
sortBy: ["PremiereDate", "ProductionYear", "SortName"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = response.data.Items;
|
|
||||||
|
|
||||||
return {
|
|
||||||
Items: data || [],
|
|
||||||
TotalRecordCount: response.data.TotalRecordCount || 0,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
|
|
||||||
if (!artist || !albums) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ParallaxScrollView
|
|
||||||
headerHeight={400}
|
|
||||||
headerImage={
|
|
||||||
<ItemImage
|
|
||||||
variant={"Primary"}
|
|
||||||
item={artist}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<View className="px-4 mb-8">
|
|
||||||
<Text className="font-bold text-2xl mb-2">{artist?.Name}</Text>
|
|
||||||
<Text className="text-neutral-500">
|
|
||||||
{albums.TotalRecordCount} albums
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-row flex-wrap justify-between px-4">
|
|
||||||
{albums.Items.map((item, idx) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
item={item}
|
|
||||||
style={{ width: "30%", marginBottom: 20 }}
|
|
||||||
key={idx}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col gap-y-2">
|
|
||||||
<ArtistPoster item={item} />
|
|
||||||
<Text numberOfLines={2}>{item.Name}</Text>
|
|
||||||
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableItemRouter>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</ParallaxScrollView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import ArtistPoster from "@/components/posters/ArtistPoster";
|
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getArtistsApi, getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { FlatList, TouchableOpacity, View } from "react-native";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const searchParams = useLocalSearchParams();
|
|
||||||
const { collectionId } = searchParams as { collectionId: string };
|
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const { data: collection } = useQuery({
|
|
||||||
queryKey: ["collection", collectionId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return null;
|
|
||||||
const response = await getItemsApi(api).getItems({
|
|
||||||
userId: user?.Id,
|
|
||||||
ids: [collectionId],
|
|
||||||
});
|
|
||||||
const data = response.data.Items?.[0];
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
enabled: !!api && !!user?.Id && !!collectionId,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [startIndex, setStartIndex] = useState<number>(0);
|
|
||||||
|
|
||||||
const { data, isLoading, isError } = useQuery<{
|
|
||||||
Items: BaseItemDto[];
|
|
||||||
TotalRecordCount: number;
|
|
||||||
}>({
|
|
||||||
queryKey: ["collection-items", collection?.Id, startIndex],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !collectionId)
|
|
||||||
return {
|
|
||||||
Items: [],
|
|
||||||
TotalRecordCount: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await getArtistsApi(api).getArtists({
|
|
||||||
sortBy: ["SortName"],
|
|
||||||
sortOrder: ["Ascending"],
|
|
||||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
|
||||||
imageTypeLimit: 1,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Banner", "Thumb"],
|
|
||||||
parentId: collectionId,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = response.data.Items;
|
|
||||||
|
|
||||||
return {
|
|
||||||
Items: data || [],
|
|
||||||
TotalRecordCount: response.data.TotalRecordCount || 0,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enabled: !!collection?.Id && !!api && !!user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalItems = useMemo(() => {
|
|
||||||
return data?.TotalRecordCount;
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
if (!data) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FlatList
|
|
||||||
contentContainerStyle={{
|
|
||||||
padding: 16,
|
|
||||||
paddingBottom: 140,
|
|
||||||
}}
|
|
||||||
ListHeaderComponent={
|
|
||||||
<View className="mb-4">
|
|
||||||
<Text className="font-bold text-3xl mb-2">Artists</Text>
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
nestedScrollEnabled
|
|
||||||
data={data.Items}
|
|
||||||
numColumns={3}
|
|
||||||
columnWrapperStyle={{
|
|
||||||
justifyContent: "space-between",
|
|
||||||
}}
|
|
||||||
renderItem={({ item, index }) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
style={{
|
|
||||||
maxWidth: "30%",
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
key={index}
|
|
||||||
item={item}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col gap-y-2">
|
|
||||||
{collection?.CollectionType === "movies" && (
|
|
||||||
<MoviePoster item={item} />
|
|
||||||
)}
|
|
||||||
{collection?.CollectionType === "music" && (
|
|
||||||
<ArtistPoster item={item} />
|
|
||||||
)}
|
|
||||||
<Text>{item.Name}</Text>
|
|
||||||
<Text className="opacity-50 text-xs">{item.ProductionYear}</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
keyExtractor={(item) => item.Id || ""}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { ItemContent } from "@/components/ItemContent";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useLocalSearchParams } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React, { useEffect } from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import Animated, {
|
|
||||||
runOnJS,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
const { id } = useLocalSearchParams() as { id: string };
|
|
||||||
|
|
||||||
const { data: item, isError } = useQuery({
|
|
||||||
queryKey: ["item", id],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api || !user || !id) return;
|
|
||||||
const res = await getUserLibraryApi(api).getItem({
|
|
||||||
itemId: id,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
staleTime: 0,
|
|
||||||
refetchOnMount: true,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const opacity = useSharedValue(1);
|
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
|
||||||
return {
|
|
||||||
opacity: opacity.value,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const fadeOut = (callback: any) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
opacity.value = withTiming(0, { duration: 500 }, (finished) => {
|
|
||||||
if (finished) {
|
|
||||||
runOnJS(callback)();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
const fadeIn = (callback: any) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
opacity.value = withTiming(1, { duration: 500 }, (finished) => {
|
|
||||||
if (finished) {
|
|
||||||
runOnJS(callback)();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (item) {
|
|
||||||
fadeOut(() => {});
|
|
||||||
} else {
|
|
||||||
fadeIn(() => {});
|
|
||||||
}
|
|
||||||
}, [item]);
|
|
||||||
|
|
||||||
if (isError)
|
|
||||||
return (
|
|
||||||
<View className="flex flex-col items-center justify-center h-screen w-screen">
|
|
||||||
<Text>Could not load item</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="flex flex-1 relative">
|
|
||||||
<Animated.View
|
|
||||||
pointerEvents={"none"}
|
|
||||||
style={[animatedStyle]}
|
|
||||||
className="absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black"
|
|
||||||
>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
height: item?.Type === "Episode" ? 300 : 450,
|
|
||||||
}}
|
|
||||||
className="bg-transparent rounded-lg mb-4 w-full"
|
|
||||||
></View>
|
|
||||||
<View className="h-6 bg-neutral-900 rounded mb-4 w-14"></View>
|
|
||||||
<View className="h-10 bg-neutral-900 rounded-lg mb-2 w-1/2"></View>
|
|
||||||
<View className="h-3 bg-neutral-900 rounded mb-3 w-8"></View>
|
|
||||||
<View className="flex flex-row space-x-1 mb-8">
|
|
||||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
|
||||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
|
||||||
<View className="h-6 bg-neutral-900 rounded mb-3 w-14"></View>
|
|
||||||
</View>
|
|
||||||
<View className="h-3 bg-neutral-900 rounded w-2/3 mb-1"></View>
|
|
||||||
<View className="h-10 bg-neutral-900 rounded-lg w-full mb-2"></View>
|
|
||||||
<View className="h-12 bg-neutral-900 rounded-lg w-full mb-2"></View>
|
|
||||||
<View className="h-24 bg-neutral-900 rounded-lg mb-1 w-full"></View>
|
|
||||||
</Animated.View>
|
|
||||||
{item && <ItemContent item={item} />}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,320 +0,0 @@
|
|||||||
import { Button } from "@/components/Button";
|
|
||||||
import { Input } from "@/components/common/Input";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { GenreTags } from "@/components/GenreTags";
|
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|
||||||
import { JellyserrRatings } from "@/components/Ratings";
|
|
||||||
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
|
||||||
import {
|
|
||||||
IssueType,
|
|
||||||
IssueTypeName,
|
|
||||||
} from "@/utils/jellyseerr/server/constants/issue";
|
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
|
||||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
|
||||||
import { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import {
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
BottomSheetBackdropProps,
|
|
||||||
BottomSheetModal,
|
|
||||||
BottomSheetView,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useLocalSearchParams } from "expo-router";
|
|
||||||
import React, { useCallback, useRef, useState } from "react";
|
|
||||||
import { Modal, TouchableOpacity, View } from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
||||||
|
|
||||||
const Page: React.FC = () => {
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const params = useLocalSearchParams();
|
|
||||||
const {
|
|
||||||
mediaTitle,
|
|
||||||
releaseYear,
|
|
||||||
canRequest: canRequestString,
|
|
||||||
posterSrc,
|
|
||||||
...result
|
|
||||||
} = params as unknown as {
|
|
||||||
mediaTitle: string;
|
|
||||||
releaseYear: number;
|
|
||||||
canRequest: string;
|
|
||||||
posterSrc: string;
|
|
||||||
} & Partial<MovieResult | TvResult>;
|
|
||||||
|
|
||||||
const canRequest = canRequestString === "true";
|
|
||||||
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
|
||||||
|
|
||||||
const [issueType, setIssueType] = useState<IssueType>();
|
|
||||||
const [issueMessage, setIssueMessage] = useState<string>();
|
|
||||||
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
|
||||||
const [isIssueTypeModalVisible, setIsIssueTypeModalVisible] = useState(false);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: details,
|
|
||||||
isFetching,
|
|
||||||
isLoading,
|
|
||||||
} = useQuery({
|
|
||||||
enabled: !!jellyseerrApi && !!result && !!result.id,
|
|
||||||
queryKey: ["jellyseerr", "detail", result.mediaType, result.id],
|
|
||||||
staleTime: 0,
|
|
||||||
refetchOnMount: true,
|
|
||||||
refetchOnReconnect: true,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
retryOnMount: true,
|
|
||||||
queryFn: async () => {
|
|
||||||
return result.mediaType === MediaType.MOVIE
|
|
||||||
? jellyseerrApi?.movieDetails(result.id!!)
|
|
||||||
: jellyseerrApi?.tvDetails(result.id!!);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderBackdrop = useCallback(
|
|
||||||
(props: BottomSheetBackdropProps) => (
|
|
||||||
<BottomSheetBackdrop
|
|
||||||
{...props}
|
|
||||||
disappearsOnIndex={-1}
|
|
||||||
appearsOnIndex={0}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const submitIssue = useCallback(() => {
|
|
||||||
if (result.id && issueType && issueMessage && details) {
|
|
||||||
jellyseerrApi
|
|
||||||
?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage)
|
|
||||||
.then(() => {
|
|
||||||
setIssueType(undefined);
|
|
||||||
setIssueMessage(undefined);
|
|
||||||
bottomSheetModalRef?.current?.close();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
|
||||||
|
|
||||||
const request = useCallback(
|
|
||||||
() =>
|
|
||||||
requestMedia(mediaTitle, {
|
|
||||||
mediaId: Number(result.id!!),
|
|
||||||
mediaType: result.mediaType!!,
|
|
||||||
tvdbId: details?.externalIds?.tvdbId,
|
|
||||||
seasons: (details as TvDetails)?.seasons
|
|
||||||
?.filter?.((s) => s.seasonNumber !== 0)
|
|
||||||
?.map?.((s) => s.seasonNumber),
|
|
||||||
}),
|
|
||||||
[details, result, requestMedia]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
className="flex-1 relative"
|
|
||||||
style={{
|
|
||||||
paddingLeft: insets.left,
|
|
||||||
paddingRight: insets.right,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ParallaxScrollView
|
|
||||||
className="flex-1 opacity-100"
|
|
||||||
headerHeight={300}
|
|
||||||
headerImage={
|
|
||||||
<View>
|
|
||||||
{result.backdropPath ? (
|
|
||||||
<Image
|
|
||||||
cachePolicy={"memory-disk"}
|
|
||||||
transition={300}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
source={{
|
|
||||||
uri: `https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${result.backdropPath}`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
className="flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900"
|
|
||||||
>
|
|
||||||
<Ionicons
|
|
||||||
name="image-outline"
|
|
||||||
size={24}
|
|
||||||
color="white"
|
|
||||||
style={{ opacity: 0.4 }}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<View className="space-y-4">
|
|
||||||
<View className="px-4">
|
|
||||||
<View className="flex flex-row justify-between w-full">
|
|
||||||
<View className="flex flex-col w-56">
|
|
||||||
<JellyserrRatings result={result as MovieResult | TvResult} />
|
|
||||||
<Text
|
|
||||||
uiTextView
|
|
||||||
selectable
|
|
||||||
className="font-bold text-2xl mb-1"
|
|
||||||
>
|
|
||||||
{mediaTitle}
|
|
||||||
</Text>
|
|
||||||
<Text className="opacity-50">{releaseYear}</Text>
|
|
||||||
</View>
|
|
||||||
<Image
|
|
||||||
className="absolute bottom-1 right-1 rounded-lg w-28 aspect-[10/15] border-2 border-neutral-800/50 drop-shadow-2xl"
|
|
||||||
cachePolicy={"memory-disk"}
|
|
||||||
transition={300}
|
|
||||||
source={{
|
|
||||||
uri: posterSrc,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View className="mb-4">
|
|
||||||
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
|
||||||
</View>
|
|
||||||
{canRequest ? (
|
|
||||||
<Button color="purple" onPress={request}>
|
|
||||||
Request
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
className="bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100"
|
|
||||||
color="transparent"
|
|
||||||
onPress={() => bottomSheetModalRef?.current?.present()}
|
|
||||||
iconLeft={
|
|
||||||
<Ionicons name="warning-outline" size={24} color="white" />
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
borderWidth: 1,
|
|
||||||
borderStyle: "solid",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Report issue
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<OverviewText text={result.overview} className="mt-4" />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{result.mediaType === MediaType.TV && (
|
|
||||||
<JellyseerrSeasons
|
|
||||||
isLoading={isLoading || isFetching}
|
|
||||||
result={result as TvResult}
|
|
||||||
details={details as TvDetails}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</ParallaxScrollView>
|
|
||||||
<BottomSheetModal
|
|
||||||
ref={bottomSheetModalRef}
|
|
||||||
enableDynamicSizing
|
|
||||||
handleIndicatorStyle={{
|
|
||||||
backgroundColor: "white",
|
|
||||||
}}
|
|
||||||
backgroundStyle={{
|
|
||||||
backgroundColor: "#171717",
|
|
||||||
}}
|
|
||||||
backdropComponent={renderBackdrop}
|
|
||||||
>
|
|
||||||
<BottomSheetView>
|
|
||||||
<View className="flex flex-col space-y-4 px-4 pb-8 pt-2">
|
|
||||||
<View>
|
|
||||||
<Text className="font-bold text-2xl text-neutral-100">
|
|
||||||
Whats wrong?
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="flex flex-col space-y-2 items-start">
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<View className="flex flex-col">
|
|
||||||
<Text className="opacity-50 mb-1 text-xs">Issue Type</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
className="bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between"
|
|
||||||
onPress={() => setIsIssueTypeModalVisible(true)}
|
|
||||||
>
|
|
||||||
<Text className="" numberOfLines={1}>
|
|
||||||
{issueType ? IssueTypeName[issueType] : "Select an issue"}
|
|
||||||
</Text>
|
|
||||||
<Ionicons
|
|
||||||
name="chevron-down"
|
|
||||||
size={16}
|
|
||||||
color="white"
|
|
||||||
style={{ opacity: 0.5 }}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
visible={isIssueTypeModalVisible}
|
|
||||||
transparent
|
|
||||||
animationType="slide"
|
|
||||||
onRequestClose={() => setIsIssueTypeModalVisible(false)}
|
|
||||||
>
|
|
||||||
<TouchableOpacity
|
|
||||||
className="flex-1 bg-black/50"
|
|
||||||
activeOpacity={1}
|
|
||||||
onPress={() => setIsIssueTypeModalVisible(false)}
|
|
||||||
>
|
|
||||||
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
|
||||||
<View className="p-4 border-b border-neutral-800">
|
|
||||||
<Text className="text-lg font-bold text-center">
|
|
||||||
Select Issue Type
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="max-h-[50%]">
|
|
||||||
{Object.entries(IssueTypeName)
|
|
||||||
.reverse()
|
|
||||||
.map(([key, value]) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={key}
|
|
||||||
className="p-4 border-b border-neutral-800"
|
|
||||||
onPress={() => {
|
|
||||||
setIssueType(key as unknown as IssueType);
|
|
||||||
setIsIssueTypeModalVisible(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className="text-center">{value}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
className="p-4 border-t border-neutral-800"
|
|
||||||
onPress={() => setIsIssueTypeModalVisible(false)}
|
|
||||||
>
|
|
||||||
<Text className="text-center text-purple-400">
|
|
||||||
Cancel
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</Modal>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
className="w-full"
|
|
||||||
placeholder="(optional) Describe the issue..."
|
|
||||||
value={issueMessage}
|
|
||||||
keyboardType="default"
|
|
||||||
returnKeyType="done"
|
|
||||||
autoCapitalize="none"
|
|
||||||
textContentType="none"
|
|
||||||
maxLength={254}
|
|
||||||
onChangeText={setIssueMessage}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Button className="mt-auto" onPress={submitIssue} color="purple">
|
|
||||||
Submit
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</BottomSheetView>
|
|
||||||
</BottomSheetModal>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import React from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
return (
|
|
||||||
<View className="flex items-center justify-center h-full -mt-12">
|
|
||||||
<Text>Coming soon</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,27 +1,29 @@
|
|||||||
import { ItemCardText } from "@/components/ItemCardText";
|
import type { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { OverviewText } from "@/components/OverviewText";
|
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
|
||||||
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|
||||||
import { BaseItemDtoQueryResult } from "@jellyfin/sdk/lib/generated-client/models";
|
|
||||||
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams } from "expo-router";
|
import { useLocalSearchParams } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
|
import { InfiniteHorizontalScroll } from "@/components/common/InfiniteHorrizontalScroll";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { MoviesTitleHeader } from "@/components/movies/MoviesTitleHeader";
|
||||||
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const local = useLocalSearchParams();
|
const local = useLocalSearchParams();
|
||||||
const { actorId } = local as { actorId: string };
|
const { actorId } = local as { actorId: string };
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
@@ -66,7 +68,7 @@ const page: React.FC = () => {
|
|||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
[api, user?.Id, actorId]
|
[api, user?.Id, actorId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const backdropUrl = useMemo(
|
const backdropUrl = useMemo(
|
||||||
@@ -77,12 +79,12 @@ const page: React.FC = () => {
|
|||||||
quality: 90,
|
quality: 90,
|
||||||
width: 1000,
|
width: 1000,
|
||||||
}),
|
}),
|
||||||
[item]
|
[item],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (l1)
|
if (l1)
|
||||||
return (
|
return (
|
||||||
<View className="justify-center items-center h-full">
|
<View className='justify-center items-center h-full'>
|
||||||
<Loader />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -103,14 +105,14 @@ const page: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col space-y-4 my-4">
|
<View className='flex flex-col space-y-4 my-4'>
|
||||||
<View className="px-4 mb-4">
|
<View className='px-4 mb-4'>
|
||||||
<MoviesTitleHeader item={item} className="mb-4" />
|
<MoviesTitleHeader item={item} className='mb-4' />
|
||||||
<OverviewText text={item.Overview} />
|
<OverviewText text={item.Overview} />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text className="px-4 text-2xl font-bold mb-2 text-neutral-100">
|
<Text className='px-4 text-2xl font-bold mb-2 text-neutral-100'>
|
||||||
Appeared In
|
{t("item_card.appeared_in")}
|
||||||
</Text>
|
</Text>
|
||||||
<InfiniteHorizontalScroll
|
<InfiniteHorizontalScroll
|
||||||
height={247}
|
height={247}
|
||||||
@@ -131,7 +133,7 @@ const page: React.FC = () => {
|
|||||||
queryFn={fetchItems}
|
queryFn={fetchItems}
|
||||||
queryKey={["actor", "movies", actorId]}
|
queryKey={["actor", "movies", actorId]}
|
||||||
/>
|
/>
|
||||||
<View className="h-12"></View>
|
<View className='h-12' />
|
||||||
</View>
|
</View>
|
||||||
</ParallaxScrollView>
|
</ParallaxScrollView>
|
||||||
);
|
);
|
||||||
@@ -1,22 +1,4 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
import type {
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
|
||||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import {
|
|
||||||
genreFilterAtom,
|
|
||||||
sortByAtom,
|
|
||||||
SortByOption,
|
|
||||||
sortOptions,
|
|
||||||
sortOrderAtom,
|
|
||||||
SortOrderOption,
|
|
||||||
sortOrderOptions,
|
|
||||||
tagsFilterAtom,
|
|
||||||
yearFilterAtom,
|
|
||||||
} from "@/utils/atoms/filters";
|
|
||||||
import {
|
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
ItemSortBy,
|
ItemSortBy,
|
||||||
@@ -30,8 +12,29 @@ import { FlashList } from "@shopify/flash-list";
|
|||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { FlatList, View } from "react-native";
|
import { FlatList, View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
genreFilterAtom,
|
||||||
|
SortByOption,
|
||||||
|
SortOrderOption,
|
||||||
|
sortByAtom,
|
||||||
|
sortOptions,
|
||||||
|
sortOrderAtom,
|
||||||
|
sortOrderOptions,
|
||||||
|
tagsFilterAtom,
|
||||||
|
yearFilterAtom,
|
||||||
|
} from "@/utils/atoms/filters";
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -40,6 +43,12 @@ const page: React.FC = () => {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
const [orientation, _setOrientation] = useState(
|
||||||
|
ScreenOrientation.Orientation.PORTRAIT_UP,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
const [selectedGenres, setSelectedGenres] = useAtom(genreFilterAtom);
|
||||||
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
const [selectedYears, setSelectedYears] = useAtom(yearFilterAtom);
|
||||||
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
const [selectedTags, setSelectedTags] = useAtom(tagsFilterAtom);
|
||||||
@@ -103,8 +112,8 @@ const page: React.FC = () => {
|
|||||||
recursive: true,
|
recursive: true,
|
||||||
genres: selectedGenres,
|
genres: selectedGenres,
|
||||||
tags: selectedTags,
|
tags: selectedTags,
|
||||||
years: selectedYears.map((year) => parseInt(year)),
|
years: selectedYears.map((year) => Number.parseInt(year, 10)),
|
||||||
includeItemTypes: ["Movie", "Series", "MusicAlbum"],
|
includeItemTypes: ["Movie", "Series"],
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data || null;
|
return response.data || null;
|
||||||
@@ -118,7 +127,7 @@ const page: React.FC = () => {
|
|||||||
selectedTags,
|
selectedTags,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
]
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
@@ -143,14 +152,13 @@ const page: React.FC = () => {
|
|||||||
const totalItems = lastPage.TotalRecordCount;
|
const totalItems = lastPage.TotalRecordCount;
|
||||||
const accumulatedItems = pages.reduce(
|
const accumulatedItems = pages.reduce(
|
||||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||||
0
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (accumulatedItems < totalItems) {
|
if (accumulatedItems < totalItems) {
|
||||||
return lastPage?.Items?.length * pages.length;
|
return lastPage?.Items?.length * pages.length;
|
||||||
} else {
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
return undefined;
|
||||||
},
|
},
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
enabled: !!api && !!user?.Id && !!collection,
|
enabled: !!api && !!user?.Id && !!collection,
|
||||||
@@ -169,7 +177,8 @@ const page: React.FC = () => {
|
|||||||
key={item.Id}
|
key={item.Id}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
marginBottom: 16,
|
marginBottom:
|
||||||
|
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 4 : 16,
|
||||||
}}
|
}}
|
||||||
item={item}
|
item={item}
|
||||||
>
|
>
|
||||||
@@ -179,8 +188,8 @@ const page: React.FC = () => {
|
|||||||
index % 3 === 0
|
index % 3 === 0
|
||||||
? "flex-end"
|
? "flex-end"
|
||||||
: (index + 1) % 3 === 0
|
: (index + 1) % 3 === 0
|
||||||
? "flex-start"
|
? "flex-start"
|
||||||
: "center",
|
: "center",
|
||||||
width: "89%",
|
width: "89%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -190,14 +199,14 @@ const page: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
),
|
),
|
||||||
[orientation]
|
[orientation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
|
|
||||||
const ListHeaderComponent = useCallback(
|
const ListHeaderComponent = useCallback(
|
||||||
() => (
|
() => (
|
||||||
<View className="">
|
<View className=''>
|
||||||
<FlatList
|
<FlatList
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
@@ -223,13 +232,13 @@ const page: React.FC = () => {
|
|||||||
key: "genre",
|
key: "genre",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className="mr-1"
|
className='mr-1'
|
||||||
collectionId={collectionId}
|
id={collectionId}
|
||||||
queryKey="genreFilter"
|
queryKey='genreFilter'
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
const response = await getFilterApi(
|
const response = await getFilterApi(
|
||||||
api
|
api,
|
||||||
).getQueryFiltersLegacy({
|
).getQueryFiltersLegacy({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: collectionId,
|
parentId: collectionId,
|
||||||
@@ -238,7 +247,7 @@ const page: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
set={setSelectedGenres}
|
set={setSelectedGenres}
|
||||||
values={selectedGenres}
|
values={selectedGenres}
|
||||||
title="Genres"
|
title={t("library.filters.genres")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
searchFilter={(item, search) =>
|
searchFilter={(item, search) =>
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
@@ -250,13 +259,13 @@ const page: React.FC = () => {
|
|||||||
key: "year",
|
key: "year",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className="mr-1"
|
className='mr-1'
|
||||||
collectionId={collectionId}
|
id={collectionId}
|
||||||
queryKey="yearFilter"
|
queryKey='yearFilter'
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
const response = await getFilterApi(
|
const response = await getFilterApi(
|
||||||
api
|
api,
|
||||||
).getQueryFiltersLegacy({
|
).getQueryFiltersLegacy({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: collectionId,
|
parentId: collectionId,
|
||||||
@@ -265,7 +274,7 @@ const page: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
set={setSelectedYears}
|
set={setSelectedYears}
|
||||||
values={selectedYears}
|
values={selectedYears}
|
||||||
title="Years"
|
title={t("library.filters.years")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
searchFilter={(item, search) => item.includes(search)}
|
searchFilter={(item, search) => item.includes(search)}
|
||||||
/>
|
/>
|
||||||
@@ -275,13 +284,13 @@ const page: React.FC = () => {
|
|||||||
key: "tags",
|
key: "tags",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className="mr-1"
|
className='mr-1'
|
||||||
collectionId={collectionId}
|
id={collectionId}
|
||||||
queryKey="tagsFilter"
|
queryKey='tagsFilter'
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
const response = await getFilterApi(
|
const response = await getFilterApi(
|
||||||
api
|
api,
|
||||||
).getQueryFiltersLegacy({
|
).getQueryFiltersLegacy({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: collectionId,
|
parentId: collectionId,
|
||||||
@@ -290,7 +299,7 @@ const page: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
set={setSelectedTags}
|
set={setSelectedTags}
|
||||||
values={selectedTags}
|
values={selectedTags}
|
||||||
title="Tags"
|
title={t("library.filters.tags")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
searchFilter={(item, search) =>
|
searchFilter={(item, search) =>
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
@@ -302,13 +311,13 @@ const page: React.FC = () => {
|
|||||||
key: "sortBy",
|
key: "sortBy",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className="mr-1"
|
className='mr-1'
|
||||||
collectionId={collectionId}
|
id={collectionId}
|
||||||
queryKey="sortBy"
|
queryKey='sortBy'
|
||||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||||
set={setSortBy}
|
set={setSortBy}
|
||||||
values={sortBy}
|
values={sortBy}
|
||||||
title="Sort By"
|
title={t("library.filters.sort_by")}
|
||||||
renderItemLabel={(item) =>
|
renderItemLabel={(item) =>
|
||||||
sortOptions.find((i) => i.key === item)?.value || ""
|
sortOptions.find((i) => i.key === item)?.value || ""
|
||||||
}
|
}
|
||||||
@@ -322,13 +331,13 @@ const page: React.FC = () => {
|
|||||||
key: "sortOrder",
|
key: "sortOrder",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className="mr-1"
|
className='mr-1'
|
||||||
collectionId={collectionId}
|
id={collectionId}
|
||||||
queryKey="sortOrder"
|
queryKey='sortOrder'
|
||||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||||
set={setSortOrder}
|
set={setSortOrder}
|
||||||
values={sortOrder}
|
values={sortOrder}
|
||||||
title="Sort Order"
|
title={t("library.filters.sort_order")}
|
||||||
renderItemLabel={(item) =>
|
renderItemLabel={(item) =>
|
||||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||||
}
|
}
|
||||||
@@ -359,7 +368,7 @@ const page: React.FC = () => {
|
|||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSortOrder,
|
||||||
isFetching,
|
isFetching,
|
||||||
]
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!collection) return null;
|
if (!collection) return null;
|
||||||
@@ -367,8 +376,10 @@ const page: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<FlashList
|
<FlashList
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className="flex flex-col items-center justify-center h-full">
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
<Text className="font-bold text-xl text-neutral-500">No results</Text>
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
|
{t("search.no_results")}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
extraData={[
|
extraData={[
|
||||||
@@ -378,12 +389,14 @@ const page: React.FC = () => {
|
|||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
]}
|
]}
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
data={flatData}
|
data={flatData}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
estimatedItemSize={255}
|
estimatedItemSize={255}
|
||||||
numColumns={5}
|
numColumns={
|
||||||
|
orientation === ScreenOrientation.Orientation.PORTRAIT_UP ? 3 : 5
|
||||||
|
}
|
||||||
onEndReached={() => {
|
onEndReached={() => {
|
||||||
if (hasNextPage) {
|
if (hasNextPage) {
|
||||||
fetchNextPage();
|
fetchNextPage();
|
||||||
@@ -398,7 +411,7 @@ const page: React.FC = () => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
}}
|
}}
|
||||||
></View>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import Animated, {
|
||||||
|
runOnJS,
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { ItemContent } from "@/components/ItemContent";
|
||||||
|
import { useItemQuery } from "@/hooks/useItemQuery";
|
||||||
|
|
||||||
|
const Page: React.FC = () => {
|
||||||
|
const { id } = useLocalSearchParams() as { id: string };
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { offline } = useLocalSearchParams() as { offline?: string };
|
||||||
|
const isOffline = offline === "true";
|
||||||
|
|
||||||
|
const { data: item, isError } = useItemQuery(id, isOffline);
|
||||||
|
|
||||||
|
const opacity = useSharedValue(1);
|
||||||
|
const animatedStyle = useAnimatedStyle(() => {
|
||||||
|
return {
|
||||||
|
opacity: opacity.value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const fadeOut = (callback: any) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
opacity.value = withTiming(0, { duration: 500 }, (finished) => {
|
||||||
|
if (finished) {
|
||||||
|
runOnJS(callback)();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fadeIn = (callback: any) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
opacity.value = withTiming(1, { duration: 500 }, (finished) => {
|
||||||
|
if (finished) {
|
||||||
|
runOnJS(callback)();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (item) {
|
||||||
|
fadeOut(() => {});
|
||||||
|
} else {
|
||||||
|
fadeIn(() => {});
|
||||||
|
}
|
||||||
|
}, [item]);
|
||||||
|
|
||||||
|
if (isError)
|
||||||
|
return (
|
||||||
|
<View className='flex flex-col items-center justify-center h-screen w-screen'>
|
||||||
|
<Text>{t("item_card.could_not_load_item")}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='flex flex-1 relative'>
|
||||||
|
<Animated.View
|
||||||
|
pointerEvents={"none"}
|
||||||
|
style={[animatedStyle]}
|
||||||
|
className='absolute top-0 left-0 flex flex-col items-start h-screen w-screen px-4 z-50 bg-black'
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
height: item?.Type === "Episode" ? 300 : 450,
|
||||||
|
}}
|
||||||
|
className='bg-transparent rounded-lg mb-4 w-full'
|
||||||
|
/>
|
||||||
|
<View className='h-6 bg-neutral-900 rounded mb-4 w-14' />
|
||||||
|
<View className='h-10 bg-neutral-900 rounded-lg mb-2 w-1/2' />
|
||||||
|
<View className='h-3 bg-neutral-900 rounded mb-3 w-8' />
|
||||||
|
<View className='flex flex-row space-x-1 mb-8'>
|
||||||
|
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||||
|
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||||
|
<View className='h-6 bg-neutral-900 rounded mb-3 w-14' />
|
||||||
|
</View>
|
||||||
|
<View className='h-3 bg-neutral-900 rounded w-2/3 mb-1' />
|
||||||
|
<View className='h-10 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||||
|
<View className='h-12 bg-neutral-900 rounded-lg w-full mb-2' />
|
||||||
|
<View className='h-24 bg-neutral-900 rounded-lg mb-1 w-full' />
|
||||||
|
</Animated.View>
|
||||||
|
{item && <ItemContent item={item} isOffline={isOffline} />}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Page;
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { uniqBy } from "lodash";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||||
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
|
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
|
import {
|
||||||
|
type MovieResult,
|
||||||
|
type TvResult,
|
||||||
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import { COMPANY_LOGO_IMAGE_FILTER } from "@/utils/jellyseerr/src/components/Discover/NetworkSlider";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const local = useLocalSearchParams();
|
||||||
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
|
||||||
|
const { companyId, image, type } = local as unknown as {
|
||||||
|
companyId: string;
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
type: DiscoverSliderType;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
|
queryKey: ["jellyseerr", "company", type, companyId],
|
||||||
|
queryFn: async ({ pageParam }) => {
|
||||||
|
const params: any = {
|
||||||
|
page: Number(pageParam),
|
||||||
|
};
|
||||||
|
|
||||||
|
return jellyseerrApi?.discover(
|
||||||
|
`${
|
||||||
|
type === DiscoverSliderType.NETWORKS
|
||||||
|
? Endpoints.DISCOVER_TV_NETWORK
|
||||||
|
: Endpoints.DISCOVER_MOVIES_STUDIO
|
||||||
|
}/${companyId}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enabled: !!jellyseerrApi && !!companyId,
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage, pages) =>
|
||||||
|
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
||||||
|
1,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const flatData = useMemo(
|
||||||
|
() =>
|
||||||
|
uniqBy(
|
||||||
|
data?.pages
|
||||||
|
?.filter((p) => p?.results.length)
|
||||||
|
.flatMap((p) => p?.results ?? []),
|
||||||
|
"id",
|
||||||
|
) ?? [],
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const backdrops = useMemo(
|
||||||
|
() =>
|
||||||
|
jellyseerrApi
|
||||||
|
? flatData.map((r) =>
|
||||||
|
jellyseerrApi.imageProxy(
|
||||||
|
(r as TvResult | MovieResult).backdropPath,
|
||||||
|
"w1920_and_h800_multi_faces",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
[jellyseerrApi, flatData],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ParallaxSlideShow
|
||||||
|
data={flatData}
|
||||||
|
images={backdrops}
|
||||||
|
listHeader=''
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
logo={
|
||||||
|
<Image
|
||||||
|
id={companyId}
|
||||||
|
key={companyId}
|
||||||
|
className='bottom-1 w-1/2'
|
||||||
|
source={{
|
||||||
|
uri: jellyseerrApi?.imageProxy(image, COMPANY_LOGO_IMAGE_FILTER),
|
||||||
|
}}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit='contain'
|
||||||
|
style={{
|
||||||
|
aspectRatio: "4/3",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
renderItem={(item, _index) => (
|
||||||
|
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { uniqBy } from "lodash";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { textShadowStyle } from "@/components/jellyseerr/discover/GenericSlideCard";
|
||||||
|
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||||
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
|
import { Endpoints, useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { DiscoverSliderType } from "@/utils/jellyseerr/server/constants/discover";
|
||||||
|
import {
|
||||||
|
type MovieResult,
|
||||||
|
type TvResult,
|
||||||
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const local = useLocalSearchParams();
|
||||||
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
|
||||||
|
const { genreId, name, type } = local as unknown as {
|
||||||
|
genreId: string;
|
||||||
|
name: string;
|
||||||
|
type: DiscoverSliderType;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
|
||||||
|
queryKey: ["jellyseerr", "company", type, genreId],
|
||||||
|
queryFn: async ({ pageParam }) => {
|
||||||
|
const params: any = {
|
||||||
|
page: Number(pageParam),
|
||||||
|
genre: genreId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return jellyseerrApi?.discover(
|
||||||
|
type === DiscoverSliderType.MOVIE_GENRES
|
||||||
|
? Endpoints.DISCOVER_MOVIES
|
||||||
|
: Endpoints.DISCOVER_TV,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enabled: !!jellyseerrApi && !!genreId,
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage, pages) =>
|
||||||
|
(lastPage?.page || pages?.findLast((p) => p?.results.length)?.page || 1) +
|
||||||
|
1,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const flatData = useMemo(
|
||||||
|
() =>
|
||||||
|
uniqBy(
|
||||||
|
data?.pages
|
||||||
|
?.filter((p) => p?.results.length)
|
||||||
|
.flatMap((p) => p?.results ?? []),
|
||||||
|
"id",
|
||||||
|
) ?? [],
|
||||||
|
[data],
|
||||||
|
);
|
||||||
|
|
||||||
|
const backdrops = useMemo(
|
||||||
|
() =>
|
||||||
|
jellyseerrApi
|
||||||
|
? flatData.map((r) =>
|
||||||
|
jellyseerrApi.imageProxy(
|
||||||
|
(r as TvResult | MovieResult).backdropPath,
|
||||||
|
"w1920_and_h800_multi_faces",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
[jellyseerrApi, flatData],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ParallaxSlideShow
|
||||||
|
data={flatData}
|
||||||
|
images={backdrops}
|
||||||
|
listHeader=''
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
onEndReached={() => {
|
||||||
|
if (hasNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
logo={
|
||||||
|
<Text
|
||||||
|
className='text-4xl font-bold text-center bottom-1'
|
||||||
|
style={{
|
||||||
|
...textShadowStyle.shadow,
|
||||||
|
shadowRadius: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
renderItem={(item, _index) => (
|
||||||
|
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import {
|
||||||
|
BottomSheetBackdrop,
|
||||||
|
type BottomSheetBackdropProps,
|
||||||
|
BottomSheetModal,
|
||||||
|
BottomSheetTextInput,
|
||||||
|
BottomSheetView,
|
||||||
|
} from "@gorhom/bottom-sheet";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useNavigation, useRouter } from "expo-router";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { GenreTags } from "@/components/GenreTags";
|
||||||
|
import Cast from "@/components/jellyseerr/Cast";
|
||||||
|
import DetailFacts from "@/components/jellyseerr/DetailFacts";
|
||||||
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
|
import { JellyserrRatings } from "@/components/Ratings";
|
||||||
|
import JellyseerrSeasons from "@/components/series/JellyseerrSeasons";
|
||||||
|
import { ItemActions } from "@/components/series/SeriesActions";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { useJellyseerrCanRequest } from "@/utils/_jellyseerr/useJellyseerrCanRequest";
|
||||||
|
import {
|
||||||
|
type IssueType,
|
||||||
|
IssueTypeName,
|
||||||
|
} from "@/utils/jellyseerr/server/constants/issue";
|
||||||
|
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
||||||
|
import type {
|
||||||
|
MovieResult,
|
||||||
|
TvResult,
|
||||||
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
import type { TvDetails } from "@/utils/jellyseerr/server/models/Tv";
|
||||||
|
|
||||||
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
|
||||||
|
import RequestModal from "@/components/jellyseerr/RequestModal";
|
||||||
|
import { ANIME_KEYWORD_ID } from "@/utils/jellyseerr/server/api/themoviedb/constants";
|
||||||
|
import type { MediaRequestBody } from "@/utils/jellyseerr/server/interfaces/api/requestInterfaces";
|
||||||
|
import type { MovieDetails } from "@/utils/jellyseerr/server/models/Movie";
|
||||||
|
|
||||||
|
const Page: React.FC = () => {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const params = useLocalSearchParams();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { mediaTitle, releaseYear, posterSrc, mediaType, ...result } =
|
||||||
|
params as unknown as {
|
||||||
|
mediaTitle: string;
|
||||||
|
releaseYear: number;
|
||||||
|
canRequest: string;
|
||||||
|
posterSrc: string;
|
||||||
|
mediaType: MediaType;
|
||||||
|
} & Partial<MovieResult | TvResult | MovieDetails | TvDetails>;
|
||||||
|
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const { jellyseerrApi, requestMedia } = useJellyseerr();
|
||||||
|
|
||||||
|
const [issueType, setIssueType] = useState<IssueType>();
|
||||||
|
const [issueMessage, setIssueMessage] = useState<string>();
|
||||||
|
const [requestBody, _setRequestBody] = useState<MediaRequestBody>();
|
||||||
|
const advancedReqModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: details,
|
||||||
|
isFetching,
|
||||||
|
isLoading,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
enabled: !!jellyseerrApi && !!result && !!result.id,
|
||||||
|
queryKey: ["jellyseerr", "detail", mediaType, result.id],
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnReconnect: true,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
retryOnMount: true,
|
||||||
|
refetchInterval: 0,
|
||||||
|
queryFn: async () => {
|
||||||
|
return mediaType === MediaType.MOVIE
|
||||||
|
? jellyseerrApi?.movieDetails(result.id!)
|
||||||
|
: jellyseerrApi?.tvDetails(result.id!);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [canRequest, hasAdvancedRequestPermission] =
|
||||||
|
useJellyseerrCanRequest(details);
|
||||||
|
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: BottomSheetBackdropProps) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitIssue = useCallback(() => {
|
||||||
|
if (result.id && issueType && issueMessage && details) {
|
||||||
|
jellyseerrApi
|
||||||
|
?.submitIssue(details.mediaInfo.id, Number(issueType), issueMessage)
|
||||||
|
.then(() => {
|
||||||
|
setIssueType(undefined);
|
||||||
|
setIssueMessage(undefined);
|
||||||
|
bottomSheetModalRef?.current?.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [jellyseerrApi, details, result, issueType, issueMessage]);
|
||||||
|
|
||||||
|
const setRequestBody = useCallback(
|
||||||
|
(body: MediaRequestBody) => {
|
||||||
|
_setRequestBody(body);
|
||||||
|
advancedReqModalRef?.current?.present?.();
|
||||||
|
},
|
||||||
|
[requestBody, _setRequestBody, advancedReqModalRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
const request = useCallback(async () => {
|
||||||
|
const body: MediaRequestBody = {
|
||||||
|
mediaId: Number(result.id!),
|
||||||
|
mediaType: mediaType!,
|
||||||
|
tvdbId: details?.externalIds?.tvdbId,
|
||||||
|
seasons: (details as TvDetails)?.seasons
|
||||||
|
?.filter?.((s) => s.seasonNumber !== 0)
|
||||||
|
?.map?.((s) => s.seasonNumber),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasAdvancedRequestPermission) {
|
||||||
|
setRequestBody(body);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestMedia(mediaTitle, body, refetch);
|
||||||
|
}, [details, result, requestMedia, hasAdvancedRequestPermission]);
|
||||||
|
|
||||||
|
const isAnime = useMemo(
|
||||||
|
() =>
|
||||||
|
(details?.keywords.some((k) => k.id === ANIME_KEYWORD_ID) || false) &&
|
||||||
|
mediaType === MediaType.TV,
|
||||||
|
[details],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (details) {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<TouchableOpacity className='rounded-full p-2 bg-neutral-800/80'>
|
||||||
|
<ItemActions item={details} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [details]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className='flex-1 relative'
|
||||||
|
style={{
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ParallaxScrollView
|
||||||
|
className='flex-1 opacity-100'
|
||||||
|
headerHeight={300}
|
||||||
|
headerImage={
|
||||||
|
<View>
|
||||||
|
{result.backdropPath ? (
|
||||||
|
<Image
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
transition={300}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
source={{
|
||||||
|
uri: jellyseerrApi?.imageProxy(
|
||||||
|
result.backdropPath,
|
||||||
|
"w1920_and_h800_multi_faces",
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
className='flex flex-col items-center justify-center border border-neutral-800 bg-neutral-900'
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name='image-outline'
|
||||||
|
size={24}
|
||||||
|
color='white'
|
||||||
|
style={{ opacity: 0.4 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<View className='flex flex-col'>
|
||||||
|
<View className='space-y-4'>
|
||||||
|
<View className='px-4'>
|
||||||
|
<View className='flex flex-row justify-between w-full'>
|
||||||
|
<View className='flex flex-col w-56'>
|
||||||
|
<JellyserrRatings
|
||||||
|
result={
|
||||||
|
result as
|
||||||
|
| MovieResult
|
||||||
|
| TvResult
|
||||||
|
| MovieDetails
|
||||||
|
| TvDetails
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text selectable className='font-bold text-2xl mb-1'>
|
||||||
|
{mediaTitle}
|
||||||
|
</Text>
|
||||||
|
<Text className='opacity-50'>{releaseYear}</Text>
|
||||||
|
</View>
|
||||||
|
<Image
|
||||||
|
className='absolute bottom-1 right-1 rounded-lg w-28 aspect-[10/15] border-2 border-neutral-800/50 drop-shadow-2xl'
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
transition={300}
|
||||||
|
source={{
|
||||||
|
uri: posterSrc,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<GenreTags genres={details?.genres?.map((g) => g.name) || []} />
|
||||||
|
</View>
|
||||||
|
{isLoading || isFetching ? (
|
||||||
|
<Button
|
||||||
|
loading={true}
|
||||||
|
disabled={true}
|
||||||
|
color='purple'
|
||||||
|
className='mt-4'
|
||||||
|
/>
|
||||||
|
) : canRequest ? (
|
||||||
|
<Button color='purple' onPress={request} className='mt-4'>
|
||||||
|
{t("jellyseerr.request_button")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
details?.mediaInfo?.jellyfinMediaId && (
|
||||||
|
<View className='flex flex-row space-x-2 mt-4'>
|
||||||
|
{!Platform.isTV && (
|
||||||
|
<Button
|
||||||
|
className='flex-1 bg-yellow-500/50 border-yellow-400 ring-yellow-400 text-yellow-100'
|
||||||
|
color='transparent'
|
||||||
|
onPress={() => bottomSheetModalRef?.current?.present()}
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons
|
||||||
|
name='warning-outline'
|
||||||
|
size={20}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='text-sm'>
|
||||||
|
{t("jellyseerr.report_issue_button")}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
className='flex-1 bg-purple-600/50 border-purple-400 ring-purple-400 text-purple-100'
|
||||||
|
onPress={() => {
|
||||||
|
const url =
|
||||||
|
mediaType === MediaType.MOVIE
|
||||||
|
? `/(auth)/(tabs)/(search)/items/page?id=${details?.mediaInfo.jellyfinMediaId}`
|
||||||
|
: `/(auth)/(tabs)/(search)/series/${details?.mediaInfo.jellyfinMediaId}`;
|
||||||
|
// @ts-expect-error
|
||||||
|
router.push(url);
|
||||||
|
}}
|
||||||
|
iconLeft={
|
||||||
|
<Ionicons name='play-outline' size={20} color='white' />
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='text-sm'>Play</Text>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<OverviewText text={result.overview} className='mt-4' />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{mediaType === MediaType.TV && (
|
||||||
|
<JellyseerrSeasons
|
||||||
|
isLoading={isLoading || isFetching}
|
||||||
|
details={details as TvDetails}
|
||||||
|
refetch={refetch}
|
||||||
|
hasAdvancedRequest={hasAdvancedRequestPermission}
|
||||||
|
onAdvancedRequest={(data) => setRequestBody(data)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<DetailFacts
|
||||||
|
className='p-2 border border-neutral-800 bg-neutral-900 rounded-xl'
|
||||||
|
details={details}
|
||||||
|
/>
|
||||||
|
<Cast details={details} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ParallaxScrollView>
|
||||||
|
<RequestModal
|
||||||
|
ref={advancedReqModalRef}
|
||||||
|
requestBody={requestBody}
|
||||||
|
title={mediaTitle}
|
||||||
|
id={result.id!}
|
||||||
|
type={mediaType}
|
||||||
|
isAnime={isAnime}
|
||||||
|
onRequested={() => {
|
||||||
|
_setRequestBody(undefined);
|
||||||
|
advancedReqModalRef?.current?.close();
|
||||||
|
refetch();
|
||||||
|
}}
|
||||||
|
onDismiss={() => _setRequestBody(undefined)}
|
||||||
|
/>
|
||||||
|
{!Platform.isTV && (
|
||||||
|
// This is till it's fixed because the menu isn't selectable on TV
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={bottomSheetModalRef}
|
||||||
|
enableDynamicSizing
|
||||||
|
handleIndicatorStyle={{
|
||||||
|
backgroundColor: "white",
|
||||||
|
}}
|
||||||
|
backgroundStyle={{
|
||||||
|
backgroundColor: "#171717",
|
||||||
|
}}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
>
|
||||||
|
<BottomSheetView>
|
||||||
|
<View className='flex flex-col space-y-4 px-4 pb-8 pt-2'>
|
||||||
|
<View>
|
||||||
|
<Text className='font-bold text-2xl text-neutral-100'>
|
||||||
|
{t("jellyseerr.whats_wrong")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View className='flex flex-col space-y-2 items-start'>
|
||||||
|
<View className='flex flex-col'>
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger>
|
||||||
|
<View className='flex flex-col'>
|
||||||
|
<Text className='opacity-50 mb-1 text-xs'>
|
||||||
|
{t("jellyseerr.issue_type")}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity className='bg-neutral-900 h-10 rounded-xl border-neutral-800 border px-3 py-2 flex flex-row items-center justify-between'>
|
||||||
|
<Text style={{}} className='' numberOfLines={1}>
|
||||||
|
{issueType
|
||||||
|
? IssueTypeName[issueType]
|
||||||
|
: t("jellyseerr.select_an_issue")}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
loop={false}
|
||||||
|
side='bottom'
|
||||||
|
align='center'
|
||||||
|
alignOffset={0}
|
||||||
|
avoidCollisions={true}
|
||||||
|
collisionPadding={0}
|
||||||
|
sideOffset={0}
|
||||||
|
>
|
||||||
|
<DropdownMenu.Label>
|
||||||
|
{t("jellyseerr.types")}
|
||||||
|
</DropdownMenu.Label>
|
||||||
|
{Object.entries(IssueTypeName)
|
||||||
|
.reverse()
|
||||||
|
.map(([key, value], _idx) => (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={value}
|
||||||
|
onSelect={() =>
|
||||||
|
setIssueType(key as unknown as IssueType)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemTitle>
|
||||||
|
{value}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='p-4 border border-neutral-800 rounded-xl bg-neutral-900 w-full'>
|
||||||
|
<BottomSheetTextInput
|
||||||
|
multiline
|
||||||
|
maxLength={254}
|
||||||
|
style={{ color: "white" }}
|
||||||
|
clearButtonMode='always'
|
||||||
|
placeholder={t("jellyseerr.describe_the_issue")}
|
||||||
|
placeholderTextColor='#9CA3AF'
|
||||||
|
// Issue with multiline + Textinput inside a portal
|
||||||
|
// https://github.com/callstack/react-native-paper/issues/1668
|
||||||
|
defaultValue={issueMessage}
|
||||||
|
onChangeText={setIssueMessage}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Button className='mt-auto' onPress={submitIssue} color='purple'>
|
||||||
|
{t("jellyseerr.submit_button")}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</BottomSheetView>
|
||||||
|
</BottomSheetModal>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Page;
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams } from "expo-router";
|
||||||
|
import { orderBy, uniqBy } from "lodash";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import ParallaxSlideShow from "@/components/jellyseerr/ParallaxSlideShow";
|
||||||
|
import { OverviewText } from "@/components/OverviewText";
|
||||||
|
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import type { PersonCreditCast } from "@/utils/jellyseerr/server/models/Person";
|
||||||
|
import type {
|
||||||
|
MovieResult,
|
||||||
|
TvResult,
|
||||||
|
} from "@/utils/jellyseerr/server/models/Search";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const local = useLocalSearchParams();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const {
|
||||||
|
jellyseerrApi,
|
||||||
|
jellyseerrRegion: region,
|
||||||
|
jellyseerrLocale: locale,
|
||||||
|
} = useJellyseerr();
|
||||||
|
|
||||||
|
const { personId } = local as { personId: string };
|
||||||
|
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ["jellyseerr", "person", personId],
|
||||||
|
queryFn: async () => ({
|
||||||
|
details: await jellyseerrApi?.personDetails(personId),
|
||||||
|
combinedCredits: await jellyseerrApi?.personCombinedCredits(personId),
|
||||||
|
}),
|
||||||
|
enabled: !!jellyseerrApi && !!personId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const castedRoles: PersonCreditCast[] = useMemo(
|
||||||
|
() =>
|
||||||
|
uniqBy(
|
||||||
|
orderBy(
|
||||||
|
data?.combinedCredits?.cast,
|
||||||
|
["voteCount", "voteAverage"],
|
||||||
|
"desc",
|
||||||
|
),
|
||||||
|
"id",
|
||||||
|
),
|
||||||
|
[data?.combinedCredits],
|
||||||
|
);
|
||||||
|
const backdrops = useMemo(
|
||||||
|
() =>
|
||||||
|
jellyseerrApi
|
||||||
|
? castedRoles.map((c) =>
|
||||||
|
jellyseerrApi.imageProxy(
|
||||||
|
c.backdropPath,
|
||||||
|
"w1920_and_h800_multi_faces",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
[jellyseerrApi, data?.combinedCredits],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ParallaxSlideShow
|
||||||
|
data={castedRoles}
|
||||||
|
images={backdrops}
|
||||||
|
listHeader={t("jellyseerr.appearances")}
|
||||||
|
keyExtractor={(item) => item.id.toString()}
|
||||||
|
logo={
|
||||||
|
<Image
|
||||||
|
key={data?.details?.id}
|
||||||
|
id={data?.details?.id.toString()}
|
||||||
|
className='rounded-full bottom-1'
|
||||||
|
source={{
|
||||||
|
uri: jellyseerrApi?.imageProxy(
|
||||||
|
data?.details?.profilePath,
|
||||||
|
"w600_and_h600_bestv2",
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
cachePolicy={"memory-disk"}
|
||||||
|
contentFit='cover'
|
||||||
|
style={{
|
||||||
|
width: 125,
|
||||||
|
height: 125,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
HeaderContent={() => (
|
||||||
|
<>
|
||||||
|
<Text className='font-bold text-2xl mb-1'>{data?.details?.name}</Text>
|
||||||
|
<Text className='opacity-50'>
|
||||||
|
{t("jellyseerr.born")}{" "}
|
||||||
|
{new Date(data?.details?.birthday!).toLocaleDateString(
|
||||||
|
`${locale}-${region}`,
|
||||||
|
{
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
},
|
||||||
|
)}{" "}
|
||||||
|
| {data?.details?.placeOfBirth}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
MainContent={() => (
|
||||||
|
<OverviewText text={data?.details?.biography} className='mt-4' />
|
||||||
|
)}
|
||||||
|
renderItem={(item, _index) => (
|
||||||
|
<JellyseerrPoster item={item as MovieResult | TvResult} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import type {
|
import {
|
||||||
|
createMaterialTopTabNavigator,
|
||||||
MaterialTopTabNavigationEventMap,
|
MaterialTopTabNavigationEventMap,
|
||||||
MaterialTopTabNavigationOptions,
|
MaterialTopTabNavigationOptions,
|
||||||
} from "@react-navigation/material-top-tabs";
|
} from "@react-navigation/material-top-tabs";
|
||||||
import { createMaterialTopTabNavigator } from "@react-navigation/material-top-tabs";
|
import type {
|
||||||
import { ParamListBase, TabNavigationState } from "@react-navigation/native";
|
ParamListBase,
|
||||||
|
TabNavigationState,
|
||||||
|
} from "@react-navigation/native";
|
||||||
import { Stack, withLayoutContext } from "expo-router";
|
import { Stack, withLayoutContext } from "expo-router";
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
const { Navigator } = createMaterialTopTabNavigator();
|
const { Navigator } = createMaterialTopTabNavigator();
|
||||||
|
|
||||||
@@ -21,8 +23,8 @@ const Layout = () => {
|
|||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: "Live TV" }} />
|
<Stack.Screen options={{ title: "Live TV" }} />
|
||||||
<Tab
|
<Tab
|
||||||
initialRouteName="programs"
|
initialRouteName='programs'
|
||||||
keyboardDismissMode="none"
|
keyboardDismissMode='none'
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
tabBarBounces: true,
|
tabBarBounces: true,
|
||||||
tabBarLabelStyle: { fontSize: 10 },
|
tabBarLabelStyle: { fontSize: 10 },
|
||||||
@@ -37,10 +39,10 @@ const Layout = () => {
|
|||||||
tabBarScrollEnabled: true,
|
tabBarScrollEnabled: true,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Tab.Screen name="programs" />
|
<Tab.Screen name='programs' />
|
||||||
<Tab.Screen name="guide" />
|
<Tab.Screen name='guide' />
|
||||||
<Tab.Screen name="channels" />
|
<Tab.Screen name='channels' />
|
||||||
<Tab.Screen name="recordings" />
|
<Tab.Screen name='recordings' />
|
||||||
</Tab>
|
</Tab>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -1,18 +1,17 @@
|
|||||||
import { ItemImage } from "@/components/common/ItemImage";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React from "react";
|
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const insets = useSafeAreaInsets();
|
const _insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const { data: channels } = useQuery({
|
const { data: channels } = useQuery({
|
||||||
queryKey: ["livetv", "channels"],
|
queryKey: ["livetv", "channels"],
|
||||||
@@ -31,13 +30,13 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-1">
|
<View className='flex flex-1'>
|
||||||
<FlashList
|
<FlashList
|
||||||
data={channels?.Items}
|
data={channels?.Items}
|
||||||
estimatedItemSize={76}
|
estimatedItemSize={76}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<View className="flex flex-row items-center px-4 mb-2">
|
<View className='flex flex-row items-center px-4 mb-2'>
|
||||||
<View className="w-22 mr-4 rounded-lg overflow-hidden">
|
<View className='w-22 mr-4 rounded-lg overflow-hidden'>
|
||||||
<ItemImage
|
<ItemImage
|
||||||
style={{
|
style={{
|
||||||
aspectRatio: "1/1",
|
aspectRatio: "1/1",
|
||||||
@@ -47,7 +46,7 @@ export default function page() {
|
|||||||
item={item}
|
item={item}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text className="font-bold">{item.Name}</Text>
|
<Text className='font-bold'>{item.Name}</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@@ -1,22 +1,16 @@
|
|||||||
import { ItemImage } from "@/components/common/ItemImage";
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { HourHeader } from "@/components/livetv/HourHeader";
|
|
||||||
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
|
|
||||||
import { TAB_HEIGHT } from "@/constants/Values";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, { useCallback, useMemo, useState } from "react";
|
import React, { useCallback, useState } from "react";
|
||||||
import {
|
import { useTranslation } from "react-i18next";
|
||||||
Button,
|
import { Dimensions, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
Dimensions,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
View,
|
|
||||||
} from "react-native";
|
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { ItemImage } from "@/components/common/ItemImage";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { HourHeader } from "@/components/livetv/HourHeader";
|
||||||
|
import { LiveTVGuideRow } from "@/components/livetv/LiveTVGuideRow";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
const HOUR_HEIGHT = 30;
|
const HOUR_HEIGHT = 30;
|
||||||
const ITEMS_PER_PAGE = 20;
|
const ITEMS_PER_PAGE = 20;
|
||||||
@@ -27,17 +21,9 @@ export default function page() {
|
|||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [date, setDate] = useState<Date>(new Date());
|
const [date, _setDate] = useState<Date>(new Date());
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
const { data: guideInfo } = useQuery({
|
|
||||||
queryKey: ["livetv", "guideInfo"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await getLiveTvApi(api!).getGuideInfo();
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: channels } = useQuery({
|
const { data: channels } = useQuery({
|
||||||
queryKey: ["livetv", "channels", currentPage],
|
queryKey: ["livetv", "channels", currentPage],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -70,7 +56,7 @@ export default function page() {
|
|||||||
MaxStartDate: endOfDay.toISOString(),
|
MaxStartDate: endOfDay.toISOString(),
|
||||||
MinEndDate: isToday ? now.toISOString() : startOfDay.toISOString(),
|
MinEndDate: isToday ? now.toISOString() : startOfDay.toISOString(),
|
||||||
ChannelIds: channels?.Items?.map((c) => c.Id).filter(
|
ChannelIds: channels?.Items?.map((c) => c.Id).filter(
|
||||||
Boolean
|
Boolean,
|
||||||
) as string[],
|
) as string[],
|
||||||
ImageTypeLimit: 1,
|
ImageTypeLimit: 1,
|
||||||
EnableImages: false,
|
EnableImages: false,
|
||||||
@@ -99,7 +85,7 @@ export default function page() {
|
|||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
key={"home"}
|
key={"home"}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
@@ -116,16 +102,16 @@ export default function page() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<View className="flex flex-row">
|
<View className='flex flex-row'>
|
||||||
<View className="flex flex-col w-[64px]">
|
<View className='flex flex-col w-[64px]'>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
height: HOUR_HEIGHT,
|
height: HOUR_HEIGHT,
|
||||||
}}
|
}}
|
||||||
className="bg-neutral-800"
|
className='bg-neutral-800'
|
||||||
></View>
|
/>
|
||||||
{channels?.Items?.map((c, i) => (
|
{channels?.Items?.map((c, i) => (
|
||||||
<View className="h-16 w-16 mr-4 rounded-lg overflow-hidden" key={i}>
|
<View className='h-16 w-16 mr-4 rounded-lg overflow-hidden' key={i}>
|
||||||
<ItemImage
|
<ItemImage
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
@@ -147,9 +133,9 @@ export default function page() {
|
|||||||
setScrollX(e.nativeEvent.contentOffset.x);
|
setScrollX(e.nativeEvent.contentOffset.x);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col">
|
<View className='flex flex-col'>
|
||||||
<HourHeader height={HOUR_HEIGHT} />
|
<HourHeader height={HOUR_HEIGHT} />
|
||||||
{channels?.Items?.map((c, i) => (
|
{channels?.Items?.map((c, _i) => (
|
||||||
<MemoizedLiveTVGuideRow
|
<MemoizedLiveTVGuideRow
|
||||||
channel={c}
|
channel={c}
|
||||||
programs={programs?.Items}
|
programs={programs?.Items}
|
||||||
@@ -177,15 +163,16 @@ const PageButtons: React.FC<PageButtonsProps> = ({
|
|||||||
onNextPage,
|
onNextPage,
|
||||||
isNextDisabled,
|
isNextDisabled,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<View className="flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2">
|
<View className='flex flex-row justify-between items-center bg-neutral-800 w-full px-4 py-2'>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onPrevPage}
|
onPress={onPrevPage}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
className="flex flex-row items-center"
|
className='flex flex-row items-center'
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="chevron-back"
|
name='chevron-back'
|
||||||
size={24}
|
size={24}
|
||||||
color={currentPage === 1 ? "gray" : "white"}
|
color={currentPage === 1 ? "gray" : "white"}
|
||||||
/>
|
/>
|
||||||
@@ -194,22 +181,22 @@ const PageButtons: React.FC<PageButtonsProps> = ({
|
|||||||
currentPage === 1 ? "text-gray-500" : "text-white"
|
currentPage === 1 ? "text-gray-500" : "text-white"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Previous
|
{t("live_tv.previous")}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<Text className="text-white">Page {currentPage}</Text>
|
<Text className='text-white'>Page {currentPage}</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onNextPage}
|
onPress={onNextPage}
|
||||||
disabled={isNextDisabled}
|
disabled={isNextDisabled}
|
||||||
className="flex flex-row items-center"
|
className='flex flex-row items-center'
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
|
className={`mr-1 ${isNextDisabled ? "text-gray-500" : "text-white"}`}
|
||||||
>
|
>
|
||||||
Next
|
{t("live_tv.next")}
|
||||||
</Text>
|
</Text>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="chevron-forward"
|
name='chevron-forward'
|
||||||
size={24}
|
size={24}
|
||||||
color={isNextDisabled ? "gray" : "white"}
|
color={isNextDisabled ? "gray" : "white"}
|
||||||
/>
|
/>
|
||||||
@@ -1,22 +1,23 @@
|
|||||||
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
import type { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { TAB_HEIGHT } from "@/constants/Values";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getLiveTvApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React from "react";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ScrollView, View } from "react-native";
|
import { ScrollView, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { ScrollingCollectionList } from "@/components/home/ScrollingCollectionList";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
export default function page() {
|
export default function page() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
const [user] = useAtom(userAtom);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
nestedScrollEnabled
|
nestedScrollEnabled
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
key={"home"}
|
key={"home"}
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
@@ -25,10 +26,10 @@ export default function page() {
|
|||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col space-y-2">
|
<View className='flex flex-col space-y-2'>
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
queryKey={["livetv", "recommended"]}
|
queryKey={["livetv", "recommended"]}
|
||||||
title={"On now"}
|
title={t("live_tv.on_now")}
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return [] as BaseItemDto[];
|
if (!api) return [] as BaseItemDto[];
|
||||||
const res = await getLiveTvApi(api).getRecommendedPrograms({
|
const res = await getLiveTvApi(api).getRecommendedPrograms({
|
||||||
@@ -42,11 +43,11 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
return res.data.Items || [];
|
return res.data.Items || [];
|
||||||
}}
|
}}
|
||||||
orientation="horizontal"
|
orientation='horizontal'
|
||||||
/>
|
/>
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
queryKey={["livetv", "shows"]}
|
queryKey={["livetv", "shows"]}
|
||||||
title={"Shows"}
|
title={t("live_tv.shows")}
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return [] as BaseItemDto[];
|
if (!api) return [] as BaseItemDto[];
|
||||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||||
@@ -64,11 +65,11 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
return res.data.Items || [];
|
return res.data.Items || [];
|
||||||
}}
|
}}
|
||||||
orientation="horizontal"
|
orientation='horizontal'
|
||||||
/>
|
/>
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
queryKey={["livetv", "movies"]}
|
queryKey={["livetv", "movies"]}
|
||||||
title={"Movies"}
|
title={t("live_tv.movies")}
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return [] as BaseItemDto[];
|
if (!api) return [] as BaseItemDto[];
|
||||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||||
@@ -82,11 +83,11 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
return res.data.Items || [];
|
return res.data.Items || [];
|
||||||
}}
|
}}
|
||||||
orientation="horizontal"
|
orientation='horizontal'
|
||||||
/>
|
/>
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
queryKey={["livetv", "sports"]}
|
queryKey={["livetv", "sports"]}
|
||||||
title={"Sports"}
|
title={t("live_tv.sports")}
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return [] as BaseItemDto[];
|
if (!api) return [] as BaseItemDto[];
|
||||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||||
@@ -100,11 +101,11 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
return res.data.Items || [];
|
return res.data.Items || [];
|
||||||
}}
|
}}
|
||||||
orientation="horizontal"
|
orientation='horizontal'
|
||||||
/>
|
/>
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
queryKey={["livetv", "kids"]}
|
queryKey={["livetv", "kids"]}
|
||||||
title={"For Kids"}
|
title={t("live_tv.for_kids")}
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return [] as BaseItemDto[];
|
if (!api) return [] as BaseItemDto[];
|
||||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||||
@@ -118,11 +119,11 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
return res.data.Items || [];
|
return res.data.Items || [];
|
||||||
}}
|
}}
|
||||||
orientation="horizontal"
|
orientation='horizontal'
|
||||||
/>
|
/>
|
||||||
<ScrollingCollectionList
|
<ScrollingCollectionList
|
||||||
queryKey={["livetv", "news"]}
|
queryKey={["livetv", "news"]}
|
||||||
title={"News"}
|
title={t("live_tv.news")}
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return [] as BaseItemDto[];
|
if (!api) return [] as BaseItemDto[];
|
||||||
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
const res = await getLiveTvApi(api).getLiveTvPrograms({
|
||||||
@@ -136,7 +137,7 @@ export default function page() {
|
|||||||
});
|
});
|
||||||
return res.data.Items || [];
|
return res.data.Items || [];
|
||||||
}}
|
}}
|
||||||
orientation="horizontal"
|
orientation='horizontal'
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
|
||||||
|
export default function page() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<View className='flex items-center justify-center h-full -mt-12'>
|
||||||
|
<Text>{t("live_tv.coming_soon")}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,15 @@
|
|||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Image } from "expo-image";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import type React from "react";
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform, View } from "react-native";
|
||||||
|
import { AddToFavorites } from "@/components/AddToFavorites";
|
||||||
|
import { DownloadItems } from "@/components/DownloadItem";
|
||||||
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
import { ParallaxScrollView } from "@/components/ParallaxPage";
|
||||||
import { NextUp } from "@/components/series/NextUp";
|
import { NextUp } from "@/components/series/NextUp";
|
||||||
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
import { SeasonPicker } from "@/components/series/SeasonPicker";
|
||||||
@@ -6,17 +18,10 @@ import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
||||||
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
import { getLogoImageUrlById } from "@/utils/jellyfin/image/getLogoImageUrlById";
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { getTvShowsApi } from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React, { useEffect, useMemo } from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
|
|
||||||
const page: React.FC = () => {
|
const page: React.FC = () => {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
const { t } = useTranslation();
|
||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const { id: seriesId, seasonIndex } = params as {
|
const { id: seriesId, seasonIndex } = params as {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -34,7 +39,6 @@ const page: React.FC = () => {
|
|||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
itemId: seriesId,
|
itemId: seriesId,
|
||||||
}),
|
}),
|
||||||
enabled: !!seriesId && !!api,
|
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -46,7 +50,7 @@ const page: React.FC = () => {
|
|||||||
quality: 90,
|
quality: 90,
|
||||||
width: 1000,
|
width: 1000,
|
||||||
}),
|
}),
|
||||||
[item]
|
[item],
|
||||||
);
|
);
|
||||||
|
|
||||||
const logoUrl = useMemo(
|
const logoUrl = useMemo(
|
||||||
@@ -55,7 +59,7 @@ const page: React.FC = () => {
|
|||||||
api,
|
api,
|
||||||
item,
|
item,
|
||||||
}),
|
}),
|
||||||
[item]
|
[item],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data: allEpisodes, isLoading } = useQuery({
|
const { data: allEpisodes, isLoading } = useQuery({
|
||||||
@@ -65,14 +69,53 @@ const page: React.FC = () => {
|
|||||||
seriesId: item?.Id!,
|
seriesId: item?.Id!,
|
||||||
userId: user?.Id!,
|
userId: user?.Id!,
|
||||||
enableUserData: true,
|
enableUserData: true,
|
||||||
fields: ["MediaSources", "MediaStreams", "Overview"],
|
// Note: Including trick play is necessary to enable trick play downloads
|
||||||
|
fields: ["MediaSources", "MediaStreams", "Overview", "Trickplay"],
|
||||||
});
|
});
|
||||||
return res?.data.Items || [];
|
return res?.data.Items || [];
|
||||||
},
|
},
|
||||||
|
select: (data) =>
|
||||||
|
// This needs to be sorted by parent index number and then index number, that way we can download the episodes in the correct order.
|
||||||
|
[...(data || [])].sort(
|
||||||
|
(a, b) =>
|
||||||
|
(a.ParentIndexNumber ?? 0) - (b.ParentIndexNumber ?? 0) ||
|
||||||
|
(a.IndexNumber ?? 0) - (b.IndexNumber ?? 0),
|
||||||
|
),
|
||||||
staleTime: 60,
|
staleTime: 60,
|
||||||
enabled: !!api && !!user?.Id && !!item?.Id,
|
enabled: !!api && !!user?.Id && !!item?.Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () =>
|
||||||
|
!isLoading &&
|
||||||
|
item &&
|
||||||
|
allEpisodes &&
|
||||||
|
allEpisodes.length > 0 && (
|
||||||
|
<View className='flex flex-row items-center space-x-2'>
|
||||||
|
<AddToFavorites item={item} />
|
||||||
|
{!Platform.isTV && (
|
||||||
|
<DownloadItems
|
||||||
|
size='large'
|
||||||
|
title={t("item_card.download.download_series")}
|
||||||
|
items={allEpisodes || []}
|
||||||
|
MissingDownloadIconComponent={() => (
|
||||||
|
<Ionicons name='download' size={22} color='white' />
|
||||||
|
)}
|
||||||
|
DownloadedIconComponent={() => (
|
||||||
|
<Ionicons
|
||||||
|
name='checkmark-done-outline'
|
||||||
|
size={24}
|
||||||
|
color='#9333ea'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [allEpisodes, isLoading, item]);
|
||||||
|
|
||||||
if (!item || !backdropUrl) return null;
|
if (!item || !backdropUrl) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -90,25 +133,23 @@ const page: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
logo={
|
logo={
|
||||||
<>
|
logoUrl ? (
|
||||||
{logoUrl ? (
|
<Image
|
||||||
<Image
|
source={{
|
||||||
source={{
|
uri: logoUrl,
|
||||||
uri: logoUrl,
|
}}
|
||||||
}}
|
style={{
|
||||||
style={{
|
height: 130,
|
||||||
height: 130,
|
width: "100%",
|
||||||
width: "100%",
|
resizeMode: "contain",
|
||||||
resizeMode: "contain",
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
) : undefined
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col pt-4">
|
<View className='flex flex-col pt-4'>
|
||||||
<SeriesHeader item={item} />
|
<SeriesHeader item={item} />
|
||||||
<View className="mb-4">
|
<View className='mb-4'>
|
||||||
<NextUp seriesId={seriesId} />
|
<NextUp seriesId={seriesId} />
|
||||||
</View>
|
</View>
|
||||||
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
|
<SeasonPicker item={item} initialSeasonIndex={Number(seasonIndex)} />
|
||||||
@@ -1,33 +1,4 @@
|
|||||||
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
import type {
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import React, { useCallback, useEffect, useMemo } from "react";
|
|
||||||
import { FlatList, useWindowDimensions, View } from "react-native";
|
|
||||||
|
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import { FilterButton } from "@/components/filters/FilterButton";
|
|
||||||
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { ItemPoster } from "@/components/posters/ItemPoster";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import {
|
|
||||||
genreFilterAtom,
|
|
||||||
getSortByPreference,
|
|
||||||
getSortOrderPreference,
|
|
||||||
sortByAtom,
|
|
||||||
SortByOption,
|
|
||||||
sortByPreferenceAtom,
|
|
||||||
sortOptions,
|
|
||||||
sortOrderAtom,
|
|
||||||
SortOrderOption,
|
|
||||||
sortOrderOptions,
|
|
||||||
sortOrderPreferenceAtom,
|
|
||||||
tagsFilterAtom,
|
|
||||||
yearFilterAtom,
|
|
||||||
} from "@/utils/atoms/filters";
|
|
||||||
import {
|
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemDtoQueryResult,
|
BaseItemDtoQueryResult,
|
||||||
BaseItemKind,
|
BaseItemKind,
|
||||||
@@ -38,8 +9,38 @@ import {
|
|||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
} from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import React, { useCallback, useEffect, useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { FlatList, useWindowDimensions, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { colletionTypeToItemType } from "@/utils/collectionTypeToItemType";
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
|
import { ResetFiltersButton } from "@/components/filters/ResetFiltersButton";
|
||||||
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { ItemPoster } from "@/components/posters/ItemPoster";
|
||||||
|
import { useOrientation } from "@/hooks/useOrientation";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import {
|
||||||
|
genreFilterAtom,
|
||||||
|
getSortByPreference,
|
||||||
|
getSortOrderPreference,
|
||||||
|
SortByOption,
|
||||||
|
SortOrderOption,
|
||||||
|
sortByAtom,
|
||||||
|
sortByPreferenceAtom,
|
||||||
|
sortOptions,
|
||||||
|
sortOrderAtom,
|
||||||
|
sortOrderOptions,
|
||||||
|
sortOrderPreferenceAtom,
|
||||||
|
tagsFilterAtom,
|
||||||
|
yearFilterAtom,
|
||||||
|
} from "@/utils/atoms/filters";
|
||||||
|
|
||||||
const Page = () => {
|
const Page = () => {
|
||||||
const searchParams = useLocalSearchParams();
|
const searchParams = useLocalSearchParams();
|
||||||
@@ -56,9 +57,13 @@ const Page = () => {
|
|||||||
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
const [sortOrder, _setSortOrder] = useAtom(sortOrderAtom);
|
||||||
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
const [sortByPreference, setSortByPreference] = useAtom(sortByPreferenceAtom);
|
||||||
const [sortOrderPreference, setOderByPreference] = useAtom(
|
const [sortOrderPreference, setOderByPreference] = useAtom(
|
||||||
sortOrderPreferenceAtom
|
sortOrderPreferenceAtom,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { orientation } = useOrientation();
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
const sop = getSortOrderPreference(libraryId, sortOrderPreference);
|
||||||
if (sop) {
|
if (sop) {
|
||||||
@@ -82,7 +87,7 @@ const Page = () => {
|
|||||||
}
|
}
|
||||||
_setSortBy(sortBy);
|
_setSortBy(sortBy);
|
||||||
},
|
},
|
||||||
[libraryId, sortByPreference]
|
[libraryId, sortByPreference],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setSortOrder = useCallback(
|
const setSortOrder = useCallback(
|
||||||
@@ -96,7 +101,7 @@ const Page = () => {
|
|||||||
}
|
}
|
||||||
_setSortOrder(sortOrder);
|
_setSortOrder(sortOrder);
|
||||||
},
|
},
|
||||||
[libraryId, sortOrderPreference]
|
[libraryId, sortOrderPreference],
|
||||||
);
|
);
|
||||||
|
|
||||||
const nrOfCols = useMemo(() => {
|
const nrOfCols = useMemo(() => {
|
||||||
@@ -163,7 +168,7 @@ const Page = () => {
|
|||||||
fields: ["PrimaryImageAspectRatio", "SortName"],
|
fields: ["PrimaryImageAspectRatio", "SortName"],
|
||||||
genres: selectedGenres,
|
genres: selectedGenres,
|
||||||
tags: selectedTags,
|
tags: selectedTags,
|
||||||
years: selectedYears.map((year) => parseInt(year)),
|
years: selectedYears.map((year) => Number.parseInt(year, 10)),
|
||||||
includeItemTypes: itemType ? [itemType] : undefined,
|
includeItemTypes: itemType ? [itemType] : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -179,7 +184,7 @@ const Page = () => {
|
|||||||
selectedTags,
|
selectedTags,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
]
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
const { data, isFetching, fetchNextPage, hasNextPage, isLoading } =
|
||||||
@@ -205,14 +210,13 @@ const Page = () => {
|
|||||||
const totalItems = lastPage.TotalRecordCount;
|
const totalItems = lastPage.TotalRecordCount;
|
||||||
const accumulatedItems = pages.reduce(
|
const accumulatedItems = pages.reduce(
|
||||||
(acc, curr) => acc + (curr?.Items?.length || 0),
|
(acc, curr) => acc + (curr?.Items?.length || 0),
|
||||||
0
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (accumulatedItems < totalItems) {
|
if (accumulatedItems < totalItems) {
|
||||||
return lastPage?.Items?.length * pages.length;
|
return lastPage?.Items?.length * pages.length;
|
||||||
} else {
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
return undefined;
|
||||||
},
|
},
|
||||||
initialPageParam: 0,
|
initialPageParam: 0,
|
||||||
enabled: !!api && !!user?.Id && !!library,
|
enabled: !!api && !!user?.Id && !!library,
|
||||||
@@ -237,7 +241,14 @@ const Page = () => {
|
|||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
alignSelf: "center",
|
alignSelf:
|
||||||
|
orientation === ScreenOrientation.OrientationLock.PORTRAIT_UP
|
||||||
|
? index % nrOfCols === 0
|
||||||
|
? "flex-end"
|
||||||
|
: (index + 1) % nrOfCols === 0
|
||||||
|
? "flex-start"
|
||||||
|
: "center"
|
||||||
|
: "center",
|
||||||
width: "89%",
|
width: "89%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -247,14 +258,14 @@ const Page = () => {
|
|||||||
</View>
|
</View>
|
||||||
</TouchableItemRouter>
|
</TouchableItemRouter>
|
||||||
),
|
),
|
||||||
[orientation]
|
[orientation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
const keyExtractor = useCallback((item: BaseItemDto) => item.Id || "", []);
|
||||||
|
|
||||||
const ListHeaderComponent = useCallback(
|
const ListHeaderComponent = useCallback(
|
||||||
() => (
|
() => (
|
||||||
<View className="">
|
<View className=''>
|
||||||
<FlatList
|
<FlatList
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
@@ -273,13 +284,13 @@ const Page = () => {
|
|||||||
key: "genre",
|
key: "genre",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className="mr-1"
|
className='mr-1'
|
||||||
collectionId={libraryId}
|
id={libraryId}
|
||||||
queryKey="genreFilter"
|
queryKey='genreFilter'
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
const response = await getFilterApi(
|
const response = await getFilterApi(
|
||||||
api
|
api,
|
||||||
).getQueryFiltersLegacy({
|
).getQueryFiltersLegacy({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: libraryId,
|
parentId: libraryId,
|
||||||
@@ -288,7 +299,7 @@ const Page = () => {
|
|||||||
}}
|
}}
|
||||||
set={setSelectedGenres}
|
set={setSelectedGenres}
|
||||||
values={selectedGenres}
|
values={selectedGenres}
|
||||||
title="Genres"
|
title={t("library.filters.genres")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
searchFilter={(item, search) =>
|
searchFilter={(item, search) =>
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
@@ -300,13 +311,13 @@ const Page = () => {
|
|||||||
key: "year",
|
key: "year",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className="mr-1"
|
className='mr-1'
|
||||||
collectionId={libraryId}
|
id={libraryId}
|
||||||
queryKey="yearFilter"
|
queryKey='yearFilter'
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
const response = await getFilterApi(
|
const response = await getFilterApi(
|
||||||
api
|
api,
|
||||||
).getQueryFiltersLegacy({
|
).getQueryFiltersLegacy({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: libraryId,
|
parentId: libraryId,
|
||||||
@@ -315,7 +326,7 @@ const Page = () => {
|
|||||||
}}
|
}}
|
||||||
set={setSelectedYears}
|
set={setSelectedYears}
|
||||||
values={selectedYears}
|
values={selectedYears}
|
||||||
title="Years"
|
title={t("library.filters.years")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
searchFilter={(item, search) => item.includes(search)}
|
searchFilter={(item, search) => item.includes(search)}
|
||||||
/>
|
/>
|
||||||
@@ -325,13 +336,13 @@ const Page = () => {
|
|||||||
key: "tags",
|
key: "tags",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className="mr-1"
|
className='mr-1'
|
||||||
collectionId={libraryId}
|
id={libraryId}
|
||||||
queryKey="tagsFilter"
|
queryKey='tagsFilter'
|
||||||
queryFn={async () => {
|
queryFn={async () => {
|
||||||
if (!api) return null;
|
if (!api) return null;
|
||||||
const response = await getFilterApi(
|
const response = await getFilterApi(
|
||||||
api
|
api,
|
||||||
).getQueryFiltersLegacy({
|
).getQueryFiltersLegacy({
|
||||||
userId: user?.Id,
|
userId: user?.Id,
|
||||||
parentId: libraryId,
|
parentId: libraryId,
|
||||||
@@ -340,7 +351,7 @@ const Page = () => {
|
|||||||
}}
|
}}
|
||||||
set={setSelectedTags}
|
set={setSelectedTags}
|
||||||
values={selectedTags}
|
values={selectedTags}
|
||||||
title="Tags"
|
title={t("library.filters.tags")}
|
||||||
renderItemLabel={(item) => item.toString()}
|
renderItemLabel={(item) => item.toString()}
|
||||||
searchFilter={(item, search) =>
|
searchFilter={(item, search) =>
|
||||||
item.toLowerCase().includes(search.toLowerCase())
|
item.toLowerCase().includes(search.toLowerCase())
|
||||||
@@ -352,13 +363,13 @@ const Page = () => {
|
|||||||
key: "sortBy",
|
key: "sortBy",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className="mr-1"
|
className='mr-1'
|
||||||
collectionId={libraryId}
|
id={libraryId}
|
||||||
queryKey="sortBy"
|
queryKey='sortBy'
|
||||||
queryFn={async () => sortOptions.map((s) => s.key)}
|
queryFn={async () => sortOptions.map((s) => s.key)}
|
||||||
set={setSortBy}
|
set={setSortBy}
|
||||||
values={sortBy}
|
values={sortBy}
|
||||||
title="Sort By"
|
title={t("library.filters.sort_by")}
|
||||||
renderItemLabel={(item) =>
|
renderItemLabel={(item) =>
|
||||||
sortOptions.find((i) => i.key === item)?.value || ""
|
sortOptions.find((i) => i.key === item)?.value || ""
|
||||||
}
|
}
|
||||||
@@ -372,13 +383,13 @@ const Page = () => {
|
|||||||
key: "sortOrder",
|
key: "sortOrder",
|
||||||
component: (
|
component: (
|
||||||
<FilterButton
|
<FilterButton
|
||||||
className="mr-1"
|
className='mr-1'
|
||||||
collectionId={libraryId}
|
id={libraryId}
|
||||||
queryKey="sortOrder"
|
queryKey='sortOrder'
|
||||||
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
queryFn={async () => sortOrderOptions.map((s) => s.key)}
|
||||||
set={setSortOrder}
|
set={setSortOrder}
|
||||||
values={sortOrder}
|
values={sortOrder}
|
||||||
title="Sort Order"
|
title={t("library.filters.sort_order")}
|
||||||
renderItemLabel={(item) =>
|
renderItemLabel={(item) =>
|
||||||
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
sortOrderOptions.find((i) => i.key === item)?.value || ""
|
||||||
}
|
}
|
||||||
@@ -409,34 +420,29 @@ const Page = () => {
|
|||||||
sortOrder,
|
sortOrder,
|
||||||
setSortOrder,
|
setSortOrder,
|
||||||
isFetching,
|
isFetching,
|
||||||
]
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
if (isLoading || isLibraryLoading)
|
if (isLoading || isLibraryLoading)
|
||||||
return (
|
return (
|
||||||
<View className="w-full h-full flex items-center justify-center">
|
<View className='w-full h-full flex items-center justify-center'>
|
||||||
<Loader />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (flatData.length === 0)
|
|
||||||
return (
|
|
||||||
<View className="h-full w-full flex justify-center items-center">
|
|
||||||
<Text className="text-lg text-neutral-500">No items found</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlashList
|
<FlashList
|
||||||
key={orientation}
|
key={orientation}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<View className="flex flex-col items-center justify-center h-full">
|
<View className='flex flex-col items-center justify-center h-full'>
|
||||||
<Text className="font-bold text-xl text-neutral-500">No results</Text>
|
<Text className='font-bold text-xl text-neutral-500'>
|
||||||
|
{t("library.no_results")}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
data={flatData}
|
data={flatData}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
extraData={[orientation, nrOfCols]}
|
extraData={[orientation, nrOfCols]}
|
||||||
@@ -461,7 +467,7 @@ const Page = () => {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
}}
|
}}
|
||||||
></View>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,230 +1,208 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import { useState } from "react";
|
import { Platform } from "react-native";
|
||||||
import { Modal, Platform, TouchableOpacity, View } from "react-native";
|
import { nestedTabPageScreenOptions } from "@/components/stacks/NestedTabPageStack";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
const DropdownMenu = !Platform.isTV ? require("zeego/dropdown-menu") : null;
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function IndexLayout() {
|
export default function IndexLayout() {
|
||||||
const [settings, updateSettings] = useSettings();
|
const [settings, updateSettings, pluginSettings] = useSettings();
|
||||||
const [isMenuVisible, setIsMenuVisible] = useState(false);
|
|
||||||
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const MenuItem = ({
|
const { t } = useTranslation();
|
||||||
label,
|
|
||||||
selected,
|
|
||||||
onPress,
|
|
||||||
disabled = false,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
selected?: boolean;
|
|
||||||
onPress: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
className={`p-4 border-b border-neutral-800 flex-row items-center justify-between ${
|
|
||||||
disabled ? "opacity-50" : ""
|
|
||||||
}`}
|
|
||||||
onPress={onPress}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
<Text className="text-base">{label}</Text>
|
|
||||||
{selected && <Ionicons name="checkmark" size={24} color="white" />}
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
|
|
||||||
const MenuSection = ({ title }: { title: string }) => (
|
|
||||||
<View className="p-4 border-b border-neutral-800 bg-neutral-800/30">
|
|
||||||
<Text className="text-sm opacity-50 font-medium">{title}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!settings?.libraryOptions) return null;
|
if (!settings?.libraryOptions) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="index"
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: "Library",
|
headerTitle: t("tabs.library"),
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerLargeStyle: {
|
||||||
|
backgroundColor: "black",
|
||||||
|
},
|
||||||
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerRight: () => (
|
headerRight: () =>
|
||||||
<Modal
|
!pluginSettings?.libraryOptions?.locked &&
|
||||||
visible={isMenuVisible}
|
!Platform.isTV && (
|
||||||
transparent
|
<DropdownMenu.Root>
|
||||||
animationType="slide"
|
<DropdownMenu.Trigger>
|
||||||
onRequestClose={() => {
|
<Ionicons
|
||||||
setIsMenuVisible(false);
|
name='ellipsis-horizontal-outline'
|
||||||
setActiveSubmenu(null);
|
size={24}
|
||||||
}}
|
color='white'
|
||||||
>
|
/>
|
||||||
<TouchableOpacity
|
</DropdownMenu.Trigger>
|
||||||
className="flex-1 bg-black/50"
|
<DropdownMenu.Content
|
||||||
activeOpacity={1}
|
align={"end"}
|
||||||
onPress={() => {
|
alignOffset={-10}
|
||||||
setIsMenuVisible(false);
|
avoidCollisions={false}
|
||||||
setActiveSubmenu(null);
|
collisionPadding={0}
|
||||||
}}
|
loop={false}
|
||||||
>
|
side={"bottom"}
|
||||||
<View className="mt-auto bg-neutral-900 rounded-t-xl">
|
sideOffset={10}
|
||||||
{!activeSubmenu ? (
|
>
|
||||||
<>
|
<DropdownMenu.Label>
|
||||||
<MenuSection title="Display" />
|
{t("library.options.display")}
|
||||||
<MenuItem
|
</DropdownMenu.Label>
|
||||||
label="Display"
|
<DropdownMenu.Group key='display-group'>
|
||||||
onPress={() => setActiveSubmenu("display")}
|
<DropdownMenu.Sub>
|
||||||
/>
|
<DropdownMenu.SubTrigger key='image-style-trigger'>
|
||||||
<MenuItem
|
{t("library.options.display")}
|
||||||
label="Image style"
|
</DropdownMenu.SubTrigger>
|
||||||
onPress={() => setActiveSubmenu("imageStyle")}
|
<DropdownMenu.SubContent
|
||||||
/>
|
alignOffset={-10}
|
||||||
<MenuItem
|
avoidCollisions={true}
|
||||||
label="Show titles"
|
collisionPadding={0}
|
||||||
selected={settings.libraryOptions.showTitles}
|
loop={true}
|
||||||
disabled={
|
sideOffset={10}
|
||||||
settings.libraryOptions.imageStyle === "poster"
|
>
|
||||||
}
|
<DropdownMenu.CheckboxItem
|
||||||
onPress={() => {
|
key='display-option-1'
|
||||||
if (settings.libraryOptions.imageStyle === "poster")
|
value={settings.libraryOptions.display === "row"}
|
||||||
return;
|
onValueChange={() =>
|
||||||
updateSettings({
|
updateSettings({
|
||||||
libraryOptions: {
|
libraryOptions: {
|
||||||
...settings.libraryOptions,
|
...settings.libraryOptions,
|
||||||
showTitles: !settings.libraryOptions.showTitles,
|
display: "row",
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}}
|
}
|
||||||
/>
|
|
||||||
<MenuItem
|
|
||||||
label="Show stats"
|
|
||||||
selected={settings.libraryOptions.showStats}
|
|
||||||
onPress={() => {
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
showStats: !settings.libraryOptions.showStats,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : activeSubmenu === "display" ? (
|
|
||||||
<>
|
|
||||||
<View className="p-4 border-b border-neutral-800 flex-row items-center">
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setActiveSubmenu(null)}
|
|
||||||
>
|
>
|
||||||
<Ionicons
|
<DropdownMenu.ItemIndicator />
|
||||||
name="chevron-back"
|
<DropdownMenu.ItemTitle key='display-title-1'>
|
||||||
size={24}
|
{t("library.options.row")}
|
||||||
color="white"
|
</DropdownMenu.ItemTitle>
|
||||||
/>
|
</DropdownMenu.CheckboxItem>
|
||||||
</TouchableOpacity>
|
<DropdownMenu.CheckboxItem
|
||||||
<Text className="text-lg font-bold ml-2">Display</Text>
|
key='display-option-2'
|
||||||
</View>
|
value={settings.libraryOptions.display === "list"}
|
||||||
<MenuItem
|
onValueChange={() =>
|
||||||
label="Row"
|
updateSettings({
|
||||||
selected={settings.libraryOptions.display === "row"}
|
libraryOptions: {
|
||||||
onPress={() => {
|
...settings.libraryOptions,
|
||||||
updateSettings({
|
display: "list",
|
||||||
libraryOptions: {
|
},
|
||||||
...settings.libraryOptions,
|
})
|
||||||
display: "row",
|
}
|
||||||
},
|
|
||||||
});
|
|
||||||
setActiveSubmenu(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<MenuItem
|
|
||||||
label="List"
|
|
||||||
selected={settings.libraryOptions.display === "list"}
|
|
||||||
onPress={() => {
|
|
||||||
updateSettings({
|
|
||||||
libraryOptions: {
|
|
||||||
...settings.libraryOptions,
|
|
||||||
display: "list",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setActiveSubmenu(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : activeSubmenu === "imageStyle" ? (
|
|
||||||
<>
|
|
||||||
<View className="p-4 border-b border-neutral-800 flex-row items-center">
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setActiveSubmenu(null)}
|
|
||||||
>
|
>
|
||||||
<Ionicons
|
<DropdownMenu.ItemIndicator />
|
||||||
name="chevron-back"
|
<DropdownMenu.ItemTitle key='display-title-2'>
|
||||||
size={24}
|
{t("library.options.list")}
|
||||||
color="white"
|
</DropdownMenu.ItemTitle>
|
||||||
/>
|
</DropdownMenu.CheckboxItem>
|
||||||
</TouchableOpacity>
|
</DropdownMenu.SubContent>
|
||||||
<Text className="text-lg font-bold ml-2">
|
</DropdownMenu.Sub>
|
||||||
Image Style
|
<DropdownMenu.Sub>
|
||||||
</Text>
|
<DropdownMenu.SubTrigger key='image-style-trigger'>
|
||||||
</View>
|
{t("library.options.image_style")}
|
||||||
<MenuItem
|
</DropdownMenu.SubTrigger>
|
||||||
label="Poster"
|
<DropdownMenu.SubContent
|
||||||
selected={
|
alignOffset={-10}
|
||||||
settings.libraryOptions.imageStyle === "poster"
|
avoidCollisions={true}
|
||||||
}
|
collisionPadding={0}
|
||||||
onPress={() => {
|
loop={true}
|
||||||
updateSettings({
|
sideOffset={10}
|
||||||
libraryOptions: {
|
>
|
||||||
...settings.libraryOptions,
|
<DropdownMenu.CheckboxItem
|
||||||
imageStyle: "poster",
|
key='poster-option'
|
||||||
},
|
value={
|
||||||
});
|
settings.libraryOptions.imageStyle === "poster"
|
||||||
setActiveSubmenu(null);
|
}
|
||||||
}}
|
onValueChange={() =>
|
||||||
/>
|
updateSettings({
|
||||||
<MenuItem
|
libraryOptions: {
|
||||||
label="Cover"
|
...settings.libraryOptions,
|
||||||
selected={
|
imageStyle: "poster",
|
||||||
settings.libraryOptions.imageStyle === "cover"
|
},
|
||||||
}
|
})
|
||||||
onPress={() => {
|
}
|
||||||
updateSettings({
|
>
|
||||||
libraryOptions: {
|
<DropdownMenu.ItemIndicator />
|
||||||
...settings.libraryOptions,
|
<DropdownMenu.ItemTitle key='poster-title'>
|
||||||
imageStyle: "cover",
|
{t("library.options.poster")}
|
||||||
},
|
</DropdownMenu.ItemTitle>
|
||||||
});
|
</DropdownMenu.CheckboxItem>
|
||||||
setActiveSubmenu(null);
|
<DropdownMenu.CheckboxItem
|
||||||
}}
|
key='cover-option'
|
||||||
/>
|
value={settings.libraryOptions.imageStyle === "cover"}
|
||||||
</>
|
onValueChange={() =>
|
||||||
) : null}
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
imageStyle: "cover",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key='cover-title'>
|
||||||
|
{t("library.options.cover")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
</DropdownMenu.SubContent>
|
||||||
|
</DropdownMenu.Sub>
|
||||||
|
</DropdownMenu.Group>
|
||||||
|
<DropdownMenu.Group key='show-titles-group'>
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
disabled={settings.libraryOptions.imageStyle === "poster"}
|
||||||
|
key='show-titles-option'
|
||||||
|
value={settings.libraryOptions.showTitles}
|
||||||
|
onValueChange={(newValue: string) => {
|
||||||
|
if (settings.libraryOptions.imageStyle === "poster")
|
||||||
|
return;
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
showTitles: newValue === "on",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key='show-titles-title'>
|
||||||
|
{t("library.options.show_titles")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
<DropdownMenu.CheckboxItem
|
||||||
|
key='show-stats-option'
|
||||||
|
value={settings.libraryOptions.showStats}
|
||||||
|
onValueChange={(newValue: string) => {
|
||||||
|
updateSettings({
|
||||||
|
libraryOptions: {
|
||||||
|
...settings.libraryOptions,
|
||||||
|
showStats: newValue === "on",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemIndicator />
|
||||||
|
<DropdownMenu.ItemTitle key='show-stats-title'>
|
||||||
|
{t("library.options.show_stats")}
|
||||||
|
</DropdownMenu.ItemTitle>
|
||||||
|
</DropdownMenu.CheckboxItem>
|
||||||
|
</DropdownMenu.Group>
|
||||||
|
|
||||||
<TouchableOpacity
|
<DropdownMenu.Separator />
|
||||||
className="p-4 border-t border-neutral-800"
|
</DropdownMenu.Content>
|
||||||
onPress={() => {
|
</DropdownMenu.Root>
|
||||||
setIsMenuVisible(false);
|
),
|
||||||
setActiveSubmenu(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text className="text-center text-purple-400">Done</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</Modal>
|
|
||||||
),
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="[libraryId]"
|
name='[libraryId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -232,12 +210,12 @@ export default function IndexLayout() {
|
|||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="collections/[collectionId]"
|
name='collections/[collectionId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import {
|
import {
|
||||||
getUserLibraryApi,
|
getUserLibraryApi,
|
||||||
getUserViewsApi,
|
getUserViewsApi,
|
||||||
@@ -10,9 +5,15 @@ import {
|
|||||||
import { FlashList } from "@shopify/flash-list";
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { StyleSheet, View } from "react-native";
|
import { StyleSheet, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import { Loader } from "@/components/Loader";
|
||||||
|
import { LibraryItemCard } from "@/components/library/LibraryItemCard";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
export default function index() {
|
export default function index() {
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
@@ -20,23 +21,29 @@ export default function index() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
|
||||||
const { data, isLoading: isLoading } = useQuery({
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["user-views", user?.Id],
|
queryKey: ["user-views", user?.Id],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!api || !user?.Id) {
|
const response = await getUserViewsApi(api!).getUserViews({
|
||||||
return null;
|
userId: user?.Id,
|
||||||
}
|
|
||||||
|
|
||||||
const response = await getUserViewsApi(api).getUserViews({
|
|
||||||
userId: user.Id,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data.Items || null;
|
return response.data.Items || null;
|
||||||
},
|
},
|
||||||
enabled: !!api && !!user?.Id,
|
staleTime: 60,
|
||||||
staleTime: 60 * 1000 * 60,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const libraries = useMemo(
|
||||||
|
() =>
|
||||||
|
data
|
||||||
|
?.filter((l) => !settings?.hiddenLibraries?.includes(l.Id!))
|
||||||
|
.filter((l) => l.CollectionType !== "music")
|
||||||
|
.filter((l) => l.CollectionType !== "books") || [],
|
||||||
|
[data, settings?.hiddenLibraries],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
for (const item of data || []) {
|
for (const item of data || []) {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
@@ -58,22 +65,24 @@ export default function index() {
|
|||||||
|
|
||||||
if (isLoading)
|
if (isLoading)
|
||||||
return (
|
return (
|
||||||
<View className="justify-center items-center h-full">
|
<View className='justify-center items-center h-full'>
|
||||||
<Loader />
|
<Loader />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!data)
|
if (!libraries)
|
||||||
return (
|
return (
|
||||||
<View className="h-full w-full flex justify-center items-center">
|
<View className='h-full w-full flex justify-center items-center'>
|
||||||
<Text className="text-lg text-neutral-500">No libraries found</Text>
|
<Text className='text-lg text-neutral-500'>
|
||||||
|
{t("library.no_libraries_found")}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlashList
|
<FlashList
|
||||||
extraData={settings}
|
extraData={settings}
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
contentContainerStyle={{
|
contentContainerStyle={{
|
||||||
paddingTop: 17,
|
paddingTop: 17,
|
||||||
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
paddingHorizontal: settings?.libraryOptions?.display === "row" ? 0 : 17,
|
||||||
@@ -81,7 +90,7 @@ export default function index() {
|
|||||||
paddingLeft: insets.left,
|
paddingLeft: insets.left,
|
||||||
paddingRight: insets.right,
|
paddingRight: insets.right,
|
||||||
}}
|
}}
|
||||||
data={data}
|
data={libraries}
|
||||||
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
renderItem={({ item }) => <LibraryItemCard library={item} />}
|
||||||
keyExtractor={(item) => item.Id || ""}
|
keyExtractor={(item) => item.Id || ""}
|
||||||
ItemSeparatorComponent={() =>
|
ItemSeparatorComponent={() =>
|
||||||
@@ -90,10 +99,10 @@ export default function index() {
|
|||||||
style={{
|
style={{
|
||||||
height: StyleSheet.hairlineWidth,
|
height: StyleSheet.hairlineWidth,
|
||||||
}}
|
}}
|
||||||
className="bg-neutral-800 mx-2 my-4"
|
className='bg-neutral-800 mx-2 my-4'
|
||||||
></View>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View className="h-4" />
|
<View className='h-4' />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
estimatedItemSize={200}
|
estimatedItemSize={200}
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
import {commonScreenOptions, nestedTabPageScreenOptions} from "@/components/stacks/NestedTabPageStack";
|
|
||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
|
import {
|
||||||
|
commonScreenOptions,
|
||||||
|
nestedTabPageScreenOptions,
|
||||||
|
} from "@/components/stacks/NestedTabPageStack";
|
||||||
|
|
||||||
export default function SearchLayout() {
|
export default function SearchLayout() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="index"
|
name='index'
|
||||||
options={{
|
options={{
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerLargeTitle: true,
|
headerLargeTitle: true,
|
||||||
headerTitle: "Search",
|
headerTitle: t("tabs.search"),
|
||||||
|
headerLargeStyle: {
|
||||||
|
backgroundColor: "black",
|
||||||
|
},
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -20,17 +28,26 @@ export default function SearchLayout() {
|
|||||||
<Stack.Screen key={name} name={name} options={options} />
|
<Stack.Screen key={name} name={name} options={options} />
|
||||||
))}
|
))}
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="collections/[collectionId]"
|
name='collections/[collectionId]'
|
||||||
options={{
|
options={{
|
||||||
title: "",
|
title: "",
|
||||||
headerShown: true,
|
headerShown: !Platform.isTV,
|
||||||
headerBlurEffect: "prominent",
|
headerBlurEffect: "prominent",
|
||||||
headerTransparent: Platform.OS === "ios" ? true : false,
|
headerTransparent: Platform.OS === "ios",
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen name='jellyseerr/page' options={commonScreenOptions} />
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="jellyseerr/page"
|
name='jellyseerr/person/[personId]'
|
||||||
|
options={commonScreenOptions}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='jellyseerr/company/[companyId]'
|
||||||
|
options={commonScreenOptions}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name='jellyseerr/genre/[genreId]'
|
||||||
options={commonScreenOptions}
|
options={commonScreenOptions}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,42 +1,44 @@
|
|||||||
import { Input } from "@/components/common/Input";
|
import type {
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
|
||||||
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
|
||||||
import { ItemCardText } from "@/components/ItemCardText";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import AlbumCover from "@/components/posters/AlbumCover";
|
|
||||||
import MoviePoster from "@/components/posters/MoviePoster";
|
|
||||||
import SeriesPoster from "@/components/posters/SeriesPoster";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getUserItemData } from "@/utils/jellyfin/user-library/getUserItemData";
|
|
||||||
import {
|
|
||||||
BaseItemDto,
|
BaseItemDto,
|
||||||
BaseItemKind,
|
BaseItemKind,
|
||||||
} from "@jellyfin/sdk/lib/generated-client/models";
|
} from "@jellyfin/sdk/lib/generated-client/models";
|
||||||
import { getItemsApi, getSearchApi } from "@jellyfin/sdk/lib/utils/api";
|
import { getItemsApi } from "@jellyfin/sdk/lib/utils/api";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { Href, router, useLocalSearchParams, useNavigation } from "expo-router";
|
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import React, {
|
import {
|
||||||
PropsWithChildren,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useId,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
import { Platform, ScrollView, TouchableOpacity, View } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { useDebounce } from "use-debounce";
|
import { useDebounce } from "use-debounce";
|
||||||
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
import ContinueWatchingPoster from "@/components/ContinueWatchingPoster";
|
||||||
import { MovieResult, TvResult } from "@/utils/jellyseerr/server/models/Search";
|
import { Input } from "@/components/common/Input";
|
||||||
import { MediaType } from "@/utils/jellyseerr/server/constants/media";
|
import { Text } from "@/components/common/Text";
|
||||||
import JellyseerrPoster from "@/components/posters/JellyseerrPoster";
|
import { TouchableItemRouter } from "@/components/common/TouchableItemRouter";
|
||||||
|
import { FilterButton } from "@/components/filters/FilterButton";
|
||||||
import { Tag } from "@/components/GenreTags";
|
import { Tag } from "@/components/GenreTags";
|
||||||
import DiscoverSlide from "@/components/jellyseerr/DiscoverSlide";
|
import { ItemCardText } from "@/components/ItemCardText";
|
||||||
import { sortBy } from "lodash";
|
import {
|
||||||
|
JellyseerrSearchSort,
|
||||||
|
JellyserrIndexPage,
|
||||||
|
} from "@/components/jellyseerr/JellyseerrIndexPage";
|
||||||
|
import MoviePoster from "@/components/posters/MoviePoster";
|
||||||
|
import SeriesPoster from "@/components/posters/SeriesPoster";
|
||||||
|
import { LoadingSkeleton } from "@/components/search/LoadingSkeleton";
|
||||||
|
import { SearchItemWrapper } from "@/components/search/SearchItemWrapper";
|
||||||
|
import { useJellyseerr } from "@/hooks/useJellyseerr";
|
||||||
|
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
|
||||||
type SearchType = "Library" | "Discover";
|
type SearchType = "Library" | "Discover";
|
||||||
|
|
||||||
@@ -53,7 +55,14 @@ export default function search() {
|
|||||||
const params = useLocalSearchParams();
|
const params = useLocalSearchParams();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const { q, prev } = params as { q: string; prev: Href<string> };
|
const [user] = useAtom(userAtom);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const searchFilterId = useId();
|
||||||
|
const orderFilterId = useId();
|
||||||
|
|
||||||
|
const { q } = params as { q: string };
|
||||||
|
|
||||||
const [searchType, setSearchType] = useState<SearchType>("Library");
|
const [searchType, setSearchType] = useState<SearchType>("Library");
|
||||||
const [search, setSearch] = useState<string>("");
|
const [search, setSearch] = useState<string>("");
|
||||||
@@ -61,17 +70,27 @@ export default function search() {
|
|||||||
const [debouncedSearch] = useDebounce(search, 500);
|
const [debouncedSearch] = useDebounce(search, 500);
|
||||||
|
|
||||||
const [api] = useAtom(apiAtom);
|
const [api] = useAtom(apiAtom);
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
const { jellyseerrApi } = useJellyseerr();
|
const { jellyseerrApi } = useJellyseerr();
|
||||||
|
const [jellyseerrOrderBy, setJellyseerrOrderBy] =
|
||||||
|
useState<JellyseerrSearchSort>(
|
||||||
|
JellyseerrSearchSort[
|
||||||
|
JellyseerrSearchSort.DEFAULT
|
||||||
|
] as unknown as JellyseerrSearchSort,
|
||||||
|
);
|
||||||
|
const [jellyseerrSortOrder, setJellyseerrSortOrder] = useState<
|
||||||
|
"asc" | "desc"
|
||||||
|
>("desc");
|
||||||
|
|
||||||
const searchEngine = useMemo(() => {
|
const searchEngine = useMemo(() => {
|
||||||
return settings?.searchEngine || "Jellyfin";
|
return settings?.searchEngine || "Jellyfin";
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (q && q.length > 0) setSearch(q);
|
if (q && q.length > 0) {
|
||||||
|
setSearch(q);
|
||||||
|
}
|
||||||
}, [q]);
|
}, [q]);
|
||||||
|
|
||||||
const searchFn = useCallback(
|
const searchFn = useCallback(
|
||||||
@@ -82,61 +101,94 @@ export default function search() {
|
|||||||
types: BaseItemKind[];
|
types: BaseItemKind[];
|
||||||
query: string;
|
query: string;
|
||||||
}): Promise<BaseItemDto[]> => {
|
}): Promise<BaseItemDto[]> => {
|
||||||
if (!api || !query) return [];
|
if (!api || !query) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (searchEngine === "Jellyfin") {
|
if (searchEngine === "Jellyfin") {
|
||||||
const searchApi = await getSearchApi(api).getSearchHints({
|
const searchApi = await getItemsApi(api).getItems({
|
||||||
searchTerm: query,
|
searchTerm: query,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
includeItemTypes: types,
|
includeItemTypes: types,
|
||||||
|
recursive: true,
|
||||||
|
userId: user?.Id,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (searchApi.data.SearchHints as BaseItemDto[]) || [];
|
return (searchApi.data.Items as BaseItemDto[]) || [];
|
||||||
} else {
|
|
||||||
if (!settings?.marlinServerUrl) return [];
|
|
||||||
const url = `${
|
|
||||||
settings.marlinServerUrl
|
|
||||||
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
|
||||||
.map((type) => encodeURIComponent(type))
|
|
||||||
.join("&includeItemTypes=")}`;
|
|
||||||
|
|
||||||
const response1 = await axios.get(url);
|
|
||||||
const ids = response1.data.ids;
|
|
||||||
|
|
||||||
if (!ids || !ids.length) return [];
|
|
||||||
|
|
||||||
const response2 = await getItemsApi(api).getItems({
|
|
||||||
ids,
|
|
||||||
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
|
||||||
});
|
|
||||||
|
|
||||||
return (response2.data.Items as BaseItemDto[]) || [];
|
|
||||||
}
|
}
|
||||||
|
if (!settings?.marlinServerUrl) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${
|
||||||
|
settings.marlinServerUrl
|
||||||
|
}/search?q=${encodeURIComponent(query)}&includeItemTypes=${types
|
||||||
|
.map((type) => encodeURIComponent(type))
|
||||||
|
.join("&includeItemTypes=")}`;
|
||||||
|
|
||||||
|
const response1 = await axios.get(url);
|
||||||
|
|
||||||
|
const ids = response1.data.ids;
|
||||||
|
|
||||||
|
if (!ids || !ids.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const response2 = await getItemsApi(api).getItems({
|
||||||
|
ids,
|
||||||
|
enableImageTypes: ["Primary", "Backdrop", "Thumb"],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (response2.data.Items as BaseItemDto[]) || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error during search:", error);
|
console.error("Error during search:", error);
|
||||||
return []; // Ensure an empty array is returned in case of an error
|
return []; // Ensure an empty array is returned in case of an error
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, searchEngine, settings]
|
[api, searchEngine, settings],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
type HeaderSearchBarRef = {
|
||||||
|
focus: () => void;
|
||||||
|
blur: () => void;
|
||||||
|
setText: (text: string) => void;
|
||||||
|
clearText: () => void;
|
||||||
|
cancelSearch: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchBarRef = useRef<HeaderSearchBarRef>(null);
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (Platform.OS === "ios")
|
navigation.setOptions({
|
||||||
navigation.setOptions({
|
headerSearchBarOptions: {
|
||||||
headerSearchBarOptions: {
|
ref: searchBarRef,
|
||||||
placeholder: "Search...",
|
placeholder: t("search.search"),
|
||||||
onChangeText: (e: any) => {
|
onChangeText: (e: any) => {
|
||||||
router.setParams({ q: "" });
|
router.setParams({ q: "" });
|
||||||
setSearch(e.nativeEvent.text);
|
setSearch(e.nativeEvent.text);
|
||||||
},
|
|
||||||
hideWhenScrolling: false,
|
|
||||||
autoFocus: true,
|
|
||||||
},
|
},
|
||||||
});
|
hideWhenScrolling: false,
|
||||||
|
autoFocus: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
}, [navigation]);
|
}, [navigation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = eventBus.on("searchTabPressed", () => {
|
||||||
|
// Screen not active
|
||||||
|
if (!searchBarRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Screen is active, focus search bar
|
||||||
|
searchBarRef.current?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { data: movies, isFetching: l1 } = useQuery({
|
const { data: movies, isFetching: l1 } = useQuery({
|
||||||
queryKey: ["search", "movies", debouncedSearch],
|
queryKey: ["search", "movies", debouncedSearch],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -147,48 +199,6 @@ export default function search() {
|
|||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: jellyseerrResults, isFetching: j1 } = useQuery({
|
|
||||||
queryKey: ["search", "jellyseerrResults", debouncedSearch],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await jellyseerrApi?.search({
|
|
||||||
query: new URLSearchParams(debouncedSearch).toString(),
|
|
||||||
page: 1, // todo: maybe rework page & page-size if first results are not enough...
|
|
||||||
language: "en",
|
|
||||||
});
|
|
||||||
|
|
||||||
return response?.results;
|
|
||||||
},
|
|
||||||
enabled:
|
|
||||||
!!jellyseerrApi &&
|
|
||||||
searchType === "Discover" &&
|
|
||||||
debouncedSearch.length > 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: jellyseerrDiscoverSettings, isFetching: j2 } = useQuery({
|
|
||||||
queryKey: ["search", "jellyseerrDiscoverSettings", debouncedSearch],
|
|
||||||
queryFn: async () => jellyseerrApi?.discoverSettings(),
|
|
||||||
enabled:
|
|
||||||
!!jellyseerrApi &&
|
|
||||||
searchType === "Discover" &&
|
|
||||||
debouncedSearch.length == 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const jellyseerrMovieResults: MovieResult[] | undefined = useMemo(
|
|
||||||
() =>
|
|
||||||
jellyseerrResults?.filter(
|
|
||||||
(r) => r.mediaType === MediaType.MOVIE
|
|
||||||
) as MovieResult[],
|
|
||||||
[jellyseerrResults]
|
|
||||||
);
|
|
||||||
|
|
||||||
const jellyseerrTvResults: TvResult[] | undefined = useMemo(
|
|
||||||
() =>
|
|
||||||
jellyseerrResults?.filter(
|
|
||||||
(r) => r.mediaType === MediaType.TV
|
|
||||||
) as TvResult[],
|
|
||||||
[jellyseerrResults]
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: series, isFetching: l2 } = useQuery({
|
const { data: series, isFetching: l2 } = useQuery({
|
||||||
queryKey: ["search", "series", debouncedSearch],
|
queryKey: ["search", "series", debouncedSearch],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -229,363 +239,238 @@ export default function search() {
|
|||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: artists, isFetching: l4 } = useQuery({
|
|
||||||
queryKey: ["search", "artists", debouncedSearch],
|
|
||||||
queryFn: () =>
|
|
||||||
searchFn({
|
|
||||||
query: debouncedSearch,
|
|
||||||
types: ["MusicArtist"],
|
|
||||||
}),
|
|
||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: albums, isFetching: l5 } = useQuery({
|
|
||||||
queryKey: ["search", "albums", debouncedSearch],
|
|
||||||
queryFn: () =>
|
|
||||||
searchFn({
|
|
||||||
query: debouncedSearch,
|
|
||||||
types: ["MusicAlbum"],
|
|
||||||
}),
|
|
||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: songs, isFetching: l6 } = useQuery({
|
|
||||||
queryKey: ["search", "songs", debouncedSearch],
|
|
||||||
queryFn: () =>
|
|
||||||
searchFn({
|
|
||||||
query: debouncedSearch,
|
|
||||||
types: ["Audio"],
|
|
||||||
}),
|
|
||||||
enabled: searchType === "Library" && debouncedSearch.length > 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const noResults = useMemo(() => {
|
const noResults = useMemo(() => {
|
||||||
return !(
|
return !(
|
||||||
artists?.length ||
|
|
||||||
albums?.length ||
|
|
||||||
songs?.length ||
|
|
||||||
movies?.length ||
|
movies?.length ||
|
||||||
episodes?.length ||
|
episodes?.length ||
|
||||||
series?.length ||
|
series?.length ||
|
||||||
collections?.length ||
|
collections?.length ||
|
||||||
actors?.length ||
|
actors?.length
|
||||||
jellyseerrMovieResults?.length ||
|
|
||||||
jellyseerrTvResults?.length
|
|
||||||
);
|
);
|
||||||
}, [
|
}, [episodes, movies, series, collections, actors]);
|
||||||
artists,
|
|
||||||
episodes,
|
|
||||||
albums,
|
|
||||||
songs,
|
|
||||||
movies,
|
|
||||||
series,
|
|
||||||
collections,
|
|
||||||
actors,
|
|
||||||
jellyseerrResults,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const loading = useMemo(() => {
|
const loading = useMemo(() => {
|
||||||
return l1 || l2 || l3 || l4 || l5 || l6 || l7 || l8 || j1 || j2;
|
return l1 || l2 || l3 || l7 || l8;
|
||||||
}, [l1, l2, l3, l4, l5, l6, l7, l8, j1, j2]);
|
}, [l1, l2, l3, l7, l8]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ScrollView
|
||||||
<ScrollView
|
keyboardDismissMode='on-drag'
|
||||||
keyboardDismissMode="on-drag"
|
contentInsetAdjustmentBehavior='automatic'
|
||||||
contentInsetAdjustmentBehavior="automatic"
|
contentContainerStyle={{
|
||||||
contentContainerStyle={{
|
paddingLeft: insets.left,
|
||||||
paddingLeft: insets.left,
|
paddingRight: insets.right,
|
||||||
paddingRight: insets.right,
|
}}
|
||||||
|
>
|
||||||
|
{/* <View
|
||||||
|
className='flex flex-col'
|
||||||
|
style={{
|
||||||
|
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||||
|
}}
|
||||||
|
> */}
|
||||||
|
{Platform.isTV && (
|
||||||
|
<Input
|
||||||
|
placeholder={t("search.search")}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
router.setParams({ q: "" });
|
||||||
|
setSearch(text);
|
||||||
|
}}
|
||||||
|
keyboardType='default'
|
||||||
|
returnKeyType='done'
|
||||||
|
autoCapitalize='none'
|
||||||
|
clearButtonMode='while-editing'
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<View
|
||||||
|
className='flex flex-col'
|
||||||
|
style={{
|
||||||
|
marginTop: Platform.OS === "android" ? 16 : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View className="flex flex-col pt-2">
|
{jellyseerrApi && (
|
||||||
{Platform.OS === "android" && (
|
<ScrollView
|
||||||
<View className="mb-4 px-4">
|
horizontal
|
||||||
<Input
|
className='flex flex-row flex-wrap space-x-2 px-4 mb-2'
|
||||||
autoCorrect={false}
|
>
|
||||||
returnKeyType="done"
|
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
||||||
keyboardType="web-search"
|
<Tag
|
||||||
placeholder="Search here..."
|
text={t("search.library")}
|
||||||
value={search}
|
textClass='p-1'
|
||||||
onChangeText={(text) => setSearch(text)}
|
className={
|
||||||
|
searchType === "Library" ? "bg-purple-600" : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</View>
|
</TouchableOpacity>
|
||||||
)}
|
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
||||||
{jellyseerrApi && (
|
<Tag
|
||||||
<View className="flex flex-row flex-wrap space-x-2 px-4 mb-2">
|
text={t("search.discover")}
|
||||||
<TouchableOpacity onPress={() => setSearchType("Library")}>
|
textClass='p-1'
|
||||||
<Tag
|
className={
|
||||||
text="Library"
|
searchType === "Discover" ? "bg-purple-600" : undefined
|
||||||
textClass="p-1"
|
}
|
||||||
className={
|
|
||||||
searchType === "Library" ? "bg-neutral-600" : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity onPress={() => setSearchType("Discover")}>
|
|
||||||
<Tag
|
|
||||||
text="Discover"
|
|
||||||
textClass="p-1"
|
|
||||||
className={
|
|
||||||
searchType === "Discover" ? "bg-neutral-600" : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{!!q && (
|
|
||||||
<View className="px-4 flex flex-col space-y-2">
|
|
||||||
<Text className="text-neutral-500 ">
|
|
||||||
Results for <Text className="text-purple-600">{q}</Text>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{searchType === "Library" && (
|
|
||||||
<>
|
|
||||||
<SearchItemWrapper
|
|
||||||
header="Movies"
|
|
||||||
ids={movies?.map((m) => m.Id!)}
|
|
||||||
renderItem={(item: BaseItemDto) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
key={item.Id}
|
|
||||||
className="flex flex-col w-28 mr-2"
|
|
||||||
item={item}
|
|
||||||
>
|
|
||||||
<MoviePoster item={item} key={item.Id} />
|
|
||||||
<Text numberOfLines={2} className="mt-2">
|
|
||||||
{item.Name}
|
|
||||||
</Text>
|
|
||||||
<Text className="opacity-50 text-xs">
|
|
||||||
{item.ProductionYear}
|
|
||||||
</Text>
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<SearchItemWrapper
|
</TouchableOpacity>
|
||||||
ids={series?.map((m) => m.Id!)}
|
{searchType === "Discover" &&
|
||||||
header="Series"
|
!loading &&
|
||||||
renderItem={(item: BaseItemDto) => (
|
noResults &&
|
||||||
<TouchableItemRouter
|
debouncedSearch.length > 0 && (
|
||||||
key={item.Id}
|
<View className='flex flex-row justify-end items-center space-x-1'>
|
||||||
item={item}
|
<FilterButton
|
||||||
className="flex flex-col w-28 mr-2"
|
id={searchFilterId}
|
||||||
>
|
queryKey='jellyseerr_search'
|
||||||
<SeriesPoster item={item} key={item.Id} />
|
queryFn={async () =>
|
||||||
<Text numberOfLines={2} className="mt-2">
|
Object.keys(JellyseerrSearchSort).filter((v) =>
|
||||||
{item.Name}
|
Number.isNaN(Number(v)),
|
||||||
</Text>
|
)
|
||||||
<Text className="opacity-50 text-xs">
|
}
|
||||||
{item.ProductionYear}
|
set={(value) => setJellyseerrOrderBy(value[0])}
|
||||||
</Text>
|
values={[jellyseerrOrderBy]}
|
||||||
</TouchableItemRouter>
|
title={t("library.filters.sort_by")}
|
||||||
)}
|
renderItemLabel={(item) =>
|
||||||
/>
|
t(`home.settings.plugins.jellyseerr.order_by.${item}`)
|
||||||
<SearchItemWrapper
|
}
|
||||||
ids={episodes?.map((m) => m.Id!)}
|
showSearch={false}
|
||||||
header="Episodes"
|
/>
|
||||||
renderItem={(item: BaseItemDto) => (
|
<FilterButton
|
||||||
<TouchableItemRouter
|
id={orderFilterId}
|
||||||
item={item}
|
queryKey='jellysearr_search'
|
||||||
key={item.Id}
|
queryFn={async () => ["asc", "desc"]}
|
||||||
className="flex flex-col w-44 mr-2"
|
set={(value) => setJellyseerrSortOrder(value[0])}
|
||||||
>
|
values={[jellyseerrSortOrder]}
|
||||||
<ContinueWatchingPoster item={item} />
|
title={t("library.filters.sort_order")}
|
||||||
<ItemCardText item={item} />
|
renderItemLabel={(item) => t(`library.filters.${item}`)}
|
||||||
</TouchableItemRouter>
|
showSearch={false}
|
||||||
)}
|
/>
|
||||||
/>
|
</View>
|
||||||
<SearchItemWrapper
|
)}
|
||||||
ids={collections?.map((m) => m.Id!)}
|
</ScrollView>
|
||||||
header="Collections"
|
)}
|
||||||
renderItem={(item: BaseItemDto) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
key={item.Id}
|
|
||||||
item={item}
|
|
||||||
className="flex flex-col w-28 mr-2"
|
|
||||||
>
|
|
||||||
<MoviePoster item={item} key={item.Id} />
|
|
||||||
<Text numberOfLines={2} className="mt-2">
|
|
||||||
{item.Name}
|
|
||||||
</Text>
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<SearchItemWrapper
|
|
||||||
ids={actors?.map((m) => m.Id!)}
|
|
||||||
header="Actors"
|
|
||||||
renderItem={(item: BaseItemDto) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
item={item}
|
|
||||||
key={item.Id}
|
|
||||||
className="flex flex-col w-28 mr-2"
|
|
||||||
>
|
|
||||||
<MoviePoster item={item} />
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<SearchItemWrapper
|
|
||||||
ids={artists?.map((m) => m.Id!)}
|
|
||||||
header="Artists"
|
|
||||||
renderItem={(item: BaseItemDto) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
item={item}
|
|
||||||
key={item.Id}
|
|
||||||
className="flex flex-col w-28 mr-2"
|
|
||||||
>
|
|
||||||
<AlbumCover id={item.Id} />
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<SearchItemWrapper
|
|
||||||
ids={albums?.map((m) => m.Id!)}
|
|
||||||
header="Albums"
|
|
||||||
renderItem={(item: BaseItemDto) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
item={item}
|
|
||||||
key={item.Id}
|
|
||||||
className="flex flex-col w-28 mr-2"
|
|
||||||
>
|
|
||||||
<AlbumCover id={item.Id} />
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<SearchItemWrapper
|
|
||||||
ids={songs?.map((m) => m.Id!)}
|
|
||||||
header="Songs"
|
|
||||||
renderItem={(item: BaseItemDto) => (
|
|
||||||
<TouchableItemRouter
|
|
||||||
item={item}
|
|
||||||
key={item.Id}
|
|
||||||
className="flex flex-col w-28 mr-2"
|
|
||||||
>
|
|
||||||
<AlbumCover id={item.AlbumId} />
|
|
||||||
<ItemCardText item={item} />
|
|
||||||
</TouchableItemRouter>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{searchType === "Discover" && (
|
|
||||||
<>
|
|
||||||
<SearchItemWrapper
|
|
||||||
header="Request Movies"
|
|
||||||
items={jellyseerrMovieResults}
|
|
||||||
renderItem={(item: MovieResult) => (
|
|
||||||
<JellyseerrPoster item={item} key={item.id} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<SearchItemWrapper
|
|
||||||
header="Request Series"
|
|
||||||
items={jellyseerrTvResults}
|
|
||||||
renderItem={(item: TvResult) => (
|
|
||||||
<JellyseerrPoster item={item} key={item.id} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
<View className='mt-2'>
|
||||||
<View className="mt-4 flex justify-center items-center">
|
<LoadingSkeleton isLoading={loading} />
|
||||||
<Loader />
|
</View>
|
||||||
</View>
|
|
||||||
) : noResults && debouncedSearch.length > 0 ? (
|
{searchType === "Library" ? (
|
||||||
|
<View className={l1 || l2 ? "opacity-0" : "opacity-100"}>
|
||||||
|
<SearchItemWrapper
|
||||||
|
header={t("search.movies")}
|
||||||
|
items={movies}
|
||||||
|
renderItem={(item: BaseItemDto) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
key={item.Id}
|
||||||
|
className='flex flex-col w-28 mr-2'
|
||||||
|
item={item}
|
||||||
|
>
|
||||||
|
<MoviePoster item={item} key={item.Id} />
|
||||||
|
<Text numberOfLines={2} className='mt-2'>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className='opacity-50 text-xs'>
|
||||||
|
{item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
items={series}
|
||||||
|
header={t("search.series")}
|
||||||
|
renderItem={(item: BaseItemDto) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
key={item.Id}
|
||||||
|
item={item}
|
||||||
|
className='flex flex-col w-28 mr-2'
|
||||||
|
>
|
||||||
|
<SeriesPoster item={item} key={item.Id} />
|
||||||
|
<Text numberOfLines={2} className='mt-2'>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
<Text className='opacity-50 text-xs'>
|
||||||
|
{item.ProductionYear}
|
||||||
|
</Text>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
items={episodes}
|
||||||
|
header={t("search.episodes")}
|
||||||
|
renderItem={(item: BaseItemDto) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
|
key={item.Id}
|
||||||
|
className='flex flex-col w-44 mr-2'
|
||||||
|
>
|
||||||
|
<ContinueWatchingPoster item={item} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
items={collections}
|
||||||
|
header={t("search.collections")}
|
||||||
|
renderItem={(item: BaseItemDto) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
key={item.Id}
|
||||||
|
item={item}
|
||||||
|
className='flex flex-col w-28 mr-2'
|
||||||
|
>
|
||||||
|
<MoviePoster item={item} key={item.Id} />
|
||||||
|
<Text numberOfLines={2} className='mt-2'>
|
||||||
|
{item.Name}
|
||||||
|
</Text>
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<SearchItemWrapper
|
||||||
|
items={actors}
|
||||||
|
header={t("search.actors")}
|
||||||
|
renderItem={(item: BaseItemDto) => (
|
||||||
|
<TouchableItemRouter
|
||||||
|
item={item}
|
||||||
|
key={item.Id}
|
||||||
|
className='flex flex-col w-28 mr-2'
|
||||||
|
>
|
||||||
|
<MoviePoster item={item} />
|
||||||
|
<ItemCardText item={item} />
|
||||||
|
</TouchableItemRouter>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<JellyserrIndexPage
|
||||||
|
searchQuery={debouncedSearch}
|
||||||
|
sortType={jellyseerrOrderBy}
|
||||||
|
order={jellyseerrSortOrder}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{searchType === "Library" &&
|
||||||
|
(!loading && noResults && debouncedSearch.length > 0 ? (
|
||||||
<View>
|
<View>
|
||||||
<Text className="text-center text-lg font-bold mt-4">
|
<Text className='text-center text-lg font-bold mt-4'>
|
||||||
No results found for
|
{t("search.no_results_found_for")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-xs text-purple-600 text-center">
|
<Text className='text-xs text-purple-600 text-center'>
|
||||||
"{debouncedSearch}"
|
"{debouncedSearch}"
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : debouncedSearch.length === 0 && searchType === "Library" ? (
|
) : debouncedSearch.length === 0 ? (
|
||||||
<View className="mt-4 flex flex-col items-center space-y-2">
|
<View className='mt-4 flex flex-col items-center space-y-2'>
|
||||||
{exampleSearches.map((e) => (
|
{exampleSearches.map((e) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => setSearch(e)}
|
onPress={() => {
|
||||||
|
setSearch(e);
|
||||||
|
searchBarRef.current?.setText(e);
|
||||||
|
}}
|
||||||
key={e}
|
key={e}
|
||||||
className="mb-2"
|
className='mb-2'
|
||||||
>
|
>
|
||||||
<Text className="text-purple-600">{e}</Text>
|
<Text className='text-purple-600'>{e}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
) : debouncedSearch.length === 0 && searchType === "Discover" ? (
|
) : null)}
|
||||||
<View className="flex flex-col px-4">
|
</View>
|
||||||
{sortBy?.(
|
</ScrollView>
|
||||||
jellyseerrDiscoverSettings?.filter((s) => s.enabled),
|
|
||||||
"order"
|
|
||||||
).map((slide) => (
|
|
||||||
<DiscoverSlide key={slide.id} slide={slide} />
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props<T> = {
|
|
||||||
ids?: string[] | null;
|
|
||||||
items?: T[];
|
|
||||||
renderItem: (item: any) => React.ReactNode;
|
|
||||||
header?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SearchItemWrapper = <T extends unknown>({
|
|
||||||
ids,
|
|
||||||
items,
|
|
||||||
renderItem,
|
|
||||||
header,
|
|
||||||
}: PropsWithChildren<Props<T>>) => {
|
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const [user] = useAtom(userAtom);
|
|
||||||
|
|
||||||
const { data, isLoading: l1 } = useQuery({
|
|
||||||
queryKey: ["items", ids],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!user?.Id || !api || !ids || ids.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemPromises = ids.map((id) =>
|
|
||||||
getUserItemData({
|
|
||||||
api,
|
|
||||||
userId: user.Id,
|
|
||||||
itemId: id,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const results = await Promise.all(itemPromises);
|
|
||||||
|
|
||||||
// Filter out null items
|
|
||||||
return results.filter(
|
|
||||||
(item) => item !== null
|
|
||||||
) as unknown as BaseItemDto[];
|
|
||||||
},
|
|
||||||
enabled: !!ids && ids.length > 0 && !!api && !!user?.Id,
|
|
||||||
staleTime: Infinity,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!data && (!items || items.length === 0)) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Text className="font-bold text-lg px-4 mb-2">{header}</Text>
|
|
||||||
<ScrollView
|
|
||||||
horizontal
|
|
||||||
className="px-4 mb-2"
|
|
||||||
showsHorizontalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
{data && data?.length > 0
|
|
||||||
? data.map((item) => renderItem(item))
|
|
||||||
: items && items?.length > 0
|
|
||||||
? items.map((i) => renderItem(i))
|
|
||||||
: undefined}
|
|
||||||
</ScrollView>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,27 +1,26 @@
|
|||||||
import React from "react";
|
|
||||||
import { Platform } from "react-native";
|
|
||||||
|
|
||||||
import { withLayoutContext } from "expo-router";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createNativeBottomTabNavigator,
|
createNativeBottomTabNavigator,
|
||||||
NativeBottomTabNavigationEventMap,
|
type NativeBottomTabNavigationEventMap,
|
||||||
|
type NativeBottomTabNavigationOptions,
|
||||||
} from "@bottom-tabs/react-navigation";
|
} from "@bottom-tabs/react-navigation";
|
||||||
|
|
||||||
const { Navigator } = createNativeBottomTabNavigator();
|
|
||||||
|
|
||||||
import { BottomTabNavigationOptions } from "@react-navigation/bottom-tabs";
|
|
||||||
|
|
||||||
import { Colors } from "@/constants/Colors";
|
|
||||||
import type {
|
import type {
|
||||||
ParamListBase,
|
ParamListBase,
|
||||||
TabNavigationState,
|
TabNavigationState,
|
||||||
} from "@react-navigation/native";
|
} from "@react-navigation/native";
|
||||||
|
import { useFocusEffect, useRouter, withLayoutContext } from "expo-router";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Platform } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
import { useSettings } from "@/utils/atoms/settings";
|
||||||
|
import { eventBus } from "@/utils/eventBus";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
|
const { Navigator } = createNativeBottomTabNavigator();
|
||||||
|
|
||||||
export const NativeTabs = withLayoutContext<
|
export const NativeTabs = withLayoutContext<
|
||||||
BottomTabNavigationOptions,
|
NativeBottomTabNavigationOptions,
|
||||||
typeof Navigator,
|
typeof Navigator,
|
||||||
TabNavigationState<ParamListBase>,
|
TabNavigationState<ParamListBase>,
|
||||||
NativeBottomTabNavigationEventMap
|
NativeBottomTabNavigationEventMap
|
||||||
@@ -29,48 +28,113 @@ export const NativeTabs = withLayoutContext<
|
|||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const [settings] = useSettings();
|
const [settings] = useSettings();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
const hasShownIntro = storage.getBoolean("hasShownIntro");
|
||||||
|
if (!hasShownIntro) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
router.push("/intro/page");
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, []),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SystemBars hidden={false} style="light" />
|
<SystemBars hidden={false} style='light' />
|
||||||
<NativeTabs
|
<NativeTabs
|
||||||
sidebarAdaptable
|
sidebarAdaptable={false}
|
||||||
ignoresTopSafeArea
|
tabBarStyle={{
|
||||||
barTintColor={Platform.OS === "android" ? "#121212" : undefined}
|
backgroundColor: "#121212",
|
||||||
|
}}
|
||||||
tabBarActiveTintColor={Colors.primary}
|
tabBarActiveTintColor={Colors.primary}
|
||||||
scrollEdgeAppearance="default"
|
scrollEdgeAppearance='default'
|
||||||
>
|
>
|
||||||
<NativeTabs.Screen redirect name="index" />
|
<NativeTabs.Screen redirect name='index' />
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
name="(home)"
|
listeners={(_e) => ({
|
||||||
|
tabPress: (_e) => {
|
||||||
|
eventBus.emit("scrollToTop");
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
name='(home)'
|
||||||
options={{
|
options={{
|
||||||
title: "Home",
|
title: t("tabs.home"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS == "android"
|
Platform.OS === "android"
|
||||||
? ({ color, focused, size }) =>
|
? (_e) => require("@/assets/icons/house.fill.png")
|
||||||
require("@/assets/icons/house.fill.png")
|
: ({ focused }) =>
|
||||||
: () => ({ sfSymbol: "house" }),
|
focused
|
||||||
|
? { sfSymbol: "house.fill" }
|
||||||
|
: { sfSymbol: "house" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
name="(search)"
|
listeners={(_e) => ({
|
||||||
|
tabPress: (_e) => {
|
||||||
|
eventBus.emit("searchTabPressed");
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
name='(search)'
|
||||||
options={{
|
options={{
|
||||||
title: "Search",
|
title: t("tabs.search"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS == "android"
|
Platform.OS === "android"
|
||||||
? ({ color, focused, size }) =>
|
? (_e) => require("@/assets/icons/magnifyingglass.png")
|
||||||
require("@/assets/icons/magnifyingglass.png")
|
: ({ focused }) =>
|
||||||
: () => ({ sfSymbol: "magnifyingglass" }),
|
focused
|
||||||
|
? { sfSymbol: "magnifyingglass" }
|
||||||
|
: { sfSymbol: "magnifyingglass" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<NativeTabs.Screen
|
<NativeTabs.Screen
|
||||||
name="(libraries)"
|
name='(favorites)'
|
||||||
options={{
|
options={{
|
||||||
title: "Library",
|
title: t("tabs.favorites"),
|
||||||
tabBarIcon:
|
tabBarIcon:
|
||||||
Platform.OS == "android"
|
Platform.OS === "android"
|
||||||
? ({ color, focused, size }) =>
|
? ({ focused }) =>
|
||||||
require("@/assets/icons/server.rack.png")
|
focused
|
||||||
: () => ({ sfSymbol: "rectangle.stack" }),
|
? require("@/assets/icons/heart.fill.png")
|
||||||
|
: require("@/assets/icons/heart.png")
|
||||||
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "heart.fill" }
|
||||||
|
: { sfSymbol: "heart" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<NativeTabs.Screen
|
||||||
|
name='(libraries)'
|
||||||
|
options={{
|
||||||
|
title: t("tabs.library"),
|
||||||
|
tabBarIcon:
|
||||||
|
Platform.OS === "android"
|
||||||
|
? (_e) => require("@/assets/icons/server.rack.png")
|
||||||
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "rectangle.stack.fill" }
|
||||||
|
: { sfSymbol: "rectangle.stack" },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<NativeTabs.Screen
|
||||||
|
name='(custom-links)'
|
||||||
|
options={{
|
||||||
|
title: t("tabs.custom_links"),
|
||||||
|
tabBarItemHidden: !settings?.showCustomMenuLinks,
|
||||||
|
tabBarIcon:
|
||||||
|
Platform.OS === "android"
|
||||||
|
? (_e) => require("@/assets/icons/list.png")
|
||||||
|
: ({ focused }) =>
|
||||||
|
focused
|
||||||
|
? { sfSymbol: "list.dash.fill" }
|
||||||
|
: { sfSymbol: "list.dash" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</NativeTabs>
|
</NativeTabs>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Stack } from "expo-router";
|
import { Stack } from "expo-router";
|
||||||
import React from "react";
|
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
@@ -8,25 +7,7 @@ export default function Layout() {
|
|||||||
<SystemBars hidden />
|
<SystemBars hidden />
|
||||||
<Stack>
|
<Stack>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="direct-player"
|
name='direct-player'
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
autoHideHomeIndicator: true,
|
|
||||||
title: "",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="transcoding-player"
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
autoHideHomeIndicator: true,
|
|
||||||
title: "",
|
|
||||||
animation: "fade",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="music-player"
|
|
||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
autoHideHomeIndicator: true,
|
autoHideHomeIndicator: true,
|
||||||
|
|||||||
@@ -1,413 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { Controls } from "@/components/video-player/controls/Controls";
|
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import {
|
|
||||||
getPlaystateApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { Image } from "expo-image";
|
|
||||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
|
||||||
import { Pressable, useWindowDimensions, View } from "react-native";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
import Video, { OnProgressData, VideoRef } from "react-native-video";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const [settings] = useSettings();
|
|
||||||
const videoRef = useRef<VideoRef | null>(null);
|
|
||||||
const windowDimensions = useWindowDimensions();
|
|
||||||
|
|
||||||
const firstTime = useRef(true);
|
|
||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
|
||||||
const [showControls, setShowControls] = useState(true);
|
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
|
||||||
const isSeeking = useSharedValue(false);
|
|
||||||
const cacheProgress = useSharedValue(0);
|
|
||||||
|
|
||||||
const {
|
|
||||||
itemId,
|
|
||||||
audioIndex: audioIndexStr,
|
|
||||||
subtitleIndex: subtitleIndexStr,
|
|
||||||
mediaSourceId,
|
|
||||||
bitrateValue: bitrateValueStr,
|
|
||||||
} = useLocalSearchParams<{
|
|
||||||
itemId: string;
|
|
||||||
audioIndex: string;
|
|
||||||
subtitleIndex: string;
|
|
||||||
mediaSourceId: string;
|
|
||||||
bitrateValue: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
|
||||||
const subtitleIndex = subtitleIndexStr
|
|
||||||
? parseInt(subtitleIndexStr, 10)
|
|
||||||
: undefined;
|
|
||||||
const bitrateValue = bitrateValueStr
|
|
||||||
? parseInt(bitrateValueStr, 10)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: item,
|
|
||||||
isLoading: isLoadingItem,
|
|
||||||
isError: isErrorItem,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["item", itemId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return;
|
|
||||||
const res = await getUserLibraryApi(api).getItem({
|
|
||||||
itemId,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
enabled: !!itemId && !!api,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: stream,
|
|
||||||
isLoading: isLoadingStreamUrl,
|
|
||||||
isError: isErrorStreamUrl,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["stream-url"],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) return;
|
|
||||||
const res = await getStreamUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
|
||||||
userId: user?.Id,
|
|
||||||
audioStreamIndex: audioIndex,
|
|
||||||
maxStreamingBitrate: bitrateValue,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
subtitleStreamIndex: subtitleIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res) return null;
|
|
||||||
|
|
||||||
const { mediaSource, sessionId, url } = res;
|
|
||||||
|
|
||||||
if (!sessionId || !mediaSource || !url) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
mediaSource,
|
|
||||||
sessionId,
|
|
||||||
url,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const poster = usePoster(item, api);
|
|
||||||
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
|
||||||
|
|
||||||
const togglePlay = useCallback(
|
|
||||||
async (ticks: number) => {
|
|
||||||
if (isPlaying) {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(ticks),
|
|
||||||
isPaused: true,
|
|
||||||
playMethod: stream?.url.includes("m3u8")
|
|
||||||
? "Transcode"
|
|
||||||
: "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
videoRef.current?.resume();
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(ticks),
|
|
||||||
isPaused: false,
|
|
||||||
playMethod: stream?.url.includes("m3u8")
|
|
||||||
? "Transcode"
|
|
||||||
: "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
isPlaying,
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
videoRef,
|
|
||||||
settings,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
stream,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const play = useCallback(() => {
|
|
||||||
console.log("play");
|
|
||||||
videoRef.current?.resume();
|
|
||||||
reportPlaybackStart();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
|
||||||
console.log("play");
|
|
||||||
videoRef.current?.pause();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
console.log("stop");
|
|
||||||
setIsPlaybackStopped(true);
|
|
||||||
videoRef.current?.pause();
|
|
||||||
reportPlaybackStopped();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const seek = useCallback(
|
|
||||||
(seconds: number) => {
|
|
||||||
videoRef.current?.seek(seconds);
|
|
||||||
},
|
|
||||||
[videoRef]
|
|
||||||
);
|
|
||||||
|
|
||||||
const reportPlaybackStopped = async () => {
|
|
||||||
if (!item?.Id) return;
|
|
||||||
await getPlaystateApi(api!).onPlaybackStopped({
|
|
||||||
itemId: item.Id,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(progress.value),
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const reportPlaybackStart = async () => {
|
|
||||||
if (!item?.Id) return;
|
|
||||||
await getPlaystateApi(api!).onPlaybackStart({
|
|
||||||
itemId: item?.Id,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onProgress = useCallback(
|
|
||||||
async (data: OnProgressData) => {
|
|
||||||
if (isSeeking.value === true) return;
|
|
||||||
if (isPlaybackStopped === true) return;
|
|
||||||
|
|
||||||
const ticks = data.currentTime * 10000000;
|
|
||||||
|
|
||||||
progress.value = secondsToTicks(data.currentTime);
|
|
||||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
|
||||||
setIsBuffering(data.playableDuration === 0);
|
|
||||||
|
|
||||||
if (!item?.Id || data.currentTime === 0) return;
|
|
||||||
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
|
||||||
itemId: item.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.round(ticks),
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[
|
|
||||||
item,
|
|
||||||
isPlaying,
|
|
||||||
api,
|
|
||||||
isPlaybackStopped,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
stream,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
useCallback(() => {
|
|
||||||
play();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
stop();
|
|
||||||
};
|
|
||||||
}, [play, stop])
|
|
||||||
);
|
|
||||||
|
|
||||||
useWebSocket({
|
|
||||||
isPlaying: isPlaying,
|
|
||||||
pauseVideo: pause,
|
|
||||||
playVideo: play,
|
|
||||||
stopPlayback: stop,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoadingItem || isLoadingStreamUrl)
|
|
||||||
return (
|
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isErrorItem || isErrorStreamUrl)
|
|
||||||
return (
|
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
|
||||||
<Text className="text-white">Error</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!item || !stream)
|
|
||||||
return (
|
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
|
||||||
<Text className="text-white">Error</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
width: windowDimensions.width,
|
|
||||||
height: windowDimensions.height,
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
className="flex flex-col items-center justify-center"
|
|
||||||
>
|
|
||||||
<View className="h-screen w-screen top-0 left-0 flex flex-col items-center justify-center p-4 absolute z-0">
|
|
||||||
<Image
|
|
||||||
source={poster}
|
|
||||||
style={{ width: "100%", height: "100%", resizeMode: "contain" }}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
onPress={() => {
|
|
||||||
setShowControls(!showControls);
|
|
||||||
}}
|
|
||||||
className="absolute z-0 h-full w-full opacity-0"
|
|
||||||
>
|
|
||||||
{videoSource && (
|
|
||||||
<Video
|
|
||||||
ref={videoRef}
|
|
||||||
source={videoSource}
|
|
||||||
style={{ width: "100%", height: "100%" }}
|
|
||||||
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
|
||||||
onProgress={onProgress}
|
|
||||||
onError={() => {}}
|
|
||||||
onLoad={() => {
|
|
||||||
if (firstTime.current === true) {
|
|
||||||
play();
|
|
||||||
firstTime.current = false;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
progressUpdateInterval={500}
|
|
||||||
playWhenInactive={true}
|
|
||||||
allowsExternalPlayback={true}
|
|
||||||
playInBackground={true}
|
|
||||||
pictureInPicture={true}
|
|
||||||
showNotificationControls={true}
|
|
||||||
ignoreSilentSwitch="ignore"
|
|
||||||
fullscreen={false}
|
|
||||||
onPlaybackStateChanged={(state) => {
|
|
||||||
setIsPlaying(state.isPlaying);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<Controls
|
|
||||||
item={item}
|
|
||||||
videoRef={videoRef}
|
|
||||||
togglePlay={togglePlay}
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
isSeeking={isSeeking}
|
|
||||||
progress={progress}
|
|
||||||
cacheProgress={cacheProgress}
|
|
||||||
isBuffering={isBuffering}
|
|
||||||
showControls={showControls}
|
|
||||||
setShowControls={setShowControls}
|
|
||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
|
||||||
enableTrickplay={false}
|
|
||||||
pause={pause}
|
|
||||||
play={play}
|
|
||||||
seek={seek}
|
|
||||||
isVlc={false}
|
|
||||||
stop={stop}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePoster(
|
|
||||||
item: BaseItemDto | null | undefined,
|
|
||||||
api: Api | null
|
|
||||||
): string | undefined {
|
|
||||||
const poster = useMemo(() => {
|
|
||||||
if (!item || !api) return undefined;
|
|
||||||
return item.Type === "Audio"
|
|
||||||
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
|
||||||
: getBackdropUrl({
|
|
||||||
api,
|
|
||||||
item: item,
|
|
||||||
quality: 70,
|
|
||||||
width: 200,
|
|
||||||
});
|
|
||||||
}, [item, api]);
|
|
||||||
|
|
||||||
return poster ?? undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useVideoSource(
|
|
||||||
item: BaseItemDto | null | undefined,
|
|
||||||
api: Api | null,
|
|
||||||
poster: string | undefined,
|
|
||||||
url?: string | null
|
|
||||||
) {
|
|
||||||
const videoSource = useMemo(() => {
|
|
||||||
if (!item || !api || !url) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startPosition = item?.UserData?.PlaybackPositionTicks
|
|
||||||
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
uri: url,
|
|
||||||
isNetwork: true,
|
|
||||||
startPosition,
|
|
||||||
headers: getAuthHeaders(api),
|
|
||||||
metadata: {
|
|
||||||
artist: item?.AlbumArtist ?? undefined,
|
|
||||||
title: item?.Name || "Unknown",
|
|
||||||
description: item?.Overview ?? undefined,
|
|
||||||
imageUri: poster,
|
|
||||||
subtitle: item?.Album ?? undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, [item, api, poster]);
|
|
||||||
|
|
||||||
return videoSource;
|
|
||||||
}
|
|
||||||
@@ -1,551 +0,0 @@
|
|||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { Loader } from "@/components/Loader";
|
|
||||||
import { Controls } from "@/components/video-player/controls/Controls";
|
|
||||||
import { useInvalidatePlaybackProgressCache } from "@/hooks/useRevalidatePlaybackProgressCache";
|
|
||||||
import { useWebSocket } from "@/hooks/useWebsockets";
|
|
||||||
import { TrackInfo } from "@/modules/vlc-player";
|
|
||||||
import { apiAtom, userAtom } from "@/providers/JellyfinProvider";
|
|
||||||
import { useSettings } from "@/utils/atoms/settings";
|
|
||||||
import { getBackdropUrl } from "@/utils/jellyfin/image/getBackdropUrl";
|
|
||||||
import { getAuthHeaders } from "@/utils/jellyfin/jellyfin";
|
|
||||||
import { getStreamUrl } from "@/utils/jellyfin/media/getStreamUrl";
|
|
||||||
import transcoding from "@/utils/profiles/transcoding";
|
|
||||||
import { secondsToTicks } from "@/utils/secondsToTicks";
|
|
||||||
import { Api } from "@jellyfin/sdk";
|
|
||||||
import { BaseItemDto } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import {
|
|
||||||
getPlaystateApi,
|
|
||||||
getUserLibraryApi,
|
|
||||||
} from "@jellyfin/sdk/lib/utils/api";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import React, {
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { View } from "react-native";
|
|
||||||
import { useSharedValue } from "react-native-reanimated";
|
|
||||||
import Video, {
|
|
||||||
OnProgressData,
|
|
||||||
SelectedTrack,
|
|
||||||
SelectedTrackType,
|
|
||||||
VideoRef,
|
|
||||||
} from "react-native-video";
|
|
||||||
import { SubtitleHelper } from "@/utils/SubtitleHelper";
|
|
||||||
|
|
||||||
const Player = () => {
|
|
||||||
const api = useAtomValue(apiAtom);
|
|
||||||
const user = useAtomValue(userAtom);
|
|
||||||
const [settings] = useSettings();
|
|
||||||
const videoRef = useRef<VideoRef | null>(null);
|
|
||||||
|
|
||||||
const firstTime = useRef(true);
|
|
||||||
const revalidateProgressCache = useInvalidatePlaybackProgressCache();
|
|
||||||
|
|
||||||
const [isPlaybackStopped, setIsPlaybackStopped] = useState(false);
|
|
||||||
const [showControls, _setShowControls] = useState(true);
|
|
||||||
const [ignoreSafeAreas, setIgnoreSafeAreas] = useState(false);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [isBuffering, setIsBuffering] = useState(true);
|
|
||||||
const [isVideoLoaded, setIsVideoLoaded] = useState(false);
|
|
||||||
|
|
||||||
const setShowControls = useCallback((show: boolean) => {
|
|
||||||
_setShowControls(show);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const progress = useSharedValue(0);
|
|
||||||
const isSeeking = useSharedValue(false);
|
|
||||||
const cacheProgress = useSharedValue(0);
|
|
||||||
|
|
||||||
const {
|
|
||||||
itemId,
|
|
||||||
audioIndex: audioIndexStr,
|
|
||||||
subtitleIndex: subtitleIndexStr,
|
|
||||||
mediaSourceId,
|
|
||||||
bitrateValue: bitrateValueStr,
|
|
||||||
} = useLocalSearchParams<{
|
|
||||||
itemId: string;
|
|
||||||
audioIndex: string;
|
|
||||||
subtitleIndex: string;
|
|
||||||
mediaSourceId: string;
|
|
||||||
bitrateValue: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const audioIndex = audioIndexStr ? parseInt(audioIndexStr, 10) : undefined;
|
|
||||||
const subtitleIndex = subtitleIndexStr
|
|
||||||
? parseInt(subtitleIndexStr, 10)
|
|
||||||
: undefined;
|
|
||||||
const bitrateValue = bitrateValueStr
|
|
||||||
? parseInt(bitrateValueStr, 10)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: item,
|
|
||||||
isLoading: isLoadingItem,
|
|
||||||
isError: isErrorItem,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["item", itemId],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) {
|
|
||||||
throw new Error("No api");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!itemId) {
|
|
||||||
console.warn("No itemId");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getUserLibraryApi(api).getItem({
|
|
||||||
itemId,
|
|
||||||
userId: user?.Id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: NEED TO FIND A WAY TO FROM SWITCHING TO IMAGE BASED TO TEXT BASED SUBTITLES, THERE IS A BUG.
|
|
||||||
// MOST LIKELY LIKELY NEED A MASSIVE REFACTOR.
|
|
||||||
const {
|
|
||||||
data: stream,
|
|
||||||
isLoading: isLoadingStreamUrl,
|
|
||||||
isError: isErrorStreamUrl,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["stream-url", itemId, bitrateValue, mediaSourceId, audioIndex],
|
|
||||||
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!api) {
|
|
||||||
throw new Error("No api");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
console.warn("No item", itemId, item);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await getStreamUrl({
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
startTimeTicks: item?.UserData?.PlaybackPositionTicks!,
|
|
||||||
userId: user?.Id,
|
|
||||||
audioStreamIndex: audioIndex,
|
|
||||||
maxStreamingBitrate: bitrateValue,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
subtitleStreamIndex: subtitleIndex,
|
|
||||||
deviceProfile: transcoding,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res) return null;
|
|
||||||
|
|
||||||
const { mediaSource, sessionId, url } = res;
|
|
||||||
|
|
||||||
if (!sessionId || !mediaSource || !url) {
|
|
||||||
console.warn("No sessionId or mediaSource or url", url);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
mediaSource,
|
|
||||||
sessionId,
|
|
||||||
url,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
enabled: !!item,
|
|
||||||
staleTime: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const poster = usePoster(item, api);
|
|
||||||
const videoSource = useVideoSource(item, api, poster, stream?.url);
|
|
||||||
|
|
||||||
const togglePlay = useCallback(async () => {
|
|
||||||
if (isPlaying) {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(progress.value),
|
|
||||||
isPaused: true,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
videoRef.current?.resume();
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
|
||||||
itemId: item?.Id!,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(progress.value),
|
|
||||||
isPaused: false,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
isPlaying,
|
|
||||||
api,
|
|
||||||
item,
|
|
||||||
videoRef,
|
|
||||||
settings,
|
|
||||||
stream,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
mediaSourceId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const play = useCallback(() => {
|
|
||||||
videoRef.current?.resume();
|
|
||||||
reportPlaybackStart();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
|
||||||
videoRef.current?.pause();
|
|
||||||
}, [videoRef]);
|
|
||||||
|
|
||||||
const seek = useCallback(
|
|
||||||
(seconds: number) => {
|
|
||||||
videoRef.current?.seek(seconds);
|
|
||||||
},
|
|
||||||
[videoRef]
|
|
||||||
);
|
|
||||||
|
|
||||||
const reportPlaybackStopped = async () => {
|
|
||||||
if (!item?.Id) return;
|
|
||||||
await getPlaystateApi(api!).onPlaybackStopped({
|
|
||||||
itemId: item.Id,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.floor(progress.value),
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
revalidateProgressCache();
|
|
||||||
};
|
|
||||||
|
|
||||||
const stop = useCallback(() => {
|
|
||||||
reportPlaybackStopped();
|
|
||||||
videoRef.current?.pause();
|
|
||||||
setIsPlaybackStopped(true);
|
|
||||||
}, [videoRef, reportPlaybackStopped]);
|
|
||||||
|
|
||||||
const reportPlaybackStart = async () => {
|
|
||||||
if (!item?.Id) return;
|
|
||||||
await getPlaystateApi(api!).onPlaybackStart({
|
|
||||||
itemId: item.Id,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onProgress = useCallback(
|
|
||||||
async (data: OnProgressData) => {
|
|
||||||
if (isSeeking.value === true) return;
|
|
||||||
if (isPlaybackStopped === true) return;
|
|
||||||
|
|
||||||
const ticks = secondsToTicks(data.currentTime);
|
|
||||||
|
|
||||||
progress.value = ticks;
|
|
||||||
cacheProgress.value = secondsToTicks(data.playableDuration);
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
"onProgress ~",
|
|
||||||
ticks,
|
|
||||||
isPlaying,
|
|
||||||
`AUDIO index: ${audioIndex} SUB index" ${subtitleIndex}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// TODO: Use this when streaming with HLS url, but NOT when direct playing
|
|
||||||
// TODO: since playable duration is always 0 then.
|
|
||||||
setIsBuffering(data.playableDuration === 0);
|
|
||||||
|
|
||||||
if (!item?.Id || data.currentTime === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await getPlaystateApi(api!).onPlaybackProgress({
|
|
||||||
itemId: item.Id,
|
|
||||||
audioStreamIndex: audioIndex ? audioIndex : undefined,
|
|
||||||
subtitleStreamIndex: subtitleIndex ? subtitleIndex : undefined,
|
|
||||||
mediaSourceId: mediaSourceId,
|
|
||||||
positionTicks: Math.round(ticks),
|
|
||||||
isPaused: !isPlaying,
|
|
||||||
playMethod: stream?.url.includes("m3u8") ? "Transcode" : "DirectStream",
|
|
||||||
playSessionId: stream?.sessionId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[
|
|
||||||
item,
|
|
||||||
isPlaying,
|
|
||||||
api,
|
|
||||||
isPlaybackStopped,
|
|
||||||
isSeeking,
|
|
||||||
stream,
|
|
||||||
mediaSourceId,
|
|
||||||
audioIndex,
|
|
||||||
subtitleIndex,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
useWebSocket({
|
|
||||||
isPlaying: isPlaying,
|
|
||||||
togglePlay: togglePlay,
|
|
||||||
stopPlayback: stop,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [selectedTextTrack, setSelectedTextTrack] = useState<
|
|
||||||
SelectedTrack | undefined
|
|
||||||
>();
|
|
||||||
|
|
||||||
const [embededTextTracks, setEmbededTextTracks] = useState<
|
|
||||||
{
|
|
||||||
index: number;
|
|
||||||
language?: string | undefined;
|
|
||||||
selected?: boolean | undefined;
|
|
||||||
title?: string | undefined;
|
|
||||||
type: any;
|
|
||||||
}[]
|
|
||||||
>([]);
|
|
||||||
|
|
||||||
const [audioTracks, setAudioTracks] = useState<TrackInfo[]>([]);
|
|
||||||
const [selectedAudioTrack, setSelectedAudioTrack] = useState<
|
|
||||||
SelectedTrack | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedTextTrack === undefined) {
|
|
||||||
const subtitleHelper = new SubtitleHelper(
|
|
||||||
stream?.mediaSource.MediaStreams ?? []
|
|
||||||
);
|
|
||||||
const embeddedTrackIndex = subtitleHelper.getEmbeddedTrackIndex(
|
|
||||||
subtitleIndex!
|
|
||||||
);
|
|
||||||
|
|
||||||
// Most likely the subtitle is burned in.
|
|
||||||
if (embeddedTrackIndex === -1) return;
|
|
||||||
console.log(
|
|
||||||
"Setting selected text track",
|
|
||||||
subtitleIndex,
|
|
||||||
embeddedTrackIndex
|
|
||||||
);
|
|
||||||
setSelectedTextTrack({
|
|
||||||
type: SelectedTrackType.INDEX,
|
|
||||||
value: embeddedTrackIndex,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [embededTextTracks]);
|
|
||||||
|
|
||||||
const getAudioTracks = (): TrackInfo[] => {
|
|
||||||
return audioTracks.map((t) => ({
|
|
||||||
name: t.name,
|
|
||||||
index: t.index,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSubtitleTracks = (): TrackInfo[] => {
|
|
||||||
return embededTextTracks.map((t) => ({
|
|
||||||
name: t.title ?? "",
|
|
||||||
index: t.index,
|
|
||||||
language: t.language,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
useFocusEffect(
|
|
||||||
React.useCallback(() => {
|
|
||||||
return async () => {
|
|
||||||
stop();
|
|
||||||
};
|
|
||||||
}, [])
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoadingItem || isLoadingStreamUrl)
|
|
||||||
return (
|
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
|
||||||
<Loader />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isErrorItem || isErrorStreamUrl)
|
|
||||||
return (
|
|
||||||
<View className="w-screen h-screen flex flex-col items-center justify-center bg-black">
|
|
||||||
<Text className="text-white">Error</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={{ flex: 1, backgroundColor: "black" }}>
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
position: "relative",
|
|
||||||
flexDirection: "column",
|
|
||||||
justifyContent: "center",
|
|
||||||
opacity: showControls ? 0.5 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{videoSource ? (
|
|
||||||
<>
|
|
||||||
<Video
|
|
||||||
ref={videoRef}
|
|
||||||
source={videoSource}
|
|
||||||
style={{
|
|
||||||
height: "100%",
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
resizeMode={ignoreSafeAreas ? "cover" : "contain"}
|
|
||||||
onProgress={onProgress}
|
|
||||||
onError={(e) => {
|
|
||||||
console.error("Error playing video", e);
|
|
||||||
}}
|
|
||||||
onLoad={() => {
|
|
||||||
if (firstTime.current === true) {
|
|
||||||
play();
|
|
||||||
firstTime.current = false;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
progressUpdateInterval={500}
|
|
||||||
playWhenInactive={true}
|
|
||||||
allowsExternalPlayback={true}
|
|
||||||
playInBackground={true}
|
|
||||||
pictureInPicture={true}
|
|
||||||
showNotificationControls={true}
|
|
||||||
ignoreSilentSwitch="ignore"
|
|
||||||
fullscreen={false}
|
|
||||||
onPlaybackStateChanged={(state) => {
|
|
||||||
if (isSeeking.value === false) setIsPlaying(state.isPlaying);
|
|
||||||
}}
|
|
||||||
onTextTracks={(data) => {
|
|
||||||
setEmbededTextTracks(data.textTracks as any);
|
|
||||||
}}
|
|
||||||
onBuffer={(e) => {
|
|
||||||
setIsBuffering(e.isBuffering);
|
|
||||||
}}
|
|
||||||
onAudioTracks={(e) => {
|
|
||||||
console.log("onAudioTracks: ", e.audioTracks);
|
|
||||||
setAudioTracks(
|
|
||||||
e.audioTracks.map((t) => ({
|
|
||||||
index: t.index,
|
|
||||||
name: t.title ?? "",
|
|
||||||
language: t.language,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
selectedTextTrack={selectedTextTrack}
|
|
||||||
selectedAudioTrack={selectedAudioTrack}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Text>No video source...</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{item && (
|
|
||||||
<Controls
|
|
||||||
mediaSource={stream?.mediaSource}
|
|
||||||
videoRef={videoRef}
|
|
||||||
enableTrickplay={true}
|
|
||||||
item={item}
|
|
||||||
togglePlay={togglePlay}
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
isSeeking={isSeeking}
|
|
||||||
progress={progress}
|
|
||||||
cacheProgress={cacheProgress}
|
|
||||||
isBuffering={isBuffering}
|
|
||||||
showControls={showControls}
|
|
||||||
setShowControls={setShowControls}
|
|
||||||
setIgnoreSafeAreas={setIgnoreSafeAreas}
|
|
||||||
ignoreSafeAreas={ignoreSafeAreas}
|
|
||||||
seek={seek}
|
|
||||||
play={play}
|
|
||||||
pause={pause}
|
|
||||||
stop={stop}
|
|
||||||
getSubtitleTracks={getSubtitleTracks}
|
|
||||||
setSubtitleTrack={(i) => {
|
|
||||||
if (i === -1) {
|
|
||||||
setSelectedTextTrack({
|
|
||||||
type: SelectedTrackType.DISABLED,
|
|
||||||
value: undefined,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelectedTextTrack({
|
|
||||||
type: SelectedTrackType.INDEX,
|
|
||||||
value: i,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
getAudioTracks={getAudioTracks}
|
|
||||||
setAudioTrack={(i) => {
|
|
||||||
console.log("setAudioTrack ~", i);
|
|
||||||
setSelectedAudioTrack({
|
|
||||||
type: SelectedTrackType.INDEX,
|
|
||||||
value: i,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function usePoster(
|
|
||||||
item: BaseItemDto | null | undefined,
|
|
||||||
api: Api | null
|
|
||||||
): string | undefined {
|
|
||||||
const poster = useMemo(() => {
|
|
||||||
if (!item || !api) return undefined;
|
|
||||||
return item.Type === "Audio"
|
|
||||||
? `${api.basePath}/Items/${item.AlbumId}/Images/Primary?tag=${item.AlbumPrimaryImageTag}&quality=90&maxHeight=200&maxWidth=200`
|
|
||||||
: getBackdropUrl({
|
|
||||||
api,
|
|
||||||
item: item,
|
|
||||||
quality: 70,
|
|
||||||
width: 200,
|
|
||||||
});
|
|
||||||
}, [item, api]);
|
|
||||||
|
|
||||||
return poster ?? undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useVideoSource(
|
|
||||||
item: BaseItemDto | null | undefined,
|
|
||||||
api: Api | null,
|
|
||||||
poster: string | undefined,
|
|
||||||
url?: string | null
|
|
||||||
) {
|
|
||||||
const videoSource = useMemo(() => {
|
|
||||||
if (!item || !api || !url) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startPosition = item?.UserData?.PlaybackPositionTicks
|
|
||||||
? Math.round(item.UserData.PlaybackPositionTicks / 10000)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
uri: url,
|
|
||||||
isNetwork: true,
|
|
||||||
startPosition,
|
|
||||||
headers: getAuthHeaders(api),
|
|
||||||
metadata: {
|
|
||||||
artist: item?.AlbumArtist ?? undefined,
|
|
||||||
title: item?.Name || "Unknown",
|
|
||||||
description: item?.Overview ?? undefined,
|
|
||||||
imageUri: poster,
|
|
||||||
subtitle: item?.Album ?? undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, [item, api, poster, url]);
|
|
||||||
|
|
||||||
return videoSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Player;
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { useGlobalSearchParams } from "expo-router";
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { Alert, Dimensions, View } from "react-native";
|
|
||||||
import YoutubePlayer, { PLAYER_STATES } from "react-native-youtube-iframe";
|
|
||||||
|
|
||||||
export default function page() {
|
|
||||||
const searchParams = useGlobalSearchParams();
|
|
||||||
console.log(searchParams);
|
|
||||||
|
|
||||||
const { url } = searchParams as { url: string };
|
|
||||||
|
|
||||||
const videoId = useMemo(() => {
|
|
||||||
return url.split("v=")[1];
|
|
||||||
}, [url]);
|
|
||||||
|
|
||||||
const [playing, setPlaying] = useState(false);
|
|
||||||
|
|
||||||
const onStateChange = useCallback((state: PLAYER_STATES) => {
|
|
||||||
if (state === "ended") {
|
|
||||||
setPlaying(false);
|
|
||||||
Alert.alert("video has finished playing!");
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const togglePlaying = useCallback(() => {
|
|
||||||
setPlaying((prev) => !prev);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
togglePlaying();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const screenWidth = Dimensions.get("screen").width;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View className="flex flex-col bg-black items-center justify-center h-full">
|
|
||||||
<YoutubePlayer
|
|
||||||
height={300}
|
|
||||||
play={playing}
|
|
||||||
videoId={videoId}
|
|
||||||
onChangeState={onStateChange}
|
|
||||||
width={screenWidth}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,13 +7,13 @@ import { type PropsWithChildren } from "react";
|
|||||||
*/
|
*/
|
||||||
export default function Root({ children }: PropsWithChildren) {
|
export default function Root({ children }: PropsWithChildren) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang='en'>
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet='utf-8' />
|
||||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
<meta httpEquiv='X-UA-Compatible' content='IE=edge' />
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name='viewport'
|
||||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
content='width=device-width, initial-scale=1, shrink-to-fit=no'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
import { Link, Stack, usePathname } from "expo-router";
|
import { Link, Stack } from "expo-router";
|
||||||
import { StyleSheet } from "react-native";
|
import { StyleSheet } from "react-native";
|
||||||
|
|
||||||
import { ThemedText } from "@/components/ThemedText";
|
import { ThemedText } from "@/components/ThemedText";
|
||||||
import { ThemedView } from "@/components/ThemedView";
|
import { ThemedView } from "@/components/ThemedView";
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
export default function NotFoundScreen() {
|
export default function NotFoundScreen() {
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: "Oops!" }} />
|
<Stack.Screen options={{ title: "Oops!" }} />
|
||||||
<ThemedView style={styles.container}>
|
<ThemedView style={styles.container}>
|
||||||
<ThemedText type="title">This screen doesn't exist.</ThemedText>
|
<ThemedText type='title'>This screen doesn't exist.</ThemedText>
|
||||||
<Link href={"/home"} style={styles.link}>
|
<Link href={"/home"} style={styles.link}>
|
||||||
<ThemedText type="link">Go to home screen!</ThemedText>
|
<ThemedText type='link'>Go to home screen!</ThemedText>
|
||||||
</Link>
|
</Link>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
</>
|
</>
|
||||||
|
|||||||
506
app/_layout.tsx
@@ -1,47 +1,212 @@
|
|||||||
import "@/augmentations";
|
import "@/augmentations";
|
||||||
import { JellyfinProvider } from "@/providers/JellyfinProvider";
|
|
||||||
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
|
||||||
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
|
||||||
import { LogProvider } from "@/utils/log";
|
|
||||||
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
import { ActionSheetProvider } from "@expo/react-native-action-sheet";
|
||||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import i18n from "@/i18n";
|
||||||
|
import { DownloadProvider } from "@/providers/DownloadProvider";
|
||||||
|
import {
|
||||||
|
apiAtom,
|
||||||
|
getOrSetDeviceId,
|
||||||
|
getTokenFromStorage,
|
||||||
|
JellyfinProvider,
|
||||||
|
} from "@/providers/JellyfinProvider";
|
||||||
|
import { PlaySettingsProvider } from "@/providers/PlaySettingsProvider";
|
||||||
|
import { WebSocketProvider } from "@/providers/WebSocketProvider";
|
||||||
|
import { type Settings, useSettings } from "@/utils/atoms/settings";
|
||||||
|
import {
|
||||||
|
BACKGROUND_FETCH_TASK,
|
||||||
|
BACKGROUND_FETCH_TASK_SESSIONS,
|
||||||
|
registerBackgroundFetchAsyncSessions,
|
||||||
|
} from "@/utils/background-tasks";
|
||||||
|
import {
|
||||||
|
LogProvider,
|
||||||
|
writeDebugLog,
|
||||||
|
writeErrorLog,
|
||||||
|
writeToLog,
|
||||||
|
} from "@/utils/log";
|
||||||
|
import { storage } from "@/utils/mmkv";
|
||||||
|
|
||||||
|
const BackGroundDownloader = !Platform.isTV
|
||||||
|
? require("@kesha-antonov/react-native-background-downloader")
|
||||||
|
: null;
|
||||||
|
|
||||||
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
import { DarkTheme, ThemeProvider } from "@react-navigation/native";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useFonts } from "expo-font";
|
|
||||||
import { useKeepAwake } from "expo-keep-awake";
|
const BackgroundFetch = !Platform.isTV
|
||||||
import { Stack } from "expo-router";
|
? require("expo-background-fetch")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
import * as Device from "expo-device";
|
||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
|
|
||||||
|
const Notifications = !Platform.isTV ? require("expo-notifications") : null;
|
||||||
|
|
||||||
|
import { router, Stack, useSegments } from "expo-router";
|
||||||
import * as SplashScreen from "expo-splash-screen";
|
import * as SplashScreen from "expo-splash-screen";
|
||||||
|
import * as ScreenOrientation from "@/packages/expo-screen-orientation";
|
||||||
|
|
||||||
|
const TaskManager = !Platform.isTV ? require("expo-task-manager") : null;
|
||||||
|
|
||||||
|
import { getLocales } from "expo-localization";
|
||||||
import { Provider as JotaiProvider } from "jotai";
|
import { Provider as JotaiProvider } from "jotai";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Appearance } from "react-native";
|
import { I18nextProvider } from "react-i18next";
|
||||||
|
import { Appearance, AppState } from "react-native";
|
||||||
import { SystemBars } from "react-native-edge-to-edge";
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import "react-native-reanimated";
|
import "react-native-reanimated";
|
||||||
|
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
|
||||||
|
import type { EventSubscription } from "expo-modules-core";
|
||||||
|
import type {
|
||||||
|
Notification,
|
||||||
|
NotificationResponse,
|
||||||
|
} from "expo-notifications/build/Notifications.types";
|
||||||
|
import type { ExpoPushToken } from "expo-notifications/build/Tokens.types";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
import { Toaster } from "sonner-native";
|
import { Toaster } from "sonner-native";
|
||||||
|
import { userAtom } from "@/providers/JellyfinProvider";
|
||||||
|
import { store } from "@/utils/store";
|
||||||
|
|
||||||
|
if (!Platform.isTV) {
|
||||||
|
Notifications.setNotificationHandler({
|
||||||
|
handleNotification: async () => ({
|
||||||
|
shouldShowAlert: true,
|
||||||
|
shouldPlaySound: true,
|
||||||
|
shouldSetBadge: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the splash screen visible while we fetch resources
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
export default function RootLayout() {
|
// Set the animation options. This is optional.
|
||||||
const [loaded] = useFonts({
|
SplashScreen.setOptions({
|
||||||
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
|
duration: 500,
|
||||||
|
fade: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
function useNotificationObserver() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.isTV) return;
|
||||||
|
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
function redirect(notification: typeof Notifications.Notification) {
|
||||||
|
const url = notification.request.content.data?.url;
|
||||||
|
if (url) {
|
||||||
|
router.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Notifications.getLastNotificationResponseAsync().then(
|
||||||
|
(response: { notification: any }) => {
|
||||||
|
if (!isMounted || !response?.notification) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
redirect(response?.notification);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscription = Notifications.addNotificationResponseReceivedListener(
|
||||||
|
(response: { notification: any }) => {
|
||||||
|
redirect(response.notification);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
subscription.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Platform.isTV) {
|
||||||
|
TaskManager.defineTask(BACKGROUND_FETCH_TASK_SESSIONS, async () => {
|
||||||
|
console.log("TaskManager ~ sessions trigger");
|
||||||
|
|
||||||
|
const api = store.get(apiAtom);
|
||||||
|
if (api === null || api === undefined) return;
|
||||||
|
|
||||||
|
const response = await getSessionApi(api).getSessions({
|
||||||
|
activeWithinSeconds: 360,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = response.data.filter((s) => s.NowPlayingItem);
|
||||||
|
Notifications.setBadgeCountAsync(result.length);
|
||||||
|
|
||||||
|
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||||
if (loaded) {
|
console.log("TaskManager ~ trigger");
|
||||||
SplashScreen.hideAsync();
|
|
||||||
}
|
|
||||||
}, [loaded]);
|
|
||||||
|
|
||||||
|
const settingsData = storage.getString("settings");
|
||||||
|
|
||||||
|
if (!settingsData) return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
|
const settings: Partial<Settings> = JSON.parse(settingsData);
|
||||||
|
|
||||||
|
if (!settings?.autoDownload)
|
||||||
|
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
|
const token = getTokenFromStorage();
|
||||||
|
const deviceId = getOrSetDeviceId();
|
||||||
|
const baseDirectory = FileSystem.documentDirectory;
|
||||||
|
|
||||||
|
if (!token || !deviceId || !baseDirectory)
|
||||||
|
return BackgroundFetch.BackgroundFetchResult.NoData;
|
||||||
|
|
||||||
|
// Be sure to return the successful result type!
|
||||||
|
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkAndRequestPermissions = async () => {
|
||||||
|
try {
|
||||||
|
const hasAskedBefore = storage.getString(
|
||||||
|
"hasAskedForNotificationPermission",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasAskedBefore !== "true") {
|
||||||
|
const { status } = await Notifications.requestPermissionsAsync();
|
||||||
|
|
||||||
|
if (status === "granted") {
|
||||||
|
writeToLog("INFO", "Notification permissions granted.");
|
||||||
|
console.log("Notification permissions granted.");
|
||||||
|
} else {
|
||||||
|
writeToLog("ERROR", "Notification permissions denied.");
|
||||||
|
console.log("Notification permissions denied.");
|
||||||
|
}
|
||||||
|
|
||||||
|
storage.set("hasAskedForNotificationPermission", "true");
|
||||||
|
} else {
|
||||||
|
console.log("Already asked for notification permissions before.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
writeToLog(
|
||||||
|
"ERROR",
|
||||||
|
"Error checking/requesting notification permissions:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
console.error("Error checking/requesting notification permissions:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
Appearance.setColorScheme("dark");
|
Appearance.setColorScheme("dark");
|
||||||
|
|
||||||
if (!loaded) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<JotaiProvider>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<Layout />
|
<JotaiProvider>
|
||||||
</JotaiProvider>
|
<ActionSheetProvider>
|
||||||
|
<I18nextProvider i18n={i18n}>
|
||||||
|
<Layout />
|
||||||
|
</I18nextProvider>
|
||||||
|
</ActionSheetProvider>
|
||||||
|
</JotaiProvider>
|
||||||
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,75 +223,234 @@ const queryClient = new QueryClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function Layout() {
|
function Layout() {
|
||||||
useKeepAwake();
|
const [settings] = useSettings();
|
||||||
|
const [user] = useAtom(userAtom);
|
||||||
|
const [api] = useAtom(apiAtom);
|
||||||
|
const appState = useRef(AppState.currentState);
|
||||||
|
const segments = useSegments();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
i18n.changeLanguage(
|
||||||
|
settings?.preferedLanguage ?? getLocales()[0].languageCode ?? "en",
|
||||||
|
);
|
||||||
|
}, [settings?.preferedLanguage, i18n]);
|
||||||
|
|
||||||
|
useNotificationObserver();
|
||||||
|
|
||||||
|
const [expoPushToken, setExpoPushToken] = useState<ExpoPushToken>();
|
||||||
|
const notificationListener = useRef<EventSubscription>();
|
||||||
|
const responseListener = useRef<EventSubscription>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Platform.isTV && expoPushToken && api && user) {
|
||||||
|
api
|
||||||
|
?.post("/Streamyfin/device", {
|
||||||
|
token: expoPushToken.data,
|
||||||
|
deviceId: getOrSetDeviceId(),
|
||||||
|
userId: user.Id,
|
||||||
|
})
|
||||||
|
.then((_) => console.log("Posted expo push token"))
|
||||||
|
.catch((_) =>
|
||||||
|
writeErrorLog("Failed to push expo push token to plugin"),
|
||||||
|
);
|
||||||
|
} else console.log("No token available");
|
||||||
|
}, [api, expoPushToken, user]);
|
||||||
|
|
||||||
|
async function registerNotifications() {
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
console.log("Setting android notification channel 'default'");
|
||||||
|
await Notifications?.setNotificationChannelAsync("default", {
|
||||||
|
name: "default",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkAndRequestPermissions();
|
||||||
|
|
||||||
|
if (!Platform.isTV && user && user.Policy?.IsAdministrator) {
|
||||||
|
await registerBackgroundFetchAsyncSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
// only create push token for real devices (pointless for emulators)
|
||||||
|
if (Device.isDevice) {
|
||||||
|
Notifications?.getExpoPushTokenAsync()
|
||||||
|
.then((token: ExpoPushToken) => token && setExpoPushToken(token))
|
||||||
|
.catch((reason: any) => console.log("Failed to get token", reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Platform.isTV) {
|
||||||
|
registerNotifications();
|
||||||
|
|
||||||
|
notificationListener.current =
|
||||||
|
Notifications?.addNotificationReceivedListener(
|
||||||
|
(notification: Notification) => {
|
||||||
|
console.log(
|
||||||
|
"Notification received while app running",
|
||||||
|
notification,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
responseListener.current =
|
||||||
|
Notifications?.addNotificationResponseReceivedListener(
|
||||||
|
(response: NotificationResponse) => {
|
||||||
|
// Currently the notifications supported by the plugin will send data for deep links.
|
||||||
|
const { title, data } = response.notification.request.content;
|
||||||
|
writeDebugLog(
|
||||||
|
`Notification ${title} opened`,
|
||||||
|
response.notification.request.content,
|
||||||
|
);
|
||||||
|
if (data && Object.keys(data).length > 0) {
|
||||||
|
const type = data?.type?.toLower?.();
|
||||||
|
const itemId = data?.id;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "movie":
|
||||||
|
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
|
||||||
|
break;
|
||||||
|
case "episode":
|
||||||
|
// We just clicked a notification for an individual episode.
|
||||||
|
if (itemId) {
|
||||||
|
router.push(`/(auth)/(tabs)/home/items/page?id=${itemId}`);
|
||||||
|
// summarized season notification for multiple episodes. Bring them to series season
|
||||||
|
} else {
|
||||||
|
const seriesId = data.seriesId;
|
||||||
|
const seasonIndex = data.seasonIndex;
|
||||||
|
if (seasonIndex) {
|
||||||
|
router.push(
|
||||||
|
`/(auth)/(tabs)/home/series/${seriesId}?seasonIndex=${seasonIndex}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
router.push(`/(auth)/(tabs)/home/series/${seriesId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
notificationListener.current &&
|
||||||
|
Notifications?.removeNotificationSubscription(
|
||||||
|
notificationListener.current,
|
||||||
|
);
|
||||||
|
responseListener.current &&
|
||||||
|
Notifications?.removeNotificationSubscription(
|
||||||
|
responseListener.current,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [user, api]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments.includes("direct-player" as never)) {
|
||||||
|
if (
|
||||||
|
!settings.followDeviceOrientation &&
|
||||||
|
settings.defaultVideoOrientation
|
||||||
|
) {
|
||||||
|
ScreenOrientation.lockAsync(settings.defaultVideoOrientation);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settings.followDeviceOrientation === true) {
|
||||||
|
ScreenOrientation.unlockAsync();
|
||||||
|
} else {
|
||||||
|
ScreenOrientation.lockAsync(
|
||||||
|
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
settings.followDeviceOrientation,
|
||||||
|
settings.defaultVideoOrientation,
|
||||||
|
segments,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.isTV) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
||||||
|
if (
|
||||||
|
appState.current.match(/inactive|background/) &&
|
||||||
|
nextAppState === "active"
|
||||||
|
) {
|
||||||
|
BackGroundDownloader.checkForExistingDownloads();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
BackGroundDownloader.checkForExistingDownloads();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<JellyfinProvider>
|
||||||
<ActionSheetProvider>
|
<PlaySettingsProvider>
|
||||||
<JellyfinProvider>
|
<LogProvider>
|
||||||
<PlaySettingsProvider>
|
<WebSocketProvider>
|
||||||
<LogProvider>
|
<DownloadProvider>
|
||||||
<WebSocketProvider>
|
<BottomSheetModalProvider>
|
||||||
<BottomSheetModalProvider>
|
<SystemBars style='light' hidden={false} />
|
||||||
<SystemBars style="light" hidden={false} />
|
<ThemeProvider value={DarkTheme}>
|
||||||
<ThemeProvider value={DarkTheme}>
|
<Stack initialRouteName='(auth)/(tabs)'>
|
||||||
<Stack initialRouteName="/home">
|
<Stack.Screen
|
||||||
<Stack.Screen
|
name='(auth)/(tabs)'
|
||||||
name="(auth)/(tabs)"
|
options={{
|
||||||
options={{
|
headerShown: false,
|
||||||
headerShown: false,
|
title: "",
|
||||||
title: "",
|
header: () => null,
|
||||||
header: () => null,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="(auth)/player"
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
title: "",
|
|
||||||
header: () => null,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="(auth)/trailer/page"
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
presentation: "modal",
|
|
||||||
title: "",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="login"
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: "",
|
|
||||||
headerTransparent: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen name="+not-found" />
|
|
||||||
</Stack>
|
|
||||||
<Toaster
|
|
||||||
duration={4000}
|
|
||||||
toastOptions={{
|
|
||||||
style: {
|
|
||||||
backgroundColor: "#262626",
|
|
||||||
borderColor: "#363639",
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
titleStyle: {
|
|
||||||
color: "white",
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
closeButton
|
|
||||||
/>
|
/>
|
||||||
</ThemeProvider>
|
<Stack.Screen
|
||||||
</BottomSheetModalProvider>
|
name='(auth)/player'
|
||||||
</WebSocketProvider>
|
options={{
|
||||||
</LogProvider>
|
headerShown: false,
|
||||||
</PlaySettingsProvider>
|
title: "",
|
||||||
</JellyfinProvider>
|
header: () => null,
|
||||||
</ActionSheetProvider>
|
}}
|
||||||
</QueryClientProvider>
|
/>
|
||||||
</GestureHandlerRootView>
|
<Stack.Screen
|
||||||
|
name='login'
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
title: "",
|
||||||
|
headerTransparent: true,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen name='+not-found' />
|
||||||
|
</Stack>
|
||||||
|
<Toaster
|
||||||
|
duration={4000}
|
||||||
|
toastOptions={{
|
||||||
|
style: {
|
||||||
|
backgroundColor: "#262626",
|
||||||
|
borderColor: "#363639",
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
titleStyle: {
|
||||||
|
color: "white",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
closeButton
|
||||||
|
/>
|
||||||
|
</ThemeProvider>
|
||||||
|
</BottomSheetModalProvider>
|
||||||
|
</DownloadProvider>
|
||||||
|
</WebSocketProvider>
|
||||||
|
</LogProvider>
|
||||||
|
</PlaySettingsProvider>
|
||||||
|
</JellyfinProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
301
app/login.tsx
@@ -1,33 +1,39 @@
|
|||||||
import { Button } from "@/components/Button";
|
import { Ionicons, MaterialCommunityIcons } from "@expo/vector-icons";
|
||||||
import { Input } from "@/components/common/Input";
|
import type { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
||||||
import { Text } from "@/components/common/Text";
|
|
||||||
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
|
||||||
import { PublicSystemInfo } from "@jellyfin/sdk/lib/generated-client";
|
|
||||||
import { Image } from "expo-image";
|
import { Image } from "expo-image";
|
||||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
import { useAtom } from "jotai";
|
import { t } from "i18next";
|
||||||
import React, { useEffect, useState } from "react";
|
import { useAtomValue } from "jotai";
|
||||||
|
import type React from "react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
Keyboard,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { Button } from "@/components/Button";
|
||||||
|
import { Input } from "@/components/common/Input";
|
||||||
|
import { Text } from "@/components/common/Text";
|
||||||
|
import JellyfinServerDiscovery from "@/components/JellyfinServerDiscovery";
|
||||||
|
import { PreviousServersList } from "@/components/PreviousServersList";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { apiAtom, useJellyfin } from "@/providers/JellyfinProvider";
|
||||||
|
|
||||||
const CredentialsSchema = z.object({
|
const CredentialsSchema = z.object({
|
||||||
username: z.string().min(1, "Username is required"),
|
username: z.string().min(1, t("login.username_required")),
|
||||||
});
|
});
|
||||||
|
|
||||||
const Login: React.FC = () => {
|
const Login: React.FC = () => {
|
||||||
|
const api = useAtomValue(apiAtom);
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const params = useLocalSearchParams();
|
||||||
const { setServer, login, removeServer, initiateQuickConnect } =
|
const { setServer, login, removeServer, initiateQuickConnect } =
|
||||||
useJellyfin();
|
useJellyfin();
|
||||||
const [api] = useAtom(apiAtom);
|
|
||||||
const params = useLocalSearchParams();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
apiUrl: _apiUrl,
|
apiUrl: _apiUrl,
|
||||||
@@ -35,9 +41,10 @@ const Login: React.FC = () => {
|
|||||||
password: _password,
|
password: _password,
|
||||||
} = params as { apiUrl: string; username: string; password: string };
|
} = params as { apiUrl: string; username: string; password: string };
|
||||||
|
|
||||||
|
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
const [serverURL, setServerURL] = useState<string>(_apiUrl);
|
||||||
const [serverName, setServerName] = useState<string>("");
|
const [serverName, setServerName] = useState<string>("");
|
||||||
const [error, setError] = useState<string>("");
|
|
||||||
const [credentials, setCredentials] = useState<{
|
const [credentials, setCredentials] = useState<{
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -46,12 +53,13 @@ const Login: React.FC = () => {
|
|||||||
password: _password,
|
password: _password,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A way to auto login based on a link
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
// we might re-use the checkUrl function here to check the url as well
|
|
||||||
// however, I don't think it should be necessary for now
|
|
||||||
if (_apiUrl) {
|
if (_apiUrl) {
|
||||||
setServer({
|
await setServer({
|
||||||
address: _apiUrl,
|
address: _apiUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,7 +73,6 @@ const Login: React.FC = () => {
|
|||||||
})();
|
})();
|
||||||
}, [_apiUrl, _username, _password]);
|
}, [_apiUrl, _username, _password]);
|
||||||
|
|
||||||
const navigation = useNavigation();
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerTitle: serverName,
|
headerTitle: serverName,
|
||||||
@@ -75,16 +82,20 @@ const Login: React.FC = () => {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
removeServer();
|
removeServer();
|
||||||
}}
|
}}
|
||||||
|
className='flex flex-row items-center'
|
||||||
>
|
>
|
||||||
<Ionicons name="chevron-back" size={24} color="white" />
|
<Ionicons name='chevron-back' size={18} color={Colors.primary} />
|
||||||
|
<Text className='ml-2 text-purple-600'>
|
||||||
|
{t("login.change_server")}
|
||||||
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : null,
|
) : null,
|
||||||
});
|
});
|
||||||
}, [serverName, navigation, api?.basePath]);
|
}, [serverName, navigation, api?.basePath]);
|
||||||
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
|
Keyboard.dismiss();
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = CredentialsSchema.safeParse(credentials);
|
const result = CredentialsSchema.safeParse(credentials);
|
||||||
@@ -93,17 +104,18 @@ const Login: React.FC = () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
setError(error.message);
|
Alert.alert(t("login.connection_failed"), error.message);
|
||||||
} else {
|
} else {
|
||||||
setError("An unexpected error occurred");
|
Alert.alert(
|
||||||
|
t("login.connection_failed"),
|
||||||
|
t("login.an_unexpected_error_occured"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [loadingServerCheck, setLoadingServerCheck] = useState<boolean>(false);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks the availability and validity of a Jellyfin server URL.
|
* Checks the availability and validity of a Jellyfin server URL.
|
||||||
*
|
*
|
||||||
@@ -119,7 +131,7 @@ const Login: React.FC = () => {
|
|||||||
* - Sets loadingServerCheck state to true at the beginning and false at the end.
|
* - Sets loadingServerCheck state to true at the beginning and false at the end.
|
||||||
* - Logs errors and timeout information to the console.
|
* - Logs errors and timeout information to the console.
|
||||||
*/
|
*/
|
||||||
async function checkUrl(url: string) {
|
const checkUrl = useCallback(async (url: string) => {
|
||||||
setLoadingServerCheck(true);
|
setLoadingServerCheck(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -129,15 +141,18 @@ const Login: React.FC = () => {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = (await response.json()) as PublicSystemInfo;
|
const data = (await response.json()) as PublicSystemInfo;
|
||||||
|
|
||||||
setServerName(data.ServerName || "");
|
setServerName(data.ServerName || "");
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
} catch {
|
||||||
return undefined;
|
return undefined;
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingServerCheck(false);
|
setLoadingServerCheck(false);
|
||||||
}
|
}
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the connection attempt to a Jellyfin server.
|
* Handles the connection attempt to a Jellyfin server.
|
||||||
@@ -155,159 +170,171 @@ const Login: React.FC = () => {
|
|||||||
* - Sets the server address using `setServer` if the connection is successful.
|
* - Sets the server address using `setServer` if the connection is successful.
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
const handleConnect = async (url: string) => {
|
const handleConnect = useCallback(async (url: string) => {
|
||||||
url = url.trim();
|
url = url.trim().replace(/\/$/, "");
|
||||||
|
|
||||||
const result = await checkUrl(url);
|
const result = await checkUrl(url);
|
||||||
|
|
||||||
if (result === undefined) {
|
if (result === undefined) {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
"Connection failed",
|
t("login.connection_failed"),
|
||||||
"Could not connect to the server. Please check the URL and your network connection."
|
t("login.could_not_connect_to_server"),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setServer({ address: url });
|
await setServer({ address: url });
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleQuickConnect = async () => {
|
const handleQuickConnect = async () => {
|
||||||
try {
|
try {
|
||||||
const code = await initiateQuickConnect();
|
const code = await initiateQuickConnect();
|
||||||
if (code) {
|
if (code) {
|
||||||
Alert.alert("Quick Connect", `Enter code ${code} to login`, [
|
Alert.alert(
|
||||||
{
|
t("login.quick_connect"),
|
||||||
text: "Got It",
|
t("login.enter_code_to_login", { code: code }),
|
||||||
},
|
[
|
||||||
]);
|
{
|
||||||
|
text: t("login.got_it"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
Alert.alert("Error", "Failed to initiate Quick Connect");
|
Alert.alert(
|
||||||
|
t("login.error_title"),
|
||||||
|
t("login.failed_to_initiate_quick_connect"),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (api?.basePath) {
|
return (
|
||||||
return (
|
<SafeAreaView style={{ flex: 1, paddingBottom: 16 }}>
|
||||||
<SafeAreaView style={{ flex: 1 }}>
|
<KeyboardAvoidingView
|
||||||
<KeyboardAvoidingView
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
>
|
||||||
style={{ flex: 1, height: "100%" }}
|
{api?.basePath ? (
|
||||||
>
|
<View className='flex flex-col h-full relative items-center justify-center'>
|
||||||
<View className="flex flex-col h-full relative items-center justify-center">
|
<View className='px-4 -mt-20 w-full'>
|
||||||
<View className="px-4 -mt-20 w-full">
|
<View className='flex flex-col space-y-2'>
|
||||||
<View className="flex flex-col space-y-2">
|
<Text className='text-2xl font-bold -mb-2'>
|
||||||
<Text className="text-2xl font-bold -mb-2">
|
{serverName ? (
|
||||||
Log in
|
<>
|
||||||
<>
|
{`${t("login.login_to_title")} `}
|
||||||
{serverName ? (
|
<Text className='text-purple-600'>{serverName}</Text>
|
||||||
<>
|
</>
|
||||||
{" to "}
|
) : (
|
||||||
<Text className="text-purple-600">{serverName}</Text>
|
t("login.login_title")
|
||||||
</>
|
)}
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-xs text-neutral-400">{serverURL}</Text>
|
<Text className='text-xs text-neutral-400'>{api.basePath}</Text>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Username"
|
placeholder={t("login.username_placeholder")}
|
||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
setCredentials({ ...credentials, username: text })
|
setCredentials({ ...credentials, username: text })
|
||||||
}
|
}
|
||||||
value={credentials.username}
|
value={credentials.username}
|
||||||
autoFocus
|
keyboardType='default'
|
||||||
secureTextEntry={false}
|
returnKeyType='done'
|
||||||
keyboardType="default"
|
autoCapitalize='none'
|
||||||
returnKeyType="done"
|
// Changed from username to oneTimeCode because it is a known issue in RN
|
||||||
autoCapitalize="none"
|
// https://github.com/facebook/react-native/issues/47106#issuecomment-2521270037
|
||||||
textContentType="username"
|
textContentType='oneTimeCode'
|
||||||
clearButtonMode="while-editing"
|
clearButtonMode='while-editing'
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
className="mb-2"
|
placeholder={t("login.password_placeholder")}
|
||||||
placeholder="Password"
|
|
||||||
onChangeText={(text) =>
|
onChangeText={(text) =>
|
||||||
setCredentials({ ...credentials, password: text })
|
setCredentials({ ...credentials, password: text })
|
||||||
}
|
}
|
||||||
value={credentials.password}
|
value={credentials.password}
|
||||||
secureTextEntry
|
secureTextEntry
|
||||||
keyboardType="default"
|
keyboardType='default'
|
||||||
returnKeyType="done"
|
returnKeyType='done'
|
||||||
autoCapitalize="none"
|
autoCapitalize='none'
|
||||||
textContentType="password"
|
textContentType='password'
|
||||||
clearButtonMode="while-editing"
|
clearButtonMode='while-editing'
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
/>
|
/>
|
||||||
|
<View className='flex flex-row items-center justify-between'>
|
||||||
|
<Button
|
||||||
|
onPress={handleLogin}
|
||||||
|
loading={loading}
|
||||||
|
className='flex-1 mr-2'
|
||||||
|
>
|
||||||
|
{t("login.login_button")}
|
||||||
|
</Button>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleQuickConnect}
|
||||||
|
className='p-2 bg-neutral-900 rounded-xl h-12 w-12 flex items-center justify-center'
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name='cellphone-lock'
|
||||||
|
size={24}
|
||||||
|
color='white'
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text className="text-red-600 mb-2">{error}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="absolute bottom-0 left-0 w-full px-4 mb-2">
|
<View className='absolute bottom-0 left-0 w-full px-4 mb-2' />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className='flex flex-col h-full items-center justify-center w-full'>
|
||||||
|
<View className='flex flex-col gap-y-2 px-4 w-full -mt-36'>
|
||||||
|
<Image
|
||||||
|
style={{
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
marginLeft: -23,
|
||||||
|
marginBottom: -20,
|
||||||
|
}}
|
||||||
|
source={require("@/assets/images/icon-ios-plain.png")}
|
||||||
|
/>
|
||||||
|
<Text className='text-3xl font-bold'>Streamyfin</Text>
|
||||||
|
<Text className='text-neutral-500'>
|
||||||
|
{t("server.enter_url_to_jellyfin_server")}
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
aria-label='Server URL'
|
||||||
|
placeholder={t("server.server_url_placeholder")}
|
||||||
|
onChangeText={setServerURL}
|
||||||
|
value={serverURL}
|
||||||
|
keyboardType='url'
|
||||||
|
returnKeyType='done'
|
||||||
|
autoCapitalize='none'
|
||||||
|
textContentType='URL'
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
color="black"
|
loading={loadingServerCheck}
|
||||||
onPress={handleQuickConnect}
|
disabled={loadingServerCheck}
|
||||||
className="w-full mb-2"
|
onPress={async () => {
|
||||||
|
await handleConnect(serverURL);
|
||||||
|
}}
|
||||||
|
className='w-full grow'
|
||||||
>
|
>
|
||||||
Use Quick Connect
|
{t("server.connect_button")}
|
||||||
</Button>
|
|
||||||
<Button onPress={handleLogin} loading={loading}>
|
|
||||||
Log in
|
|
||||||
</Button>
|
</Button>
|
||||||
|
<JellyfinServerDiscovery
|
||||||
|
onServerSelect={async (server) => {
|
||||||
|
setServerURL(server.address);
|
||||||
|
if (server.serverName) {
|
||||||
|
setServerName(server.serverName);
|
||||||
|
}
|
||||||
|
await handleConnect(server.address);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PreviousServersList
|
||||||
|
onServerSelect={async (s) => {
|
||||||
|
await handleConnect(s.address);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</KeyboardAvoidingView>
|
)}
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={{ flex: 1 }}>
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
|
||||||
style={{ flex: 1, height: "100%" }}
|
|
||||||
>
|
|
||||||
<View className="flex flex-col h-full relative items-center justify-center w-full">
|
|
||||||
<View className="flex flex-col gap-y-2 px-4 w-full -mt-36">
|
|
||||||
<Image
|
|
||||||
style={{
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
marginLeft: -23,
|
|
||||||
marginBottom: -20,
|
|
||||||
}}
|
|
||||||
source={require("@/assets/images/StreamyFinFinal.png")}
|
|
||||||
/>
|
|
||||||
<Text className="text-3xl font-bold">Streamyfin</Text>
|
|
||||||
<Text className="text-neutral-500">
|
|
||||||
Enter the URL to your Jellyfin server
|
|
||||||
</Text>
|
|
||||||
<Input
|
|
||||||
placeholder="Server URL"
|
|
||||||
onChangeText={setServerURL}
|
|
||||||
value={serverURL}
|
|
||||||
keyboardType="url"
|
|
||||||
returnKeyType="done"
|
|
||||||
autoCapitalize="none"
|
|
||||||
textContentType="URL"
|
|
||||||
maxLength={500}
|
|
||||||
/>
|
|
||||||
<Text className="text-xs text-neutral-500">
|
|
||||||
Make sure to include http or https
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View className="mb-2 absolute bottom-0 left-0 w-full px-4">
|
|
||||||
<Button
|
|
||||||
loading={loadingServerCheck}
|
|
||||||
disabled={loadingServerCheck}
|
|
||||||
onPress={async () => await handleConnect(serverURL)}
|
|
||||||
className="w-full grow"
|
|
||||||
>
|
|
||||||
Connect
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|||||||
BIN
assets/icons/heart.fill.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
assets/icons/heart.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
118
assets/icons/jellyseerr-logo.svg
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 91 KiB |
BIN
assets/images/icon-android-plain.png
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
assets/images/icon-android-themed.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
assets/images/icon-ios-light.png
Normal file
|
After Width: | Height: | Size: 305 KiB |
BIN
assets/images/icon-ios-plain.png
Normal file
|
After Width: | Height: | Size: 326 KiB |
BIN
assets/images/icon-ios-tinted.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 36 KiB |
BIN
assets/images/jellyseerr.PNG
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
assets/images/notification.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 75 KiB |
59
augmentations/api.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { Api, AUTHORIZATION_HEADER } from "@jellyfin/sdk";
|
||||||
|
import type { AxiosRequestConfig, AxiosResponse } from "axios";
|
||||||
|
import type { StreamyfinPluginConfig } from "@/utils/atoms/settings";
|
||||||
|
|
||||||
|
declare module "@jellyfin/sdk" {
|
||||||
|
interface Api {
|
||||||
|
get<T, D = any>(
|
||||||
|
url: string,
|
||||||
|
config?: AxiosRequestConfig<D>,
|
||||||
|
): Promise<AxiosResponse<T>>;
|
||||||
|
post<T, D = any>(
|
||||||
|
url: string,
|
||||||
|
data: D,
|
||||||
|
config?: AxiosRequestConfig<D>,
|
||||||
|
): Promise<AxiosResponse<T>>;
|
||||||
|
delete<T, D = any>(
|
||||||
|
url: string,
|
||||||
|
config?: AxiosRequestConfig<D>,
|
||||||
|
): Promise<AxiosResponse<T>>;
|
||||||
|
getStreamyfinPluginConfig(): Promise<AxiosResponse<StreamyfinPluginConfig>>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Api.prototype.get = function <T, D = any>(
|
||||||
|
url: string,
|
||||||
|
config: AxiosRequestConfig<D> = {},
|
||||||
|
): Promise<AxiosResponse<T>> {
|
||||||
|
return this.axiosInstance.get<T>(`${this.basePath}${url}`, {
|
||||||
|
...(config ?? {}),
|
||||||
|
headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Api.prototype.post = function <T, D = any>(
|
||||||
|
url: string,
|
||||||
|
data: D,
|
||||||
|
config: AxiosRequestConfig<D>,
|
||||||
|
): Promise<AxiosResponse<T>> {
|
||||||
|
return this.axiosInstance.post<T>(`${this.basePath}${url}`, data, {
|
||||||
|
...(config || {}),
|
||||||
|
headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Api.prototype.delete = function <T, D = any>(
|
||||||
|
url: string,
|
||||||
|
config: AxiosRequestConfig<D>,
|
||||||
|
): Promise<AxiosResponse<T>> {
|
||||||
|
return this.axiosInstance.delete<T>(`${this.basePath}${url}`, {
|
||||||
|
...(config || {}),
|
||||||
|
headers: { [AUTHORIZATION_HEADER]: this.authorizationHeader },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
Api.prototype.getStreamyfinPluginConfig = function (): Promise<
|
||||||
|
AxiosResponse<StreamyfinPluginConfig>
|
||||||
|
> {
|
||||||
|
return this.get<StreamyfinPluginConfig>("/Streamyfin/config");
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from "./api";
|
||||||
export * from "./mmkv";
|
export * from "./mmkv";
|
||||||
export * from "./number";
|
export * from "./number";
|
||||||
export * from "./string";
|
export * from "./string";
|
||||||
|
|||||||
@@ -1,17 +1,33 @@
|
|||||||
import {MMKV} from "react-native-mmkv";
|
import { MMKV } from "react-native-mmkv";
|
||||||
|
|
||||||
declare module "react-native-mmkv" {
|
declare module "react-native-mmkv" {
|
||||||
interface MMKV {
|
interface MMKV {
|
||||||
get<T>(key: string): T | undefined
|
get<T>(key: string): T | undefined;
|
||||||
setAny(key: string, value: any | undefined): void
|
setAny(key: string, value: any | undefined): void;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MMKV.prototype.get = function <T> (key: string): T | undefined {
|
// Add the augmentation methods directly to the MMKV prototype
|
||||||
const serializedItem = this.getString(key);
|
// This follows the recommended pattern while adding the helper methods your app uses
|
||||||
return serializedItem ? JSON.parse(serializedItem) : undefined;
|
MMKV.prototype.get = function <T>(key: string): T | undefined {
|
||||||
}
|
try {
|
||||||
|
const serializedItem = this.getString(key);
|
||||||
|
if (!serializedItem) return undefined;
|
||||||
|
return JSON.parse(serializedItem);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to parse MMKV value for key "${key}":`, error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
MMKV.prototype.setAny = function (key: string, value: any | undefined): void {
|
||||||
this.set(key, JSON.stringify(value));
|
try {
|
||||||
}
|
if (value === undefined) {
|
||||||
|
this.delete(key);
|
||||||
|
} else {
|
||||||
|
this.set(key, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Failed to set MMKV value for key "${key}":`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,37 +1,35 @@
|
|||||||
declare global {
|
declare global {
|
||||||
interface Number {
|
interface Number {
|
||||||
bytesToReadable(): string;
|
bytesToReadable(decimals?: number): string;
|
||||||
secondsToMilliseconds(): number
|
secondsToMilliseconds(): number;
|
||||||
minutesToMilliseconds(): number
|
minutesToMilliseconds(): number;
|
||||||
hoursToMilliseconds(): number
|
hoursToMilliseconds(): number;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Number.prototype.bytesToReadable = function () {
|
Number.prototype.bytesToReadable = function (decimals = 2) {
|
||||||
const bytes = this.valueOf();
|
const bytes = this.valueOf();
|
||||||
const gb = bytes / 1e9;
|
if (bytes === 0) return "0 Bytes";
|
||||||
|
|
||||||
if (gb >= 1) return `${gb.toFixed(2)} GB`;
|
const k = 1024;
|
||||||
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
|
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
|
||||||
|
|
||||||
const mb = bytes / 1024.0 / 1024.0;
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
if (mb >= 1) return `${mb.toFixed(2)} MB`;
|
|
||||||
|
|
||||||
const kb = bytes / 1024.0;
|
return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
|
||||||
if (kb >= 1) return `${kb.toFixed(2)} KB`;
|
};
|
||||||
|
|
||||||
return `${bytes.toFixed(2)} B`;
|
|
||||||
}
|
|
||||||
|
|
||||||
Number.prototype.secondsToMilliseconds = function () {
|
Number.prototype.secondsToMilliseconds = function () {
|
||||||
return this.valueOf() * 1000
|
return this.valueOf() * 1000;
|
||||||
}
|
};
|
||||||
|
|
||||||
Number.prototype.minutesToMilliseconds = function () {
|
Number.prototype.minutesToMilliseconds = function () {
|
||||||
return this.valueOf() * (60).secondsToMilliseconds()
|
return this.valueOf() * (60).secondsToMilliseconds();
|
||||||
}
|
};
|
||||||
|
|
||||||
Number.prototype.hoursToMilliseconds = function () {
|
Number.prototype.hoursToMilliseconds = function () {
|
||||||
return this.valueOf() * (60).minutesToMilliseconds()
|
return this.valueOf() * (60).minutesToMilliseconds();
|
||||||
}
|
};
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String.prototype.toTitle = function () {
|
String.prototype.toTitle = function () {
|
||||||
return this
|
return this.replaceAll("_", " ").replace(
|
||||||
.replaceAll("_", " ")
|
/\w\S*/g,
|
||||||
.replace(
|
(text) => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase(),
|
||||||
/\w\S*/g,
|
);
|
||||||
text => text.charAt(0).toUpperCase() + text.substring(1).toLowerCase()
|
};
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module.exports = function (api) {
|
module.exports = (api) => {
|
||||||
api.cache(true);
|
api.cache(true);
|
||||||
return {
|
return {
|
||||||
presets: ["babel-preset-expo"],
|
presets: ["babel-preset-expo"],
|
||||||
|
|||||||
61
biome.json
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
|
||||||
|
"files": {
|
||||||
|
"includes": [
|
||||||
|
"**/*",
|
||||||
|
"!node_modules",
|
||||||
|
"!ios",
|
||||||
|
"!android",
|
||||||
|
"!Streamyfin.app",
|
||||||
|
"!utils/jellyseerr",
|
||||||
|
"!.expo"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"style": {
|
||||||
|
"useImportType": "off",
|
||||||
|
"noNonNullAssertion": "off",
|
||||||
|
"noParameterAssign": "off",
|
||||||
|
"useLiteralEnumMembers": "off"
|
||||||
|
},
|
||||||
|
"complexity": {
|
||||||
|
"noForEach": "off"
|
||||||
|
},
|
||||||
|
"recommended": true,
|
||||||
|
"correctness": {
|
||||||
|
"useExhaustiveDependencies": "off"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"noExplicitAny": "off",
|
||||||
|
"noArrayIndexKey": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"formatWithErrors": true,
|
||||||
|
"attributePosition": "auto",
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2,
|
||||||
|
"lineEnding": "lf",
|
||||||
|
"lineWidth": 80
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"arrowParentheses": "always",
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"jsxQuoteStyle": "single",
|
||||||
|
"quoteProperties": "asNeeded",
|
||||||
|
"semicolons": "always",
|
||||||
|
"lineWidth": 80
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"json": {
|
||||||
|
"formatter": {
|
||||||
|
"trailingCommas": "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||