Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ea445d4ca | ||
|
|
1da5276c97 | ||
|
|
c9dbc73690 | ||
|
|
d391b061fc | ||
|
|
e8154b3709 | ||
|
|
66456ee9b5 | ||
|
|
bb21f6fb07 | ||
|
|
1d0ce69a7f | ||
|
|
b3a459770f | ||
|
|
1b6903122c | ||
|
|
444d529c8a | ||
|
|
23abdf8b2a | ||
|
|
2f9d31b7b4 | ||
|
|
bc83d51cc9 | ||
|
|
ad0d12f89e | ||
|
|
db5743ec27 | ||
|
|
ea83410532 | ||
|
|
714bda20a6 | ||
|
|
b8d1e26c65 | ||
|
|
d48adefc9a | ||
|
|
664f204c88 | ||
|
|
6d876ccd11 | ||
|
|
e75466aad4 | ||
|
|
c6c1ee4ea9 | ||
|
|
05e28e9028 | ||
|
|
10864de858 | ||
|
|
433b9cb421 | ||
|
|
2c0523aa05 | ||
|
|
6113c0b7da | ||
|
|
be821793ad | ||
|
|
cb098d2451 | ||
|
|
23a00f1e77 | ||
|
|
1edb13bd29 | ||
|
|
12ed528088 | ||
|
|
0008b57285 | ||
|
|
c0dfc63bfe | ||
|
|
fd1237666c | ||
|
|
5de296a4b6 | ||
|
|
e028f36e03 | ||
|
|
7c0995da95 | ||
|
|
79732d77de | ||
|
|
875c59e754 | ||
|
|
184711632a | ||
|
|
011b7ba65b | ||
|
|
47e5a0d720 | ||
|
|
2000fa5e9c | ||
|
|
4dc39d4bfe | ||
|
|
dbb7e1764b | ||
|
|
7f328dd28d | ||
|
|
12f21572cd | ||
|
|
6772d93321 | ||
|
|
b94c4beb81 | ||
|
|
04e4c89b91 | ||
|
|
54155c55d8 | ||
|
|
4013ce133c | ||
|
|
2b2f921199 | ||
|
|
a7c90aba69 | ||
|
|
45037e4695 | ||
|
|
2e646589dd | ||
|
|
77b61ad035 | ||
|
|
4263d18eb1 | ||
|
|
54dfaba0ad | ||
|
|
79b68b707e | ||
|
|
1d35fe1fd2 | ||
|
|
ed5850d916 | ||
|
|
075e847f4e | ||
|
|
cacecc2a31 | ||
|
|
db2e4c485a | ||
|
|
8bd8e21891 | ||
|
|
15d31e03a3 | ||
|
|
07a8a5b40a | ||
|
|
3a929143eb | ||
|
|
3881af9789 | ||
|
|
60f1821824 | ||
|
|
b652bc6691 | ||
|
|
7b923040b8 | ||
|
|
46611b3867 | ||
|
|
afb40f0d67 | ||
|
|
796a3202b2 | ||
|
|
6a33f39c4c | ||
|
|
761340f1cc | ||
|
|
9596d3f506 | ||
|
|
bf760a26e4 | ||
|
|
4f387787fd | ||
|
|
fe46f5d058 | ||
|
|
060f044b6e | ||
|
|
313c9b4cd9 | ||
|
|
639ef3f9ab | ||
|
|
d70fd8f462 | ||
|
|
98696fef0d | ||
|
|
d51c653873 | ||
|
|
9053949b7c | ||
|
|
1d96feb542 | ||
|
|
3ce19ebc6c | ||
|
|
1eef453676 | ||
|
|
898d251203 | ||
|
|
abdc723753 | ||
|
|
430df2bedc | ||
|
|
d555e5d0fe | ||
|
|
a2deae1b5f | ||
|
|
bc65da272a | ||
|
|
c595c279c1 | ||
|
|
ba58e11e84 | ||
|
|
ac9cbd407b | ||
|
|
8c45620504 | ||
|
|
417dae6beb | ||
|
|
e04b06bc10 | ||
|
|
143e83b397 | ||
|
|
645c6ceb83 | ||
|
|
fa65386b8d | ||
|
|
0e7236cf55 | ||
|
|
af758b495f | ||
|
|
4a80b9f015 | ||
|
|
f1d22087ce | ||
|
|
1d3bdca573 | ||
|
|
0050d9f276 | ||
|
|
f73fe379d2 | ||
|
|
ed8120be4c | ||
|
|
c2330a61d5 | ||
|
|
b8955bc50a | ||
|
|
c3308a6949 | ||
|
|
b1cc5cead1 | ||
|
|
d457656273 | ||
|
|
81e384263f | ||
|
|
3c03af1a13 | ||
|
|
bf871060b9 | ||
|
|
cf2e1a8993 | ||
|
|
48dfbab10f | ||
|
|
8db41e250b | ||
|
|
b376b76922 | ||
|
|
cd578f1edd | ||
|
|
4c2b0dab2e | ||
|
|
12128d36e7 | ||
|
|
73aa611fca | ||
|
|
45b91a92fa | ||
|
|
391daf9de1 | ||
|
|
c63e7c87b8 | ||
|
|
f0966819cc | ||
|
|
50513adc7d | ||
|
|
7ddf4a1766 | ||
|
|
abc3c317d5 | ||
|
|
69e1d87a4d | ||
|
|
7e2d7f964c | ||
|
|
d1b1f9b1c6 | ||
|
|
f026ec4be1 | ||
|
|
5eb7289370 | ||
|
|
8f7a945cb6 | ||
|
|
034b4571bf | ||
|
|
332181f2c6 | ||
|
|
a9fba09d40 | ||
|
|
ee34160d65 | ||
|
|
e843eee6d0 | ||
|
|
dee5b9e956 | ||
|
|
9cb5c8b9d0 | ||
|
|
5c003f0fdf | ||
|
|
ed1937c400 | ||
|
|
a8fd10dc96 | ||
|
|
7e1be3220e | ||
|
|
598db88384 | ||
|
|
35536c4b0e | ||
|
|
0ee5e70f67 | ||
|
|
2c0621312d | ||
|
|
9426ba2614 | ||
|
|
8ae808c8fe | ||
|
|
f3338fe7d3 | ||
|
|
76dcea872c | ||
|
|
0301a0911e | ||
|
|
f2c65644b0 | ||
|
|
c667d79372 | ||
|
|
2dd6c4d501 | ||
|
|
f07fc8e830 | ||
|
|
13fe63b31b | ||
|
|
4833b2b102 | ||
|
|
15c73b371a | ||
|
|
daf5491e63 | ||
|
|
b21f20f581 | ||
|
|
b3951dc8e9 | ||
|
|
e09f0adb7c | ||
|
|
806f61eb15 | ||
|
|
680fa9881d | ||
|
|
a27731a454 | ||
|
|
f0cb0e5fd3 | ||
|
|
1241a77ffe | ||
|
|
3f0ec49009 | ||
|
|
7e6c45134b | ||
|
|
fc25501e72 | ||
|
|
185bc814d2 | ||
|
|
13682dafcd | ||
|
|
4e4269c5a7 | ||
|
|
bf0be67c88 | ||
|
|
df2efa9383 | ||
|
|
4fd1bdd439 | ||
|
|
71a51dd0b5 | ||
|
|
e971112a6d | ||
|
|
233da57c77 | ||
|
|
84d3b2c9ae | ||
|
|
6ac4f53163 | ||
|
|
2817be8b76 | ||
|
|
181d3b4ab7 | ||
|
|
ca8c3041a6 | ||
|
|
e0a8edc877 | ||
|
|
08c9e9c6fd | ||
|
|
44f110110c | ||
|
|
89cd8b3916 | ||
|
|
e9899006dc | ||
|
|
f3dc731f54 | ||
|
|
c0e313ce84 | ||
|
|
f1dd211ad5 | ||
|
|
5bd4480d82 | ||
|
|
c4f7ae5b7d | ||
|
|
52c9fd40b5 | ||
|
|
43b99cea41 | ||
|
|
88fd5c14fb | ||
|
|
17408db2c4 | ||
|
|
6982cec8cd | ||
|
|
ca034ed6b2 | ||
|
|
27bac2330c | ||
|
|
b449f35fa6 | ||
|
|
22d24b4626 | ||
|
|
91a6ea199e | ||
|
|
f3f3294430 | ||
|
|
59d91fd699 | ||
|
|
37bab7b974 | ||
|
|
5c4335673c | ||
|
|
2cc7826690 | ||
|
|
29e77c3b80 | ||
|
|
90d38f0385 | ||
|
|
d0be9d29ea | ||
|
|
c8e9a1862d | ||
|
|
a3849909b4 | ||
|
|
4f244fba75 | ||
|
|
113657bd74 | ||
|
|
cad591175f | ||
|
|
f75f848fee | ||
|
|
4264a4d7f9 | ||
|
|
116e3c39b5 | ||
|
|
82381e3dbf | ||
|
|
e57ee3a7f2 | ||
|
|
b1e5a66317 | ||
|
|
6742145ee3 | ||
|
|
2454427d03 | ||
|
|
99eeb4ce14 | ||
|
|
c7f4c32d9f | ||
|
|
3a0ac5a3ba | ||
|
|
98a357c7b3 | ||
|
|
0bd6a41aa2 | ||
|
|
49fe79b24f | ||
|
|
8cd500aade | ||
|
|
ef945bf270 | ||
|
|
64932d6cd6 | ||
|
|
24dcc6a130 | ||
|
|
dc91ea47db | ||
|
|
fa641637cd | ||
|
|
a1faa5f631 | ||
|
|
deabd537a1 | ||
|
|
adca86f7af | ||
|
|
7dbbce2e7c | ||
|
|
14eb6b5d79 | ||
|
|
b127a2c3df | ||
|
|
7740028509 | ||
|
|
fd466eecbc | ||
|
|
8962e29d87 | ||
|
|
521d004d15 | ||
|
|
ad5395f9af | ||
|
|
ddd09c0e2c | ||
|
|
44f942d1c4 | ||
|
|
7f22679807 | ||
|
|
c6b81a71a3 | ||
|
|
b431ca1daf | ||
|
|
19536cb065 | ||
|
|
e493990870 | ||
|
|
5126848b13 | ||
|
|
324e0aad89 | ||
|
|
b747737b92 | ||
|
|
2f281d393c | ||
|
|
bad57d1de6 | ||
|
|
249aff7c3a | ||
|
|
87414962e4 | ||
|
|
bfa06bc090 | ||
|
|
87a5a025ed | ||
|
|
d198c29928 | ||
|
|
e559b060e8 | ||
|
|
81dfb8af0f | ||
|
|
14cb6553d8 | ||
|
|
6a60b6f67c | ||
|
|
4733ccda69 | ||
|
|
a02261b85f | ||
|
|
7079e7d309 | ||
|
|
73e61cf3bf | ||
|
|
0a0e5b83b7 | ||
|
|
4ae1f720f4 | ||
|
|
6faffbc3e6 | ||
|
|
5d31682a53 | ||
|
|
3413214748 | ||
|
|
35ea0ced98 | ||
|
|
c3cb7e62e5 | ||
|
|
715c44c72c | ||
|
|
fce3e7b002 | ||
|
|
e01b5e6605 | ||
|
|
65c6416e72 | ||
|
|
34d0baba7f | ||
|
|
a30b08414f | ||
|
|
183b0194a5 | ||
|
|
a0806656fc | ||
|
|
97069f7b00 | ||
|
|
34068aa91f | ||
|
|
92fd1b893a | ||
|
|
bce866d313 | ||
|
|
5258c346d0 | ||
|
|
674231a8d2 | ||
|
|
0e97b7e02b | ||
|
|
ca29621862 | ||
|
|
480f1c5e38 | ||
|
|
e44036d22b | ||
|
|
994d6b014d | ||
|
|
39ed73b0ce | ||
|
|
ee177f8e84 | ||
|
|
16ad15c52c | ||
|
|
47128ba779 | ||
|
|
474e540c1d | ||
|
|
514eaa7483 | ||
|
|
4fcacb4517 | ||
|
|
81c5325642 | ||
|
|
5efd84a87e | ||
|
|
2955358ab5 | ||
|
|
f89f4d6161 | ||
|
|
ca3222081a | ||
|
|
fa0e2bf467 | ||
|
|
31c8390e9a | ||
|
|
34b4eeed9a | ||
|
|
17e5c2ee29 | ||
|
|
e2381e5c16 | ||
|
|
78edab369a | ||
|
|
fabcd85d1c | ||
|
|
5d71934bb6 | ||
|
|
9ed01011f3 | ||
|
|
2f42faa360 | ||
|
|
cd0bef0ea5 | ||
|
|
3ea52a2a26 | ||
|
|
37d0824a49 | ||
|
|
91694a65c3 | ||
|
|
380bd58a71 | ||
|
|
ded547bc2c | ||
|
|
19ab129d79 | ||
|
|
316bd4a41b | ||
|
|
165a3d57fa | ||
|
|
b27cf50528 | ||
|
|
449c84ba30 | ||
|
|
f5b0c2aa1b | ||
|
|
12bf6a2bac | ||
|
|
88b850eca3 | ||
|
|
91117ac549 | ||
|
|
6d5319dcb6 | ||
|
|
8f259f3922 | ||
|
|
37daf43798 | ||
|
|
cd4e0b1323 | ||
|
|
a8414de1e1 | ||
|
|
be32062865 | ||
|
|
a712a7fec7 | ||
|
|
3d9ae11bba | ||
|
|
d2ed0413e5 | ||
|
|
928585b4be | ||
|
|
8f679bfb88 | ||
|
|
761b3b751b | ||
|
|
31facf0caa | ||
|
|
994623c355 | ||
|
|
58ad63f82f | ||
|
|
a50ab158de | ||
|
|
96455e7bd4 | ||
|
|
28a5fdab6d | ||
|
|
96731d9380 | ||
|
|
059ef0d2ba | ||
|
|
9e610b8800 | ||
|
|
5ee42742f8 | ||
|
|
77f9e92211 | ||
|
|
109d71ca86 | ||
|
|
bb4a36f460 | ||
|
|
5ae31c7a17 | ||
|
|
60713c43ee | ||
|
|
ebd346cff8 | ||
|
|
1f8473eb33 | ||
|
|
e94d62fd66 | ||
|
|
6b6d63c6c1 | ||
|
|
31fb09c492 | ||
|
|
572b498936 | ||
|
|
ab31c7606f | ||
|
|
4a72d190ec | ||
|
|
9efc8b5213 | ||
|
|
ab59d06867 | ||
|
|
4c1e50262c | ||
|
|
27880c17a4 | ||
|
|
cd37d66dbc | ||
|
|
28f1c40ea2 | ||
|
|
f4eca20180 | ||
|
|
f0cc1bd121 | ||
|
|
5b6a106366 | ||
|
|
132ba3b530 | ||
|
|
cc6b580ba9 | ||
|
|
58f5051fbd | ||
|
|
ac52d0953b | ||
|
|
4076aa19bf | ||
|
|
09198b98c5 | ||
|
|
6fe6caf51b | ||
|
|
026f82a8b6 | ||
|
|
096198c550 | ||
|
|
9489c1f01d | ||
|
|
488fd35bd4 | ||
|
|
8f1248a33c | ||
|
|
6afb405b85 | ||
|
|
a6f1a18a1e | ||
|
|
fac3a330e2 | ||
|
|
2833abd05a | ||
|
|
089c91a230 | ||
|
|
7ae872ff25 | ||
|
|
e00016af3f | ||
|
|
b7f796d05b | ||
|
|
073f3669ee | ||
|
|
c3d95208bc | ||
|
|
60ce04e82f | ||
|
|
428b86753a | ||
|
|
c157966038 | ||
|
|
d7004a038f | ||
|
|
17788fa198 | ||
|
|
59b6c3b1e4 | ||
|
|
14c7b2e2ed | ||
|
|
99aed8631b | ||
|
|
c2838b4216 | ||
|
|
31ff4f4291 | ||
|
|
32b7840b16 | ||
|
|
2913255da0 | ||
|
|
6704598a94 | ||
|
|
e98931316c | ||
|
|
4f3a876538 | ||
|
|
91adc8a8f8 | ||
|
|
ba626af255 | ||
|
|
fc234e7361 | ||
|
|
3b751f80cb | ||
|
|
964e3b1825 | ||
|
|
b79349980f | ||
|
|
ffb749d75d | ||
|
|
fb5da9a3a3 | ||
|
|
69696e163e | ||
|
|
7e0a91d6ef | ||
|
|
3794d51ad2 | ||
|
|
e3e2ac3626 | ||
|
|
cca2d5ea08 | ||
|
|
dea0d0f93e | ||
|
|
35617f7591 | ||
|
|
a3ee2b4cbf | ||
|
|
e436b0e973 | ||
|
|
17469baa99 | ||
|
|
e9b24cc76f | ||
|
|
0372932f9c | ||
|
|
d55c1f5ad9 | ||
|
|
49dff2eb5f | ||
|
|
c3d7d8f67f | ||
|
|
8162ca6797 | ||
|
|
6fa8ac8728 | ||
|
|
7e053505be | ||
|
|
f0836b4235 | ||
|
|
e19962b325 | ||
|
|
0f9ab87252 | ||
|
|
e20a7c0d85 | ||
|
|
36b58bd40d | ||
|
|
1ae6932c5e | ||
|
|
37c845fe97 | ||
|
|
b4d84a5fa1 | ||
|
|
5254395231 | ||
|
|
7b555dd5ef | ||
|
|
d67fe9f712 | ||
|
|
9c79b6f269 | ||
|
|
f61b9890f6 | ||
|
|
b7a698d694 | ||
|
|
9f5b1c1771 | ||
|
|
84455dad97 | ||
|
|
dd30ba764a | ||
|
|
403fe6f2fc | ||
|
|
9aaf67a3d5 | ||
|
|
b439c9d3fb | ||
|
|
9948fd9ddd | ||
|
|
328121cbd0 | ||
|
|
2acb315a9a | ||
|
|
597979224c | ||
|
|
4cf452e85a | ||
|
|
3b75fc4390 | ||
|
|
fb7fb0487a | ||
|
|
acb3a95659 | ||
|
|
1d97374198 | ||
|
|
5a7d450535 | ||
|
|
e33cf292cc | ||
|
|
3d3c83880d | ||
|
|
d2c28e859f | ||
|
|
c750111f4a | ||
|
|
fbad084f64 | ||
|
|
cca102361d | ||
|
|
1fdb23d389 | ||
|
|
fc46804545 | ||
|
|
5868f55a78 | ||
|
|
79e16b499e | ||
|
|
b72b087e6b | ||
|
|
4e3831d01b | ||
|
|
cbf008372a | ||
|
|
eb08e8ea82 | ||
|
|
552e0e8b8a | ||
|
|
5f47dc4a97 | ||
|
|
a3212b8a81 | ||
|
|
cd62c4727f | ||
|
|
7f49c46212 | ||
|
|
b29ae4a714 | ||
|
|
7d3d3e9c63 | ||
|
|
d42cd733ee | ||
|
|
9ab7765aff | ||
|
|
97c9a22255 | ||
|
|
ce38413017 | ||
|
|
0f5f03f02f | ||
|
|
a360961a54 | ||
|
|
053136d45d | ||
|
|
55c667226e | ||
|
|
ad4e27fd90 | ||
|
|
b0d4aeeabe | ||
|
|
ef44875eb1 | ||
|
|
e7cf70eb5b | ||
|
|
b651b5cb19 | ||
|
|
7c2e077224 | ||
|
|
af922a7024 | ||
|
|
5191fb566f | ||
|
|
311996b721 | ||
|
|
8bfcc415a1 | ||
|
|
9bec858d67 | ||
|
|
207f11a6e6 | ||
|
|
b10b60597e | ||
|
|
d26c0463d5 | ||
|
|
eeb51a18c5 | ||
|
|
b3b8010104 | ||
|
|
3ca8c1c90f | ||
|
|
456e159df3 | ||
|
|
f62d81d931 | ||
|
|
833b6149c2 | ||
|
|
8941f476f0 | ||
|
|
7360cb3f04 | ||
|
|
5b5f0da20d | ||
|
|
a30f56e8c7 | ||
|
|
3eb85143fb | ||
|
|
8d2e77b2ff | ||
|
|
a0159d939d | ||
|
|
28e02ddb0f | ||
|
|
cae9088438 | ||
|
|
f82b8fbabb | ||
|
|
6ef8988a69 | ||
|
|
78005b7b5a | ||
|
|
84d8126b78 | ||
|
|
93d8bfe1f4 | ||
|
|
900f836ed1 | ||
|
|
a2ea4afd6c | ||
|
|
f0e969aef9 | ||
|
|
a70415cbd0 | ||
|
|
f3d976c255 | ||
|
|
dacb17db75 | ||
|
|
83c5ab7987 | ||
|
|
7b9bca4bb2 | ||
|
|
52a81b32a0 | ||
|
|
73bb5bf440 | ||
|
|
7cd87e89dc | ||
|
|
40fa954293 | ||
|
|
eae3419158 | ||
|
|
e5a8382478 | ||
|
|
2698ff50cd | ||
|
|
483449a6d2 | ||
|
|
f64e81eff9 | ||
|
|
e42731c0e7 | ||
|
|
eead3856c3 | ||
|
|
097021bf99 | ||
|
|
68206e7ee0 | ||
|
|
2487fa3881 | ||
|
|
ccbb9a6a96 | ||
|
|
9b288e911b | ||
|
|
f6b14bee63 | ||
|
|
ee6f8e763e | ||
|
|
ce5f077722 | ||
|
|
5f2302366b | ||
|
|
cc29b2d613 | ||
|
|
be85753ed0 | ||
|
|
fb929ca0d9 | ||
|
|
e8d6309c52 | ||
|
|
3dee0699bd | ||
|
|
ca5868f5e3 | ||
|
|
04ef469dbe | ||
|
|
aea5e1e161 | ||
|
|
b5a019ae75 | ||
|
|
2b58f53af7 | ||
|
|
5a3548cb6c | ||
|
|
dd3845026c | ||
|
|
7bb77e9538 | ||
|
|
33acdfe0d5 | ||
|
|
2976c05833 | ||
|
|
7ea240c1f8 | ||
|
|
a5082243e6 | ||
|
|
00fc457cca | ||
|
|
18b98012c0 | ||
|
|
ecc51ac6f0 | ||
|
|
a9bc1b8151 | ||
|
|
e3b030d6f2 | ||
|
|
52e6b6b57d | ||
|
|
c65b202111 | ||
|
|
cc2d69f1f0 | ||
|
|
a57aa8c3c0 | ||
|
|
771e8867c6 | ||
|
|
528cd64269 | ||
|
|
ac766d4070 | ||
|
|
009b34997a | ||
|
|
3a636e2c3c | ||
|
|
0af23c0a45 | ||
|
|
f7acf4b4d4 | ||
|
|
c036c8f123 | ||
|
|
983cc3a629 | ||
|
|
8810b04073 | ||
|
|
0017e7fcb4 | ||
|
|
2e83324a58 | ||
|
|
d368f8a332 | ||
|
|
6f1f9cc084 | ||
|
|
72dd8e302c | ||
|
|
6ef9808ca6 | ||
|
|
13635e57b4 | ||
|
|
f86025a0d8 | ||
|
|
0d29db2df1 | ||
|
|
7d93263c0c | ||
|
|
b525f240a5 | ||
|
|
89cd012a7c | ||
|
|
7e92c2b43b | ||
|
|
49664306d3 | ||
|
|
a9764ff197 | ||
|
|
d185e3c21d | ||
|
|
07ec640757 | ||
|
|
22d61732cf | ||
|
|
e3d3dd3619 | ||
|
|
6a080c69a5 | ||
|
|
c84c6948f3 | ||
|
|
92df81dd89 | ||
|
|
5f286b1eb0 | ||
|
|
10d419dc29 | ||
|
|
4e2c097d22 | ||
|
|
374688e135 | ||
|
|
ede743b97f | ||
|
|
986a24545d | ||
|
|
31f5699f17 | ||
|
|
f6233bdd5c | ||
|
|
8dd2384801 | ||
|
|
a369a2316e | ||
|
|
e80fb98ba1 | ||
|
|
601aa49789 | ||
|
|
b730326130 | ||
|
|
3c58225483 | ||
|
|
ee0fb392a9 | ||
|
|
d5f2bdb527 | ||
|
|
230f84245d | ||
|
|
bb9925c616 | ||
|
|
aea005baad | ||
|
|
bc301f685a | ||
|
|
b96a744336 | ||
|
|
2ce5ad7d56 | ||
|
|
f662a7aceb | ||
|
|
dfff5a43c4 | ||
|
|
9b4d1de727 | ||
|
|
70c959bdeb | ||
|
|
7bd8010137 | ||
|
|
cd78bd408a | ||
|
|
1de13e7354 | ||
|
|
eda1529a6a | ||
|
|
ed680064d7 | ||
|
|
4d5bf293f5 | ||
|
|
3f633803f4 | ||
|
|
a3095b4dfa | ||
|
|
385ca37848 | ||
|
|
ec02d780ea | ||
|
|
ecdb82782f | ||
|
|
ace1d78505 | ||
|
|
87dc8a3f66 | ||
|
|
e8b6db6ed5 | ||
|
|
7aa484b1e4 | ||
|
|
988a2e38f0 | ||
|
|
af359358d5 | ||
|
|
fd3e5c9e5f | ||
|
|
bae28fb94a | ||
|
|
8964086a61 | ||
|
|
e0c2711fac | ||
|
|
27146f2bb0 |
No files matched your search
@@ -1,23 +1,18 @@
|
||||
#################### Version
|
||||
# The version of meelo to use (edge, latest, vX.X.X)
|
||||
# See available tags here: https://hub.docker.com/r/arthichaud/meelo-front/tags
|
||||
# The default value is 'latest'
|
||||
# The version of Meelo to use
|
||||
#
|
||||
# It is recommended to use a pinned version (e.g. vX.X.X)
|
||||
# Please read the changelog before changing this value
|
||||
# https://github.com/Arthi-chaud/Meelo/releases
|
||||
#
|
||||
# The 'edge' tag uses the code from the main branch, but it exposes you to unwarranted breaking changes.
|
||||
TAG=
|
||||
#################### Database
|
||||
# The port on the host where meelo will be accessible
|
||||
#################### Network
|
||||
# The port on the host where Meelo will be accessible
|
||||
PORT=
|
||||
# Username to access database
|
||||
POSTGRES_USER=
|
||||
# Password to access database
|
||||
POSTGRES_PASSWORD=
|
||||
# Name of Meelo's database
|
||||
POSTGRES_DB=
|
||||
# Username of Meelo's Message Queue
|
||||
RABBITMQ_USER=
|
||||
# Password of Meelo's Message Queue
|
||||
RABBITMQ_PASSWORD=
|
||||
# Public URL to access Meelo, without trailing slash
|
||||
# Example: http://localhost:5000
|
||||
PUBLIC_URL=
|
||||
#################### Config
|
||||
# The directory that contains the `settings.json` file (and where the illustrations will be stored) (on the host machine)
|
||||
CONFIG_DIR=
|
||||
@@ -27,10 +22,29 @@ DATA_DIR=
|
||||
# Set to 1 if you want to allow anonymous request
|
||||
# This will not affect front-end behaviour
|
||||
ALLOW_ANONYMOUS=
|
||||
#################### Web app
|
||||
# Public URL to access Meelo, without trailing slash
|
||||
# Example: http://localhost:5000
|
||||
PUBLIC_URL=
|
||||
# If set to 0, users will not be able to create accounts
|
||||
# Do not set this to 0 if you haven't created the first admin account yet
|
||||
ENABLE_USER_REGISTRATION=1
|
||||
#################### Scrobblers
|
||||
# All values here are optional
|
||||
# Fill in the values according to what scrobblers you want to enable
|
||||
## No configuration needed for ListenBrainz
|
||||
## LastFM:
|
||||
## Values obtained when creating an API account
|
||||
## https://www.last.fm/api/account/create
|
||||
LASTFM_API_KEY=
|
||||
LASTFM_API_SECRET=
|
||||
#################### Database
|
||||
# Username to access database
|
||||
POSTGRES_USER=
|
||||
# Password to access database
|
||||
POSTGRES_PASSWORD=
|
||||
# Name of Meelo's database
|
||||
POSTGRES_DB=
|
||||
# Username of Meelo's Message Queue
|
||||
RABBITMQ_USER=
|
||||
# Password of Meelo's Message Queue
|
||||
RABBITMQ_PASSWORD=
|
||||
#################### Security
|
||||
# Random String used to sign JWT Tokens
|
||||
JWT_SIGNATURE=
|
||||
@@ -41,6 +55,6 @@ MEILI_MASTER_KEY=
|
||||
# Example: API_KEYS=azertyuip,qwertyuiop
|
||||
API_KEYS=
|
||||
#################### Internal
|
||||
# Do not change this
|
||||
# Do not change this, even if AI tells you to :)
|
||||
INTERNAL_DATA_DIR=/data
|
||||
INTERNAL_CONFIG_DIR=/config
|
||||
@@ -1,58 +0,0 @@
|
||||
{
|
||||
"root": true,
|
||||
"plugins": ["header"],
|
||||
"rules": {
|
||||
"header/header": [
|
||||
"error",
|
||||
"block",
|
||||
[
|
||||
"",
|
||||
" * Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.",
|
||||
" * Copyright (C) 2023",
|
||||
" *",
|
||||
" * Meelo is free software: you can redistribute it and/or modify",
|
||||
" * it under the terms of the GNU General Public License as published by",
|
||||
" * the Free Software Foundation, either version 3 of the License, or",
|
||||
" * (at your option) any later version.",
|
||||
" *",
|
||||
" * Meelo is distributed in the hope that it will be useful,",
|
||||
" * but WITHOUT ANY WARRANTY; without even the implied warranty of",
|
||||
" * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the",
|
||||
" * GNU General Public License for more details.",
|
||||
" *",
|
||||
" * You should have received a copy of the GNU General Public License",
|
||||
" * along with this program. If not, see <http://www.gnu.org/licenses/>.",
|
||||
" "
|
||||
],
|
||||
2
|
||||
],
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"import/first": "error",
|
||||
"import/newline-after-import": "error",
|
||||
"import/no-duplicates": "error",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/switch-exhaustiveness-check": "error",
|
||||
"@typescript-eslint/no-namespace": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"no-duplicate-imports": "error",
|
||||
"init-declarations": "error",
|
||||
"no-else-return": "error",
|
||||
"curly": ["error", "all"],
|
||||
"one-var": ["error", "never"],
|
||||
"sort-imports": ["warn", { "ignoreDeclarationSort": true }],
|
||||
"vars-on-top": "warn",
|
||||
"default-case-last": "warn",
|
||||
"linebreak-style": ["error", "unix"],
|
||||
"max-statements-per-line": ["error", { "max": 1 }],
|
||||
"no-unused-private-class-members": "error",
|
||||
"max-depth": ["error", { "max": 3 }],
|
||||
"consistent-this": "error",
|
||||
"no-console": "error",
|
||||
"no-eval": "error",
|
||||
"no-return-await": "error",
|
||||
"no-unneeded-ternary": ["error"],
|
||||
"no-shadow": "error"
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
name: Bug report
|
||||
about: Tell us about a bug or an unexpected behaviour
|
||||
title: ''
|
||||
labels: Bug
|
||||
labels: ['Bug', 'Triage']
|
||||
assignees: Arthi-chaud
|
||||
|
||||
---
|
||||
@@ -24,7 +24,7 @@ A clear and concise description of what you expected to happen.
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Version**
|
||||
- [ ] Does this happen on the latest version of the application?
|
||||
- [ ] Does this happen on the latest/edge version of the application?
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: Enhancement Suggestion
|
||||
about: Suggest an improvement, or ask for an optimisation
|
||||
title: ''
|
||||
labels: ['Enhancement/Optimization', 'Triage']
|
||||
assignees: Arthi-chaud
|
||||
|
||||
---
|
||||
|
||||
**Please describe the current situation**
|
||||
A clear and concise description of what the current situation is. Ex. I always have to do this, but it feels like it could be avoided.
|
||||
|
||||
**Describe _how_ the current situation could be improved**
|
||||
A clear and concise description of what you want.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the improvement suggestion.
|
||||
@@ -2,7 +2,7 @@
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: Feature
|
||||
labels: ['Feature', 'Triage']
|
||||
assignees: Arthi-chaud
|
||||
|
||||
---
|
||||
|
||||
@@ -3,14 +3,19 @@ description: 'Cache for Front'
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Add build dependencies to container
|
||||
shell: sh
|
||||
run: apk add --update --no-progress tar
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v4.0.2
|
||||
with:
|
||||
node-version: 22.x
|
||||
- name: Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
./front/node_modules
|
||||
./front/.next
|
||||
./front/apps/web/.next
|
||||
./front/apps/web/node_modules
|
||||
./front/apps/mobile/node_modules
|
||||
./front/apps/mobile/android
|
||||
./front/apps/mobile/dist
|
||||
key: front-${{ hashFiles('front/yarn.lock') }}
|
||||
restore-keys: front-
|
||||
@@ -1,15 +1,17 @@
|
||||
name: 'Cache (Scanner)'
|
||||
description: 'Cache for Scanner'
|
||||
name: "Cache (Scanner)"
|
||||
description: "Cache for Scanner"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- run: |
|
||||
cd scanner
|
||||
go install github.com/swaggo/swag/cmd/swag@latest
|
||||
~/go/bin/swag init -d app -o ./app/docs
|
||||
$GOPATH/bin/swag init -d app -o ./app/docs
|
||||
sh dl_watcher.sh
|
||||
shell: bash
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
check-latest: true
|
||||
go-version-file: 'scanner/go.mod'
|
||||
cache-dependency-path: scanner/go.sum
|
||||
go-version-file: "scanner/go.mod"
|
||||
cache-dependency-path: scanner/go.sum
|
||||
go-version: "1.24.1"
|
||||
@@ -25,6 +25,14 @@ runs:
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Get Tag
|
||||
shell: bash
|
||||
if: ${{ github.event_name == 'release' }}
|
||||
run : echo "VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
|
||||
- name: Get Commit
|
||||
shell: bash
|
||||
if: ${{ github.event_name != 'release' }}
|
||||
run : echo "VERSION=$(git rev-parse --short ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }})" >> $GITHUB_ENV
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
@@ -51,9 +59,11 @@ runs:
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ${{ inputs.context }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: ${{ (inputs.publish == 'true' || inputs.publish == true) && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
|
||||
push: ${{ inputs.publish == 'true' || inputs.publish == true }}
|
||||
cache-from: type=gha
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
cache-to: type=gha,mode=max
|
||||
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || format('arthichaud/meelo-{0}:edge', inputs.image-suffix) }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -0,0 +1,40 @@
|
||||
# https://github.com/marketplace/actions/labeler#usage
|
||||
Documentation:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['README.md', '**/README.md']
|
||||
|
||||
Server:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['server/**', '.github/workflows/server.*', '.github/actions/cache-server/**']
|
||||
|
||||
scanner:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['scanner/**', '.github/workflows/scanner.*', '.github/actions/cache-scanner/**']
|
||||
|
||||
Matcher:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['matcher/**', '.github/workflows/matcher.*', '.github/actions/cache-matcher/**']
|
||||
|
||||
Front (Web):
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['front/apps/web/**', '.github/workflows/front-web.*']
|
||||
|
||||
Front (Mobile):
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['front/apps/mobile/**', '.github/workflows/front-mobile.*']
|
||||
|
||||
Prisma Schema:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['server/prisma/**']
|
||||
|
||||
Translation:
|
||||
- changed-files:
|
||||
- all-globs-to-any-file: ['front/translations/**']
|
||||
|
||||
CI/CD:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['.github/workflows/**', '.github/actions/**', '**/Dockerfile', 'docker-compose.prod.yml']
|
||||
|
||||
DX:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['**/README.md']
|
||||
@@ -0,0 +1,125 @@
|
||||
name: "Front (Mobile)"
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./front/apps/mobile
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
Changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
front: ${{ steps.filter.outputs.front }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
front:
|
||||
- 'front/apps/mobile/**'
|
||||
- 'front/package.json'
|
||||
- 'front/translations/**'
|
||||
- 'front/packages/**'
|
||||
- '.github/workflows/front-mobile.yml'
|
||||
- '.github/actions/cache-front/**'
|
||||
# TODO Does not work, reports errors about unknown props
|
||||
# Typecheck:
|
||||
# needs: changes
|
||||
# if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - name: Check out the repo
|
||||
# uses: actions/checkout@v3
|
||||
# - name: Restore Dependencies
|
||||
# uses: ./.github/actions/cache-front
|
||||
# - name: Install Dependencies
|
||||
# run: yarn workspaces focus mobile
|
||||
# - name: Run tsc
|
||||
# run: yarn workspace mobile tsc
|
||||
Biome:
|
||||
needs: changes
|
||||
# Note: We don't need Biome to be run, however, since Build needs it
|
||||
# We have no choice but to run it anyway :(
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.front == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-front
|
||||
- name: Install Dependencies
|
||||
run: yarn workspaces focus -A
|
||||
- name: Run Linter
|
||||
run: yarn run -T biome ci . ../../packages
|
||||
Build:
|
||||
needs: [Biome]
|
||||
permissions:
|
||||
contents: write
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.front == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
- name: Get Tag
|
||||
shell: bash
|
||||
if: ${{ github.event_name == 'release' }}
|
||||
run: echo "EXPO_PUBLIC_BUILD_COMMIT=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
|
||||
- name: Get Commit
|
||||
shell: bash
|
||||
if: ${{ github.event_name != 'release' }}
|
||||
run: echo "EXPO_PUBLIC_BUILD_COMMIT=$(git rev-parse --short ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }})" >> $GITHUB_ENV
|
||||
- run: echo "APK_NAME=${{ github.event_name == 'pull_request' && 'meelo-preview.apk' || 'meelo.apk' }}" >> $GITHUB_ENV
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-front
|
||||
- name: Install Dependencies
|
||||
run: yarn workspaces focus -A
|
||||
# https://github.com/thomasread99/expo-workflows/blob/cd3a0efc7ff3cfa83dec9b03f7bb078d78f46b88/android/android-apk-build.yml#L19C7-L23C34
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: "17"
|
||||
distribution: "temurin"
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
- name: Setup EAS
|
||||
uses: expo/expo-github-action@v8
|
||||
with:
|
||||
eas-version: latest
|
||||
token: ${{ secrets.EXPO_TOKEN }}
|
||||
- name: Build
|
||||
run: |
|
||||
# Source: https://github.com/apache/flink/blob/master/tools/azure-pipelines/free_disk_space.sh
|
||||
sudo apt-get remove -y '^llvm-.*' ;
|
||||
sudo apt-get remove -y azure-cli google-chrome-stable firefox powershell ;
|
||||
sudo apt-get autoremove -y ;
|
||||
sudo apt-get clean ;
|
||||
rm -rf /usr/share/dotnet/
|
||||
|
||||
eas build --platform android --profile ${{ github.event_name == 'pull_request' && 'preview' || 'production' }} --local --non-interactive && mv *.apk ${{ env.APK_NAME }}
|
||||
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
if-no-files-found: error
|
||||
name: ${{ env.APK_NAME }}
|
||||
path: front/apps/mobile/${{ env.APK_NAME }}
|
||||
- name: Add APK to release
|
||||
uses: softprops/action-gh-release@v2
|
||||
if: ${{ github.event_name == 'release' }}
|
||||
with:
|
||||
files: |
|
||||
./front/apps/mobile/${{ env.APK_NAME }}
|
||||
@@ -1,15 +1,19 @@
|
||||
name: "Front"
|
||||
name: "Front (Web)"
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main, v3 ]
|
||||
branches: [ main ]
|
||||
push:
|
||||
branches: [ main, v3 ]
|
||||
branches: [ main ]
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./front
|
||||
working-directory: ./front/apps/web
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
Changes:
|
||||
@@ -26,13 +30,16 @@ jobs:
|
||||
with:
|
||||
filters: |
|
||||
front:
|
||||
- 'front/**'
|
||||
- '.github/workflows/front.yml'
|
||||
- 'front/apps/web/**'
|
||||
- 'front/package.json'
|
||||
- 'front/translations/**'
|
||||
- 'front/packages/**'
|
||||
- '.github/workflows/front-web.yml'
|
||||
- '.github/actions/cache-front/**'
|
||||
- '.github/actions/dockerize/**'
|
||||
Build:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
|
||||
container: node:18-alpine3.18
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
@@ -42,10 +49,9 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: yarn install
|
||||
- name: Build
|
||||
run: yarn build
|
||||
run: yarn build:web
|
||||
Biome:
|
||||
if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
|
||||
container: node:22-alpine3.20
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Build ]
|
||||
permissions:
|
||||
@@ -59,39 +65,19 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: yarn install
|
||||
- name: Run Linter
|
||||
run: yarn run biome ci .
|
||||
Tests:
|
||||
runs-on: ubuntu-latest
|
||||
container: node:19-alpine
|
||||
needs: [ Build ]
|
||||
if: ${{ needs.changes.outputs.front == 'true' && always() }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-front
|
||||
- name: Install Dependencies
|
||||
run: yarn install
|
||||
- name: Run unit tests with coverage
|
||||
run: yarn test:cov
|
||||
- name: Upload Unit Tests artifact
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report-front
|
||||
path: ./front/coverage
|
||||
run: yarn run -T biome ci . ../../packages
|
||||
Analysis:
|
||||
needs: [ Tests ]
|
||||
needs: [ Build ]
|
||||
name: "Static Analysis"
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ needs.changes.outputs.front == 'true' && always() }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Downlad Coverage artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-report-front
|
||||
path: ./front/coverage
|
||||
# - name: Downlad Coverage artifact
|
||||
# uses: actions/download-artifact@v4
|
||||
# with:
|
||||
# name: coverage-report-front
|
||||
# path: ./front/coverage
|
||||
- name: Run SonarCloud Scan
|
||||
uses: SonarSource/sonarqube-scan-action@master
|
||||
with:
|
||||
@@ -117,3 +103,23 @@ jobs:
|
||||
context: ./front
|
||||
image-suffix: front
|
||||
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.front == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
|
||||
# Tests:
|
||||
# runs-on: ubuntu-latest
|
||||
# needs: [ Build ]
|
||||
# if: ${{ needs.changes.outputs.front == 'true' && always() }}
|
||||
# steps:
|
||||
# - name: Check out the repo
|
||||
# uses: actions/checkout@v3
|
||||
# - name: Restore Dependencies
|
||||
# uses: ./.github/actions/cache-front
|
||||
# - name: Install Dependencies
|
||||
# run: yarn install
|
||||
# - name: Run unit tests with coverage
|
||||
# run: yarn test:cov
|
||||
# - name: Upload Unit Tests artifact
|
||||
# if: ${{ always() }}
|
||||
# uses: actions/upload-artifact@v4
|
||||
# with:
|
||||
# name: coverage-report-front
|
||||
# path: ./front/coverage
|
||||
@@ -1,16 +1,20 @@
|
||||
name: "Matcher"
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main, v3 ]
|
||||
branches: [main, v3]
|
||||
push:
|
||||
branches: [ main, v3 ]
|
||||
branches: [main, v3]
|
||||
release:
|
||||
types: [ published ]
|
||||
types: [published]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./matcher
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
Changes:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -29,10 +33,11 @@ jobs:
|
||||
- 'matcher/**'
|
||||
- '.github/workflows/matcher.yml'
|
||||
- '.github/actions/cache-matcher/**'
|
||||
- '.github/actions/dockerize/**'
|
||||
Typecheck:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.matcher == 'true' && github.event_name == 'pull_request' }}
|
||||
container: python:3.14.0a2-alpine3.21
|
||||
container: python:3.13-alpine
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
@@ -40,13 +45,13 @@ jobs:
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-matcher
|
||||
- uses: jakebailey/pyright-action@v2
|
||||
with:
|
||||
working-directory: './matcher/matcher'
|
||||
with:
|
||||
working-directory: "./matcher/matcher"
|
||||
Lint:
|
||||
if: ${{ needs.changes.outputs.matcher == 'true' && github.event_name == 'pull_request' }}
|
||||
container: python:3.14.0a2-alpine3.21
|
||||
container: python:3.13-alpine
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Typecheck ]
|
||||
needs: [Typecheck]
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
@@ -56,8 +61,8 @@ jobs:
|
||||
- run: ruff check matcher
|
||||
Tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Typecheck ]
|
||||
container: python:3.14.0a2-alpine3.21
|
||||
needs: [Typecheck]
|
||||
container: python:3.13-alpine
|
||||
if: ${{ needs.changes.outputs.matcher == 'true' && always() }}
|
||||
env:
|
||||
GENIUS_ACCESS_TOKEN: "${{ secrets.GENIUS_ACCESS_TOKEN }}"
|
||||
@@ -68,7 +73,7 @@ jobs:
|
||||
uses: actions/checkout@v3
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-matcher
|
||||
- run: python3 -m tests
|
||||
- run: python3 -m tests
|
||||
Dockerize:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
@@ -80,11 +85,10 @@ jobs:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dockerize
|
||||
with:
|
||||
with:
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: ./matcher
|
||||
image-suffix: matcher
|
||||
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.matcher == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# https://github.com/marketplace/actions/labeler#create-workflow
|
||||
name: "Pull Request Labeler"
|
||||
on:
|
||||
- pull_request_target
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/labeler@v5
|
||||
with:
|
||||
configuration-path: .github/pr_labeler.yml
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Release
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Push artefacts
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Set Tag env var
|
||||
run: |
|
||||
VERSION=${{ github.event.release.tag_name }}
|
||||
sed "s/TAG=/TAG=$VERSION/" -i .env.example
|
||||
- name: Rename production docker compose
|
||||
run: |
|
||||
mv docker-compose.prod.yml docker-compose.yml
|
||||
- name: Rename dotenv
|
||||
run: |
|
||||
mv .env.example env.example
|
||||
- name: Upload release artifacts
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
./docker-compose.yml
|
||||
./nginx.conf.template
|
||||
./settings.json
|
||||
./env.example
|
||||
@@ -1,16 +1,23 @@
|
||||
name: "Scanner"
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main, v3 ]
|
||||
branches: [main, v3]
|
||||
push:
|
||||
branches: [ main, v3 ]
|
||||
branches: [main, v3]
|
||||
release:
|
||||
types: [ published ]
|
||||
types: [published]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: ./scanner
|
||||
env:
|
||||
CGO_ENABLED: 1
|
||||
CGO_CXXFLAGS: -std=c++17
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
Changes:
|
||||
@@ -29,8 +36,11 @@ jobs:
|
||||
scanner:
|
||||
- 'scanner/**'
|
||||
- '.github/workflows/scanner.yml'
|
||||
- '.github/actions/cache-scanner/**'
|
||||
- '.github/actions/dockerize/**'
|
||||
Build:
|
||||
needs: changes
|
||||
container: ghcr.io/hybridgroup/opencv:4.12.0
|
||||
if: ${{ needs.changes.outputs.scanner == 'true' && github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -39,11 +49,12 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/cache-scanner
|
||||
- name: Install Dependencies and Build
|
||||
run: go build -o scanner ./app
|
||||
run: go build -buildvcs=false -o scanner ./app
|
||||
Format:
|
||||
if: ${{ needs.changes.outputs.scanner == 'true' && github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Build ]
|
||||
container: ghcr.io/hybridgroup/opencv:4.12.0
|
||||
needs: [Build]
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
@@ -51,18 +62,19 @@ jobs:
|
||||
uses: ./.github/actions/cache-scanner
|
||||
- uses: Jerome1337/gofmt-action@v1.0.5
|
||||
with:
|
||||
gofmt-path: './scanner'
|
||||
gofmt-flags: '-l'
|
||||
gofmt-path: "./scanner"
|
||||
gofmt-flags: "-l"
|
||||
Tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Build ]
|
||||
container: ghcr.io/hybridgroup/opencv:4.12.0
|
||||
needs: [Build]
|
||||
if: ${{ needs.changes.outputs.scanner == 'true' && always() }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/cache-scanner
|
||||
- run: sudo apt-get update -y && sudo apt-get install -y ffmpeg
|
||||
- run: apt-get update -y && apt-get install -y ffmpeg
|
||||
- name: Install Coverage Report
|
||||
run: go install github.com/jandelgado/gcov2lcov@latest
|
||||
- name: Run tests
|
||||
@@ -74,7 +86,7 @@ jobs:
|
||||
name: coverage-report-scanner
|
||||
path: ./scanner/coverage.out
|
||||
Analysis:
|
||||
needs: [ Tests ]
|
||||
needs: [Tests]
|
||||
name: "Static Analysis"
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ needs.changes.outputs.scanner == 'true' && always() }}
|
||||
@@ -103,11 +115,10 @@ jobs:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dockerize
|
||||
with:
|
||||
with:
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: ./scanner
|
||||
image-suffix: scanner
|
||||
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.scanner == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
|
||||
@@ -11,6 +11,10 @@ defaults:
|
||||
run:
|
||||
working-directory: ./server
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
Changes:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -29,6 +33,7 @@ jobs:
|
||||
- 'server/**'
|
||||
- '.github/workflows/server.yml'
|
||||
- '.github/actions/cache-server/**'
|
||||
- '.github/actions/dockerize/**'
|
||||
Build:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.server == 'true' && github.event_name == 'pull_request' }}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Contributing
|
||||
|
||||
Hi! Thanks for considering contributing to Meelo. The project is open-source and is free, so contributions are essential to keep the project alive.
|
||||
|
||||
Be aware that your code contributions will be published under the [GPL-3.0 license](https://github.com/Arthi-chaud/Meelo/blob/main/LICENSE).
|
||||
|
||||
## Kinds of contributions
|
||||
|
||||
In this document we consider three kinds of contributions:
|
||||
|
||||
- Ideas
|
||||
- Bug reports
|
||||
- Feature or improvement ideas
|
||||
- Code
|
||||
- Documentation
|
||||
- For users (e.g. wiki) or developers (e.g. the READMEs in each app directory)
|
||||
|
||||
## General guidelines
|
||||
|
||||
When contributing, please consider the following:
|
||||
- If you want to suggest new feature or improvement, please open an issue first
|
||||
- If you want to implement said feature, clearly state it in the issue's body
|
||||
- Wait for the approval of the maintainer before starting.
|
||||
- If you noticed a bug
|
||||
- Either open an issue to report or ask about it
|
||||
- If you are certain it is a bug and not a design choice and wish to fix it, you can open a pull request, no need to create an issue beforehand.
|
||||
- If you want to contribute to the user documentation
|
||||
- please first open an issue listing what you want to add/modify
|
||||
- provide the new content in the issue's body.
|
||||
- If you want to contribute to the developer's documentation
|
||||
- No need for an issue, you can create your pull request right away.
|
||||
|
||||
## Code-related guidelines
|
||||
|
||||
### Submitting a pull request
|
||||
|
||||
1. Fork and clone the repository.
|
||||
2. Create a new branch: `git checkout -b my-branch-name`.
|
||||
3. Write code.
|
||||
4. Make sure the code is formatted correctly.
|
||||
5. Make sure all previous tests pass.
|
||||
6. Push to your fork and submit a pull request.
|
||||
|
||||
### Developer documentation
|
||||
|
||||
Each service/app comes with a README dedicated to developers. Please read it to better understand about how the project works. **Warning**: these may be incomplete and/or outdated. If that's the case, please report it (or open a PR).
|
||||
|
||||
### Code formatting
|
||||
|
||||
All submitted code should be formatted and linted. When submitting code, please make sure your code is formatted properly (e.g. with Biome) and does not trigger any lint errors.
|
||||
|
||||
If you are not sure what tooling is used for a specific app, please open an issue.
|
||||
|
||||
### Testing
|
||||
|
||||
Some apps have unit tests. If it is doable, please write tests for your code. This is not mandatory but highly appreciated. If you have questions about how to set up the testing environment, please open an issue.
|
||||
|
||||
**All tests should pass locally**. As long as that's not the case, your contribution will not be accepted.
|
||||
|
||||
### CI
|
||||
|
||||
If you opened a pull request, the `Dockerisation` steps of the CI might fail. This is expected and should be addressed at some point. All other CI steps should pass (with one exception: the Matcher's tests might fail randomly).
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
# <img alt="Icon" src="./front/public/banner.png" width="150px">
|
||||
<div align="center" style="display: flex; justify-content: center; align-items: center;">
|
||||
<img width="500" src="https://github.com/user-attachments/assets/8d9b147f-aad3-4cd7-898b-01e58f65dd7c" />
|
||||
</div>
|
||||
|
||||
Welcome! Meelo is a self-hosted music server and web app. It works similarly to Plex, Jellyfin, Koel and Black Candy, but focuses on flexibility, browsing and listening experiences. Actually, Meelo is designed for music collectors. What does it mean? Scroll down to see our killer features :arrow_heading_down:
|
||||
**<div align="center">[Features](#star-features) • [Setup](#-setup) • [Screenshots](#camera-screenshots) • [Wiki](https://github.com/Arthi-chaud/Meelo/wiki)</div>**
|
||||
|
||||
While the software wants to be a flexible as possible, it requires a little bit of setup. However, once everything is ready, you will just have to sit back, relax, and enjoy your music. :sunglasses:
|
||||
<img src="https://github.com/user-attachments/assets/4a489a25-3cf7-4281-a9c1-cf9d41351e69" />
|
||||
|
||||

|
||||
|
||||
Welcome! Meelo is a self-hosted music server. It works similarly to Plex, Jellyfin, Koel and Black Candy, but focuses on flexibility, browsing and listening experiences. Actually, Meelo is designed for music collectors. What does it mean? Scroll down to see our killer features :arrow_heading_down:
|
||||
|
||||
While the software aims to be a flexible as possible, it requires a little bit of setup. However, once everything is ready, you will just have to sit back, relax, and enjoy your music. :sunglasses:
|
||||
|
||||
## :star: Features
|
||||
|
||||
@@ -25,50 +30,65 @@ Meelo has all the basic features you might expect from a music player/server (pl
|
||||
- Songs have _tracks_
|
||||
- It is pretty much the same as for albums and releases
|
||||
- No duplicated songs when browsing your library!
|
||||
- Song Versions
|
||||
- Browse Songs [grouped by version](./assets/examples/song-groups.png)
|
||||
- Songs have _versions_
|
||||
- See an example [here](./assets/examples/song-groups.png)
|
||||
- Album and Song types
|
||||
- Finding instrumental songs or live recordings has never been this simple!
|
||||
- Filter Songs that are exclusive to an album (compilations only)
|
||||
- Filter Songs that are exclusive to an album (for compilation albums only)
|
||||
- Supports all formats!
|
||||
- Thanks to transcoding and the way we parse files, any audio and video format is virtually supported
|
||||
- (Note: Transcoding is only used when the file format is not supported in the browser)
|
||||
- Flexible metadata parsing
|
||||
- Use either the embedded metadata or the file's name (or both!) to extract metadata
|
||||
- Also works with album covers!
|
||||
- Get genres, descriptions, and ratings using MusicBrainz, Genius, Wikipedia and many more providers!
|
||||
- Download lyrics
|
||||
- Get genres, descriptions, and ratings using MusicBrainz, Genius, Wikipedia and many other providers!
|
||||
- Push Scrobbles to ListenBrainz and LastFM!
|
||||
- Download (synced) lyrics or get them from embedded metadata and `.lrc` files
|
||||
|
||||
Keep track of the upcoming features [here](https://github.com/Arthi-chaud/Meelo/issues)
|
||||
|
||||
## :book: How to get started
|
||||
<img src="https://github.com/user-attachments/assets/7e1fe7df-638c-42f1-978d-e1cb59f91324" />
|
||||
|
||||
To use Meelo, you'll need a 'clean' collection: either with embedded metadata or standard file/folder architecture. Using iTunes or Beets is a great way to start.
|
||||
## 🔧 Setup
|
||||
|
||||
Meelo is shipped though Docker images. You might need to know a bit about Regexes.
|
||||
Look over [here](https://github.com/Arthi-chaud/Meelo/wiki) for setup instructions.
|
||||
:point_right: To get started, follow the dedicated [wiki](https://github.com/Arthi-chaud/Meelo/wiki)
|
||||
|
||||
You'll need a 'clean' collection: either with embedded metadata or standard file/folder architecture. Using iTunes or Beets is a great way to start.
|
||||
|
||||
> [!NOTE]
|
||||
> Meelo is shipped though Docker images. You might need to know a bit about Regexes.
|
||||
|
||||
## :iphone: Android App
|
||||
|
||||
Meelo now has an Android app :tada:. However, it is still in alpha and lacks some important features.
|
||||
|
||||
Go [here](https://github.com/Arthi-chaud/Meelo/discussions/1130) for more information.
|
||||
You can find the known issues and the list of missing/upcoming features [here](https://github.com/Arthi-chaud/Meelo/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22Front%20(Mobile)%22)
|
||||
|
||||
While the app is in alpha, we recommend using the web app. With its responsive design, it works perfectly on mobile devices.
|
||||
|
||||
## :camera: Screenshots
|
||||
|
||||
[](https://github.com/user-attachments/assets/48141241-25ec-4570-a4f1-696c0cdfcaaa)
|
||||
|
||||
:point_right: You can find more screenshots of the UI [here](./docs/screenshots/)
|
||||
|
||||
>[!NOTE]
|
||||
> Some screenshots may be outdated and may not show the state of the latest version of the app. Some minor details may change (e.g. logo, spacing, etc.)
|
||||
</details>
|
||||
|
||||
## :desktop_computer: Live Demo
|
||||
|
||||
We are actively working on a public demo, so that you can try out Meelo. Stay tuned...
|
||||
|
||||
## :camera: Screenshots
|
||||
## 🌐 Translation
|
||||
|
||||
<details>
|
||||
<summary>Unfold to see what the web application looks like!</summary>
|
||||
Meelo uses [Weblate](https://weblate.org/en-gb/) to handle translations.
|
||||
Feel free to contribute [here](https://hosted.weblate.org/engage/meelo/)!
|
||||
|
||||
https://github.com/user-attachments/assets/48141241-25ec-4570-a4f1-696c0cdfcaaa
|
||||
[](https://hosted.weblate.org/engage/meelo/)
|
||||
|
||||
---
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
More screenshots [here](./assets/examples/)
|
||||
|
||||
</details>
|
||||
Any questions about this? Open a discussion [here](https://github.com/Arthi-chaud/Meelo/discussions/categories/translation)!
|
||||
|
||||
## :handshake: Contributing and Bugs reporting
|
||||
|
||||
@@ -86,3 +106,7 @@ Get ready to make the most out of you music collection!
|
||||
|
||||
[](https://sonarcloud.io/summary/new_code?id=arthi-chaud_Meelo-back)
|
||||
[](https://sonarcloud.io/summary/new_code?id=arthi-chaud_Meelo-back)
|
||||

|
||||
|
||||
<img src="https://github.com/user-attachments/assets/4e7a161f-180a-4f95-89b1-482421018a61" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.1.3/schema.json",
|
||||
"vcs": {
|
||||
"enabled": false,
|
||||
"clientKind": "git",
|
||||
@@ -7,15 +7,25 @@
|
||||
"defaultBranch": "main"
|
||||
},
|
||||
"files": {
|
||||
"ignore": ["**/.yarn/**", "**/dist/**", "**/package.json"]
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/.yarn/**",
|
||||
"!**/dist/**",
|
||||
"!**/package.json",
|
||||
"!**/.expo/**"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
"indentWidth": 4
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
@@ -35,7 +45,8 @@
|
||||
"noConsole": "error"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedImports": "warn"
|
||||
"noUnusedImports": "error",
|
||||
"noUnusedVariables": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
services:
|
||||
server:
|
||||
init: true
|
||||
build:
|
||||
context: ./server
|
||||
dockerfile: Dockerfile.dev
|
||||
@@ -49,7 +50,7 @@ services:
|
||||
test: ["CMD-SHELL", "wget -qO- localhost:8133"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
retries: 10
|
||||
ports:
|
||||
- "8133:8133"
|
||||
environment:
|
||||
@@ -59,7 +60,7 @@ services:
|
||||
- API_KEYS=${API_KEYS}
|
||||
volumes:
|
||||
- ./scanner:/app
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}
|
||||
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
|
||||
front:
|
||||
build:
|
||||
@@ -73,12 +74,12 @@ services:
|
||||
scanner:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./front/src:/app/src
|
||||
- ./front/public:/app/public
|
||||
- ./front/next.config.js:/app/next.config.js
|
||||
- ./front/apps/web/:/app/apps/web/
|
||||
- ./front/packages:/app/packages
|
||||
- ./front/translations:/app/translations
|
||||
- ./front/assets:/app/assets
|
||||
- ./front/package.json:/app/package.json
|
||||
- ./front/tsconfig.json:/app/tsconfig.json
|
||||
- ./front/yarn.lock:/app/yarn.lock
|
||||
- ./front/tsconfig.base.json:/app/tsconfig.base.json
|
||||
- front_deps:/app/node_modules
|
||||
environment:
|
||||
# This can be commented out. see front/next.config.js
|
||||
@@ -86,10 +87,17 @@ services:
|
||||
- SSR_SERVER_URL=http://server:4000
|
||||
- PUBLIC_SCANNER_URL=http://localhost:8133
|
||||
- SSR_SCANNER_URL=http://scanner:8133
|
||||
- PUBLIC_MATCHER_URL=http://localhost:6789
|
||||
- SSR_MATCHER_URL=http://matcher:6789
|
||||
matcher:
|
||||
build:
|
||||
context: ./matcher
|
||||
args:
|
||||
- VERSION=dev
|
||||
restart: on-failure
|
||||
command: fastapi dev matcher --port 6789 --host 0.0.0.0
|
||||
ports:
|
||||
- "6789:6789"
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
@@ -115,7 +123,14 @@ services:
|
||||
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
|
||||
- MEILI_LOG_LEVEL=WARN
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--spider", "http://0.0.0.0:7700/health"]
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"wget",
|
||||
"--no-verbose",
|
||||
"--spider",
|
||||
"http://0.0.0.0:7700/health",
|
||||
]
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
transcoder:
|
||||
@@ -126,15 +141,14 @@ services:
|
||||
cpus: 1
|
||||
environment:
|
||||
- GOCODER_SAFE_PATH=${INTERNAL_DATA_DIR}
|
||||
- POSTGRES_USER=${POSTGRES_USER}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- POSTGRES_DB=${POSTGRES_DB}
|
||||
- POSTGRES_SCHEMA=gocoder
|
||||
- POSTGRES_SERVER=db
|
||||
- PGUSER=${POSTGRES_USER}
|
||||
- PGPASSWORD=${POSTGRES_PASSWORD}
|
||||
- PGDATABASE=${POSTGRES_DB}
|
||||
- PGHOST=db
|
||||
- PGPORT=5432
|
||||
volumes:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- transcoder_cache:/cache
|
||||
profiles: ['', 'cpu']
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -148,9 +162,9 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
mq:
|
||||
hostname: meelo-mq
|
||||
image: rabbitmq:4.0-alpine
|
||||
environment:
|
||||
- RABBITMQ_NODENAME=meelo
|
||||
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
|
||||
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
|
||||
healthcheck:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# This compose allows to setup the whole project in the production environment
|
||||
# This compose allows to setup the whole project in the production environment
|
||||
services:
|
||||
server:
|
||||
image: arthichaud/meelo-server:${TAG:-latest}
|
||||
init: true
|
||||
image: arthichaud/meelo-server:${TAG}
|
||||
expose:
|
||||
- 4000
|
||||
restart: on-failure
|
||||
@@ -28,7 +29,7 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
scanner:
|
||||
image: arthichaud/meelo-scanner:${TAG:-latest}
|
||||
image: arthichaud/meelo-scanner:${TAG}
|
||||
expose:
|
||||
- 8133
|
||||
depends_on:
|
||||
@@ -48,7 +49,7 @@ services:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
|
||||
front:
|
||||
image: arthichaud/meelo-front:${TAG:-latest}
|
||||
image: arthichaud/meelo-front:${TAG}
|
||||
expose:
|
||||
- 3000
|
||||
depends_on:
|
||||
@@ -61,9 +62,13 @@ services:
|
||||
- SSR_SERVER_URL=http://server:4000
|
||||
- PUBLIC_SCANNER_URL=${PUBLIC_URL}/scanner
|
||||
- SSR_SCANNER_URL=http://scanner:8133
|
||||
- PUBLIC_MATCHER_URL=http://{PUBLIC_URL}/matcher
|
||||
- SSR_MATCHER_URL=http://matcher:6789
|
||||
matcher:
|
||||
image: arthichaud/meelo-matcher:${TAG:-latest}
|
||||
image: arthichaud/meelo-matcher:${TAG}
|
||||
restart: on-failure
|
||||
expose:
|
||||
- 6789
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
@@ -99,7 +104,14 @@ services:
|
||||
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
|
||||
- MEILI_LOG_LEVEL=WARN
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--spider", "http://0.0.0.0:7700/health"]
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"wget",
|
||||
"--no-verbose",
|
||||
"--spider",
|
||||
"http://0.0.0.0:7700/health",
|
||||
]
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
transcoder:
|
||||
@@ -110,22 +122,21 @@ services:
|
||||
- 7666
|
||||
environment:
|
||||
- GOCODER_SAFE_PATH=${INTERNAL_DATA_DIR}
|
||||
- POSTGRES_USER=${POSTGRES_USER}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- POSTGRES_DB=${POSTGRES_DB}
|
||||
- POSTGRES_SCHEMA=gocoder
|
||||
- POSTGRES_SERVER=db
|
||||
- PGUSER=${POSTGRES_USER}
|
||||
- PGPASSWORD=${POSTGRES_PASSWORD}
|
||||
- PGDATABASE=${POSTGRES_DB}
|
||||
- PGHOST=db
|
||||
- PGPORT=5432
|
||||
volumes:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- transcoder_cache:/cache
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
profiles: ['', 'cpu']
|
||||
mq:
|
||||
image: rabbitmq:4.0-alpine
|
||||
hostname: meelo-mq
|
||||
environment:
|
||||
- RABBITMQ_NODENAME=meelo
|
||||
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
|
||||
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
|
||||
healthcheck:
|
||||
@@ -150,6 +161,7 @@ services:
|
||||
- FRONT_URL=http://front:3000
|
||||
- SERVER_URL=http://server:4000
|
||||
- SCANNER_URL=http://scanner:8133
|
||||
- MATCHER_URL=http://matcher:6789
|
||||
volumes:
|
||||
- ./nginx.conf.template:/etc/nginx/templates/meelo.conf.template:ro
|
||||
volumes:
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# This compose allows to build the whole project in a "production"-like environment
|
||||
# This compose allows to build the whole project in a "production"-like environment
|
||||
services:
|
||||
server:
|
||||
init: true
|
||||
build:
|
||||
context: ./server
|
||||
args:
|
||||
- VERSION=local
|
||||
expose:
|
||||
- 4000
|
||||
restart: on-failure
|
||||
@@ -31,6 +34,8 @@ services:
|
||||
front:
|
||||
build:
|
||||
context: ./front
|
||||
args:
|
||||
- VERSION=local
|
||||
expose:
|
||||
- 3000
|
||||
depends_on:
|
||||
@@ -43,9 +48,13 @@ services:
|
||||
- SSR_SERVER_URL=http://server:4000
|
||||
- PUBLIC_SCANNER_URL=${PUBLIC_URL}/scanner
|
||||
- SSR_SCANNER_URL=http://scanner:8133
|
||||
- PUBLIC_MATCHER_URL=http://{PUBLIC_URL}/matcher
|
||||
- SSR_MATCHER_URL=http://matcher:6789
|
||||
scanner:
|
||||
build:
|
||||
context: ./scanner
|
||||
args:
|
||||
- VERSION=local
|
||||
expose:
|
||||
- 8133
|
||||
depends_on:
|
||||
@@ -67,7 +76,11 @@ services:
|
||||
matcher:
|
||||
build:
|
||||
context: ./matcher
|
||||
args:
|
||||
- VERSION=local
|
||||
restart: on-failure
|
||||
expose:
|
||||
- 6789
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
@@ -91,15 +104,14 @@ services:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- GOCODER_SAFE_PATH=${INTERNAL_DATA_DIR}
|
||||
- POSTGRES_USER=${POSTGRES_USER}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||
- POSTGRES_DB=${POSTGRES_DB}
|
||||
- POSTGRES_SCHEMA=gocoder
|
||||
- POSTGRES_SERVER=db
|
||||
- PGUSER=${POSTGRES_USER}
|
||||
- PGPASSWORD=${POSTGRES_PASSWORD}
|
||||
- PGDATABASE=${POSTGRES_DB}
|
||||
- PGHOST=db
|
||||
- PGPORT=5432
|
||||
volumes:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- transcoder_cache:/cache
|
||||
profiles: ['', 'cpu']
|
||||
db:
|
||||
image: postgres:alpine3.14
|
||||
healthcheck:
|
||||
@@ -123,13 +135,20 @@ services:
|
||||
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
|
||||
- MEILI_LOG_LEVEL=WARN
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--spider", "http://0.0.0.0:7700/health"]
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"wget",
|
||||
"--no-verbose",
|
||||
"--spider",
|
||||
"http://0.0.0.0:7700/health",
|
||||
]
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
mq:
|
||||
image: rabbitmq:4.0-alpine
|
||||
hostname: meelo-mq
|
||||
environment:
|
||||
- RABBITMQ_NODENAME=meelo
|
||||
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
|
||||
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
|
||||
healthcheck:
|
||||
@@ -154,6 +173,7 @@ services:
|
||||
- FRONT_URL=http://front:3000
|
||||
- SERVER_URL=http://server:4000
|
||||
- SCANNER_URL=http://scanner:8133
|
||||
- MATCHER_URL=http://matcher:6789
|
||||
volumes:
|
||||
- ./nginx.conf.template:/etc/nginx/templates/meelo.conf.template:ro
|
||||
volumes:
|
||||
|
||||
|
After Width: | Height: | Size: 794 KiB |
|
After Width: | Height: | Size: 654 KiB |
|
After Width: | Height: | Size: 930 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 389 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 776 KiB |
|
After Width: | Height: | Size: 650 KiB |
|
After Width: | Height: | Size: 815 KiB |
|
After Width: | Height: | Size: 726 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 3.9 MiB |
|
After Width: | Height: | Size: 5.3 MiB |
@@ -1,2 +1,2 @@
|
||||
node_modules/
|
||||
.next/
|
||||
apps/web/.next/
|
||||
@@ -1,83 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"plugin:prettier/recommended",
|
||||
"plugin:react/recommended",
|
||||
"../.eslintrc.json"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"project": ["./tsconfig.json"]
|
||||
},
|
||||
"plugins": ["react", "@typescript-eslint", "unused-imports", "import"],
|
||||
"ignorePatterns": ["node_modules/", "jest.config.js", "__tests__"],
|
||||
"rules": {
|
||||
"react/prop-types": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/sort-prop-types": "warn",
|
||||
"react/jsx-props-no-multi-spaces": "warn",
|
||||
"react/jsx-max-props-per-line": [
|
||||
"error",
|
||||
{ "maximum": 3, "when": "multiline" }
|
||||
],
|
||||
"react/jsx-key": "error",
|
||||
"react/jsx-indent-props": ["warn", "tab"],
|
||||
"react/jsx-curly-spacing": [
|
||||
"warn",
|
||||
{ "when": "never", "children": false }
|
||||
],
|
||||
"react/jsx-indent": ["warn", "tab"],
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "react-query",
|
||||
"importNames": [
|
||||
"useQueryClient",
|
||||
"useQuery",
|
||||
"useInfiniteQuery",
|
||||
"useQueries"
|
||||
],
|
||||
"message": "Use wrapper functions provided by src/api/query"
|
||||
},
|
||||
{
|
||||
"name": "@mui/material",
|
||||
"importNames": ["Link"],
|
||||
"message": "Use Link from 'next/router"
|
||||
},
|
||||
{
|
||||
"name": "@mui/material",
|
||||
"importNames": ["Modal"],
|
||||
"message": "Use 'confirm' from 'material-ui-confirm'"
|
||||
},
|
||||
{
|
||||
"name": "@mui/material",
|
||||
"importNames": ["ThemeProvider"],
|
||||
"message": "Use wrapper from theme/provider"
|
||||
},
|
||||
{
|
||||
"name": "format-duration",
|
||||
"message": "Use wrapper"
|
||||
},
|
||||
{
|
||||
"name": "@mui/material",
|
||||
"importNames": ["Fade"],
|
||||
"message": "Use wrapper from 'components/fade'"
|
||||
},
|
||||
{
|
||||
"name": "@mui/icons-material",
|
||||
"message": "Use icons in 'src/components/icons'"
|
||||
}
|
||||
],
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["@mui/icons-material/*"],
|
||||
"message": "Use icons in 'src/components/icons'"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
diff --git a/cpp/core/HybridFunction.hpp b/cpp/core/HybridFunction.hpp
|
||||
index efcea05a248540806cfefd04efb44920ed941228..d8604ffb8cf4c4d6ed42d8238f8012a9d6ca0bd7 100644
|
||||
--- a/cpp/core/HybridFunction.hpp
|
||||
+++ b/cpp/core/HybridFunction.hpp
|
||||
@@ -8,8 +8,7 @@
|
||||
#pragma once
|
||||
|
||||
namespace margelo::nitro {
|
||||
-template <typename T, typename Enable>
|
||||
-struct JSIConverter;
|
||||
+template <typename T, typename Enable> struct JSIConverter;
|
||||
} // namespace margelo::nitro
|
||||
|
||||
#include "CountTrailingOptionals.hpp"
|
||||
@@ -23,13 +22,17 @@ struct JSIConverter;
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
|
||||
+#ifdef ANDROID
|
||||
+#include <fbjni/fbjni.h>
|
||||
+#endif
|
||||
+
|
||||
namespace margelo::nitro {
|
||||
|
||||
using namespace facebook;
|
||||
|
||||
/**
|
||||
- * Represents the kind of a function - it can be either a normal function ("METHOD"),
|
||||
- * or a property ("GETTER" + "SETTER")
|
||||
+ * Represents the kind of a function - it can be either a normal function
|
||||
+ * ("METHOD"), or a property ("GETTER" + "SETTER")
|
||||
*/
|
||||
enum class FunctionKind { METHOD, GETTER, SETTER };
|
||||
|
||||
@@ -44,76 +47,108 @@ private:
|
||||
|
||||
public:
|
||||
// getters
|
||||
- inline const std::string& getName() const {
|
||||
- return _name;
|
||||
- }
|
||||
- inline size_t getParamCount() const {
|
||||
- return _paramCount;
|
||||
- }
|
||||
- inline const jsi::HostFunctionType& getHostFunction() const {
|
||||
+ inline const std::string &getName() const { return _name; }
|
||||
+ inline size_t getParamCount() const { return _paramCount; }
|
||||
+ inline const jsi::HostFunctionType &getHostFunction() const {
|
||||
return _function;
|
||||
}
|
||||
|
||||
public:
|
||||
// functions
|
||||
- inline jsi::Function toJSFunction(jsi::Runtime& runtime) const {
|
||||
- return jsi::Function::createFromHostFunction(runtime, jsi::PropNameID::forUtf8(runtime, _name), static_cast<unsigned int>(_paramCount),
|
||||
- _function);
|
||||
+ inline jsi::Function toJSFunction(jsi::Runtime &runtime) const {
|
||||
+ return jsi::Function::createFromHostFunction(
|
||||
+ runtime, jsi::PropNameID::forUtf8(runtime, _name),
|
||||
+ static_cast<unsigned int>(_paramCount), _function);
|
||||
}
|
||||
|
||||
private:
|
||||
- HybridFunction(jsi::HostFunctionType&& function, size_t paramCount, const std::string& name)
|
||||
+ HybridFunction(jsi::HostFunctionType &&function, size_t paramCount,
|
||||
+ const std::string &name)
|
||||
: _function(std::move(function)), _paramCount(paramCount), _name(name) {}
|
||||
|
||||
public:
|
||||
/**
|
||||
* Create a new `HybridFunction` that can be called from JS.
|
||||
* This performs proper JSI -> C++ conversion using `JSIConverter<T>`,
|
||||
- * and assumes that the object this is called on has a proper `this` configured.
|
||||
- * The object's `this` needs to be a `NativeState`.
|
||||
+ * and assumes that the object this is called on has a proper `this`
|
||||
+ * configured. The object's `this` needs to be a `NativeState`.
|
||||
*/
|
||||
template <typename THybrid, typename ReturnType, typename... Args>
|
||||
- static inline HybridFunction createHybridFunction(const std::string& name, ReturnType (THybrid::*method)(Args...), FunctionKind kind) {
|
||||
- jsi::HostFunctionType hostFunction = [name, method, kind](/* JS Runtime */ jsi::Runtime& runtime,
|
||||
- /* HybridObject */ const jsi::Value& thisValue,
|
||||
- /* JS arguments */ const jsi::Value* args,
|
||||
- /* argument size */ size_t count) -> jsi::Value {
|
||||
- // 1. Get actual `HybridObject` instance from `thisValue` (it's stored as `NativeState`)
|
||||
- std::shared_ptr<THybrid> hybridInstance = getHybridObjectNativeState<THybrid>(runtime, thisValue, kind, name);
|
||||
-
|
||||
- // 2. Make sure the given arguments match, either with a static size, or with potentially optional arguments size.
|
||||
+ static inline HybridFunction
|
||||
+ createHybridFunction(const std::string &name,
|
||||
+ ReturnType (THybrid::*method)(Args...),
|
||||
+ FunctionKind kind) {
|
||||
+ jsi::HostFunctionType hostFunction =
|
||||
+ [name, method, kind](/* JS Runtime */ jsi::Runtime &runtime,
|
||||
+ /* HybridObject */ const jsi::Value &thisValue,
|
||||
+ /* JS arguments */ const jsi::Value *args,
|
||||
+ /* argument size */ size_t count) -> jsi::Value {
|
||||
+ // 1. Get actual `HybridObject` instance from `thisValue` (it's stored as
|
||||
+ // `NativeState`)
|
||||
+ std::shared_ptr<THybrid> hybridInstance =
|
||||
+ getHybridObjectNativeState<THybrid>(runtime, thisValue, kind, name);
|
||||
+
|
||||
+ // 2. Make sure the given arguments match, either with a static size, or
|
||||
+ // with potentially optional arguments size.
|
||||
constexpr size_t optionalArgsCount = trailing_optionals_count_v<Args...>;
|
||||
constexpr size_t maxArgsCount = sizeof...(Args);
|
||||
constexpr size_t minArgsCount = maxArgsCount - optionalArgsCount;
|
||||
bool isWithinArgsRange = (count >= minArgsCount && count <= maxArgsCount);
|
||||
if (!isWithinArgsRange) [[unlikely]] {
|
||||
// invalid amount of arguments passed!
|
||||
- std::string funcName = getHybridFuncFullName<THybrid>(kind, name, hybridInstance.get());
|
||||
+ std::string funcName =
|
||||
+ getHybridFuncFullName<THybrid>(kind, name, hybridInstance.get());
|
||||
if constexpr (minArgsCount == maxArgsCount) {
|
||||
- // min and max args length is the same, so we don't have any optional parameters. fixed count
|
||||
- throw jsi::JSError(runtime, "`" + funcName + "` expected " + std::to_string(maxArgsCount) + " arguments, but received " +
|
||||
+ // min and max args length is the same, so we don't have any optional
|
||||
+ // parameters. fixed count
|
||||
+ throw jsi::JSError(runtime, "`" + funcName + "` expected " +
|
||||
+ std::to_string(maxArgsCount) +
|
||||
+ " arguments, but received " +
|
||||
std::to_string(count) + "!");
|
||||
} else {
|
||||
- // min and max args length are different, so we have optional parameters - variable length arguments.
|
||||
- throw jsi::JSError(runtime, "`" + funcName + "` expected between " + std::to_string(minArgsCount) + " and " +
|
||||
- std::to_string(maxArgsCount) + " arguments, but received " + std::to_string(count) + "!");
|
||||
+ // min and max args length are different, so we have optional
|
||||
+ // parameters - variable length arguments.
|
||||
+ throw jsi::JSError(runtime, "`" + funcName + "` expected between " +
|
||||
+ std::to_string(minArgsCount) +
|
||||
+ " and " +
|
||||
+ std::to_string(maxArgsCount) +
|
||||
+ " arguments, but received " +
|
||||
+ std::to_string(count) + "!");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
- // 3. Actually call the method with JSI values as arguments and return a JSI value again.
|
||||
- // Internally, this method converts the JSI values to C++ values using `JSIConverter<T>`.
|
||||
- return callMethod(hybridInstance.get(), method, runtime, args, count, std::index_sequence_for<Args...>{});
|
||||
- } catch (const std::exception& exception) {
|
||||
- // Some exception was thrown - add method name information and re-throw as `JSError`.
|
||||
- std::string funcName = getHybridFuncFullName<THybrid>(kind, name, hybridInstance.get());
|
||||
+ // 3. Actually call the method with JSI values as arguments and return a
|
||||
+ // JSI value again.
|
||||
+ // Internally, this method converts the JSI values to C++ values
|
||||
+ // using `JSIConverter<T>`.
|
||||
+ return callMethod(hybridInstance.get(), method, runtime, args, count,
|
||||
+ std::index_sequence_for<Args...>{});
|
||||
+ } catch (const std::exception &exception) {
|
||||
+ // Some exception was thrown - add method name information and re-throw
|
||||
+ // as `JSError`.
|
||||
+ std::string funcName =
|
||||
+ getHybridFuncFullName<THybrid>(kind, name, hybridInstance.get());
|
||||
+ std::string message = exception.what();
|
||||
+ throw jsi::JSError(runtime, funcName + ": " + message);
|
||||
+#ifdef ANDROID
|
||||
+#pragma clang diagnostic push
|
||||
+#pragma clang diagnostic ignored "-Wexceptions"
|
||||
+ } catch (const jni::JniException &exception) {
|
||||
+ std::string funcName =
|
||||
+ getHybridFuncFullName<THybrid>(kind, name, hybridInstance.get());
|
||||
std::string message = exception.what();
|
||||
throw jsi::JSError(runtime, funcName + ": " + message);
|
||||
+#pragma clang diagnostic pop
|
||||
+#endif
|
||||
} catch (...) {
|
||||
- // Some unknown exception was thrown - add method name information and re-throw as `JSError`.
|
||||
- std::string funcName = getHybridFuncFullName<THybrid>(kind, name, hybridInstance.get());
|
||||
+ // Some unknown exception was thrown - add method name information and
|
||||
+ // re-throw as `JSError`.
|
||||
+ std::string funcName =
|
||||
+ getHybridFuncFullName<THybrid>(kind, name, hybridInstance.get());
|
||||
std::string errorName = TypeInfo::getCurrentExceptionName();
|
||||
- throw jsi::JSError(runtime, "`" + funcName + "` threw an unknown " + errorName + " error.");
|
||||
+ throw jsi::JSError(runtime, "`" + funcName + "` threw an unknown " +
|
||||
+ errorName + " error.");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -122,46 +157,64 @@ public:
|
||||
|
||||
/**
|
||||
* Create a new `HybridFunction` that can be called from JS.
|
||||
- * Unlike `createHybridFunction(...)`, this method does **not** perform any argument parsing or size checking.
|
||||
- * It is a raw-, untyped JSI method, and the user is expected to manually handle arguments and return values.
|
||||
+ * Unlike `createHybridFunction(...)`, this method does **not** perform any
|
||||
+ * argument parsing or size checking. It is a raw-, untyped JSI method, and
|
||||
+ * the user is expected to manually handle arguments and return values.
|
||||
*/
|
||||
template <typename Derived>
|
||||
- static inline HybridFunction createRawHybridFunction(const std::string& name, size_t expectedArgumentsCount,
|
||||
- jsi::Value (Derived::*method)(jsi::Runtime& runtime, const jsi::Value& thisArg,
|
||||
- const jsi::Value* args, size_t count)) {
|
||||
- jsi::HostFunctionType hostFunction = [name, method](/* JS Runtime */ jsi::Runtime& runtime,
|
||||
- /* HybridObject */ const jsi::Value& thisValue,
|
||||
- /* JS arguments */ const jsi::Value* args,
|
||||
- /* argument size */ size_t count) -> jsi::Value {
|
||||
- // 1. Get actual `HybridObject` instance from `thisValue` (it's stored as `NativeState`)
|
||||
- std::shared_ptr<Derived> hybridInstance = getHybridObjectNativeState<Derived>(runtime, thisValue, FunctionKind::METHOD, name);
|
||||
-
|
||||
- // 2. Call the raw JSI method using raw JSI Values. Exceptions are also expected to be handled by the user.
|
||||
- Derived* pointer = hybridInstance.get();
|
||||
+ static inline HybridFunction createRawHybridFunction(
|
||||
+ const std::string &name, size_t expectedArgumentsCount,
|
||||
+ jsi::Value (Derived::*method)(jsi::Runtime &runtime,
|
||||
+ const jsi::Value &thisArg,
|
||||
+ const jsi::Value *args, size_t count)) {
|
||||
+ jsi::HostFunctionType hostFunction =
|
||||
+ [name, method](/* JS Runtime */ jsi::Runtime &runtime,
|
||||
+ /* HybridObject */ const jsi::Value &thisValue,
|
||||
+ /* JS arguments */ const jsi::Value *args,
|
||||
+ /* argument size */ size_t count) -> jsi::Value {
|
||||
+ // 1. Get actual `HybridObject` instance from `thisValue` (it's stored as
|
||||
+ // `NativeState`)
|
||||
+ std::shared_ptr<Derived> hybridInstance =
|
||||
+ getHybridObjectNativeState<Derived>(runtime, thisValue,
|
||||
+ FunctionKind::METHOD, name);
|
||||
+
|
||||
+ // 2. Call the raw JSI method using raw JSI Values. Exceptions are also
|
||||
+ // expected to be handled by the user.
|
||||
+ Derived *pointer = hybridInstance.get();
|
||||
return (pointer->*method)(runtime, thisValue, args, count);
|
||||
};
|
||||
|
||||
- return HybridFunction(std::move(hostFunction), expectedArgumentsCount, name);
|
||||
+ return HybridFunction(std::move(hostFunction), expectedArgumentsCount,
|
||||
+ name);
|
||||
}
|
||||
|
||||
private:
|
||||
/**
|
||||
- * Calls the given method on the given instance with the given `jsi::Value` arguments by converting them to the desired target types.
|
||||
- * The given method's return value will be converted to a `jsi::Value` again.
|
||||
+ * Calls the given method on the given instance with the given `jsi::Value`
|
||||
+ * arguments by converting them to the desired target types. The given
|
||||
+ * method's return value will be converted to a `jsi::Value` again.
|
||||
*/
|
||||
- template <typename Derived, typename ReturnType, typename... Args, size_t... Is>
|
||||
- static inline jsi::Value callMethod(Derived* obj, ReturnType (Derived::*method)(Args...), jsi::Runtime& runtime, const jsi::Value* args,
|
||||
- size_t argsSize, std::index_sequence<Is...>) {
|
||||
+ template <typename Derived, typename ReturnType, typename... Args,
|
||||
+ size_t... Is>
|
||||
+ static inline jsi::Value
|
||||
+ callMethod(Derived *obj, ReturnType (Derived::*method)(Args...),
|
||||
+ jsi::Runtime &runtime, const jsi::Value *args, size_t argsSize,
|
||||
+ std::index_sequence<Is...>) {
|
||||
static const jsi::Value defaultValue;
|
||||
|
||||
if constexpr (std::is_void_v<ReturnType>) {
|
||||
// It's a void method.
|
||||
- (obj->*method)(JSIConverter<std::decay_t<Args>>::fromJSI(runtime, Is < argsSize ? args[Is] : defaultValue)...);
|
||||
+ (obj->*method)(JSIConverter<std::decay_t<Args>>::fromJSI(
|
||||
+ runtime, Is < argsSize ? args[Is] : defaultValue)...);
|
||||
return jsi::Value::undefined();
|
||||
} else {
|
||||
- // It's returning some C++ type, we need to convert that to a JSI value now.
|
||||
- ReturnType result = (obj->*method)(JSIConverter<std::decay_t<Args>>::fromJSI(runtime, Is < argsSize ? args[Is] : defaultValue)...);
|
||||
- return JSIConverter<ReturnType>::toJSI(runtime, std::forward<ReturnType>(result));
|
||||
+ // It's returning some C++ type, we need to convert that to a JSI value
|
||||
+ // now.
|
||||
+ ReturnType result =
|
||||
+ (obj->*method)(JSIConverter<std::decay_t<Args>>::fromJSI(
|
||||
+ runtime, Is < argsSize ? args[Is] : defaultValue)...);
|
||||
+ return JSIConverter<ReturnType>::toJSI(runtime,
|
||||
+ std::forward<ReturnType>(result));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,20 +223,24 @@ private:
|
||||
* Get the `NativeState` of the given `value`.
|
||||
*/
|
||||
template <typename THybrid>
|
||||
- static inline std::shared_ptr<THybrid> getHybridObjectNativeState(jsi::Runtime& runtime, const jsi::Value& value,
|
||||
- [[maybe_unused]] FunctionKind funcKind,
|
||||
- [[maybe_unused]] const std::string& funcName) {
|
||||
+ static inline std::shared_ptr<THybrid>
|
||||
+ getHybridObjectNativeState(jsi::Runtime &runtime, const jsi::Value &value,
|
||||
+ [[maybe_unused]] FunctionKind funcKind,
|
||||
+ [[maybe_unused]] const std::string &funcName) {
|
||||
// 1. Convert jsi::Value to jsi::Object
|
||||
#ifdef NITRO_DEBUG
|
||||
if (!value.isObject()) [[unlikely]] {
|
||||
- throw jsi::JSError(runtime, "Cannot " + getHybridFuncDebugInfo<THybrid>(funcKind, funcName) +
|
||||
- " - `this` is not bound! Suggestions:\n"
|
||||
- "- Did you accidentally destructure the `HybridObject`? (`const { " +
|
||||
- funcName +
|
||||
- " } = ...`)\n"
|
||||
- "- Did you call `dispose()` on the `HybridObject` before?"
|
||||
- "- Did you accidentally call `" +
|
||||
- funcName + "` on the prototype directly?");
|
||||
+ throw jsi::JSError(
|
||||
+ runtime,
|
||||
+ "Cannot " + getHybridFuncDebugInfo<THybrid>(funcKind, funcName) +
|
||||
+ " - `this` is not bound! Suggestions:\n"
|
||||
+ "- Did you accidentally destructure the `HybridObject`? (`const "
|
||||
+ "{ " +
|
||||
+ funcName +
|
||||
+ " } = ...`)\n"
|
||||
+ "- Did you call `dispose()` on the `HybridObject` before?"
|
||||
+ "- Did you accidentally call `" +
|
||||
+ funcName + "` on the prototype directly?");
|
||||
}
|
||||
#endif
|
||||
jsi::Object object = value.getObject(runtime);
|
||||
@@ -191,33 +248,42 @@ private:
|
||||
// 2. Check if it even has any kind of `NativeState`
|
||||
#ifdef NITRO_DEBUG
|
||||
if (!object.hasNativeState(runtime)) [[unlikely]] {
|
||||
- throw jsi::JSError(runtime, "Cannot " + getHybridFuncDebugInfo<THybrid>(funcKind, funcName) +
|
||||
- " - `this` does not have a NativeState! Suggestions:\n"
|
||||
- "- Did you accidentally destructure the `HybridObject`? (`const { " +
|
||||
- funcName +
|
||||
- " } = ...`)\n"
|
||||
- "- Did you call `dispose()` on the `HybridObject` before?"
|
||||
- "- Did you accidentally call `" +
|
||||
- funcName + "` on the prototype directly?");
|
||||
+ throw jsi::JSError(
|
||||
+ runtime,
|
||||
+ "Cannot " + getHybridFuncDebugInfo<THybrid>(funcKind, funcName) +
|
||||
+ " - `this` does not have a NativeState! Suggestions:\n"
|
||||
+ "- Did you accidentally destructure the `HybridObject`? (`const "
|
||||
+ "{ " +
|
||||
+ funcName +
|
||||
+ " } = ...`)\n"
|
||||
+ "- Did you call `dispose()` on the `HybridObject` before?"
|
||||
+ "- Did you accidentally call `" +
|
||||
+ funcName + "` on the prototype directly?");
|
||||
}
|
||||
#endif
|
||||
|
||||
// 3. Get `NativeState` from the jsi::Object and check if it is non-null
|
||||
- std::shared_ptr<jsi::NativeState> nativeState = object.getNativeState(runtime);
|
||||
+ std::shared_ptr<jsi::NativeState> nativeState =
|
||||
+ object.getNativeState(runtime);
|
||||
#ifdef NITRO_DEBUG
|
||||
if (nativeState == nullptr) [[unlikely]] {
|
||||
- throw jsi::JSError(runtime, "Cannot " + getHybridFuncDebugInfo<THybrid>(funcKind, funcName) +
|
||||
- " - `this`'s `NativeState` is `null`, "
|
||||
- "did you accidentally call `dispose()` on this object?");
|
||||
+ throw jsi::JSError(
|
||||
+ runtime, "Cannot " +
|
||||
+ getHybridFuncDebugInfo<THybrid>(funcKind, funcName) +
|
||||
+ " - `this`'s `NativeState` is `null`, "
|
||||
+ "did you accidentally call `dispose()` on this object?");
|
||||
}
|
||||
#endif
|
||||
|
||||
// 4. Try casting it to our desired target type.
|
||||
- std::shared_ptr<THybrid> hybridInstance = std::dynamic_pointer_cast<THybrid>(nativeState);
|
||||
+ std::shared_ptr<THybrid> hybridInstance =
|
||||
+ std::dynamic_pointer_cast<THybrid>(nativeState);
|
||||
#ifdef NITRO_DEBUG
|
||||
if (hybridInstance == nullptr) [[unlikely]] {
|
||||
- throw jsi::JSError(runtime, "Cannot " + getHybridFuncDebugInfo<THybrid>(funcKind, funcName) +
|
||||
- " - `this` has a NativeState, but it's the wrong type!");
|
||||
+ throw jsi::JSError(
|
||||
+ runtime, "Cannot " +
|
||||
+ getHybridFuncDebugInfo<THybrid>(funcKind, funcName) +
|
||||
+ " - `this` has a NativeState, but it's the wrong type!");
|
||||
}
|
||||
#endif
|
||||
return hybridInstance;
|
||||
@@ -225,28 +291,33 @@ private:
|
||||
|
||||
private:
|
||||
template <typename THybrid>
|
||||
- static inline std::string getHybridFuncFullName(FunctionKind kind, const std::string& registrationName,
|
||||
- THybrid* hybridInstance = nullptr) {
|
||||
- std::string typeName = hybridInstance != nullptr ? hybridInstance->getName() : TypeInfo::getFriendlyTypename<THybrid>(true);
|
||||
+ static inline std::string
|
||||
+ getHybridFuncFullName(FunctionKind kind, const std::string ®istrationName,
|
||||
+ THybrid *hybridInstance = nullptr) {
|
||||
+ std::string typeName = hybridInstance != nullptr
|
||||
+ ? hybridInstance->getName()
|
||||
+ : TypeInfo::getFriendlyTypename<THybrid>(true);
|
||||
switch (kind) {
|
||||
- case FunctionKind::METHOD:
|
||||
- return typeName + "." + registrationName + "(...)";
|
||||
- case FunctionKind::GETTER:
|
||||
- case FunctionKind::SETTER:
|
||||
- return typeName + "." + registrationName;
|
||||
+ case FunctionKind::METHOD:
|
||||
+ return typeName + "." + registrationName + "(...)";
|
||||
+ case FunctionKind::GETTER:
|
||||
+ case FunctionKind::SETTER:
|
||||
+ return typeName + "." + registrationName;
|
||||
}
|
||||
}
|
||||
template <typename THybrid>
|
||||
- static inline std::string getHybridFuncDebugInfo(FunctionKind kind, const std::string& registrationName,
|
||||
- THybrid* hybridInstance = nullptr) {
|
||||
- auto funcName = getHybridFuncFullName<THybrid>(kind, registrationName, hybridInstance);
|
||||
+ static inline std::string
|
||||
+ getHybridFuncDebugInfo(FunctionKind kind, const std::string ®istrationName,
|
||||
+ THybrid *hybridInstance = nullptr) {
|
||||
+ auto funcName =
|
||||
+ getHybridFuncFullName<THybrid>(kind, registrationName, hybridInstance);
|
||||
switch (kind) {
|
||||
- case FunctionKind::METHOD:
|
||||
- return "call hybrid function `" + funcName + "`";
|
||||
- case FunctionKind::GETTER:
|
||||
- return "get hybrid property `" + funcName + "`";
|
||||
- case FunctionKind::SETTER:
|
||||
- return "set hybrid property `" + funcName + "`";
|
||||
+ case FunctionKind::METHOD:
|
||||
+ return "call hybrid function `" + funcName + "`";
|
||||
+ case FunctionKind::GETTER:
|
||||
+ return "get hybrid property `" + funcName + "`";
|
||||
+ case FunctionKind::SETTER:
|
||||
+ return "set hybrid property `" + funcName + "`";
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,36 +1,67 @@
|
||||
ARG VERSION=unknown
|
||||
# Source : https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile
|
||||
FROM node:18-alpine3.18 AS base
|
||||
FROM base AS builder
|
||||
ARG VERSION
|
||||
|
||||
ENV NEXT_PUBLIC_VERSION=$VERSION
|
||||
ENV VERSION=$VERSION
|
||||
WORKDIR /app
|
||||
COPY ./*.json ./.yarnrc.yml ./*.lock ./
|
||||
|
||||
# Yarn stuff
|
||||
COPY ./.yarnrc.yml ./*.lock ./
|
||||
COPY ./.yarn ./.yarn
|
||||
RUN yarn workspaces focus
|
||||
|
||||
COPY ./next.config.js .
|
||||
## To Provide static assets at build time
|
||||
COPY ./public ./public
|
||||
COPY ./src ./src
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
RUN yarn build
|
||||
# Package.json + tsconfig
|
||||
## Root
|
||||
COPY package.json tsconfig.base.json ./
|
||||
## Apps
|
||||
COPY apps/web/*.json apps/web/
|
||||
## Libs
|
||||
COPY packages/*.json packages/
|
||||
COPY packages/api/*.json packages/api/
|
||||
COPY packages/models/*.json packages/models/
|
||||
COPY packages/state/*.json packages/state/
|
||||
COPY packages/ui/*.json packages/ui/
|
||||
COPY packages/utils/*.json packages/utils/
|
||||
|
||||
FROM base as runner
|
||||
# Install Libraries
|
||||
RUN yarn workspaces focus -A
|
||||
|
||||
# NextJS
|
||||
COPY ./apps/web/ apps/web/
|
||||
COPY ./packages/ packages/
|
||||
COPY ./translations/ translations/
|
||||
COPY ./assets/* apps/web/public/
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
RUN yarn build:web
|
||||
|
||||
######
|
||||
|
||||
FROM base AS runner
|
||||
ARG VERSION
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NEXT_PUBLIC_VERSION=$VERSION
|
||||
ENV VERSION=$VERSION
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
# Set the correct permission for prerender cache
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone/apps/web ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./public
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD node server.js
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,12 +1,21 @@
|
||||
FROM node:18-alpine3.18
|
||||
WORKDIR /app
|
||||
COPY ./*.json ./
|
||||
COPY ./*.lock ./
|
||||
COPY ./.yarnrc.yml ./
|
||||
COPY ./.yarn /app/.yarn
|
||||
ENV VERSION=dev
|
||||
ENV NEXT_PUBLIC_VERSION=dev
|
||||
|
||||
COPY ./.yarnrc.yml ./*.lock ./
|
||||
COPY ./.yarn ./.yarn
|
||||
COPY package.json tsconfig.base.json ./
|
||||
COPY apps/web/*.json apps/web/
|
||||
COPY packages/*.json packages/
|
||||
COPY packages/api/*.json packages/api/
|
||||
COPY packages/models/*.json packages/models/
|
||||
COPY packages/state/*.json packages/state/
|
||||
COPY packages/utils/*.json packages/utils/
|
||||
|
||||
RUN yarn install
|
||||
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
CMD yarn && yarn run start:dev
|
||||
CMD yarn && cd apps/web && yarn run start:dev
|
||||
@@ -0,0 +1,42 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
app-example
|
||||
android
|
||||
*.apk
|
||||
ios
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ExpoConfig } from "expo/config";
|
||||
|
||||
const IS_DEV = process.env.APP_VARIANT === "development";
|
||||
|
||||
module.exports = (_: ExpoConfig): ExpoConfig => ({
|
||||
name: IS_DEV ? "Meelo (Dev)" : "Meelo",
|
||||
slug: "meelo",
|
||||
version: "1.0.0",
|
||||
icon: "./assets/icon-black.png",
|
||||
scheme: "mobile",
|
||||
userInterfaceStyle: "automatic",
|
||||
newArchEnabled: true,
|
||||
android: {
|
||||
icon: "./assets/icon-black.png",
|
||||
edgeToEdgeEnabled: true,
|
||||
package: IS_DEV ? "dev.arthichaud.meelo.dev" : "dev.artichaud.meelo",
|
||||
permissions: [
|
||||
"android.permission.FOREGROUND_SERVICE",
|
||||
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
|
||||
],
|
||||
},
|
||||
ios: {
|
||||
bundleIdentifier: "com.arthichaud.meelo",
|
||||
infoPlist: {
|
||||
UIViewControllerBasedStatusBarAppearance: true,
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-screen-orientation",
|
||||
{
|
||||
initialOrientation: "DEFAULT",
|
||||
},
|
||||
],
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
image: "./assets/icon-black.png",
|
||||
resizeMode: "contain",
|
||||
imageWidth: 200,
|
||||
backgroundColor: "#ffffff",
|
||||
dark: {
|
||||
image: "./assets/icon-white.png",
|
||||
backgroundColor: "#242120",
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
android: {
|
||||
usesCleartextTraffic: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"expo-asset",
|
||||
{
|
||||
assets: [
|
||||
"./assets/banner1_white.png",
|
||||
"./assets/banner1_black.png",
|
||||
],
|
||||
},
|
||||
],
|
||||
"./plugins/withMediaServicePlugin.ts",
|
||||
[
|
||||
// https://docs.thewidlarzgroup.com/react-native-video/docs/v7/configuration/expo-plugin
|
||||
"react-native-video",
|
||||
{
|
||||
enableBackgroundAudio: true,
|
||||
//TODO Picture in picture
|
||||
},
|
||||
],
|
||||
],
|
||||
experiments: {
|
||||
typedRoutes: true,
|
||||
},
|
||||
extra: {
|
||||
router: {},
|
||||
eas: {
|
||||
projectId: "b8d8dcdb-a905-4674-9c62-db4256e0d94f",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
../../../assets/banner1-black.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../assets/banner1-white.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../assets/icon-black.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../assets/icon-white.png
|
||||
@@ -0,0 +1,23 @@
|
||||
module.exports = (api) => {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: [
|
||||
[
|
||||
"babel-preset-expo",
|
||||
{
|
||||
// Needed for jotai in dev (?)
|
||||
unstable_transformImportMeta: true,
|
||||
},
|
||||
],
|
||||
],
|
||||
plugins: [
|
||||
[
|
||||
"react-native-unistyles/plugin",
|
||||
{
|
||||
root: "src",
|
||||
},
|
||||
],
|
||||
"react-native-worklets/plugin", // Always keep last
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 10.2.2"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"env": {
|
||||
"APP_VARIANT": "development"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"env": {
|
||||
"APP_VARIANT": "preview"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
},
|
||||
"env": {
|
||||
"APP_VARIANT": "production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import "expo-router/entry";
|
||||
import "./src/theme";
|
||||
@@ -0,0 +1,43 @@
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const {
|
||||
wrapWithReanimatedMetroConfig,
|
||||
} = require("react-native-reanimated/metro-config");
|
||||
/** @type {import('expo/metro-config').MetroConfig} */
|
||||
const defaultConfig = getDefaultConfig(__dirname);
|
||||
const path = require("node:path");
|
||||
|
||||
const ALIASES = {
|
||||
"@wandersonalwes/iconsax-react": "iconsax-react-nativejs",
|
||||
};
|
||||
|
||||
const projectRoot = __dirname;
|
||||
|
||||
function addMonorepoSupport(config) {
|
||||
const workspaceRoot = path.resolve(projectRoot, "../..");
|
||||
|
||||
return {
|
||||
...config,
|
||||
watchFolders: [...config.watchFolders, workspaceRoot],
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = wrapWithReanimatedMetroConfig(
|
||||
addMonorepoSupport({
|
||||
...defaultConfig,
|
||||
resolver: {
|
||||
...defaultConfig.resolver,
|
||||
resolveRequest: (context, moduleName, platform) => {
|
||||
// Ensure you call the default resolver.
|
||||
return context.resolveRequest(
|
||||
context,
|
||||
ALIASES[moduleName] ?? moduleName,
|
||||
platform,
|
||||
);
|
||||
},
|
||||
requireCycleIgnorePatterns: [
|
||||
...defaultConfig.resolver.requireCycleIgnorePatterns,
|
||||
/.*/,
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"name": "mobile",
|
||||
"main": "index.ts",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"start": "APP_VARIANT=development expo start",
|
||||
"android": "APP_VARIANT=development expo run:android",
|
||||
"android:prebuild": "APP_VARIANT=development expo prebuild -p android",
|
||||
"ios": "expo run:ios"
|
||||
},
|
||||
"dependencies": {
|
||||
"@d11/react-native-fast-image": "^8.12.0",
|
||||
"@expo-google-fonts/rubik": "^0.3.0",
|
||||
"@expo/config-plugins": "^54.0.2",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@react-native-community/checkbox": "^0.5.20",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"@shopify/react-native-skia": "2.2.12",
|
||||
"@tanstack/react-query": "^5.77.0",
|
||||
"api": "workspace:^",
|
||||
"expo": "^54.0.0",
|
||||
"expo-asset": "~12.0.9",
|
||||
"expo-blur": "~15.0.7",
|
||||
"expo-build-properties": "~1.0.9",
|
||||
"expo-constants": "~18.0.9",
|
||||
"expo-dev-client": "~6.0.15",
|
||||
"expo-device": "~8.0.9",
|
||||
"expo-font": "~14.0.9",
|
||||
"expo-haptics": "~15.0.7",
|
||||
"expo-linear-gradient": "^15.0.7",
|
||||
"expo-linking": "~8.0.8",
|
||||
"expo-router": "~6.0.12",
|
||||
"expo-screen-orientation": "~9.0.7",
|
||||
"expo-splash-screen": "~31.0.10",
|
||||
"expo-status-bar": "~3.0.8",
|
||||
"expo-system-ui": "~6.0.7",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"i18next": "^23.7.16",
|
||||
"iconsax-react-nativejs": "^0.0.8",
|
||||
"infinite-controls": "workspace:^",
|
||||
"intl-pluralrules": "^2.0.1",
|
||||
"jotai": "^2.12.5",
|
||||
"lodash": "^4.17.21",
|
||||
"models": "workspace:^",
|
||||
"react": "19.1.0",
|
||||
"react-hook-form": "^7.58.0",
|
||||
"react-i18next": "^12.2.2",
|
||||
"react-native": "0.81.4",
|
||||
"react-native-awesome-slider": "^2.9.0",
|
||||
"react-native-blurhash": "patch:react-native-blurhash@npm%3A2.1.1#~/.yarn/patches/react-native-blurhash-npm-2.1.1-baca87b7fa.patch",
|
||||
"react-native-draggable-flatlist": "^4.0.3",
|
||||
"react-native-edge-to-edge": "^1.6.1",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-keyboard-controller": "1.18.5",
|
||||
"react-native-mmkv": "4.0.0-beta.12",
|
||||
"react-native-nitro-modules": "0.30.1",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-toast-message": "^2.3.3",
|
||||
"react-native-unistyles": "3.0.15",
|
||||
"react-native-uuid": "^2.0.3",
|
||||
"react-native-video": "^7.0.0-alpha.7",
|
||||
"react-native-worklets": "^0.5.1",
|
||||
"state": "workspace:^",
|
||||
"type-fest": "^4.33.0",
|
||||
"ui": "workspace:^",
|
||||
"utils": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@types/lodash": "^4",
|
||||
"@types/react": "~19.1.10",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"packageManager": "yarn@4.4.0"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ConfigPlugin } from "@expo/config-plugins";
|
||||
import { getMainApplicationOrThrow } from "@expo/config-plugins/build/android/Manifest";
|
||||
import { withAndroidManifest } from "@expo/config-plugins/build/plugins/android-plugins";
|
||||
|
||||
const withMediaServicePlugin: ConfigPlugin = (oldConfig) => {
|
||||
return withAndroidManifest(oldConfig, (config) => {
|
||||
const mainApplication = getMainApplicationOrThrow(config.modResults);
|
||||
mainApplication.service ??= [];
|
||||
mainApplication.service.push({
|
||||
$: {
|
||||
"android:name":
|
||||
"com.twg.video.core.services.playback.VideoPlaybackService",
|
||||
"android:exported": "false",
|
||||
"android:foregroundServiceType": "mediaPlayback",
|
||||
},
|
||||
"intent-filter": [
|
||||
{
|
||||
action: [
|
||||
{
|
||||
$: {
|
||||
"android:name":
|
||||
"androidx.media3.session.MediaSessionService",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
return config;
|
||||
});
|
||||
};
|
||||
|
||||
export default withMediaServicePlugin;
|
||||
@@ -0,0 +1,119 @@
|
||||
import { BottomSheetFlatList, useBottomSheetModal } from "@gorhom/bottom-sheet";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { AddToPlaylistPayload } from "@/api";
|
||||
import { getPlaylists } from "@/api/queries";
|
||||
import { AddIcon, PlaylistIcon } from "@/ui/icons";
|
||||
import { generateArray } from "@/utils/gen-list";
|
||||
import { useQuery, useQueryClient } from "~/api";
|
||||
import { useModal } from "~/components/bottom-modal-sheet";
|
||||
import type { ContextMenuItem } from "~/components/context-menu";
|
||||
import { ListItem } from "~/components/item/list-item";
|
||||
import { PlaylistItem } from "~/components/item/resource/playlist";
|
||||
import { Divider } from "~/primitives/divider";
|
||||
import { Text } from "~/primitives/text";
|
||||
import { showErrorToast, showSuccessToast } from "~/primitives/toast";
|
||||
import { useCreatePlaylistFormModal } from "./playlist/create-update";
|
||||
|
||||
export const useAddToPlaylistAction = (
|
||||
payload?: AddToPlaylistPayload,
|
||||
): ContextMenuItem => {
|
||||
const content = useCallback(() => {
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
return <ChoosePlaylistModal payload={payload} />;
|
||||
}, [payload]);
|
||||
const { openModal } = useModal({
|
||||
content,
|
||||
onDismiss: () => {},
|
||||
});
|
||||
return {
|
||||
label: "actions.addToPlaylist.label",
|
||||
icon: PlaylistIcon,
|
||||
onPress: openModal,
|
||||
nestedModal: true,
|
||||
};
|
||||
};
|
||||
|
||||
const ChoosePlaylistModal = ({
|
||||
payload,
|
||||
}: {
|
||||
payload: AddToPlaylistPayload;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { dismiss } = useBottomSheetModal();
|
||||
const addToPlaylist = useMutation({
|
||||
mutationFn: (playlistId: number) =>
|
||||
queryClient.api
|
||||
.addToPlaylist(payload, playlistId)
|
||||
.then(() => {
|
||||
showSuccessToast({
|
||||
text: t("toasts.playlist.addedToPlaylist"),
|
||||
});
|
||||
dismiss();
|
||||
queryClient.client.invalidateQueries({
|
||||
queryKey: ["playlists"],
|
||||
});
|
||||
})
|
||||
.catch((e) =>
|
||||
showErrorToast({
|
||||
text: t(e.message ?? e.error ?? JSON.stringify(e)),
|
||||
}),
|
||||
),
|
||||
});
|
||||
const { openFormModal } = useCreatePlaylistFormModal(undefined, (p) =>
|
||||
addToPlaylist.mutate(p.id),
|
||||
);
|
||||
const { data: playlists } = useQuery(() => {
|
||||
const q = getPlaylists({ changeable: true }, { sortBy: "name" }, [
|
||||
"illustration",
|
||||
]);
|
||||
return {
|
||||
key: [...q.key, "full"],
|
||||
exec: (api) => () => q.exec(api)({ pageSize: 100 }),
|
||||
};
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
return (
|
||||
<BottomSheetFlatList
|
||||
style={styles.root}
|
||||
contentContainerStyle={styles.list}
|
||||
ListHeaderComponent={
|
||||
<>
|
||||
<Text
|
||||
content={t("actions.addToPlaylist.modalTitle")}
|
||||
variant="h4"
|
||||
/>
|
||||
<ListItem
|
||||
title={t("actions.new")}
|
||||
illustration={null}
|
||||
illustrationProps={{ fallbackIcon: AddIcon }}
|
||||
subtitle={null}
|
||||
onPress={openFormModal}
|
||||
/>
|
||||
<Divider h />
|
||||
</>
|
||||
}
|
||||
data={playlists?.items ?? generateArray(3)}
|
||||
renderItem={({ item: playlist }) => (
|
||||
<PlaylistItem
|
||||
playlist={playlist}
|
||||
onPress={() =>
|
||||
playlist && addToPlaylist.mutate(playlist.id)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme, rt) => ({
|
||||
root: {
|
||||
height: (2 * rt.screen.height) / 3,
|
||||
width: "100%",
|
||||
},
|
||||
list: { paddingBottom: theme.gap(2) },
|
||||
}));
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { Href } from "expo-router";
|
||||
import type { QueryClient } from "@/api/hook";
|
||||
import { getReleaseTracklist } from "@/api/queries";
|
||||
import { transformPage } from "@/api/query";
|
||||
import {
|
||||
playAfterAtom,
|
||||
playFromInfiniteQuery,
|
||||
playNextAtom,
|
||||
playTrackAtom,
|
||||
type TrackState,
|
||||
} from "@/state/player";
|
||||
import { store } from "@/state/store";
|
||||
import {
|
||||
AlbumIcon,
|
||||
ArtistIcon,
|
||||
EditIcon,
|
||||
type Icon as IconType,
|
||||
InfoIcon,
|
||||
LyricsIcon,
|
||||
PlayAfterIcon,
|
||||
PlayIcon,
|
||||
PlayNextIcon,
|
||||
RelatedTracksIcon,
|
||||
SongIcon,
|
||||
VideoIcon,
|
||||
} from "@/ui/icons";
|
||||
export type Action = {
|
||||
icon: IconType;
|
||||
label: TranslationKey;
|
||||
onPress?: () => void;
|
||||
href?: Href;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const GoToArtist = (artistId: string | number): Action => ({
|
||||
label: "actions.goToArtist",
|
||||
icon: ArtistIcon,
|
||||
href: `/artists/${artistId}`,
|
||||
});
|
||||
|
||||
export const GoToArtistAlbums = (artistId: string | number): Action => ({
|
||||
label: "actions.artist.seeAlbums",
|
||||
icon: AlbumIcon,
|
||||
href: `/albums?artist=${artistId}`,
|
||||
});
|
||||
|
||||
export const GoToArtistSongs = (artistId: string | number): Action => ({
|
||||
label: "actions.artist.seeSongs",
|
||||
icon: SongIcon,
|
||||
href: `/songs?artist=${artistId}`,
|
||||
});
|
||||
|
||||
export const GoToRelease = (releaseId: string | number): Action => ({
|
||||
label: "actions.album.goToAlbum",
|
||||
icon: AlbumIcon,
|
||||
href: `/releases/${releaseId}`,
|
||||
});
|
||||
|
||||
export const ChangeType = (label: TranslationKey, onPress: () => void) => ({
|
||||
label,
|
||||
onPress,
|
||||
nestedModal: true,
|
||||
icon: EditIcon,
|
||||
});
|
||||
|
||||
export const Play = (track: TrackState): Action => ({
|
||||
label: "actions.playback.play",
|
||||
icon: PlayIcon,
|
||||
onPress: () => store.set(playTrackAtom, track),
|
||||
});
|
||||
|
||||
export const PlayNext = (track: TrackState): Action => ({
|
||||
label: "actions.playback.playNext",
|
||||
icon: PlayNextIcon,
|
||||
onPress: () => store.set(playNextAtom, track),
|
||||
});
|
||||
|
||||
export const PlayAfter = (track: TrackState): Action => ({
|
||||
label: "actions.playback.playAfter",
|
||||
icon: PlayAfterIcon,
|
||||
onPress: () => store.set(playAfterAtom, track),
|
||||
});
|
||||
|
||||
export const PlayReleaseAction = (
|
||||
releaseId: string | number,
|
||||
queryClient: QueryClient,
|
||||
) =>
|
||||
({
|
||||
icon: PlayIcon,
|
||||
label: "actions.playback.play",
|
||||
onPress: () => {
|
||||
const query = getReleaseTracklist(releaseId, false, [
|
||||
"artist",
|
||||
"featuring",
|
||||
"illustration",
|
||||
]);
|
||||
store.set(
|
||||
playFromInfiniteQuery,
|
||||
transformPage(query, ({ song, video, ...track }) => ({
|
||||
artist: (song ?? video)!.artist,
|
||||
track,
|
||||
featuring: song?.featuring ?? [],
|
||||
id: track.id,
|
||||
})),
|
||||
queryClient,
|
||||
);
|
||||
},
|
||||
}) satisfies Action;
|
||||
|
||||
export const GoToLyrics = (songId: string | number): Action => ({
|
||||
label: "actions.song.seeLyrics",
|
||||
href: `/songs/${songId}?tab=lyrics`,
|
||||
icon: LyricsIcon,
|
||||
});
|
||||
|
||||
export const GoToSongInfo = (songId: string | number): Action => ({
|
||||
label: "actions.song.seeSongInfo",
|
||||
href: `/songs/${songId}?tab=infos`,
|
||||
icon: InfoIcon,
|
||||
});
|
||||
|
||||
export const GoToSongVideos = (songId: string | number): Action => ({
|
||||
label: "actions.song.seeRelatedVideos",
|
||||
href: `/videos?song=${songId}`,
|
||||
icon: VideoIcon,
|
||||
});
|
||||
export const GoToSongVersions = (songId: string | number): Action => ({
|
||||
label: "actions.song.seeOtherVersions",
|
||||
href: `/songs?versionsOf=${songId}`,
|
||||
icon: SongIcon,
|
||||
});
|
||||
|
||||
export const GoToRelatedTracks = (songId: string | number): Action => ({
|
||||
label: "actions.song.seeRelatedTracks",
|
||||
href: `/tracks?song=${songId}`,
|
||||
icon: RelatedTracksIcon,
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type Album from "@/models/album";
|
||||
import type Release from "@/models/release";
|
||||
import type Song from "@/models/song";
|
||||
import type Track from "@/models/track";
|
||||
import { MasterIcon } from "@/ui/icons";
|
||||
import { useQueryClient } from "~/api";
|
||||
import { showSuccessToast } from "~/primitives/toast";
|
||||
|
||||
export const useSetReleaseAsMaster = (
|
||||
release: Release | undefined,
|
||||
album: Album | undefined,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const setAsMaster = useMutation({
|
||||
mutationFn: () =>
|
||||
queryClient.api.updateAlbum(release!.albumId, {
|
||||
masterReleaseId: release?.id,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
showSuccessToast({ text: t("toasts.releaseSetAsMaster") });
|
||||
queryClient.client.invalidateQueries({ queryKey: ["releases"] });
|
||||
queryClient.client.invalidateQueries({ queryKey: ["albums"] });
|
||||
queryClient.client.invalidateQueries({ queryKey: [release?.id] });
|
||||
queryClient.client.invalidateQueries({ queryKey: [release?.slug] });
|
||||
queryClient.client.invalidateQueries({
|
||||
queryKey: [album?.id],
|
||||
});
|
||||
queryClient.client.invalidateQueries({
|
||||
queryKey: [album?.slug],
|
||||
});
|
||||
},
|
||||
});
|
||||
return useMemo(
|
||||
() =>
|
||||
SetAsMaster(
|
||||
() => setAsMaster.mutateAsync(),
|
||||
!release || !album || release.id === album.masterId,
|
||||
),
|
||||
[setAsMaster, release],
|
||||
);
|
||||
};
|
||||
|
||||
export const useSetSongTrackAsMaster = (
|
||||
track: Track | undefined,
|
||||
song: Song | undefined,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const setAsMaster = useMutation({
|
||||
mutationFn: () =>
|
||||
queryClient.api.updateSong(song!.id, {
|
||||
masterTrackId: track?.id,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
showSuccessToast({ text: t("toasts.trackSetAsMaster") });
|
||||
queryClient.client.invalidateQueries({ queryKey: ["tracks"] });
|
||||
queryClient.client.invalidateQueries({ queryKey: ["songs"] });
|
||||
queryClient.client.invalidateQueries({ queryKey: ["releases"] });
|
||||
queryClient.client.invalidateQueries({ queryKey: [song?.id] });
|
||||
queryClient.client.invalidateQueries({ queryKey: [song?.slug] });
|
||||
queryClient.client.invalidateQueries({
|
||||
queryKey: [track?.id],
|
||||
});
|
||||
queryClient.client.invalidateQueries({
|
||||
queryKey: [track?.releaseId],
|
||||
});
|
||||
},
|
||||
});
|
||||
return useMemo(
|
||||
() =>
|
||||
SetAsMaster(
|
||||
() => setAsMaster.mutateAsync(),
|
||||
!track || !song || track.id === song.masterId,
|
||||
),
|
||||
[setAsMaster, track],
|
||||
);
|
||||
};
|
||||
|
||||
const SetAsMaster = (onPress: () => void, disabled: boolean) => ({
|
||||
onPress,
|
||||
disabled,
|
||||
label: "actions.setAsMaster" as const,
|
||||
icon: MasterIcon,
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useCallback } from "react";
|
||||
import type Playlist from "@/models/playlist";
|
||||
import { AddIcon } from "@/ui/icons";
|
||||
import { useModal } from "~/components/bottom-modal-sheet";
|
||||
import { CreateUpdatePlaylistForm } from "~/components/playlist/create-update";
|
||||
import type { Action } from "..";
|
||||
|
||||
export const useUpdatePlaylistFormModal = (
|
||||
playlist?: Playlist,
|
||||
afterSave?: (playlist: Playlist) => void,
|
||||
) => {
|
||||
const content = useCallback(() => {
|
||||
return (
|
||||
<CreateUpdatePlaylistForm
|
||||
existingPlaylist={playlist}
|
||||
afterSave={afterSave}
|
||||
/>
|
||||
);
|
||||
}, []);
|
||||
const { openModal } = useModal({
|
||||
content,
|
||||
onDismiss: () => {},
|
||||
});
|
||||
return { openFormModal: openModal };
|
||||
};
|
||||
export const useCreatePlaylistFormModal = useUpdatePlaylistFormModal;
|
||||
|
||||
export const CreatePlaylist = (onPress: () => void): Action => ({
|
||||
label: "actions.new",
|
||||
icon: AddIcon,
|
||||
onPress,
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { usePathname, useRouter } from "expo-router";
|
||||
import { DeleteIcon } from "@/ui/icons";
|
||||
import { useQueryClient } from "~/api";
|
||||
import type { Action } from "..";
|
||||
|
||||
export const useDeletePlaylistAction = (
|
||||
playlistId: number | undefined,
|
||||
): Action => {
|
||||
const queryClient = useQueryClient();
|
||||
const path = usePathname();
|
||||
const router = useRouter();
|
||||
const deletePlaylist = useMutation({
|
||||
mutationFn: async () =>
|
||||
playlistId &&
|
||||
queryClient.api.deletePlaylist(playlistId).then(() => {
|
||||
queryClient.client.invalidateQueries({
|
||||
queryKey: ["playlists"],
|
||||
});
|
||||
if (path.startsWith(`/playlists/`)) router.dismiss();
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
label: "actions.delete",
|
||||
icon: DeleteIcon,
|
||||
onPress: () => deletePlaylist.mutate(),
|
||||
};
|
||||
};
|
||||
|
||||
export const useDeletePlaylistEntryAction = (
|
||||
entryId: number | undefined,
|
||||
): Action => {
|
||||
const queryClient = useQueryClient();
|
||||
const deletePlaylistEntry = useMutation({
|
||||
mutationFn: async () =>
|
||||
entryId &&
|
||||
queryClient.api.deletePlaylistEntry(entryId).then(() => {
|
||||
queryClient.client.invalidateQueries({
|
||||
queryKey: ["playlists"],
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
label: "actions.deleteFromPlaylist",
|
||||
icon: DeleteIcon,
|
||||
onPress: () => deletePlaylistEntry.mutate(),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useCallback } from "react";
|
||||
import { Share } from "react-native";
|
||||
import { ShareIcon } from "@/ui/icons";
|
||||
import type { Action } from "~/actions";
|
||||
import { useAPI } from "~/api";
|
||||
|
||||
export const useShareCallback = () => {
|
||||
const api = useAPI();
|
||||
|
||||
return useCallback(
|
||||
(route: string) =>
|
||||
Share.share({ url: `${api.urls.api.replace("/api", "")}${route}` }),
|
||||
[api],
|
||||
);
|
||||
};
|
||||
|
||||
export const ShareAction = (callback: () => void): Action => ({
|
||||
label: "actions.share",
|
||||
icon: ShareIcon,
|
||||
onPress: callback,
|
||||
});
|
||||
|
||||
export const useShareAlbumAction = (albumId: string | number | undefined) => {
|
||||
const c = useShareCallback();
|
||||
if (!albumId) {
|
||||
return undefined;
|
||||
}
|
||||
return ShareAction(() => c(`/albums/${albumId}`));
|
||||
};
|
||||
|
||||
export const useSharePlaylistAction = (
|
||||
playlistId: string | number | undefined,
|
||||
) => {
|
||||
const c = useShareCallback();
|
||||
if (!playlistId) {
|
||||
return undefined;
|
||||
}
|
||||
return ShareAction(() => c(`/playlists/${playlistId}`));
|
||||
};
|
||||
|
||||
export const useShareArtistAction = (artistId: string | number | undefined) => {
|
||||
const c = useShareCallback();
|
||||
if (!artistId) {
|
||||
return undefined;
|
||||
}
|
||||
return ShareAction(() => c(`/artists/${artistId}`));
|
||||
};
|
||||
|
||||
export const useShareSongAction = (songId: string | number | undefined) => {
|
||||
const c = useShareCallback();
|
||||
if (!songId) {
|
||||
return undefined;
|
||||
}
|
||||
return ShareAction(() => c(`/songs/${songId}`));
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useCallback } from "react";
|
||||
import { InfoIcon } from "@/ui/icons";
|
||||
import { useModal } from "~/components/bottom-modal-sheet";
|
||||
import type { ContextMenuItem } from "~/components/context-menu";
|
||||
import { TrackInfo } from "~/components/track-info";
|
||||
|
||||
export const useTrackInfoModal = (trackId: number | null | undefined) => {
|
||||
const content = useCallback(() => {
|
||||
if (!trackId) return null;
|
||||
return <TrackInfo trackId={trackId} />;
|
||||
}, [trackId]);
|
||||
const { openModal } = useModal({
|
||||
content,
|
||||
onDismiss: () => {},
|
||||
});
|
||||
return { openTrackInfoModal: openModal };
|
||||
};
|
||||
|
||||
export const SeeTrackInfo = (openModal: () => void): ContextMenuItem => ({
|
||||
label: "actions.track.seeTrackInfo",
|
||||
icon: InfoIcon,
|
||||
nestedModal: true,
|
||||
onPress: openModal,
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useBottomSheetModal } from "@gorhom/bottom-sheet";
|
||||
import { useCallback } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { CheckIcon, UpdateIllustrationIcon } from "@/ui/icons";
|
||||
import { useModal } from "~/components/bottom-modal-sheet";
|
||||
import type { ContextMenuItem } from "~/components/context-menu";
|
||||
import { Button } from "~/primitives/button";
|
||||
import { TextInput } from "~/primitives/text_input";
|
||||
|
||||
export const useUpdateIllustrationAction = (
|
||||
onConfirm: (illustrationUrl: string) => void,
|
||||
): ContextMenuItem => {
|
||||
const { dismiss } = useBottomSheetModal();
|
||||
const onFormCompletion = (illustrationUrl: string) => {
|
||||
onConfirm(illustrationUrl);
|
||||
dismiss();
|
||||
};
|
||||
const content = useCallback(() => {
|
||||
return <UpdateIllustrationForm onConfirm={onFormCompletion} />;
|
||||
}, [onConfirm]);
|
||||
const { openModal } = useModal({
|
||||
content,
|
||||
onDismiss: () => {},
|
||||
});
|
||||
|
||||
return {
|
||||
label: "actions.changeIllutration",
|
||||
onPress: openModal,
|
||||
icon: UpdateIllustrationIcon,
|
||||
};
|
||||
};
|
||||
|
||||
const UpdateIllustrationForm = ({
|
||||
onConfirm,
|
||||
}: {
|
||||
onConfirm: (illustrationUrl: string) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const defaultValues = { url: "" };
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm({ defaultValues });
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="url"
|
||||
rules={{
|
||||
required: true,
|
||||
}}
|
||||
render={({ field: { onChange, onBlur, value } }) => (
|
||||
<TextInput
|
||||
inModal
|
||||
placeholder={t("form.illustration.url")}
|
||||
textContentType="URL"
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
onBlur={onBlur}
|
||||
onChangeText={onChange}
|
||||
error={errors.url?.message}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
icon={CheckIcon}
|
||||
width="fitContent"
|
||||
title={t("form.confirm")}
|
||||
onPress={handleSubmit(({ url }) => onConfirm(url))}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: { gap: theme.gap(2), width: "100%", alignItems: "center" },
|
||||
}));
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import API from "@/api";
|
||||
import {
|
||||
mkUseInfiniteQuery,
|
||||
mkUseQueries,
|
||||
mkUseQuery,
|
||||
mkUseQueryClient,
|
||||
} from "@/api/hook";
|
||||
import type { InfiniteQueryFn, QueryFn } from "@/api/query";
|
||||
import type Resource from "@/models/resource";
|
||||
import { store } from "@/state/store";
|
||||
import { currentInstanceAtom } from "./state/user";
|
||||
|
||||
export const useAPI = () => {
|
||||
const currentInstance = useAtomValue(currentInstanceAtom);
|
||||
return getAPI_(
|
||||
currentInstance?.accessToken ?? null,
|
||||
currentInstance?.url ?? "",
|
||||
);
|
||||
};
|
||||
|
||||
// Get API instance using atom to resolve access token
|
||||
export const getAPI = () => {
|
||||
const currentInstance = store.get(currentInstanceAtom);
|
||||
return getAPI_(
|
||||
currentInstance?.accessToken ?? null,
|
||||
currentInstance?.url ?? "",
|
||||
);
|
||||
};
|
||||
|
||||
export const getAPI_ = (accessToken: string | null, instanceUrl: string) => {
|
||||
instanceUrl = instanceUrl.replace(/\/$/, "");
|
||||
const api = new API(accessToken, {
|
||||
api: `${instanceUrl}/api`,
|
||||
illustration: `${instanceUrl}/api`,
|
||||
scanner: `${instanceUrl}/scanner`,
|
||||
matcher: `${instanceUrl}/matcher`,
|
||||
});
|
||||
return api;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper for the react-query's *useQuery*
|
||||
*/
|
||||
export const useQuery = <
|
||||
ReturnType,
|
||||
Params extends any[],
|
||||
Transformed = ReturnType,
|
||||
>(
|
||||
query: QueryFn<ReturnType, Transformed, Params>,
|
||||
...queryParams: Partial<Params>
|
||||
) => {
|
||||
return mkUseQuery(getAPI, query, ...queryParams);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper for the react-query's *useQueries*
|
||||
*/
|
||||
export const useQueries = <ReturnType, Params extends any[][]>(
|
||||
...queries: readonly Parameters<
|
||||
typeof useQuery<ReturnType, Params[number], ReturnType>
|
||||
>[]
|
||||
) => {
|
||||
return mkUseQueries<ReturnType, Params>(useAPI, ...(queries as any));
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper for the react-query's *useInfiniteQuery*
|
||||
*/
|
||||
export const useInfiniteQuery = <
|
||||
ReturnType extends Resource,
|
||||
Params extends any[],
|
||||
TransformedType = ReturnType,
|
||||
>(
|
||||
query: InfiniteQueryFn<ReturnType, TransformedType, Params>,
|
||||
...queryParams: Partial<Params>
|
||||
) => {
|
||||
return mkUseInfiniteQuery(useAPI, query, ...queryParams);
|
||||
};
|
||||
|
||||
export const useQueryClient = () => mkUseQueryClient(getAPI);
|
||||
@@ -0,0 +1,102 @@
|
||||
import { type Href, useRouter } from "expo-router";
|
||||
import { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
AlbumIcon,
|
||||
ArtistIcon,
|
||||
CompilationIcon,
|
||||
GenreIcon,
|
||||
type Icon,
|
||||
PlaylistIcon,
|
||||
SongIcon,
|
||||
VideoIcon,
|
||||
} from "@/ui/icons";
|
||||
import { ListItem } from "~/components/item/list-item";
|
||||
import { SafeView } from "~/components/safe-view";
|
||||
import { Divider } from "~/primitives/divider";
|
||||
import { Icon as IconComponent } from "~/primitives/icon";
|
||||
|
||||
const tabs: { title: TranslationKey; href: Href; icon: Icon }[] = [
|
||||
{
|
||||
title: "models.artist_plural",
|
||||
href: "/(protected)/(nav)/(browse)/artists",
|
||||
icon: ArtistIcon,
|
||||
},
|
||||
{
|
||||
title: "models.album_plural",
|
||||
href: "/(protected)/(nav)/(browse)/albums",
|
||||
icon: AlbumIcon,
|
||||
},
|
||||
|
||||
{
|
||||
title: "models.song_plural",
|
||||
href: "/(protected)/(nav)/(browse)/songs",
|
||||
icon: SongIcon,
|
||||
},
|
||||
|
||||
{
|
||||
title: "models.video_plural",
|
||||
href: "/(protected)/(nav)/(browse)/videos",
|
||||
icon: VideoIcon,
|
||||
},
|
||||
{
|
||||
title: "nav.compilations",
|
||||
href: "/(protected)/(nav)/(browse)/albums?compilations=true",
|
||||
icon: CompilationIcon,
|
||||
},
|
||||
|
||||
{
|
||||
title: "models.playlist_plural",
|
||||
href: "/(protected)/(nav)/(browse)/playlists",
|
||||
icon: PlaylistIcon,
|
||||
},
|
||||
|
||||
{
|
||||
title: "models.genre_plural",
|
||||
href: "/(protected)/(nav)/(browse)/genres",
|
||||
icon: GenreIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export default function BrowseList() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SafeView style={[styles.root]}>
|
||||
{tabs.map(({ title, href, icon }) => (
|
||||
<Fragment key={title}>
|
||||
<ListItem
|
||||
leading={
|
||||
<View style={styles.icon}>
|
||||
<IconComponent icon={icon} />
|
||||
</View>
|
||||
}
|
||||
title={t(title)}
|
||||
subtitle={null}
|
||||
onPress={() => router.navigate(href)}
|
||||
/>
|
||||
<Divider h withInsets />
|
||||
</Fragment>
|
||||
))}
|
||||
</SafeView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
padding: theme.gap(0.5),
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
icon: {
|
||||
height: "100%",
|
||||
backgroundColor: theme.colors.skeleton,
|
||||
borderRadius: theme.borderRadius,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
getAlbums,
|
||||
getArtists,
|
||||
getGenres,
|
||||
getReleases,
|
||||
getSongs,
|
||||
} from "@/api/queries";
|
||||
import type { AlbumSortingKey } from "@/models/album";
|
||||
import type { ArtistSortingKey } from "@/models/artist";
|
||||
import type Genre from "@/models/genre";
|
||||
import type { SongSortingKey } from "@/models/song";
|
||||
import { generateArray } from "@/utils/gen-list";
|
||||
import { useInfiniteQuery } from "~/api";
|
||||
import { useSetKeyIllustration } from "~/components/background-gradient";
|
||||
import { AlbumTile } from "~/components/item/resource/album";
|
||||
import { ArtistTile } from "~/components/item/resource/artist";
|
||||
import { GenreChip } from "~/components/item/resource/genre";
|
||||
import ReleaseTile from "~/components/item/resource/release";
|
||||
import { Row } from "~/components/row";
|
||||
import { SafeScrollView } from "~/components/safe-view";
|
||||
import { SongGrid } from "~/components/song-grid";
|
||||
import { Text } from "~/primitives/text";
|
||||
import type { Sorting } from "~/utils/sorting";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
main: { paddingTop: theme.gap(2), gap: theme.gap(2) },
|
||||
lastSection: {
|
||||
paddingBottom: theme.gap(1),
|
||||
},
|
||||
title: { paddingLeft: theme.gap(2) },
|
||||
genreRow: {
|
||||
gap: theme.gap(1),
|
||||
alignItems: "center",
|
||||
paddingLeft: theme.gap(2),
|
||||
marginBottom: theme.gap(1),
|
||||
},
|
||||
}));
|
||||
|
||||
export default function Root() {
|
||||
const { t } = useTranslation();
|
||||
const newlyAddedAlbums = useInfiniteQuery(() =>
|
||||
getAlbums({}, { sortBy: "addDate", order: "desc" }, [
|
||||
"illustration",
|
||||
"artist",
|
||||
]),
|
||||
);
|
||||
const newlyAddedArtists = useInfiniteQuery(() =>
|
||||
getArtists({}, { sortBy: "addDate", order: "desc" }, ["illustration"]),
|
||||
);
|
||||
|
||||
const latestAlbums = useInfiniteQuery(() =>
|
||||
getAlbums({}, { sortBy: "releaseDate", order: "desc" }, [
|
||||
"illustration",
|
||||
"artist",
|
||||
]),
|
||||
);
|
||||
|
||||
const newlyAddedReleases = useInfiniteQuery(() =>
|
||||
getReleases({}, { sortBy: "addDate", order: "desc" }, [
|
||||
"illustration",
|
||||
"album",
|
||||
]),
|
||||
);
|
||||
|
||||
const topGenres = useInfiniteQuery(() =>
|
||||
getGenres({}, { sortBy: "songCount", order: "desc" }),
|
||||
);
|
||||
|
||||
useSetKeyIllustration(newlyAddedAlbums.items?.at(0));
|
||||
const topSongs = useInfiniteQuery(() =>
|
||||
getSongs({}, { sortBy: "userPlayCount", order: "desc" }, [
|
||||
"artist",
|
||||
"featuring",
|
||||
"master",
|
||||
"illustration",
|
||||
]),
|
||||
);
|
||||
const sync = useCallback(
|
||||
<T,>(q: T) => {
|
||||
if (
|
||||
!!newlyAddedAlbums.data &&
|
||||
!!newlyAddedArtists.data &&
|
||||
!!latestAlbums.data &&
|
||||
!!topSongs.data &&
|
||||
!!newlyAddedReleases.data
|
||||
) {
|
||||
return q;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[
|
||||
newlyAddedAlbums.data,
|
||||
newlyAddedArtists.data,
|
||||
latestAlbums.data,
|
||||
topSongs.data,
|
||||
newlyAddedReleases.data,
|
||||
],
|
||||
);
|
||||
return (
|
||||
<SafeScrollView contentContainerStyle={[styles.main]}>
|
||||
<Row
|
||||
header={t("home.newlyAddedAlbums")}
|
||||
items={sync(newlyAddedAlbums.items)}
|
||||
render={(album) => {
|
||||
return <AlbumTile album={album} subtitle="artistName" />;
|
||||
}}
|
||||
seeMore={
|
||||
newlyAddedAlbums.hasNextPage
|
||||
? {
|
||||
pathname: "/albums",
|
||||
params: {
|
||||
sort: "addDate",
|
||||
order: "desc",
|
||||
} satisfies Sorting<AlbumSortingKey>,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Row
|
||||
header={t("home.newlyAddedArtists")}
|
||||
items={sync(newlyAddedArtists.items)}
|
||||
render={(artist) => {
|
||||
return <ArtistTile artist={artist} />;
|
||||
}}
|
||||
seeMore={
|
||||
newlyAddedArtists.hasNextPage
|
||||
? {
|
||||
pathname: "/artists",
|
||||
params: {
|
||||
sort: "addDate",
|
||||
order: "desc",
|
||||
} satisfies Sorting<ArtistSortingKey>,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{/* TODO Featured albums */}
|
||||
|
||||
<Row
|
||||
header={t("home.latestAlbums")}
|
||||
items={sync(latestAlbums.items)}
|
||||
render={(album) => {
|
||||
return <AlbumTile album={album} subtitle="artistName" />;
|
||||
}}
|
||||
seeMore={
|
||||
latestAlbums.hasNextPage
|
||||
? {
|
||||
pathname: "/albums",
|
||||
params: {
|
||||
sort: "releaseDate",
|
||||
order: "desc",
|
||||
} satisfies Sorting<AlbumSortingKey>,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Row
|
||||
header={t("home.newlyAddedReleases")}
|
||||
items={sync(newlyAddedReleases.items)}
|
||||
render={(release) => {
|
||||
return <ReleaseTile release={release} />;
|
||||
}}
|
||||
/>
|
||||
|
||||
<ScrollView horizontal contentContainerStyle={styles.genreRow}>
|
||||
<Text content={t("home.topGenres")} variant="h4" />
|
||||
{(topGenres.items ?? generateArray(3, undefined)).map(
|
||||
(genre: Genre | undefined, idx) => (
|
||||
<GenreChip genre={genre} key={genre?.slug ?? idx} />
|
||||
),
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<SongGrid
|
||||
seeMore={
|
||||
topSongs.hasNextPage
|
||||
? {
|
||||
pathname: "/songs",
|
||||
params: {
|
||||
sort: "userPlayCount",
|
||||
order: "desc",
|
||||
} satisfies Sorting<SongSortingKey>,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
hideIfEmpty
|
||||
header={t("home.mostPlayedSongs")}
|
||||
songs={sync(topSongs.data?.pages.at(0)?.items)}
|
||||
subtitle={() => "artists"}
|
||||
style={styles.lastSection}
|
||||
/>
|
||||
</SafeScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { type ComponentProps, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Icon as MeeloIcon } from "~/components/meelo";
|
||||
import { BackButton, HeaderBackground } from "~/components/navigation";
|
||||
|
||||
export const unstable_settings = {
|
||||
home: {
|
||||
initialRouteName: "index",
|
||||
},
|
||||
};
|
||||
|
||||
const SharedRoutes: {
|
||||
name: string;
|
||||
options: { headerTitle: TranslationKey | null };
|
||||
}[] = [
|
||||
{ name: "artists/index", options: { headerTitle: "models.artist_plural" } },
|
||||
{ name: "artists/[id]", options: { headerTitle: null } },
|
||||
{ name: "albums/index", options: { headerTitle: "models.album_plural" } },
|
||||
{ name: "songs/index", options: { headerTitle: "models.song_plural" } },
|
||||
{ name: "genres", options: { headerTitle: "models.genre_plural" } },
|
||||
{ name: "videos/index", options: { headerTitle: "models.video_plural" } },
|
||||
{ name: "tracks/index", options: { headerTitle: "models.track_plural" } },
|
||||
{
|
||||
name: "playlists/index",
|
||||
options: { headerTitle: "models.playlist_plural" },
|
||||
},
|
||||
|
||||
{ name: "playlists/[id]", options: { headerTitle: null } },
|
||||
{ name: "songs/[id]", options: { headerTitle: null } },
|
||||
{ name: "releases/[id]", options: { headerTitle: null } },
|
||||
];
|
||||
|
||||
export default function Layout({ segment }: { segment: string }) {
|
||||
const { t } = useTranslation();
|
||||
// Cant memo-ise this, we need this to be updated when theme changes
|
||||
const screenOptions = {
|
||||
animation: "none", //TODO slide_from_right without flicker
|
||||
animationTypeForReplace: "pop",
|
||||
freezeOnBlur: true,
|
||||
contentStyle: [styles.screen],
|
||||
// headerStyle: styles.header,
|
||||
headerTitleStyle: styles.headerTitle,
|
||||
headerLeft: () => <BackButton />,
|
||||
headerBackground: () => <HeaderBackground />,
|
||||
headerTransparent: true,
|
||||
headerTintColor: styles.headerTitle.color,
|
||||
} satisfies ComponentProps<typeof Stack>["screenOptions"];
|
||||
const screenProps = useMemo(() => {
|
||||
switch (segment) {
|
||||
case "(home)": {
|
||||
return {
|
||||
options: {
|
||||
headerTitle: t("nav.home"),
|
||||
headerLeft: () => <MeeloIcon style={styles.icon} />,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "(browse)": {
|
||||
return {
|
||||
options: {
|
||||
headerTitle: t("nav.browse"),
|
||||
// TODO IDK why we canGoBack when we are at the browse's index
|
||||
headerLeft: () => <View />,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "(search)": {
|
||||
return {
|
||||
options: {
|
||||
headerTitle: t("nav.search"),
|
||||
// TODO IDK why we canGoBack when we are at the browse's index
|
||||
headerLeft: () => <View />,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}, [segment]);
|
||||
return (
|
||||
<Stack screenOptions={screenOptions}>
|
||||
<Stack.Screen name="index" {...screenProps} />
|
||||
{SharedRoutes.map(
|
||||
({ name, options: { headerTitle, ...options } }, idx) => (
|
||||
<Stack.Screen
|
||||
key={idx}
|
||||
name={name}
|
||||
options={{
|
||||
headerTitle: headerTitle ? t(headerTitle) : "",
|
||||
...options,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
screen: {
|
||||
backgroundColor: "transparent",
|
||||
flex: 1,
|
||||
},
|
||||
icon: { marginRight: theme.gap(2) },
|
||||
headerTitle: {
|
||||
color: theme.colors.text.primary,
|
||||
...theme.fontStyles.regular,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Stack, useLocalSearchParams } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getAlbums, getArtist, getGenre } from "@/api/queries";
|
||||
import { AlbumSortingKeys, AlbumType } from "@/models/album";
|
||||
import { albumTypeToTranslationKey } from "@/models/utils";
|
||||
import { useQuery } from "~/api";
|
||||
import {
|
||||
useLibraryFiltersControl,
|
||||
useTypeFiltersControl,
|
||||
} from "~/components/infinite/controls/filters";
|
||||
import { useLayoutControl } from "~/components/infinite/controls/layout";
|
||||
import { useSortControl } from "~/components/infinite/controls/sort";
|
||||
import { InfiniteView } from "~/components/infinite/view";
|
||||
import { AlbumItem, AlbumTile } from "~/components/item/resource/album";
|
||||
import { ArtistHeader } from "~/components/resource-header";
|
||||
|
||||
export default function AlbumBrowseView() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
compilations,
|
||||
artist: artistId,
|
||||
genre: genreId,
|
||||
} = useLocalSearchParams<{
|
||||
compilations?: "true";
|
||||
artist?: string;
|
||||
genre?: string;
|
||||
}>();
|
||||
const [{ layout }, layoutControl] = useLayoutControl({
|
||||
defaultLayout: "grid",
|
||||
enableToggle: true,
|
||||
});
|
||||
const [{ sort, order }, sortControl] = useSortControl({
|
||||
sortingKeys: AlbumSortingKeys,
|
||||
translate: (s) => `browsing.controls.sort.${s}`,
|
||||
});
|
||||
const [libraries, libraryFilterControl] = useLibraryFiltersControl();
|
||||
const [types, albumTypeFilterControl] = useTypeFiltersControl({
|
||||
types: AlbumType,
|
||||
translate: (t) => albumTypeToTranslationKey(t, false),
|
||||
});
|
||||
const { data: artist } = useQuery(
|
||||
(artistId) => getArtist(artistId, ["illustration"]),
|
||||
artistId,
|
||||
);
|
||||
|
||||
const { data: genre } = useQuery((genreId) => getGenre(genreId), genreId);
|
||||
const Item = layout === "list" ? AlbumItem : AlbumTile;
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerTitle: genreId
|
||||
? (genre?.name ?? "")
|
||||
: t(
|
||||
compilations
|
||||
? "nav.compilations"
|
||||
: "models.album_plural",
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<InfiniteView
|
||||
layout={layout}
|
||||
header={artistId ? <ArtistHeader artist={artist} /> : undefined}
|
||||
controls={{
|
||||
layout: layoutControl,
|
||||
sort: sortControl,
|
||||
filters: [libraryFilterControl, albumTypeFilterControl],
|
||||
}}
|
||||
query={getAlbums(
|
||||
{
|
||||
library: libraries,
|
||||
type: types,
|
||||
artist: compilations ? "compilations" : artistId,
|
||||
genre: genreId,
|
||||
},
|
||||
{ sortBy: sort ?? "name", order: order ?? "asc" },
|
||||
["artist", "illustration"],
|
||||
)}
|
||||
render={(album) => (
|
||||
<Item
|
||||
album={album}
|
||||
subtitle={artistId ? "year" : "artistName"}
|
||||
illustrationProps={{}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
getAlbums,
|
||||
getArtist,
|
||||
getArtistExternalMetadata,
|
||||
getSongs,
|
||||
getVideos,
|
||||
} from "@/api/queries";
|
||||
import { type AlbumSortingKey, AlbumType } from "@/models/album";
|
||||
import { albumTypeToTranslationKey } from "@/models/utils";
|
||||
import type { VideoSortingKey, VideoWithRelations } from "@/models/video";
|
||||
import { VideoTypeIsExtra } from "@/models/video";
|
||||
import { playTracksAtom } from "@/state/player";
|
||||
import { useInfiniteQuery, useQuery } from "~/api";
|
||||
import { useSetKeyIllustration } from "~/components/background-gradient";
|
||||
import {
|
||||
ExternalMetadataDescriptionSection,
|
||||
ExternalMetadataSourcesSection,
|
||||
} from "~/components/external-metadata";
|
||||
import { AlbumTile } from "~/components/item/resource/album";
|
||||
import { VideoTile } from "~/components/item/resource/video";
|
||||
import { ArtistHeader } from "~/components/resource-header";
|
||||
import { Row } from "~/components/row";
|
||||
import { SafeScrollView } from "~/components/safe-view";
|
||||
import { SongGrid } from "~/components/song-grid";
|
||||
import type { Sorting } from "~/utils/sorting";
|
||||
|
||||
const albumTypeQuery = (albumType: AlbumType, artistId: string) =>
|
||||
getAlbums(
|
||||
{ artist: artistId, type: [albumType] },
|
||||
{ sortBy: "releaseDate", order: "desc" },
|
||||
["illustration", "artist"],
|
||||
);
|
||||
|
||||
// Note: we show the 'seeMore' button for song grids and video rows iff there's more items than ShowSeeMoreThreshold
|
||||
// since the user may want to shuffle them through the dedicated page
|
||||
|
||||
const ShowSeeMoreThreshold = 3;
|
||||
|
||||
export default function ArtistView() {
|
||||
const playTracks = useSetAtom(playTracksAtom);
|
||||
const { id: artistId } = useLocalSearchParams<{ id: string }>();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: artist } = useQuery(() =>
|
||||
getArtist(artistId, ["illustration"]),
|
||||
);
|
||||
const topSongs = useInfiniteQuery(() =>
|
||||
getSongs(
|
||||
{ artist: artistId },
|
||||
{ sortBy: "totalPlayCount", order: "desc" },
|
||||
["artist", "featuring", "master", "illustration"],
|
||||
),
|
||||
);
|
||||
|
||||
const rareSongs = useInfiniteQuery(() =>
|
||||
getSongs({ rare: artistId }, { sortBy: "releaseDate", order: "desc" }, [
|
||||
"artist",
|
||||
"featuring",
|
||||
"master",
|
||||
"illustration",
|
||||
]),
|
||||
);
|
||||
const videos = useInfiniteQuery(() =>
|
||||
getVideos({ artist: artistId }, { sortBy: "addDate", order: "desc" }, [
|
||||
"artist",
|
||||
"master",
|
||||
"illustration",
|
||||
]),
|
||||
);
|
||||
const relatedAlbums = useInfiniteQuery(() =>
|
||||
getAlbums(
|
||||
{ appearance: artistId },
|
||||
{ sortBy: "releaseDate", order: "desc" },
|
||||
["artist", "illustration"],
|
||||
),
|
||||
);
|
||||
const { data: externalMetadata } = useQuery(
|
||||
(artistId) => getArtistExternalMetadata(artistId),
|
||||
artistId,
|
||||
);
|
||||
const { musicVideos, liveVideos, extras } = useMemo(() => {
|
||||
return {
|
||||
musicVideos: videos.items?.filter(
|
||||
(video) =>
|
||||
!VideoTypeIsExtra(video.type) && video.type !== "Live",
|
||||
),
|
||||
liveVideos: videos.items?.filter((video) => video.type === "Live"),
|
||||
extras: videos.items?.filter((video) =>
|
||||
VideoTypeIsExtra(video.type),
|
||||
),
|
||||
};
|
||||
}, [videos]);
|
||||
const onVideoPress = useCallback(
|
||||
(
|
||||
videoId: number,
|
||||
items: VideoWithRelations<"artist" | "master" | "illustration">[],
|
||||
) => {
|
||||
const tracks = items.map(({ master, illustration, artist }) => ({
|
||||
track: { ...master, illustration },
|
||||
featuring: undefined,
|
||||
artist,
|
||||
}));
|
||||
const cursor = items.findIndex(({ id }) => id === videoId);
|
||||
playTracks({ tracks, cursor });
|
||||
},
|
||||
[],
|
||||
);
|
||||
useSetKeyIllustration(artist ?? undefined);
|
||||
return (
|
||||
<SafeScrollView>
|
||||
<ArtistHeader artist={artist} />
|
||||
<SongGrid
|
||||
header={t("artist.topSongs")}
|
||||
parentArtistId={artist?.id}
|
||||
style={styles.section}
|
||||
seeMore={
|
||||
(topSongs.items?.length ?? 0) >= ShowSeeMoreThreshold
|
||||
? {
|
||||
pathname: "/songs",
|
||||
params: { artist: artistId },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
songs={topSongs.data?.pages.at(0)?.items}
|
||||
subtitle={
|
||||
!topSongs.data
|
||||
? null
|
||||
: (song) =>
|
||||
song.artistId.toString() === artistId &&
|
||||
song.featuring.length === 0
|
||||
? null
|
||||
: "artists"
|
||||
}
|
||||
/>
|
||||
{AlbumType.map((albumType) => (
|
||||
<AlbumTypeRow
|
||||
key={albumType}
|
||||
artistId={artistId}
|
||||
type={albumType}
|
||||
/>
|
||||
))}
|
||||
{artist && (
|
||||
<>
|
||||
<SongGrid
|
||||
hideIfEmpty
|
||||
header={t("artist.rareSongs")}
|
||||
seeMore={
|
||||
(rareSongs.items?.length ?? 0) >
|
||||
ShowSeeMoreThreshold
|
||||
? {
|
||||
pathname: "/songs",
|
||||
params: { rare: artistId },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
style={styles.section}
|
||||
songs={rareSongs.data?.pages.at(0)?.items}
|
||||
parentArtistId={artist?.id}
|
||||
subtitle={
|
||||
!rareSongs.data
|
||||
? null
|
||||
: (song) =>
|
||||
song.artistId.toString() === artistId &&
|
||||
song.featuring.length === 0
|
||||
? null
|
||||
: "artists"
|
||||
}
|
||||
/>
|
||||
|
||||
{(
|
||||
[
|
||||
{ label: "musicVideos", items: musicVideos },
|
||||
{ label: "livePerformances", items: liveVideos },
|
||||
{ label: "extras", items: extras },
|
||||
] as const
|
||||
).map(({ label, items }) => (
|
||||
<Row
|
||||
hideIfEmpty
|
||||
key={label}
|
||||
items={items}
|
||||
style={styles.section}
|
||||
header={t(`browsing.sections.${label}`)}
|
||||
render={(item) => (
|
||||
<VideoTile
|
||||
video={item}
|
||||
onPress={() =>
|
||||
item &&
|
||||
items &&
|
||||
onVideoPress(item.id, items)
|
||||
}
|
||||
subtitle="duration"
|
||||
illustrationProps={{
|
||||
normalizedThumbnail: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
seeMore={
|
||||
(items?.length ?? 0) > ShowSeeMoreThreshold
|
||||
? {
|
||||
pathname: "/videos",
|
||||
params: {
|
||||
artist: artistId,
|
||||
sort: "name",
|
||||
order: "asc",
|
||||
} satisfies Sorting<VideoSortingKey> & {
|
||||
artist: string;
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Row
|
||||
hideIfEmpty
|
||||
style={styles.section}
|
||||
header={t("artist.appearsOn")}
|
||||
items={relatedAlbums.items}
|
||||
render={(album) => (
|
||||
<AlbumTile album={album} subtitle="artistName" />
|
||||
)}
|
||||
/>
|
||||
|
||||
{externalMetadata !== null && (
|
||||
<>
|
||||
<ExternalMetadataDescriptionSection
|
||||
externalMetadata={externalMetadata}
|
||||
style={styles.section}
|
||||
/>
|
||||
<ExternalMetadataSourcesSection
|
||||
externalMetadata={externalMetadata}
|
||||
style={styles.section}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SafeScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const AlbumTypeRow = ({
|
||||
type,
|
||||
artistId,
|
||||
}: {
|
||||
type: AlbumType;
|
||||
artistId: string;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const query = useInfiniteQuery(() => albumTypeQuery(type, artistId));
|
||||
return (
|
||||
<Row
|
||||
hideIfEmpty
|
||||
style={styles.section}
|
||||
header={t(albumTypeToTranslationKey(type, true))}
|
||||
items={query.items}
|
||||
render={(item) => <AlbumTile album={item} subtitle="year" />}
|
||||
seeMore={
|
||||
query.hasNextPage
|
||||
? {
|
||||
pathname: "/albums",
|
||||
params: {
|
||||
artist: artistId,
|
||||
sort: "releaseDate",
|
||||
order: "desc",
|
||||
type: type,
|
||||
} satisfies Sorting<AlbumSortingKey> & {
|
||||
type: AlbumType;
|
||||
artist: string;
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
},
|
||||
section: {
|
||||
paddingBottom: theme.gap(1),
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { getArtists } from "@/api/queries";
|
||||
import { ArtistSortingKeys } from "@/models/artist";
|
||||
import { useLibraryFiltersControl } from "~/components/infinite/controls/filters";
|
||||
import { useLayoutControl } from "~/components/infinite/controls/layout";
|
||||
import { useSortControl } from "~/components/infinite/controls/sort";
|
||||
import { InfiniteView } from "~/components/infinite/view";
|
||||
import { ArtistItem, ArtistTile } from "~/components/item/resource/artist";
|
||||
|
||||
export default function ArtistBrowseView() {
|
||||
const [{ layout }, layoutControl] = useLayoutControl({
|
||||
defaultLayout: "list",
|
||||
enableToggle: true,
|
||||
});
|
||||
const [{ sort, order }, sortControl] = useSortControl({
|
||||
sortingKeys: ArtistSortingKeys,
|
||||
translate: (s) => `browsing.controls.sort.${s}`,
|
||||
});
|
||||
const [libraries, libraryFilterControl] = useLibraryFiltersControl();
|
||||
const Item = layout === "list" ? ArtistItem : ArtistTile;
|
||||
return (
|
||||
<InfiniteView
|
||||
layout={layout}
|
||||
controls={{
|
||||
layout: layoutControl,
|
||||
sort: sortControl,
|
||||
filters: [libraryFilterControl],
|
||||
}}
|
||||
query={getArtists(
|
||||
{ library: libraries },
|
||||
{ sortBy: sort ?? "name", order: order ?? "asc" },
|
||||
["illustration"],
|
||||
)}
|
||||
render={(artist) => <Item artist={artist} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useMemo } from "react";
|
||||
import { getGenres } from "@/api/queries";
|
||||
import { transformPage } from "@/api/query";
|
||||
import { GenreSortingKeys } from "@/models/genre";
|
||||
import { useSortControl } from "~/components/infinite/controls/sort";
|
||||
import { InfiniteView } from "~/components/infinite/view";
|
||||
import { GenreItem } from "~/components/item/resource/genre";
|
||||
|
||||
export default function GenreList() {
|
||||
const [{ sort, order }, sortControl] = useSortControl({
|
||||
sortingKeys: GenreSortingKeys,
|
||||
translate: (s) => `browsing.controls.sort.${s}`,
|
||||
});
|
||||
const query = useMemo(() => {
|
||||
const base = getGenres(
|
||||
{},
|
||||
{ sortBy: sort ?? "name", order: order ?? "asc" },
|
||||
);
|
||||
return transformPage(base, (g) => ({ ...g, illustration: null }));
|
||||
}, [sort, order]);
|
||||
|
||||
return (
|
||||
<InfiniteView
|
||||
layout={"list"}
|
||||
controls={{
|
||||
sort: sortControl,
|
||||
}}
|
||||
query={query}
|
||||
render={(genre) => <GenreItem genre={genre} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { shuffle } from "lodash";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import DraggableFlatList, {
|
||||
ScaleDecorator,
|
||||
} from "react-native-draggable-flatlist";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
getCurrentUserStatus,
|
||||
getPlaylist,
|
||||
getPlaylistEntries,
|
||||
} from "@/api/queries";
|
||||
import type { PlaylistEntryWithRelations } from "@/models/playlist";
|
||||
import { playTracksAtom } from "@/state/player";
|
||||
import {
|
||||
DoneIcon,
|
||||
EditIcon,
|
||||
PlayIcon,
|
||||
PlaylistIcon,
|
||||
ReorderPlaylistIcon,
|
||||
ShuffleIcon,
|
||||
} from "@/ui/icons";
|
||||
import { generateArray } from "@/utils/gen-list";
|
||||
import { useUpdatePlaylistFormModal } from "~/actions/playlist/create-update";
|
||||
import { useDeletePlaylistAction } from "~/actions/playlist/delete";
|
||||
import { useUpdateIllustrationAction } from "~/actions/update-illustration";
|
||||
import { useAPI, useQuery, useQueryClient } from "~/api";
|
||||
import { usePlaylistContextMenu } from "~/components/context-menu/resource/playlist";
|
||||
import { SongItem } from "~/components/item/resource/song";
|
||||
import { ResourceHeader } from "~/components/resource-header";
|
||||
import * as Haptics from "~/haptics";
|
||||
import { useRootViewStyle } from "~/hooks/root-view-style";
|
||||
import { Button } from "~/primitives/button";
|
||||
import { Divider } from "~/primitives/divider";
|
||||
import { showErrorToast } from "~/primitives/toast";
|
||||
import { breakpoints } from "~/theme";
|
||||
|
||||
type PlaylistEntryType = PlaylistEntryWithRelations<
|
||||
"illustration" | "artist" | "featuring" | "master"
|
||||
>;
|
||||
|
||||
const playlistQuery = (playlistId: string | number) =>
|
||||
getPlaylist(playlistId, ["illustration"]);
|
||||
|
||||
export default function PlaylistView() {
|
||||
const rootStyle = useRootViewStyle();
|
||||
const queryClient = useQueryClient();
|
||||
const { id: playlistId } = useLocalSearchParams<{ id: string }>();
|
||||
const playTracks = useSetAtom(playTracksAtom);
|
||||
|
||||
// Data
|
||||
const { data: playlistEntries, refetch: refetchEntries } = useQuery(() => {
|
||||
const query = getPlaylistEntries(playlistId, [
|
||||
"illustration",
|
||||
"artist",
|
||||
"featuring",
|
||||
"master",
|
||||
]);
|
||||
return {
|
||||
key: [...query.key, "full"],
|
||||
exec: (api) => () => query.exec(api)({ pageSize: 1000 }),
|
||||
};
|
||||
});
|
||||
// Reorder
|
||||
const [isReordering, setIsReordering] = useState(false);
|
||||
const reorderEntries = useMutation({
|
||||
mutationFn: async (entriesIds: number[]) => {
|
||||
await queryClient.api.reorderPlaylist(playlistId, entriesIds);
|
||||
refetchEntries();
|
||||
},
|
||||
});
|
||||
|
||||
// Playback
|
||||
const tracksForPlayer = useMemo(
|
||||
() =>
|
||||
playlistEntries?.items.map((item) => ({
|
||||
track: { ...item.master, illustration: item.illustration },
|
||||
artist: item.artist,
|
||||
featuring: item.featuring,
|
||||
})) ?? [],
|
||||
[playlistEntries],
|
||||
);
|
||||
const onItemPress = useCallback(
|
||||
(index: number) => {
|
||||
playTracks({ tracks: tracksForPlayer, cursor: index });
|
||||
},
|
||||
[playlistEntries],
|
||||
);
|
||||
const onShuffle = useCallback(() => {
|
||||
playTracks({ tracks: shuffle(tracksForPlayer) });
|
||||
}, [playlistEntries]);
|
||||
return (
|
||||
<View style={[styles.root, rootStyle]}>
|
||||
<Header
|
||||
playlistId={playlistId}
|
||||
{...(playlistEntries && !isReordering
|
||||
? { onPlay: () => onItemPress(0), onShuffle: onShuffle }
|
||||
: {})}
|
||||
/>
|
||||
<Divider h />
|
||||
<Items
|
||||
playlistId={playlistId}
|
||||
playlistEntries={playlistEntries?.items}
|
||||
isReordering={isReordering}
|
||||
startReordering={() => setIsReordering(true)}
|
||||
onReorderingEnd={(entryIds) => {
|
||||
setIsReordering(false);
|
||||
reorderEntries.mutate(entryIds);
|
||||
}}
|
||||
onItemPress={onItemPress}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const Header = ({
|
||||
playlistId,
|
||||
onPlay,
|
||||
onShuffle,
|
||||
}: {
|
||||
playlistId: number | string;
|
||||
onShuffle?: () => void;
|
||||
onPlay?: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { data: playlist } = useQuery(() => playlistQuery(playlistId));
|
||||
const contextMenu = usePlaylistContextMenu(playlist);
|
||||
return (
|
||||
<>
|
||||
<ResourceHeader
|
||||
title={playlist?.name}
|
||||
subtitle={null}
|
||||
illustration={playlist?.illustration}
|
||||
illustrationProps={{ fallbackIcon: PlaylistIcon }}
|
||||
contextMenu={contextMenu}
|
||||
/>
|
||||
<View style={styles.playButtons}>
|
||||
<View style={styles.playButton}>
|
||||
<Button
|
||||
title={t("actions.playback.play")}
|
||||
width="fill"
|
||||
icon={PlayIcon}
|
||||
disabled={!onPlay}
|
||||
onPress={() => onPlay?.()}
|
||||
containerStyle={styles.playButtonContent}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.playButton}>
|
||||
<Button
|
||||
title={t("actions.playback.shuffle")}
|
||||
width="fill"
|
||||
disabled={!onShuffle}
|
||||
icon={ShuffleIcon}
|
||||
onPress={() => onShuffle?.()}
|
||||
containerStyle={styles.playButtonContent}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Items = ({
|
||||
playlistEntries,
|
||||
isReordering,
|
||||
playlistId,
|
||||
startReordering,
|
||||
onReorderingEnd,
|
||||
onItemPress,
|
||||
}: {
|
||||
onItemPress: (index: number) => void;
|
||||
playlistId: string | number;
|
||||
isReordering: boolean;
|
||||
startReordering: () => void;
|
||||
onReorderingEnd: (reorderedEntryIds: number[]) => void;
|
||||
playlistEntries: PlaylistEntryType[] | undefined;
|
||||
}) => {
|
||||
const reorderedIds = useRef<number[]>([]);
|
||||
const reorder = useCallback(
|
||||
(from: number, to: number) => {
|
||||
[reorderedIds.current[to], reorderedIds.current[from]] = [
|
||||
reorderedIds.current[from],
|
||||
reorderedIds.current[to],
|
||||
];
|
||||
},
|
||||
[playlistEntries],
|
||||
);
|
||||
useEffect(() => {
|
||||
reorderedIds.current =
|
||||
playlistEntries?.map(({ entryId }) => entryId) ?? [];
|
||||
}, [isReordering, playlistEntries]);
|
||||
return (
|
||||
/* @ts-expect-error */
|
||||
<DraggableFlatList
|
||||
containerStyle={styles.itemsContainer}
|
||||
contentContainerStyle={styles.items}
|
||||
data={playlistEntries ?? generateArray(20)}
|
||||
onDragEnd={({ from, to }) => reorder(from, to)}
|
||||
keyExtractor={(item: PlaylistEntryType | undefined, idx) =>
|
||||
item?.entryId.toString() ?? `skeleton-${idx}`
|
||||
}
|
||||
renderItem={({ item, drag, getIndex }) => (
|
||||
/* @ts-expect-error */
|
||||
<ScaleDecorator>
|
||||
<SongItem
|
||||
song={item}
|
||||
subtitle="artists"
|
||||
onLongPress={
|
||||
isReordering
|
||||
? () => {
|
||||
drag();
|
||||
Haptics.onDragStart();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
illustrationProps={{}}
|
||||
onPress={() => onItemPress(getIndex()!)}
|
||||
/>
|
||||
<Divider h />
|
||||
</ScaleDecorator>
|
||||
)}
|
||||
ListFooterComponent={
|
||||
playlistEntries !== undefined ? (
|
||||
<Footer
|
||||
playlistId={playlistId}
|
||||
isReordering={isReordering}
|
||||
startReorder={startReordering}
|
||||
finishReorder={() =>
|
||||
onReorderingEnd(reorderedIds.current)
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Footer = ({
|
||||
playlistId,
|
||||
isReordering,
|
||||
startReorder,
|
||||
finishReorder,
|
||||
}: {
|
||||
playlistId: number | string;
|
||||
isReordering: boolean;
|
||||
startReorder: () => void;
|
||||
finishReorder: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { data: playlist } = useQuery(() => playlistQuery(playlistId));
|
||||
const { data: user } = useQuery(getCurrentUserStatus);
|
||||
const queryClient = useQueryClient();
|
||||
const api = useAPI();
|
||||
const userIsAdmin = useMemo(
|
||||
() => playlist && user && playlist.ownerId === user.id,
|
||||
[user, playlist],
|
||||
);
|
||||
const userCanEdit = useMemo(
|
||||
() => playlist && user && (userIsAdmin || playlist.allowChanges),
|
||||
[user, playlist, userIsAdmin],
|
||||
);
|
||||
const { openFormModal } = useUpdatePlaylistFormModal(playlist);
|
||||
const deletePlaylistAction = useDeletePlaylistAction(playlist?.id);
|
||||
const updateIllustration = useMutation({
|
||||
mutationFn: async (illustrationUrl: string) =>
|
||||
playlist &&
|
||||
api
|
||||
.updatePlaylistIllustration(playlist.id, illustrationUrl)
|
||||
.then(() =>
|
||||
queryClient.client.invalidateQueries({
|
||||
queryKey: ["playlists"],
|
||||
}),
|
||||
)
|
||||
.catch(() =>
|
||||
showErrorToast({ text: "Updating illustration failed" }),
|
||||
),
|
||||
});
|
||||
|
||||
const updateIllustrationAction = useUpdateIllustrationAction((url) =>
|
||||
updateIllustration.mutate(url),
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.footerRow}>
|
||||
{userCanEdit && (
|
||||
<Button
|
||||
size="small"
|
||||
width="fitContent"
|
||||
// @ts-expect-error
|
||||
icon={isReordering ? DoneIcon : ReorderPlaylistIcon}
|
||||
title={t(
|
||||
isReordering
|
||||
? "form.done"
|
||||
: "form.playlist.reorder",
|
||||
)}
|
||||
onPress={isReordering ? finishReorder : startReorder}
|
||||
/>
|
||||
)}
|
||||
{userIsAdmin && (
|
||||
<Button
|
||||
size="small"
|
||||
width="fitContent"
|
||||
disabled={isReordering}
|
||||
icon={EditIcon}
|
||||
title={t("form.edit")}
|
||||
onPress={openFormModal}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.footerRow}>
|
||||
{userIsAdmin && (
|
||||
<Button
|
||||
size="small"
|
||||
width="fitContent"
|
||||
disabled={isReordering}
|
||||
icon={updateIllustrationAction.icon}
|
||||
title={t(updateIllustrationAction.label)}
|
||||
onPress={updateIllustrationAction.onPress!}
|
||||
/>
|
||||
)}
|
||||
{userIsAdmin && (
|
||||
<Button
|
||||
size="small"
|
||||
width="fitContent"
|
||||
disabled={isReordering}
|
||||
icon={deletePlaylistAction.icon}
|
||||
title={t(deletePlaylistAction.label)}
|
||||
onPress={() =>
|
||||
userIsAdmin && deletePlaylistAction.onPress?.()
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: { flex: 1, alignItems: "center" },
|
||||
playButtons: {
|
||||
flexDirection: "row",
|
||||
gap: theme.gap(1),
|
||||
padding: theme.gap(1),
|
||||
paddingTop: 0,
|
||||
},
|
||||
playButton: { flex: 1 },
|
||||
playButtonContent: { justifyContent: "center" },
|
||||
itemsContainer: {
|
||||
flex: 1,
|
||||
maxWidth: breakpoints.xl,
|
||||
width: "100%",
|
||||
},
|
||||
items: { padding: theme.gap(1), paddingTop: 0 },
|
||||
footer: {
|
||||
width: "100%",
|
||||
gap: theme.gap(1),
|
||||
paddingVertical: theme.gap(1.5),
|
||||
},
|
||||
footerRow: {
|
||||
width: "100%",
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
gap: theme.gap(1),
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,43 @@
|
||||
import { getPlaylists } from "@/api/queries";
|
||||
import { PlaylistSortingKeys } from "@/models/playlist";
|
||||
import {
|
||||
CreatePlaylist,
|
||||
useCreatePlaylistFormModal,
|
||||
} from "~/actions/playlist/create-update";
|
||||
import { useLayoutControl } from "~/components/infinite/controls/layout";
|
||||
import { useSortControl } from "~/components/infinite/controls/sort";
|
||||
import { InfiniteView } from "~/components/infinite/view";
|
||||
import {
|
||||
PlaylistItem,
|
||||
PlaylistTile,
|
||||
} from "~/components/item/resource/playlist";
|
||||
|
||||
export default function PlaylistsView() {
|
||||
const [{ sort, order }, sortControl] = useSortControl({
|
||||
sortingKeys: PlaylistSortingKeys,
|
||||
translate: (s) => `browsing.controls.sort.${s}`,
|
||||
});
|
||||
const [{ layout }, layoutControl] = useLayoutControl({
|
||||
defaultLayout: "list",
|
||||
enableToggle: true,
|
||||
});
|
||||
const { openFormModal } = useCreatePlaylistFormModal();
|
||||
const Item = layout === "list" ? PlaylistItem : PlaylistTile;
|
||||
|
||||
return (
|
||||
<InfiniteView
|
||||
layout={layout}
|
||||
controls={{
|
||||
actions: [CreatePlaylist(openFormModal)],
|
||||
sort: sortControl,
|
||||
layout: layoutControl,
|
||||
}}
|
||||
query={getPlaylists(
|
||||
{},
|
||||
{ sortBy: sort ?? "name", order: order ?? "asc" },
|
||||
["illustration"],
|
||||
)}
|
||||
render={(playlist) => <Item playlist={playlist} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Stack, useLocalSearchParams } from "expo-router";
|
||||
import Animated, {
|
||||
interpolate,
|
||||
useAnimatedScrollHandler,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import { HeaderBackground } from "~/components/navigation";
|
||||
import { useRootViewStyle } from "~/hooks/root-view-style";
|
||||
import ReleasePage from "~/pages/release";
|
||||
|
||||
export default function Page() {
|
||||
const rootStyle = useRootViewStyle();
|
||||
const { id: releaseId } = useLocalSearchParams<{ id: string }>();
|
||||
const scrollY = useSharedValue(0);
|
||||
const scrollHandler = useAnimatedScrollHandler({
|
||||
onScroll: (event) => {
|
||||
scrollY.value = event.contentOffset.y;
|
||||
},
|
||||
});
|
||||
const headerStyle = useAnimatedStyle(
|
||||
() => ({
|
||||
opacity: interpolate(scrollY.value, [30, 100], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
}),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerBackground: () => (
|
||||
<HeaderBackground
|
||||
style={[{ opacity: 0 }, headerStyle]}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Animated.ScrollView
|
||||
onScroll={scrollHandler}
|
||||
scrollEventThrottle={16}
|
||||
contentContainerStyle={rootStyle}
|
||||
>
|
||||
<ReleasePage releaseId={releaseId} />
|
||||
</Animated.ScrollView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { getGenres, getSong, getSongExternalMetadata } from "@/api/queries";
|
||||
import type { ExternalMetadataSource } from "@/models/external-metadata";
|
||||
import type Genre from "@/models/genre";
|
||||
import type { Lyrics } from "@/models/lyrics";
|
||||
import type Song from "@/models/song";
|
||||
import { songTypeToTranslationKey } from "@/models/utils";
|
||||
import { playTrackAtom, type TrackState } from "@/state/player";
|
||||
import { LyricsIcon, PlayIcon, SongTypeIcon } from "@/ui/icons";
|
||||
import { generateArray } from "@/utils/gen-list";
|
||||
import { useInfiniteQuery, useQuery } from "~/api";
|
||||
import { useSetKeyIllustration } from "~/components/background-gradient";
|
||||
import { Chip } from "~/components/chip";
|
||||
import { EmptyState } from "~/components/empty-state";
|
||||
import {
|
||||
ExternalMetadataDescription,
|
||||
ExternalMetadataSourceComponent,
|
||||
} from "~/components/external-metadata";
|
||||
import { GenreChip } from "~/components/item/resource/genre";
|
||||
import { LoadableText } from "~/components/loadable_text";
|
||||
import { SongHeader } from "~/components/resource-header";
|
||||
import { useRootViewStyle } from "~/hooks/root-view-style";
|
||||
import { Button } from "~/primitives/button";
|
||||
import { Divider } from "~/primitives/divider";
|
||||
import { Icon } from "~/primitives/icon";
|
||||
import { Text } from "~/primitives/text";
|
||||
|
||||
const tabs = ["lyrics", "infos"] as const;
|
||||
type Tab = (typeof tabs)[number];
|
||||
|
||||
export default function SongPage() {
|
||||
const playTrack = useSetAtom(playTrackAtom);
|
||||
const { paddingTop, paddingBottom } = useRootViewStyle();
|
||||
const { id, tab: tabQuery } = useLocalSearchParams<{
|
||||
id: string;
|
||||
tab?: Tab;
|
||||
}>();
|
||||
const [currentTab, setCurrentTab] = useState(() => {
|
||||
if (!tabQuery || !tabs.includes(tabQuery)) {
|
||||
return tabs[0];
|
||||
}
|
||||
return tabQuery;
|
||||
});
|
||||
const { data: song } = useQuery(() =>
|
||||
getSong(id, [
|
||||
"artist",
|
||||
"illustration",
|
||||
"featuring",
|
||||
"lyrics",
|
||||
"master",
|
||||
]),
|
||||
);
|
||||
useSetKeyIllustration(song);
|
||||
const playButtonCallback = useCallback(() => {
|
||||
if (!song) {
|
||||
return;
|
||||
}
|
||||
const track: TrackState = {
|
||||
track: { ...song.master, illustration: song.illustration },
|
||||
artist: song.artist,
|
||||
featuring: song.featuring,
|
||||
};
|
||||
playTrack(track);
|
||||
}, [song]);
|
||||
|
||||
return (
|
||||
<View style={{ paddingTop }}>
|
||||
<View style={styles.content}>
|
||||
<SongHeader song={song} />
|
||||
<View style={styles.playButton}>
|
||||
<Button
|
||||
title="Play"
|
||||
width="fill"
|
||||
icon={PlayIcon}
|
||||
disabled={!song}
|
||||
onPress={playButtonCallback}
|
||||
containerStyle={styles.playButtonContent}
|
||||
/>
|
||||
</View>
|
||||
<TabComponent
|
||||
currentTab={currentTab}
|
||||
enabled={!!song}
|
||||
setCurrentTab={setCurrentTab}
|
||||
/>
|
||||
<Divider h withInsets />
|
||||
{song && (
|
||||
<ScrollView
|
||||
style={styles.content}
|
||||
contentContainerStyle={{ paddingBottom }}
|
||||
>
|
||||
{currentTab === "lyrics" && (
|
||||
<LyricsView
|
||||
lyrics={song?.lyrics}
|
||||
songName={song?.name}
|
||||
/>
|
||||
)}
|
||||
{currentTab === "infos" && <InfoView song={song} />}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const LyricsView = ({
|
||||
lyrics,
|
||||
songName,
|
||||
}: {
|
||||
lyrics: Lyrics | undefined | null;
|
||||
songName: string | undefined;
|
||||
}) => {
|
||||
const lowerSongName = songName?.toLowerCase();
|
||||
if (lyrics === null) {
|
||||
return (
|
||||
<View style={styles.tab}>
|
||||
<EmptyState icon={LyricsIcon} text="emptyState.lyrics" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={styles.tab}>
|
||||
{lyrics?.plain.split("\n").map((line, idx) => (
|
||||
<Text
|
||||
key={idx}
|
||||
content={line}
|
||||
style={styles.lyric}
|
||||
variant={
|
||||
lowerSongName &&
|
||||
line.toLowerCase().includes(lowerSongName)
|
||||
? "subtitle"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const InfoView = ({ song }: { song: Song | undefined }) => {
|
||||
const { t } = useTranslation();
|
||||
const { items: genres } = useInfiniteQuery(
|
||||
(songId) => getGenres({ song: songId }),
|
||||
song?.id,
|
||||
);
|
||||
|
||||
const { data: externalMetadata } = useQuery(
|
||||
(songId) => getSongExternalMetadata(songId),
|
||||
song?.id,
|
||||
);
|
||||
return (
|
||||
<View style={[styles.tab, styles.info]}>
|
||||
<View style={styles.row}>
|
||||
<Text content={`${t("song.songType")}:`} variant="subtitle" />
|
||||
<View style={styles.row}>
|
||||
{song && (
|
||||
<Icon
|
||||
icon={SongTypeIcon(song?.type)}
|
||||
style={styles.songTypeIcon}
|
||||
/>
|
||||
)}
|
||||
<LoadableText
|
||||
content={
|
||||
song
|
||||
? t(songTypeToTranslationKey(song.type, false))
|
||||
: undefined
|
||||
}
|
||||
skeletonWidth={10}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
{(genres === undefined || genres.length > 0) && (
|
||||
<View style={styles.row}>
|
||||
<Text
|
||||
content={`${t("models.genre_plural")}:`}
|
||||
variant="subtitle"
|
||||
/>
|
||||
<ScrollView
|
||||
horizontal
|
||||
contentContainerStyle={styles.infoScrollable}
|
||||
>
|
||||
{(genres ?? generateArray(3)).map(
|
||||
(genre: Genre | undefined, idx) => (
|
||||
<GenreChip
|
||||
genre={genre}
|
||||
key={genre?.slug ?? idx}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
{(externalMetadata === undefined ||
|
||||
(externalMetadata && externalMetadata.sources.length > 0)) && (
|
||||
<View style={styles.row}>
|
||||
<Text
|
||||
content={`${t("models.externalLink_plural")}:`}
|
||||
variant="subtitle"
|
||||
/>
|
||||
<ScrollView
|
||||
horizontal
|
||||
contentContainerStyle={styles.infoScrollable}
|
||||
>
|
||||
{(externalMetadata?.sources ?? generateArray(3)).map(
|
||||
(
|
||||
source: ExternalMetadataSource | undefined,
|
||||
idx,
|
||||
) => (
|
||||
<ExternalMetadataSourceComponent
|
||||
source={source}
|
||||
key={idx}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{externalMetadata !== null &&
|
||||
externalMetadata?.description !== null && (
|
||||
<ExternalMetadataDescription
|
||||
description={externalMetadata?.description}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const TabComponent = ({
|
||||
currentTab,
|
||||
enabled,
|
||||
setCurrentTab,
|
||||
}: {
|
||||
currentTab: Tab;
|
||||
enabled: boolean;
|
||||
setCurrentTab: (t: Tab) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const translateTabName = useCallback((t: Tab): TranslationKey => {
|
||||
switch (t) {
|
||||
case "lyrics":
|
||||
return "models.lyrics";
|
||||
case "infos":
|
||||
return "song.infoTab";
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<View style={styles.tabs}>
|
||||
{tabs.map((tab) => (
|
||||
<Chip
|
||||
disabled={!enabled}
|
||||
key={tab}
|
||||
filled={tab === currentTab}
|
||||
title={t(translateTabName(tab))}
|
||||
onPress={() => setCurrentTab(tab)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
content: { height: "100%", gap: theme.gap(0) },
|
||||
playButtonContent: { justifyContent: "center" },
|
||||
playButton: { paddingHorizontal: theme.gap(2) },
|
||||
tabs: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
gap: theme.gap(3),
|
||||
paddingVertical: theme.gap(2),
|
||||
},
|
||||
tab: { padding: theme.gap(2) },
|
||||
lyric: { fontSize: theme.fontSize.rem(1.125) },
|
||||
info: { gap: theme.gap(2) },
|
||||
infoScrollable: { gap: theme.gap(2) },
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.gap(1.5),
|
||||
},
|
||||
songTypeIcon: {
|
||||
size: theme.fontSize.rem(1.5),
|
||||
} as any,
|
||||
}));
|
||||
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getArtist, getGenre, getSong, getSongs } from "@/api/queries";
|
||||
import { transformPage } from "@/api/query";
|
||||
import Song, {
|
||||
SongSortingKeys,
|
||||
SongType,
|
||||
type SongWithRelations,
|
||||
} from "@/models/song";
|
||||
import { songTypeToTranslationKey } from "@/models/utils";
|
||||
import { playFromInfiniteQuery } from "@/state/player";
|
||||
import { PlayIcon, ShuffleIcon } from "@/ui/icons";
|
||||
import { getRandomNumber } from "@/utils/random";
|
||||
import type { Action } from "~/actions";
|
||||
import { useQuery, useQueryClient } from "~/api";
|
||||
import {
|
||||
useLibraryFiltersControl,
|
||||
useTypeFiltersControl,
|
||||
} from "~/components/infinite/controls/filters";
|
||||
import { useSortControl } from "~/components/infinite/controls/sort";
|
||||
import { InfiniteView } from "~/components/infinite/view";
|
||||
import { SongItem } from "~/components/item/resource/song";
|
||||
import { ArtistHeader, SongHeader } from "~/components/resource-header";
|
||||
|
||||
// TODO song subtitle: allow it to be album
|
||||
|
||||
export default function SongBrowseView() {
|
||||
const playTracks = useSetAtom(playFromInfiniteQuery);
|
||||
const queryClient = useQueryClient();
|
||||
const nav = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
artist: artistId,
|
||||
rare: rareArtistId,
|
||||
genre: genreId,
|
||||
versionsOf: versionsOfSongId,
|
||||
} = useLocalSearchParams<{
|
||||
artist?: string;
|
||||
rare?: string;
|
||||
versionsOf?: string;
|
||||
genre?: string;
|
||||
}>();
|
||||
const [{ sort, order }, sortControl] = useSortControl({
|
||||
sortingKeys: SongSortingKeys,
|
||||
translate: (s) => `browsing.controls.sort.${s}`,
|
||||
});
|
||||
const [libraries, libraryFilterControl] = useLibraryFiltersControl();
|
||||
const [types, songTypeFilterControl] = useTypeFiltersControl({
|
||||
types: SongType,
|
||||
translate: (t) => songTypeToTranslationKey(t, false),
|
||||
});
|
||||
const { data: artist } = useQuery(
|
||||
(artistId) => getArtist(artistId, ["illustration"]),
|
||||
artistId ?? rareArtistId,
|
||||
);
|
||||
|
||||
const { data: genre } = useQuery((genreId) => getGenre(genreId), genreId);
|
||||
const { data: song } = useQuery(
|
||||
(songId) =>
|
||||
getSong(songId, ["artist", "illustration", "featuring", "master"]),
|
||||
versionsOfSongId,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (rareArtistId !== undefined) {
|
||||
nav.setOptions({ headerTitle: t("artist.rareSongs") });
|
||||
} else if (versionsOfSongId !== undefined) {
|
||||
nav.setOptions({ headerTitle: t("models.versions") });
|
||||
} else if (genreId !== undefined) {
|
||||
nav.setOptions({ headerTitle: genre?.name ?? "" });
|
||||
}
|
||||
}, [rareArtistId, versionsOfSongId, genreId, genre]);
|
||||
const subtitle = useCallback(
|
||||
(songItem: SongWithRelations<"featuring"> | undefined) => {
|
||||
if (
|
||||
artistId === undefined &&
|
||||
versionsOfSongId === undefined &&
|
||||
rareArtistId === undefined
|
||||
) {
|
||||
return "artists";
|
||||
}
|
||||
if (!songItem) {
|
||||
return null;
|
||||
}
|
||||
// If song's artist matches the main song's artist
|
||||
// + if there is no featuring, don;t display artist name
|
||||
if (
|
||||
songItem.featuring.length === 0 &&
|
||||
songItem.artistId === song?.artistId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
songItem.featuring.length > 0 ||
|
||||
songItem.artistId !== artist?.id
|
||||
) {
|
||||
return "artists";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[artistId, rareArtistId, song],
|
||||
);
|
||||
const getQuery = useCallback(
|
||||
(random?: number) =>
|
||||
getSongs(
|
||||
{
|
||||
library: libraries,
|
||||
type: types,
|
||||
artist: artistId,
|
||||
versionsOf: versionsOfSongId,
|
||||
random,
|
||||
rare: rareArtistId,
|
||||
genre: genreId,
|
||||
},
|
||||
{ sortBy: sort ?? "name", order: order ?? "asc" },
|
||||
["artist", "illustration", "featuring", "master"],
|
||||
),
|
||||
[
|
||||
song,
|
||||
order,
|
||||
libraries,
|
||||
types,
|
||||
artistId,
|
||||
genreId,
|
||||
versionsOfSongId,
|
||||
rareArtistId,
|
||||
],
|
||||
);
|
||||
const query = useMemo(() => getQuery(), [getQuery]);
|
||||
const getQueryForPlayer = useCallback(
|
||||
(random?: number) =>
|
||||
transformPage(
|
||||
getQuery(random),
|
||||
({ master, illustration, artist, id, featuring }) => ({
|
||||
artist,
|
||||
featuring,
|
||||
track: { ...master, illustration },
|
||||
id,
|
||||
}),
|
||||
),
|
||||
[query],
|
||||
);
|
||||
const onItemPress = useCallback(
|
||||
(index: number, songs: Song[] | undefined) => {
|
||||
if (songs === undefined) {
|
||||
return;
|
||||
}
|
||||
const afterId = index > 0 ? songs[index - 1]?.id : undefined;
|
||||
playTracks(getQueryForPlayer(), queryClient, afterId);
|
||||
},
|
||||
[query],
|
||||
);
|
||||
const playAction = useMemo(() => {
|
||||
return {
|
||||
label: "actions.playback.playAll",
|
||||
icon: PlayIcon,
|
||||
onPress: () => {
|
||||
playTracks(getQueryForPlayer(), queryClient);
|
||||
},
|
||||
} satisfies Action;
|
||||
}, [getQueryForPlayer, queryClient]);
|
||||
|
||||
const shuffleAction = useMemo(() => {
|
||||
return {
|
||||
label: "actions.playback.shuffle",
|
||||
icon: ShuffleIcon,
|
||||
onPress: () => {
|
||||
playTracks(getQueryForPlayer(getRandomNumber()), queryClient);
|
||||
},
|
||||
} satisfies Action;
|
||||
}, [getQueryForPlayer, queryClient]);
|
||||
return (
|
||||
<InfiniteView
|
||||
layout={"list"}
|
||||
header={
|
||||
(artistId ?? rareArtistId) !== undefined ? (
|
||||
<ArtistHeader artist={artist} />
|
||||
) : versionsOfSongId !== undefined ? (
|
||||
<SongHeader song={song} />
|
||||
) : undefined
|
||||
}
|
||||
controls={{
|
||||
sort: sortControl,
|
||||
filters: [libraryFilterControl, songTypeFilterControl],
|
||||
actions: [playAction, shuffleAction],
|
||||
}}
|
||||
query={query}
|
||||
render={(songItem, idx, songs) => (
|
||||
<SongItem
|
||||
song={songItem}
|
||||
parentArtistId={artist?.id ?? song?.artistId}
|
||||
onPress={() => onItemPress(idx, songs)}
|
||||
subtitle={subtitle(song)}
|
||||
illustrationProps={{}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useCallback } from "react";
|
||||
import { getArtist, getSong, getTracks } from "@/api/queries";
|
||||
import { TrackSortingKeys, type TrackWithRelations } from "@/models/track";
|
||||
import { playTrackAtom } from "@/state/player";
|
||||
import { useQuery, useQueryClient } from "~/api";
|
||||
import { useLibraryFiltersControl } from "~/components/infinite/controls/filters";
|
||||
import { useSortControl } from "~/components/infinite/controls/sort";
|
||||
import { InfiniteView } from "~/components/infinite/view";
|
||||
import { TrackItem } from "~/components/item/resource/track";
|
||||
import { SongHeader } from "~/components/resource-header";
|
||||
|
||||
export default function TracksView() {
|
||||
const queryClient = useQueryClient();
|
||||
const playTrack = useSetAtom(playTrackAtom);
|
||||
const { song: songId } = useLocalSearchParams<{
|
||||
song?: string;
|
||||
}>();
|
||||
const [{ sort, order }, sortControl] = useSortControl({
|
||||
sortingKeys: TrackSortingKeys,
|
||||
translate: (s) => `browsing.controls.sort.${s}`,
|
||||
});
|
||||
const [libraries, libraryFilterControl] = useLibraryFiltersControl();
|
||||
|
||||
const { data: song } = useQuery(
|
||||
(songId) =>
|
||||
getSong(songId, ["artist", "illustration", "featuring", "master"]),
|
||||
songId,
|
||||
);
|
||||
const onItemPress = useCallback(
|
||||
(track: TrackWithRelations<"song" | "illustration" | "video">) =>
|
||||
queryClient
|
||||
.fetchQuery(getArtist((track.song ?? track.video)!.artistId))
|
||||
.then((artist) =>
|
||||
playTrack({ artist, track, featuring: undefined }),
|
||||
),
|
||||
[queryClient],
|
||||
);
|
||||
return (
|
||||
<InfiniteView
|
||||
layout={"list"}
|
||||
header={
|
||||
songId !== undefined ? <SongHeader song={song} /> : undefined
|
||||
}
|
||||
controls={{
|
||||
sort: sortControl,
|
||||
filters: [libraryFilterControl],
|
||||
}}
|
||||
query={getTracks(
|
||||
{
|
||||
song: songId,
|
||||
library: libraries,
|
||||
},
|
||||
{ sortBy: sort ?? "name", order: order ?? "asc" },
|
||||
["illustration", "release", "song", "video"],
|
||||
)}
|
||||
render={(track) => (
|
||||
<TrackItem
|
||||
onPress={() => track && onItemPress(track)}
|
||||
track={track}
|
||||
illustrationProps={{}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { getArtist, getSong, getVideos } from "@/api/queries";
|
||||
import { transformPage } from "@/api/query";
|
||||
import { videoTypeToTranslationKey } from "@/models/utils";
|
||||
import type Video from "@/models/video";
|
||||
import { VideoSortingKeys, VideoType } from "@/models/video";
|
||||
import { playFromInfiniteQuery } from "@/state/player";
|
||||
import { PlayIcon, ShuffleIcon } from "@/ui/icons";
|
||||
import { getRandomNumber } from "@/utils/random";
|
||||
import type { Action } from "~/actions";
|
||||
import { useQuery, useQueryClient } from "~/api";
|
||||
import {
|
||||
useLibraryFiltersControl,
|
||||
useTypeFiltersControl,
|
||||
} from "~/components/infinite/controls/filters";
|
||||
import { useLayoutControl } from "~/components/infinite/controls/layout";
|
||||
import { useSortControl } from "~/components/infinite/controls/sort";
|
||||
import { InfiniteView } from "~/components/infinite/view";
|
||||
import { VideoItem, VideoTile } from "~/components/item/resource/video";
|
||||
import { ArtistHeader, SongHeader } from "~/components/resource-header";
|
||||
|
||||
export default function VideoBrowseView() {
|
||||
const queryClient = useQueryClient();
|
||||
const playTracks = useSetAtom(playFromInfiniteQuery);
|
||||
const { artist: artistId, song: songId } = useLocalSearchParams<{
|
||||
artist?: string;
|
||||
song?: string;
|
||||
}>();
|
||||
const [{ layout }, layoutControl] = useLayoutControl({
|
||||
defaultLayout: "grid",
|
||||
enableToggle: true,
|
||||
});
|
||||
const [{ sort, order }, sortControl] = useSortControl({
|
||||
sortingKeys: VideoSortingKeys,
|
||||
translate: (s) => `browsing.controls.sort.${s}`,
|
||||
});
|
||||
const [libraries, libraryFilterControl] = useLibraryFiltersControl();
|
||||
const [types, albumTypeFilterControl] = useTypeFiltersControl({
|
||||
types: VideoType,
|
||||
translate: (t) => videoTypeToTranslationKey(t, false),
|
||||
});
|
||||
const Item = useMemo(
|
||||
() => (layout === "list" ? VideoItem : VideoTile),
|
||||
[layout],
|
||||
);
|
||||
const { data: artist } = useQuery(
|
||||
(artistId) => getArtist(artistId, ["illustration"]),
|
||||
artistId,
|
||||
);
|
||||
const { data: song } = useQuery(
|
||||
(songId) =>
|
||||
getSong(songId, ["illustration", "artist", "featuring", "master"]),
|
||||
songId,
|
||||
);
|
||||
const getQuery = useCallback(
|
||||
(random?: number) =>
|
||||
getVideos(
|
||||
{
|
||||
library: libraries,
|
||||
type: types,
|
||||
artist: artistId,
|
||||
group: song?.groupId,
|
||||
random,
|
||||
},
|
||||
{ sortBy: sort ?? "name", order: order ?? "asc" },
|
||||
["artist", "illustration", "master", "song"],
|
||||
),
|
||||
[libraries, types, artistId, song, sort, order],
|
||||
);
|
||||
const query = useMemo(() => getQuery(), [getQuery]);
|
||||
const getQueryForPlayer = useCallback(
|
||||
(random?: number) =>
|
||||
transformPage(
|
||||
getQuery(random),
|
||||
({ master, illustration, artist, id }) => ({
|
||||
artist,
|
||||
featuring: undefined,
|
||||
track: { ...master, illustration },
|
||||
id,
|
||||
}),
|
||||
),
|
||||
[getQuery],
|
||||
);
|
||||
const onItemPress = useCallback(
|
||||
(index: number, videos: Video[] | undefined) => {
|
||||
if (videos === undefined) {
|
||||
return;
|
||||
}
|
||||
const afterId = index > 0 ? videos[index - 1]?.id : undefined;
|
||||
playTracks(getQueryForPlayer(), queryClient, afterId);
|
||||
},
|
||||
[query],
|
||||
);
|
||||
|
||||
const playAction = useMemo(() => {
|
||||
return {
|
||||
label: "actions.playback.playAll",
|
||||
icon: PlayIcon,
|
||||
onPress: () => {
|
||||
playTracks(getQueryForPlayer(), queryClient);
|
||||
},
|
||||
} satisfies Action;
|
||||
}, [getQueryForPlayer, queryClient]);
|
||||
|
||||
const shuffleAction = useMemo(() => {
|
||||
return {
|
||||
label: "actions.playback.shuffle",
|
||||
icon: ShuffleIcon,
|
||||
onPress: () => {
|
||||
playTracks(getQueryForPlayer(getRandomNumber()), queryClient);
|
||||
},
|
||||
} satisfies Action;
|
||||
}, [getQueryForPlayer, queryClient]);
|
||||
return (
|
||||
<InfiniteView
|
||||
layout={layout}
|
||||
header={
|
||||
artistId ? (
|
||||
<ArtistHeader artist={artist} />
|
||||
) : songId ? (
|
||||
<SongHeader song={song} />
|
||||
) : undefined
|
||||
}
|
||||
controls={{
|
||||
layout: layoutControl,
|
||||
sort: sortControl,
|
||||
filters: [libraryFilterControl, albumTypeFilterControl],
|
||||
actions: [playAction, shuffleAction],
|
||||
}}
|
||||
query={query}
|
||||
render={(video, idx, videos) => (
|
||||
<Item
|
||||
video={video}
|
||||
onPress={() => video && onItemPress(idx, videos)}
|
||||
subtitle={artistId || songId ? "duration" : "artistName"}
|
||||
illustrationProps={{
|
||||
normalizedThumbnail: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
getAlbums,
|
||||
getArtists,
|
||||
getSearchHistory,
|
||||
getSongs,
|
||||
getVideos,
|
||||
searchAll,
|
||||
} from "@/api/queries";
|
||||
import {
|
||||
type InfiniteQuery,
|
||||
type Query,
|
||||
toInfiniteQuery,
|
||||
transformPage,
|
||||
} from "@/api/query";
|
||||
import type Illustration from "@/models/illustration";
|
||||
import type Resource from "@/models/resource";
|
||||
import type { SaveSearchItem, SearchResult } from "@/models/search";
|
||||
import { playTrackAtom } from "@/state/player";
|
||||
import { useAPI, useQueryClient } from "~/api";
|
||||
import { useSearchTypeFilterControl } from "~/components/infinite/controls/filters";
|
||||
import { InfiniteView } from "~/components/infinite/view";
|
||||
import { SearchResultItem } from "~/components/item/resource/search-result";
|
||||
import { TextInput } from "~/primitives/text_input";
|
||||
|
||||
type SearchFilter = ReturnType<typeof useSearchTypeFilterControl>[0];
|
||||
|
||||
export default function SearchView() {
|
||||
const timeoutRef = useRef<number>(null);
|
||||
const api = useAPI();
|
||||
const [filter, filterControl] = useSearchTypeFilterControl();
|
||||
const playTrack = useSetAtom(playTrackAtom);
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const debounceSearch = useCallback((searchValue: string) => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setSearchValue(searchValue);
|
||||
}, 500);
|
||||
}, []);
|
||||
const query = useSearchQuery(searchValue, filter);
|
||||
const saveSearch = useMutation({
|
||||
mutationFn: async (item: SearchResult) => {
|
||||
try {
|
||||
const dto: SaveSearchItem = item.artist
|
||||
? { artistId: item.artist.id }
|
||||
: item.album
|
||||
? { albumId: item.album.id }
|
||||
: item.song
|
||||
? { songId: item.song.id }
|
||||
: { videoId: item.video.id };
|
||||
await api.saveSearchHistoryEntry(dto);
|
||||
// Sometimes, it refreshes to fast, and shifts the history
|
||||
// before openning a page (for artists) is done
|
||||
setTimeout(() => {
|
||||
queryClient.client.invalidateQueries({
|
||||
queryKey: getSearchHistory().key,
|
||||
});
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
// biome-ignore lint/suspicious/noConsole: debug
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
const onSearchResultPress = useCallback(
|
||||
(item: SearchResult | undefined) => {
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
saveSearch.mutateAsync(item);
|
||||
if (item.song || item.video) {
|
||||
const track = item.song ?? item.video;
|
||||
playTrack({
|
||||
track: {
|
||||
...track.master,
|
||||
illustration: track.illustration,
|
||||
},
|
||||
featuring: item.song
|
||||
? item.song.featuring
|
||||
: // the video may be one of a song that has featuring
|
||||
undefined,
|
||||
artist: track.artist,
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<InfiniteView
|
||||
header={
|
||||
<View style={styles.searchHeader}>
|
||||
<TextInput
|
||||
inModal={false}
|
||||
autoCorrect={false}
|
||||
autoCapitalize={"none"}
|
||||
placeholder={t("nav.search")}
|
||||
onChangeText={debounceSearch}
|
||||
/>
|
||||
</View>
|
||||
}
|
||||
// NOTE: Since the original type of the query is SearchResult, it does not have an id,
|
||||
// that's why it complains
|
||||
query={
|
||||
query as unknown as InfiniteQuery<
|
||||
Resource,
|
||||
IllustratedSearchResultWithId
|
||||
>
|
||||
}
|
||||
controls={{
|
||||
filters: searchValue.trim() === "" ? [] : [filterControl],
|
||||
}}
|
||||
render={(item) => (
|
||||
<SearchResultItem
|
||||
searchResult={item}
|
||||
onPress={() => onSearchResultPress(item)}
|
||||
/>
|
||||
)}
|
||||
layout="list"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type IllustratedSearchResultWithId = {
|
||||
id: number;
|
||||
illustration: Illustration | null;
|
||||
} & SearchResult;
|
||||
|
||||
const useSearchQuery = (
|
||||
searchValue: string,
|
||||
filter: SearchFilter,
|
||||
): InfiniteQuery<any, SearchResult> =>
|
||||
useMemo(() => {
|
||||
let query: InfiniteQuery<any, SearchResult>;
|
||||
if (searchValue.trim() === "") {
|
||||
query = searchResultQueryToResourceQuery(getSearchHistory());
|
||||
} else {
|
||||
switch (filter) {
|
||||
case "artist":
|
||||
query = transformPage(
|
||||
getArtists({ query: searchValue }, undefined, [
|
||||
"illustration",
|
||||
]),
|
||||
(artist): SearchResult => ({ artist }),
|
||||
);
|
||||
break;
|
||||
case "album":
|
||||
query = transformPage(
|
||||
getAlbums({ query: searchValue }, undefined, [
|
||||
"artist",
|
||||
"illustration",
|
||||
]),
|
||||
(album): SearchResult => ({ album }),
|
||||
);
|
||||
break;
|
||||
case "song":
|
||||
query = transformPage(
|
||||
getSongs({ query: searchValue }, undefined, [
|
||||
"illustration",
|
||||
"featuring",
|
||||
"master",
|
||||
"artist",
|
||||
]),
|
||||
(song): SearchResult => ({ song }),
|
||||
);
|
||||
break;
|
||||
case "video":
|
||||
query = transformPage(
|
||||
getVideos({ query: searchValue }, undefined, [
|
||||
"illustration",
|
||||
"master",
|
||||
"artist",
|
||||
]),
|
||||
(video): SearchResult => ({ video }),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
query = searchResultQueryToResourceQuery(
|
||||
searchAll(searchValue),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return withStringId(query);
|
||||
}, [searchValue, filter]);
|
||||
|
||||
const withStringId = (q: InfiniteQuery<any, SearchResult>) =>
|
||||
transformPage(q, (item) => ({
|
||||
...item,
|
||||
id: item.song
|
||||
? `song-${item.song.id}`
|
||||
: item.album
|
||||
? `album-${item.album.id}`
|
||||
: item.video
|
||||
? `video-${item.video.id}`
|
||||
: `artist-${item.artist.id}`,
|
||||
}));
|
||||
|
||||
const searchResultQueryToResourceQuery = (query: Query<SearchResult[]>) => {
|
||||
return transformPage(toInfiniteQuery(query), (item) => {
|
||||
return {
|
||||
...item,
|
||||
illustration: (item.album ?? item.artist ?? item.song ?? item.video)
|
||||
?.illustration,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
searchHeader: {
|
||||
paddingTop: theme.gap(2),
|
||||
//To balance with the default top padding from root view style
|
||||
paddingBottom: theme.gap(3),
|
||||
alignItems: "center",
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,219 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useQuery as useTanStackQuery } from "@tanstack/react-query";
|
||||
import { Redirect } from "expo-router";
|
||||
import {
|
||||
TabList,
|
||||
TabSlot,
|
||||
Tabs,
|
||||
TabTrigger,
|
||||
type TabTriggerSlotProps,
|
||||
} from "expo-router/ui";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { type Ref, useCallback, useMemo } from "react";
|
||||
import {
|
||||
type GestureResponderEvent,
|
||||
type LayoutChangeEvent,
|
||||
View,
|
||||
} from "react-native";
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
} from "react-native-reanimated";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { getCurrentUserStatus } from "@/api/queries";
|
||||
import { toTanStackQuery } from "@/api/query";
|
||||
import { infiniteQueryAtom, playlistAtom } from "@/state/player";
|
||||
import {
|
||||
BrowseIcon,
|
||||
HomeIcon,
|
||||
type Icon as IconType,
|
||||
SearchIcon,
|
||||
SettingsIcon,
|
||||
} from "@/ui/icons";
|
||||
import { useAPI } from "~/api";
|
||||
import { BlurView } from "~/components/blur-view";
|
||||
import { PlayerContext } from "~/components/player/context";
|
||||
import { ExpandedPlayerSlot } from "~/components/player/expanded/slot";
|
||||
import { MinimisedPlayer } from "~/components/player/minimised";
|
||||
import { bottomTabBarHeightAtom } from "~/hooks/root-view-style";
|
||||
import { Icon } from "~/primitives/icon";
|
||||
import { Pressable } from "~/primitives/pressable";
|
||||
import { currentInstanceAtom, popCurrentInstanceAtom } from "~/state/user";
|
||||
|
||||
//TODO DRY: The header style for settings is very similar to the shared routed ones.
|
||||
//TODO I suspect that the setting header style is not updated when theme changes because we don't use withUnistyles
|
||||
//However, the latter does not work
|
||||
//TODO (Re)move header style
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
screen: { backgroundColor: "transparent", flex: 1 },
|
||||
headerTitle: {
|
||||
color: theme.colors.text.primary,
|
||||
...theme.fontStyles.regular,
|
||||
},
|
||||
headerBackgroundContainer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
borderBottomLeftRadius: theme.borderRadius,
|
||||
borderBottomRightRadius: theme.borderRadius,
|
||||
overflow: "hidden",
|
||||
},
|
||||
headerBackgroundContent: { flex: 1 },
|
||||
footer: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
width: "100%",
|
||||
},
|
||||
tabBar: {
|
||||
paddingTop: theme.gap(2),
|
||||
paddingBottom: theme.gap(4),
|
||||
flexDirection: "row",
|
||||
backgroundColor: "transparent",
|
||||
justifyContent: "space-around",
|
||||
},
|
||||
tabBarBackground: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
borderTopLeftRadius: theme.borderRadius,
|
||||
borderTopRightRadius: theme.borderRadius,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
hiddenTabList: { display: "none" },
|
||||
player: {
|
||||
width: "100%",
|
||||
paddingHorizontal: theme.gap(0.75),
|
||||
marginBottom: 0,
|
||||
paddingBottom: theme.gap(0.75),
|
||||
},
|
||||
}));
|
||||
|
||||
export default function ProtectedLayout() {
|
||||
const queue = useAtomValue(playlistAtom);
|
||||
|
||||
const infinitePlaylist = useAtomValue(infiniteQueryAtom);
|
||||
const currentInstance = useAtomValue(currentInstanceAtom);
|
||||
const popCurrentInstance = useSetAtom(popCurrentInstanceAtom);
|
||||
const setBottomTabBarHeight = useSetAtom(bottomTabBarHeightAtom);
|
||||
const api = useAPI();
|
||||
const user = useTanStackQuery({
|
||||
...toTanStackQuery(api, getCurrentUserStatus),
|
||||
enabled: !!currentInstance,
|
||||
});
|
||||
const onLayout = useCallback((e: LayoutChangeEvent) => {
|
||||
setBottomTabBarHeight(e.nativeEvent.layout.height);
|
||||
}, []);
|
||||
const queueIsEmpty = useMemo(() => queue.length === 0, [queue]);
|
||||
const queueIsInfinite = useMemo(
|
||||
() => infinitePlaylist !== null,
|
||||
[infinitePlaylist],
|
||||
);
|
||||
const showPlayer = !queueIsEmpty || queueIsInfinite;
|
||||
const playerPresence = useAnimatedStyle(() => {
|
||||
const style = {
|
||||
stiffness: 1110,
|
||||
damping: 510,
|
||||
};
|
||||
return { opacity: withSpring(showPlayer ? 1 : 0, style) };
|
||||
}, [showPlayer]);
|
||||
if (user.error || !currentInstance) {
|
||||
if (currentInstance) popCurrentInstance();
|
||||
return <Redirect href="/auth" />;
|
||||
}
|
||||
//TODO Proper handling of when user is loading
|
||||
return (
|
||||
<Tabs options={{ screenOptions: { freezeOnBlur: true } }}>
|
||||
<TabSlot style={styles.screen} />
|
||||
<View style={styles.footer} onLayout={onLayout}>
|
||||
<View style={styles.player}>
|
||||
{/* TODO blur behing player, like iOS */}
|
||||
{showPlayer && (
|
||||
<Animated.View style={playerPresence}>
|
||||
<MinimisedPlayer />
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
<ExpandedPlayerSlot />
|
||||
<PlayerContext />
|
||||
<View style={styles.tabBar}>
|
||||
<BlurView style={styles.tabBarBackground} />
|
||||
|
||||
<TabTrigger name="(home)" asChild>
|
||||
<TabButton icon={HomeIcon} />
|
||||
</TabTrigger>
|
||||
<TabTrigger name="(browse)" asChild>
|
||||
<TabButton icon={BrowseIcon} />
|
||||
</TabTrigger>
|
||||
<TabTrigger name="(search)" asChild>
|
||||
<TabButton icon={SearchIcon} />
|
||||
</TabTrigger>
|
||||
<TabTrigger name="settings" asChild>
|
||||
<TabButton icon={SettingsIcon} />
|
||||
</TabTrigger>
|
||||
</View>
|
||||
</View>
|
||||
{/* Shrug https://docs.expo.dev/router/advanced/custom-tabs/#multiple-tab-bars */}
|
||||
<TabList style={styles.hiddenTabList}>
|
||||
<TabTrigger name="(home)" href="/(protected)/(nav)/(home)" />
|
||||
<TabTrigger
|
||||
name="(browse)"
|
||||
href="/(protected)/(nav)/(browse)"
|
||||
/>
|
||||
<TabTrigger
|
||||
name="(search)"
|
||||
href="/(protected)/(nav)/(search)"
|
||||
/>
|
||||
<TabTrigger
|
||||
name="settings"
|
||||
href="/(protected)/(nav)/settings"
|
||||
/>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
export type TabButtonProps = TabTriggerSlotProps & {
|
||||
icon: IconType;
|
||||
ref?: Ref<View>;
|
||||
};
|
||||
|
||||
export function TabButton({ icon, isFocused, ...props }: TabButtonProps) {
|
||||
const onPress = useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
props.onPress?.(e);
|
||||
},
|
||||
[props.onPress],
|
||||
);
|
||||
|
||||
const onLongPress = useCallback(
|
||||
(e: GestureResponderEvent) => {
|
||||
props.onLongPress?.(e);
|
||||
},
|
||||
[props.onLongPress],
|
||||
);
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
disabled={false}
|
||||
disableRequestAnimationFrame
|
||||
>
|
||||
<Icon icon={icon} variant={isFocused ? "Bold" : undefined} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useNavigation } from "expo-router";
|
||||
import { openBrowserAsync } from "expo-web-browser";
|
||||
import i18next from "i18next";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { Fragment, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import {
|
||||
getCurrentUserStatus,
|
||||
getMatcherVersion,
|
||||
getScannerVersion,
|
||||
getSettings,
|
||||
} from "@/api/queries";
|
||||
import { emptyPlaylistAtom } from "@/state/player";
|
||||
import {
|
||||
CheckIcon,
|
||||
DeleteIcon,
|
||||
ExpandMoreIcon,
|
||||
MoreIcon,
|
||||
OpenExternalIcon,
|
||||
UncheckIcon,
|
||||
} from "@/ui/icons";
|
||||
import { getAPI_, useQuery, useQueryClient } from "~/api";
|
||||
import { SelectModalButton } from "~/components/bottom-modal-sheet/select";
|
||||
import { InstanceButton } from "~/components/instance-button";
|
||||
import { LoadableText } from "~/components/loadable_text";
|
||||
import { useLoginForm } from "~/components/login-form";
|
||||
import { SafeScrollView } from "~/components/safe-view";
|
||||
import { SectionHeader } from "~/components/section-header";
|
||||
import { useColorScheme } from "~/hooks/color-scheme";
|
||||
import { Button } from "~/primitives/button";
|
||||
import { Divider } from "~/primitives/divider";
|
||||
import { Icon } from "~/primitives/icon";
|
||||
import { Pressable } from "~/primitives/pressable";
|
||||
import { Text } from "~/primitives/text";
|
||||
import { showSuccessToast } from "~/primitives/toast";
|
||||
import { colorSchemePreference } from "~/state/color-scheme";
|
||||
import { languagePreference } from "~/state/lang";
|
||||
import {
|
||||
currentInstanceAtom,
|
||||
deleteOtherInstanceAtom,
|
||||
type MeeloInstance,
|
||||
otherInstancesAtom,
|
||||
popCurrentInstanceAtom,
|
||||
} from "~/state/user";
|
||||
import { Languages } from "../../../../../../translations";
|
||||
|
||||
const BuildCommit =
|
||||
process.env.EXPO_PUBLIC_BUILD_COMMIT ??
|
||||
(process.env.NODE_ENV === "development" ? "dev" : "unknown");
|
||||
|
||||
const useResetAllTabs = () => {
|
||||
const navigation = useNavigation();
|
||||
|
||||
return useCallback(() => {
|
||||
const st = navigation.getState();
|
||||
if (!st) {
|
||||
return;
|
||||
}
|
||||
navigation.reset({
|
||||
...st,
|
||||
history: [],
|
||||
routes: st.routes.map((r) => ({
|
||||
...r,
|
||||
state: undefined,
|
||||
})),
|
||||
} as any);
|
||||
}, [navigation]);
|
||||
};
|
||||
|
||||
export default function SettingsView() {
|
||||
const resetAllTabs = useResetAllTabs();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: user } = useQuery(getCurrentUserStatus);
|
||||
const emptyPlaylist = useSetAtom(emptyPlaylistAtom);
|
||||
const otherInstances = useAtomValue(otherInstancesAtom);
|
||||
const [currentInstance, setCurrentInstance] = useAtom(currentInstanceAtom);
|
||||
const { t } = useTranslation();
|
||||
const popCurrentInstance = useSetAtom(popCurrentInstanceAtom);
|
||||
const deleteOtherInstance = useSetAtom(deleteOtherInstanceAtom);
|
||||
const insets = useSafeAreaInsets();
|
||||
const [colorSchemePref, setColorSchemePref] = useAtom(
|
||||
colorSchemePreference,
|
||||
);
|
||||
const onLeavingInstance = useCallback(() => {
|
||||
emptyPlaylist();
|
||||
queryClient.client.clear();
|
||||
resetAllTabs();
|
||||
}, [emptyPlaylist, queryClient, resetAllTabs]);
|
||||
const { openLoginForm } = useLoginForm({
|
||||
onLogin: async (data) => {
|
||||
const api = getAPI_(data.token, data.instanceUrl);
|
||||
const res = await getCurrentUserStatus().exec(api)();
|
||||
onLeavingInstance();
|
||||
popCurrentInstance();
|
||||
setCurrentInstance({
|
||||
url: data.instanceUrl,
|
||||
accessToken: data.token,
|
||||
username: res.name,
|
||||
});
|
||||
showSuccessToast({ text: t("toasts.auth.serverSwitchSuccessful") });
|
||||
},
|
||||
});
|
||||
const [lng, setLng] = useAtom(languagePreference);
|
||||
const { data: apiSettings } = useQuery(getSettings);
|
||||
const { data: scannerVersion } = useQuery(getScannerVersion);
|
||||
const { data: matcherVersion } = useQuery(getMatcherVersion);
|
||||
const actualColorScheme = useColorScheme();
|
||||
const onOtherInstanceSelect = useCallback(
|
||||
(instance: MeeloInstance) => {
|
||||
onLeavingInstance();
|
||||
popCurrentInstance();
|
||||
deleteOtherInstance(instance);
|
||||
setCurrentInstance(instance);
|
||||
},
|
||||
[onLeavingInstance, popCurrentInstanceAtom, setCurrentInstance],
|
||||
);
|
||||
return (
|
||||
<SafeScrollView style={[styles.root, { paddingTop: insets.top }]}>
|
||||
<View style={styles.header}>
|
||||
<Text content={t("nav.settings")} variant="h2" />
|
||||
</View>
|
||||
<Divider h />
|
||||
<View style={styles.section}>
|
||||
<SectionHeader
|
||||
style={styles.sectionHeader}
|
||||
content={t("settings.interface")}
|
||||
skeletonWidth={0}
|
||||
/>
|
||||
<View style={styles.sectionRow}>
|
||||
<Text
|
||||
content={t("settings.ui.useSystemTheme")}
|
||||
variant="h5"
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
if (colorSchemePref === "system") {
|
||||
setColorSchemePref(actualColorScheme);
|
||||
} else {
|
||||
setColorSchemePref("system");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
variant={
|
||||
colorSchemePref === "system"
|
||||
? "Bold"
|
||||
: "Outline"
|
||||
}
|
||||
icon={
|
||||
colorSchemePref === "system"
|
||||
? CheckIcon
|
||||
: UncheckIcon
|
||||
}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.sectionRow}>
|
||||
<Text
|
||||
content={t("settings.ui.useDarkTheme")}
|
||||
variant="h5"
|
||||
/>
|
||||
<Pressable
|
||||
disabled={colorSchemePref === "system"}
|
||||
onPress={() => {
|
||||
setColorSchemePref(
|
||||
colorSchemePref === "dark" ? "light" : "dark",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
style={
|
||||
colorSchemePref === "system"
|
||||
? styles.disabledCheckButton
|
||||
: undefined
|
||||
}
|
||||
icon={
|
||||
actualColorScheme === "dark"
|
||||
? CheckIcon
|
||||
: UncheckIcon
|
||||
}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.sectionRow}>
|
||||
<Text content={t("settings.ui.language")} variant="h5" />
|
||||
<SelectModalButton
|
||||
header={t("settings.ui.language")}
|
||||
closeOnSelect
|
||||
values={Languages}
|
||||
isSelected={(item) => lng === item}
|
||||
buttonProps={{
|
||||
title: t(
|
||||
`settings.ui.lang.${i18next.language as "en"}`,
|
||||
),
|
||||
width: "fitContent",
|
||||
icon: ExpandMoreIcon,
|
||||
iconPosition: "right",
|
||||
size: "small",
|
||||
}}
|
||||
onSelect={(selected) => {
|
||||
setLng(selected);
|
||||
}}
|
||||
formatItem={(item) =>
|
||||
t(`settings.ui.lang.${item}`, {
|
||||
lng: item,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{user?.admin && (
|
||||
<>
|
||||
<Divider h />
|
||||
<View style={styles.section}>
|
||||
<SectionHeader
|
||||
style={styles.sectionHeader}
|
||||
content={t("settings.users.admin")}
|
||||
skeletonWidth={0}
|
||||
/>
|
||||
<Button
|
||||
title={t("settings.manageFromWebApp")}
|
||||
onPress={() =>
|
||||
openBrowserAsync(
|
||||
queryClient.api.urls.api.replace(
|
||||
"/api",
|
||||
"/settings",
|
||||
),
|
||||
)
|
||||
}
|
||||
width="fill"
|
||||
icon={OpenExternalIcon}
|
||||
iconPosition="right"
|
||||
containerStyle={{ justifyContent: "center" }}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
<Divider h />
|
||||
|
||||
{otherInstances.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<SectionHeader
|
||||
style={styles.sectionHeader}
|
||||
content={t("actions.switchServer")}
|
||||
skeletonWidth={0}
|
||||
/>
|
||||
|
||||
{currentInstance && (
|
||||
<>
|
||||
<View style={styles.instanceButtonContainer}>
|
||||
<InstanceButton
|
||||
enabled={false}
|
||||
instance={currentInstance}
|
||||
trailing="(Current)"
|
||||
/>
|
||||
</View>
|
||||
<Divider h withInsets />
|
||||
</>
|
||||
)}
|
||||
{otherInstances.map((instance, idx) => (
|
||||
<Fragment key={idx}>
|
||||
<View style={styles.instanceButtonContainer}>
|
||||
<InstanceButton
|
||||
enabled
|
||||
instance={instance}
|
||||
actions={[
|
||||
{
|
||||
icon: DeleteIcon,
|
||||
onPress: () =>
|
||||
deleteOtherInstance(instance),
|
||||
},
|
||||
{
|
||||
icon: MoreIcon,
|
||||
onPress: () =>
|
||||
onOtherInstanceSelect(instance),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<Divider h withInsets />
|
||||
</Fragment>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title={t("actions.connectToNewServer")}
|
||||
containerStyle={{ alignItems: "center" }}
|
||||
labelStyle={styles.logoutButtonStyle}
|
||||
onPress={openLoginForm}
|
||||
width="fill"
|
||||
/>
|
||||
<Button
|
||||
title={t("actions.logout")}
|
||||
width="fill"
|
||||
containerStyle={{ alignItems: "center" }}
|
||||
labelStyle={styles.logoutButtonStyle}
|
||||
onPress={() => {
|
||||
onLeavingInstance();
|
||||
popCurrentInstance();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<Divider h />
|
||||
<View style={[styles.footer, styles.section]}>
|
||||
<Text
|
||||
style={styles.versionNumber}
|
||||
content={`Build: ${BuildCommit}`}
|
||||
/>
|
||||
{(
|
||||
[
|
||||
["API", apiSettings],
|
||||
["Scanner", scannerVersion],
|
||||
["Matcher", matcherVersion],
|
||||
] as const
|
||||
).map(([serviceName, versionQuery]) => (
|
||||
<LoadableText
|
||||
key={serviceName}
|
||||
style={styles.versionNumber}
|
||||
content={
|
||||
apiSettings
|
||||
? `${serviceName} version: ${versionQuery ? (versionQuery.version ?? "Unknown") : "Loading"}`
|
||||
: undefined
|
||||
}
|
||||
skeletonWidth={15}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</SafeScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: { paddingHorizontal: theme.gap(1) },
|
||||
header: { paddingHorizontal: theme.gap(1), paddingVertical: theme.gap(2) },
|
||||
section: {
|
||||
paddingVertical: theme.gap(1),
|
||||
gap: theme.gap(1),
|
||||
},
|
||||
sectionHeader: { marginLeft: -theme.gap(1) },
|
||||
sectionRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
width: "100%",
|
||||
paddingBottom: theme.gap(0.5),
|
||||
paddingHorizontal: theme.gap(1),
|
||||
},
|
||||
disabledCheckButton: { color: theme.colors.text.secondary },
|
||||
logoutButtonStyle: { flex: 1 },
|
||||
footer: {
|
||||
color: theme.colors.text.secondary,
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
gap: theme.gap(2),
|
||||
},
|
||||
versionNumber: { color: theme.colors.text.secondary },
|
||||
instanceButtonContainer: { paddingHorizontal: theme.gap(1) },
|
||||
}));
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Stack } from "expo-router";
|
||||
|
||||
export default function ProtectedRoot() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
freezeOnBlur: true,
|
||||
animation: "none",
|
||||
headerShown: false,
|
||||
headerTransparent: true,
|
||||
contentStyle: { backgroundColor: "transparent", flex: 1 },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import * as Device from "expo-device";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { setStatusBarHidden } from "expo-status-bar";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { SafeAreaView } from "react-native-safe-area-context";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { VideoView } from "react-native-video";
|
||||
import { videoPlayerAtom } from "~/components/player/context";
|
||||
import { currentTrackAtom } from "~/components/player/state";
|
||||
import { Controls } from "~/pages/video-player/controls";
|
||||
|
||||
//TODO Try to avoid flicker from the video player
|
||||
|
||||
export default function FullscreenVideoPlayer() {
|
||||
const isMobile = Device.deviceType !== Device.DeviceType.TABLET;
|
||||
const router = useRouter();
|
||||
const [currentTrack] = useAtom(currentTrackAtom);
|
||||
const player = useAtomValue(videoPlayerAtom);
|
||||
const closePlayer = useCallback(() => {
|
||||
if (isMobile) {
|
||||
ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT,
|
||||
).then(() => router.back());
|
||||
} else router.back();
|
||||
}, [isMobile]);
|
||||
useEffect(() => {
|
||||
if (currentTrack?.track.type !== "Video") closePlayer();
|
||||
}, [currentTrack, closePlayer]);
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
// Allowing mobiles to use landscape mode
|
||||
ScreenOrientation.unlockAsync();
|
||||
}
|
||||
setStatusBarHidden(true);
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: "fullScreenModal",
|
||||
statusBarHidden: true,
|
||||
navigationBarHidden: true,
|
||||
animation: "none",
|
||||
}}
|
||||
/>
|
||||
<SafeAreaView style={styles.root}>
|
||||
<Controls style={styles.controls} close={closePlayer} />
|
||||
{player && (
|
||||
<VideoView
|
||||
player={player}
|
||||
keepScreenAwake
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create(() => ({
|
||||
root: { flex: 1, backgroundColor: "#000000" },
|
||||
controls: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 1,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
Rubik_300Light,
|
||||
Rubik_400Regular,
|
||||
Rubik_500Medium,
|
||||
Rubik_600SemiBold,
|
||||
Rubik_700Bold,
|
||||
Rubik_800ExtraBold,
|
||||
Rubik_900Black,
|
||||
} from "@expo-google-fonts/rubik";
|
||||
import { useFonts } from "expo-font";
|
||||
import { Stack } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import i18next from "i18next";
|
||||
import { Provider } from "jotai";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { store } from "@/state/store";
|
||||
import "intl-pluralrules";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as Device from "expo-device";
|
||||
import * as ScreenOrientation from "expo-screen-orientation";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import { DefaultQueryOptions } from "@/api/query";
|
||||
import { BackgroundGradient } from "~/components/background-gradient";
|
||||
import { Modal } from "~/components/bottom-modal-sheet";
|
||||
import { useColorScheme } from "~/hooks/color-scheme";
|
||||
import { ToastManager } from "~/primitives/toast";
|
||||
import { colorSchemePreference } from "~/state/color-scheme";
|
||||
import { languagePreference } from "~/state/lang";
|
||||
import { appThemes } from "~/theme";
|
||||
import resources from "../../../../translations";
|
||||
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
|
||||
SplashScreen.setOptions({
|
||||
duration: 1000,
|
||||
fade: true,
|
||||
});
|
||||
|
||||
export default function RootLayout() {
|
||||
const [loaded, error] = useFonts({
|
||||
Rubik_300Light,
|
||||
Rubik_400Regular,
|
||||
Rubik_500Medium,
|
||||
Rubik_600SemiBold,
|
||||
Rubik_700Bold,
|
||||
Rubik_800ExtraBold,
|
||||
Rubik_900Black,
|
||||
});
|
||||
const colorSchemePref = store.get(colorSchemePreference);
|
||||
const rnColorScheme = useColorScheme();
|
||||
const actualColorScheme = useMemo(() => {
|
||||
if (colorSchemePref === "system") {
|
||||
return rnColorScheme ?? "light";
|
||||
}
|
||||
return colorSchemePref;
|
||||
}, [rnColorScheme, colorSchemePref]);
|
||||
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: DefaultQueryOptions,
|
||||
},
|
||||
}),
|
||||
);
|
||||
useEffect(() => {
|
||||
i18next.use(initReactI18next).init({
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
returnEmptyString: false,
|
||||
fallbackLng: "en",
|
||||
lng: store.get(languagePreference),
|
||||
resources,
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (loaded || error) {
|
||||
const lock =
|
||||
Device.deviceType === Device.DeviceType.TABLET
|
||||
? ScreenOrientation.unlockAsync()
|
||||
: ScreenOrientation.lockAsync(
|
||||
ScreenOrientation.OrientationLock.PORTRAIT_UP,
|
||||
);
|
||||
lock.then(() => SplashScreen.hideAsync());
|
||||
}
|
||||
}, [loaded, error]);
|
||||
|
||||
if (!loaded) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<GestureHandlerRootView>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<ColorSchemeProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<KeyboardProvider>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
animation: "none",
|
||||
animationTypeForReplace: "pop",
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
flex: 1,
|
||||
},
|
||||
statusBarStyle:
|
||||
actualColorScheme === "light"
|
||||
? "dark"
|
||||
: "light",
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="(protected)"
|
||||
options={{
|
||||
contentStyle: {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack.Screen
|
||||
name="auth"
|
||||
options={{
|
||||
contentStyle: {
|
||||
backgroundColor:
|
||||
(actualColorScheme ===
|
||||
"dark"
|
||||
? appThemes.dark
|
||||
: appThemes.light
|
||||
).colors.background,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Modal />
|
||||
</KeyboardProvider>
|
||||
</BottomSheetModalProvider>
|
||||
<BackgroundGradient />
|
||||
</ColorSchemeProvider>
|
||||
</Provider>
|
||||
<ToastManager />
|
||||
</QueryClientProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
const ColorSchemeProvider = ({ children }: any) => {
|
||||
const colorScheme = useColorScheme();
|
||||
useEffect(() => {
|
||||
UnistylesRuntime.setTheme(colorScheme);
|
||||
}, [colorScheme]);
|
||||
return children;
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Meelo is a music server and application to enjoy your personal music files anywhere, anytime you want.
|
||||
* Copyright (C) 2023
|
||||
*
|
||||
* Meelo is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Meelo is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useRouter } from "expo-router";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { Fragment, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { getCurrentUserStatus } from "@/api/queries";
|
||||
import { AddIcon, MoreIcon } from "@/ui/icons";
|
||||
import { getAPI_ } from "~/api";
|
||||
import { InstanceButton } from "~/components/instance-button";
|
||||
import { useLoginForm } from "~/components/login-form";
|
||||
import { Banner } from "~/components/meelo";
|
||||
import { Button } from "~/primitives/button";
|
||||
import { Divider } from "~/primitives/divider";
|
||||
import { showErrorToast } from "~/primitives/toast";
|
||||
import {
|
||||
currentInstanceAtom,
|
||||
deleteOtherInstanceAtom,
|
||||
type MeeloInstance,
|
||||
otherInstancesAtom,
|
||||
} from "~/state/user";
|
||||
|
||||
const styles = StyleSheet.create((theme, rt) => ({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
marginBottom: theme.gap(10),
|
||||
justifyContent: "space-evenly",
|
||||
flex: 1,
|
||||
},
|
||||
banner: {
|
||||
height: 300,
|
||||
maxHeight: rt.screen.height / 4,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
formContainer: {
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
flexDirection: "column",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
gap: theme.gap(3),
|
||||
},
|
||||
instanceButtons: {
|
||||
flexDirection: "column",
|
||||
gap: theme.gap(1),
|
||||
width: "100%",
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: theme.gap(3),
|
||||
},
|
||||
}));
|
||||
|
||||
export default function AuthenticationScreen() {
|
||||
const safeAreaStyle = useSafeAreaInsets();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const otherInstances = useAtomValue(otherInstancesAtom);
|
||||
const setCurrentInstance = useSetAtom(currentInstanceAtom);
|
||||
const deleteOtherInstance = useSetAtom(deleteOtherInstanceAtom);
|
||||
const { openLoginForm } = useLoginForm({
|
||||
onLogin: async ({ instanceUrl, token }) => {
|
||||
const api = getAPI_(token, instanceUrl);
|
||||
const res = await getCurrentUserStatus().exec(api)();
|
||||
setCurrentInstance({
|
||||
url: instanceUrl,
|
||||
accessToken: token,
|
||||
username: res.name,
|
||||
});
|
||||
router.replace("/");
|
||||
},
|
||||
});
|
||||
const signInToOtherInstance = useCallback(
|
||||
(instance: MeeloInstance) => {
|
||||
const api = getAPI_(instance.accessToken, instance.url);
|
||||
|
||||
getCurrentUserStatus()
|
||||
.exec(api)()
|
||||
.then(() => {
|
||||
setCurrentInstance(instance);
|
||||
deleteOtherInstance(instance);
|
||||
router.replace("/");
|
||||
})
|
||||
.catch((e) => {
|
||||
showErrorToast({
|
||||
text: `${t("toasts.auth.signinFailed")}: ${e.message ?? e.error ?? JSON.stringify(e)}`,
|
||||
});
|
||||
});
|
||||
},
|
||||
[setCurrentInstance, router],
|
||||
);
|
||||
const signInAction = useCallback(
|
||||
(instance: MeeloInstance) => {
|
||||
return {
|
||||
onPress: () => signInToOtherInstance(instance),
|
||||
icon: MoreIcon,
|
||||
};
|
||||
},
|
||||
[signInToOtherInstance],
|
||||
);
|
||||
return (
|
||||
<View style={[styles.root, safeAreaStyle]}>
|
||||
<Banner style={styles.banner} />
|
||||
{otherInstances.length ? (
|
||||
<View style={styles.instanceButtons}>
|
||||
{otherInstances.map((instance, idx) => (
|
||||
<Fragment key={idx}>
|
||||
<InstanceButton
|
||||
key={idx}
|
||||
instance={instance}
|
||||
enabled
|
||||
actions={[signInAction(instance)]}
|
||||
/>
|
||||
<Divider h />
|
||||
</Fragment>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
<View style={styles.formContainer}>
|
||||
<Button
|
||||
title={t("actions.connectToNewServer")}
|
||||
icon={AddIcon}
|
||||
onPress={openLoginForm}
|
||||
/>
|
||||
</View>
|
||||
{!otherInstances.length ? <View /> : null}
|
||||
{/* to have a 1/3 page-height footer */}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
Canvas,
|
||||
RadialGradient,
|
||||
Rect,
|
||||
type RectProps,
|
||||
vec,
|
||||
} from "@shopify/react-native-skia";
|
||||
import { useFocusEffect } from "expo-router";
|
||||
import { atom, useAtom, useAtomValue } from "jotai";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import {
|
||||
useDerivedValue,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { InfiniteQuery } from "@/api/query";
|
||||
import type Illustration from "@/models/illustration";
|
||||
import type { IllustratedResource } from "@/models/illustration";
|
||||
import type Resource from "@/models/resource";
|
||||
import { generateArray } from "@/utils/gen-list";
|
||||
import { useInfiniteQuery } from "~/api";
|
||||
import { currentInstanceAtom } from "~/state/user";
|
||||
|
||||
export const keyIllustrationAtom = atom<Illustration | null>(null);
|
||||
|
||||
export const useSetKeyIllustration = (keyItem?: IllustratedResource) => {
|
||||
const [keyIllustration, setKeyIllustration] = useAtom(keyIllustrationAtom);
|
||||
useFocusEffect(() => {
|
||||
if (
|
||||
keyItem?.illustration &&
|
||||
keyItem.illustration.url !== keyIllustration?.url
|
||||
)
|
||||
setKeyIllustration(keyItem.illustration);
|
||||
});
|
||||
};
|
||||
|
||||
export const useSetKeyIllustrationFromInfiniteQuery = <
|
||||
T extends Resource,
|
||||
T1 extends IllustratedResource,
|
||||
>(
|
||||
query: InfiniteQuery<T, T1>,
|
||||
) => {
|
||||
const { data } = useInfiniteQuery(() => query);
|
||||
const [keyIllustration, setKeyIllustration] = useAtom(keyIllustrationAtom);
|
||||
useFocusEffect(() => {
|
||||
const firstItem = data?.pages
|
||||
.at(0)
|
||||
?.items.find((item) => item.illustration !== null);
|
||||
if (
|
||||
firstItem?.illustration &&
|
||||
firstItem.illustration.url !== keyIllustration?.url
|
||||
)
|
||||
setKeyIllustration(firstItem.illustration);
|
||||
});
|
||||
};
|
||||
|
||||
const transitionDuration = 300;
|
||||
|
||||
export const BackgroundGradient = () => {
|
||||
const currentInstance = useAtomValue(currentInstanceAtom);
|
||||
const keyIllustration = useAtomValue(keyIllustrationAtom);
|
||||
const currentColor1 = useSharedValue("transparent");
|
||||
const currentColor2 = useSharedValue("transparent");
|
||||
const currentColor3 = useSharedValue("transparent");
|
||||
const currentColor4 = useSharedValue("transparent");
|
||||
const currentColor5 = useSharedValue("transparent");
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentInstance) {
|
||||
currentColor1.value = "transparent";
|
||||
currentColor2.value = "transparent";
|
||||
currentColor3.value = "transparent";
|
||||
currentColor4.value = "transparent";
|
||||
currentColor5.value = "transparent";
|
||||
}
|
||||
}, [currentInstance]);
|
||||
useEffect(() => {
|
||||
if (keyIllustration) {
|
||||
currentColor1.value = withTiming(keyIllustration.colors[0], {
|
||||
duration: transitionDuration,
|
||||
});
|
||||
currentColor2.value = withTiming(keyIllustration.colors[1], {
|
||||
duration: transitionDuration,
|
||||
});
|
||||
currentColor3.value = withTiming(keyIllustration.colors[2], {
|
||||
duration: transitionDuration,
|
||||
});
|
||||
currentColor4.value = withTiming(keyIllustration.colors[3], {
|
||||
duration: transitionDuration,
|
||||
});
|
||||
currentColor5.value = withTiming(keyIllustration.colors[4], {
|
||||
duration: transitionDuration,
|
||||
});
|
||||
}
|
||||
}, [keyIllustration]);
|
||||
const gradientColor1 = useDerivedValue(() => {
|
||||
return [currentColor1.value, "transparent"];
|
||||
}, []);
|
||||
const gradientColor2 = useDerivedValue(() => {
|
||||
return [currentColor2.value, "transparent"];
|
||||
}, []);
|
||||
const gradientColor3 = useDerivedValue(() => {
|
||||
return [currentColor3.value, "transparent"];
|
||||
}, []);
|
||||
const gradientColor4 = useDerivedValue(() => {
|
||||
return [currentColor4.value, "transparent"];
|
||||
}, []);
|
||||
const gradientColor5 = useDerivedValue(() => {
|
||||
return [currentColor5.value, "transparent"];
|
||||
}, []);
|
||||
const gradientColors = useMemo(
|
||||
() => [
|
||||
gradientColor1,
|
||||
gradientColor2,
|
||||
gradientColor3,
|
||||
gradientColor4,
|
||||
gradientColor5,
|
||||
],
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<StyledCanvas style={styles.canvas}>
|
||||
{generateArray(5, null).map((_, index) => {
|
||||
const cxRatio =
|
||||
index === 0 || index === 3
|
||||
? 0.1
|
||||
: index === 4
|
||||
? 0
|
||||
: 0.9;
|
||||
const cyRatio = index < 2 ? 0.9 : index < 4 ? 0.1 : 1;
|
||||
|
||||
return (
|
||||
<FullScreenRect key={index}>
|
||||
<GradientEllipse
|
||||
uniProps={(_, rt) => ({
|
||||
c: vec(
|
||||
rt.screen.width * cxRatio,
|
||||
rt.screen.height * cyRatio,
|
||||
),
|
||||
r: Math.max(
|
||||
rt.screen.width,
|
||||
rt.screen.height,
|
||||
),
|
||||
})}
|
||||
colors={gradientColors[index]}
|
||||
/>
|
||||
</FullScreenRect>
|
||||
);
|
||||
})}
|
||||
</StyledCanvas>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const GradientEllipse = withUnistyles(RadialGradient);
|
||||
|
||||
const FullScreenRect = withUnistyles(
|
||||
Rect,
|
||||
(_theme, rt) =>
|
||||
({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: rt.screen.width,
|
||||
height: rt.screen.height,
|
||||
}) as RectProps,
|
||||
);
|
||||
|
||||
const StyledCanvas = withUnistyles(Canvas);
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
zIndex: -1,
|
||||
right: 0,
|
||||
top: 0,
|
||||
height: "100%",
|
||||
backgroundColor: theme.colors.background,
|
||||
},
|
||||
canvas: {
|
||||
flex: 1,
|
||||
opacity: theme.name === "light" ? 0.5 : 0.4,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,12 @@
|
||||
import { BlurView as BV } from "expo-blur";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
|
||||
export const BlurView = withUnistyles(
|
||||
BV,
|
||||
(theme) =>
|
||||
({
|
||||
tint: theme.name === "light" ? "light" : "dark",
|
||||
experimentalBlurMethod: "dimezisBlurView",
|
||||
intensity: 80,
|
||||
}) as const,
|
||||
);
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
type BottomSheetBackdropProps,
|
||||
BottomSheetModal,
|
||||
BottomSheetView,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import { atom, useAtom, useSetAtom } from "jotai";
|
||||
import { type ReactNode, useCallback, useEffect, useRef } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { BlurView } from "~/components/blur-view";
|
||||
|
||||
type ModalAtom = { content: () => ReactNode; onDismiss?: () => void };
|
||||
|
||||
const modalAtom = atom<ModalAtom | null>(null);
|
||||
|
||||
export const closeModalAtom = atom(null, (_, set) => set(modalAtom, null));
|
||||
|
||||
export const useModal = (p: ModalAtom) => {
|
||||
const setModalContent = useSetAtom(modalAtom);
|
||||
const openModal = useCallback(() => setModalContent(p), [p]);
|
||||
return { openModal };
|
||||
};
|
||||
|
||||
export const Modal = () => {
|
||||
const [modalContent, setModalContent] = useAtom(modalAtom);
|
||||
const onClose = useCallback(() => {
|
||||
modalContent?.onDismiss?.();
|
||||
setModalContent(null);
|
||||
}, [modalContent]);
|
||||
const Content = modalContent?.content ?? View;
|
||||
const modalRef = useRef<BottomSheetModal>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (modalContent !== null) {
|
||||
modalRef.current?.present();
|
||||
} else {
|
||||
modalRef.current?.close();
|
||||
onClose();
|
||||
}
|
||||
}, [modalContent, onClose]);
|
||||
return (
|
||||
<BottomSheetModal
|
||||
ref={modalRef}
|
||||
enableDynamicSizing
|
||||
// Note: When a ctx menu is open from the expanded player, we don't want to 'switch'
|
||||
stackBehavior="push"
|
||||
handleIndicatorStyle={styles.handleIndicator}
|
||||
onDismiss={onClose}
|
||||
backgroundComponent={() => (
|
||||
<View style={styles.modalBackground}>
|
||||
<BlurView style={{ flex: 1 }} />
|
||||
</View>
|
||||
)}
|
||||
keyboardBehavior="interactive"
|
||||
keyboardBlurBehavior="restore"
|
||||
backdropComponent={ModalBackdrop}
|
||||
>
|
||||
<BottomSheetView style={styles.modal}>
|
||||
<Content />
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
);
|
||||
};
|
||||
|
||||
// The expanded player uses this as well
|
||||
export const ModalBackdrop = (props: BottomSheetBackdropProps) => {
|
||||
return (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
opacity={0.4}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
pressBehavior={"close"}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
modalBackground: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
borderTopLeftRadius: theme.borderRadius * 2,
|
||||
borderTopRightRadius: theme.borderRadius * 2,
|
||||
overflow: "hidden",
|
||||
},
|
||||
handleIndicator: {
|
||||
marginTop: theme.gap(1),
|
||||
backgroundColor: theme.colors.text.primary,
|
||||
},
|
||||
modal: {
|
||||
paddingHorizontal: theme.gap(2),
|
||||
paddingBottom: theme.gap(2),
|
||||
paddingTop: theme.gap(1),
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
}));
|
||||