Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
e7e89a4baf | ||
|
|
315807aca2 | ||
|
|
7817a871bf | ||
|
|
fb22c1cee7 | ||
|
|
961a9f0036 | ||
|
|
81f4f3aecb | ||
|
|
f2eabb94c8 | ||
|
|
1ba71dbf15 | ||
|
|
a29cf5f7da | ||
|
|
95bf08c3fb | ||
|
|
cde9df4d81 | ||
|
|
30d3f1600f | ||
|
|
c2d28d1dc8 | ||
|
|
8387805f86 | ||
|
|
c555b0063f | ||
|
|
c741a6122a | ||
|
|
c1c474e2e8 | ||
|
|
9da35dc7ff | ||
|
|
640653bed3 | ||
|
|
93662b802b | ||
|
|
549eff117b | ||
|
|
12dea95ccb | ||
|
|
945188928e | ||
|
|
82b169718f | ||
|
|
becfd85476 | ||
|
|
05cb71f3e3 | ||
|
|
55a929dee8 | ||
|
|
e7d7d20997 | ||
|
|
4d09f9e519 | ||
|
|
103725bc15 | ||
|
|
340a17165c | ||
|
|
f58c3b1081 | ||
|
|
a0623fdeff | ||
|
|
ba16ff1c14 | ||
|
|
788a86331d | ||
|
|
568e8996d4 | ||
|
|
4f232ac245 | ||
|
|
8ce73e9920 | ||
|
|
a45d42bb95 | ||
|
|
1258de0571 | ||
|
|
6f0a651b53 | ||
|
|
8c0f341632 | ||
|
|
b122a48cba | ||
|
|
a7eb3514fc | ||
|
|
54514bb178 | ||
|
|
693f617957 | ||
|
|
a3fa306ac1 | ||
|
|
a7162352ec | ||
|
|
51de0d1a03 | ||
|
|
43f5d9f973 | ||
|
|
a1e1060ae3 | ||
|
|
3063fad0d2 | ||
|
|
4ad434a71c | ||
|
|
8be291d9d2 | ||
|
|
9b07d2ecbc | ||
|
|
6264d094c9 | ||
|
|
280cf19742 | ||
|
|
4625a738ef | ||
|
|
698913bbaf | ||
|
|
d072fbf7af | ||
|
|
3517e2650b | ||
|
|
b118fd20a6 | ||
|
|
0a42fd6460 | ||
|
|
f5b80e1873 | ||
|
|
016c33143d | ||
|
|
cdb29d8cab | ||
|
|
5f63a5122d | ||
|
|
b6283838ce | ||
|
|
7c3dd7738c | ||
|
|
aed577a855 | ||
|
|
928445af44 | ||
|
|
526c546f58 | ||
|
|
64050bbdb9 | ||
|
|
95e55b9307 | ||
|
|
dc9d141072 | ||
|
|
e4bc45aa04 | ||
|
|
41285090f7 | ||
|
|
d091cb550b | ||
|
|
e3ce0a843b | ||
|
|
7ba90a6e0d | ||
|
|
ce298f5340 | ||
|
|
21b3df3b00 | ||
|
|
e92e0cf394 | ||
|
|
9d12a9ae2c | ||
|
|
2596726088 | ||
|
|
79d5aaf5dd | ||
|
|
c757027286 | ||
|
|
85ce3fbbf4 | ||
|
|
7c65d8f113 | ||
|
|
a2a283e146 | ||
|
|
24c5c3aead | ||
|
|
3dddcf4c36 | ||
|
|
ec64e16553 | ||
|
|
ff31958623 | ||
|
|
e31c7f49b6 | ||
|
|
16cc16830c | ||
|
|
fbbc2fbae5 | ||
|
|
3d6f60e850 | ||
|
|
d788a5f704 | ||
|
|
affd84272c | ||
|
|
634b0f2de4 | ||
|
|
32a52ec102 | ||
|
|
118484b766 | ||
|
|
50647a3e86 | ||
|
|
655728b657 | ||
|
|
79aee767cf | ||
|
|
2cd96e10dc | ||
|
|
f05b51300c | ||
|
|
2fa3946af7 | ||
|
|
b00badc0ca | ||
|
|
95dc125712 | ||
|
|
7fe4de658e | ||
|
|
b254796fc7 | ||
|
|
556df14a87 | ||
|
|
de2cfc18f7 | ||
|
|
68229e5e0c | ||
|
|
f02d3e3b6a | ||
|
|
e9b9577673 | ||
|
|
28f220c4cb | ||
|
|
3f200dcf3a | ||
|
|
db9150af9f | ||
|
|
5aaaa63a63 | ||
|
|
0f50129c21 | ||
|
|
a913482e18 | ||
|
|
c86b8f0fb7 | ||
|
|
f95127a105 | ||
|
|
6f485b3099 | ||
|
|
a07b78e697 | ||
|
|
d11dc7308b | ||
|
|
42ecd2d930 | ||
|
|
225babc1d0 | ||
|
|
be1503633a | ||
|
|
fc3fdcf19c | ||
|
|
390a7fe2f4 | ||
|
|
dc4a5ce0da | ||
|
|
d915a4dfdb | ||
|
|
7cbdca9439 | ||
|
|
d7b6feec2b | ||
|
|
0c05dfee29 | ||
|
|
026eb05cd3 | ||
|
|
02d5bc95e8 | ||
|
|
ce4153a954 | ||
|
|
3aa66792c9 | ||
|
|
24222e3623 | ||
|
|
a908d6756c | ||
|
|
3479713970 | ||
|
|
40b2a3b010 | ||
|
|
1ef9026776 | ||
|
|
19549ba923 | ||
|
|
878579a0b8 | ||
|
|
d0603e0141 | ||
|
|
5089aa0c3f | ||
|
|
ab29710e99 | ||
|
|
0a05e20497 | ||
|
|
4bc542e360 | ||
|
|
b65590c1eb | ||
|
|
3f176700bb | ||
|
|
dc9cbbd442 | ||
|
|
5dc0ed1623 | ||
|
|
a9ab63591a | ||
|
|
135bb7d40d | ||
|
|
72559e7a8f | ||
|
|
b042bdf9cb | ||
|
|
0226ca166c | ||
|
|
77abd9f3e6 | ||
|
|
26772e2b76 | ||
|
|
321f2b3d88 | ||
|
|
37c34f6e3e | ||
|
|
4bbe1a4514 | ||
|
|
d521415458 | ||
|
|
fcc2407d26 | ||
|
|
76bc2410ec | ||
|
|
2c91936682 | ||
|
|
c411bc7b37 | ||
|
|
1cb6820c2b | ||
|
|
3a26a696dc | ||
|
|
a3e6bdefff | ||
|
|
09777dcfc1 | ||
|
|
b10a1b0cba | ||
|
|
5fb615013d | ||
|
|
7607f09396 | ||
|
|
99805cf9d6 | ||
|
|
faa837fff7 | ||
|
|
fb0756aa23 | ||
|
|
1d8f4963d4 | ||
|
|
98e1fdff2e | ||
|
|
6e36c4988b | ||
|
|
6c335874e1 | ||
|
|
34ca446bc9 | ||
|
|
b415eba73a | ||
|
|
8fe7db2ea6 | ||
|
|
751997d6cb | ||
|
|
cb1013fde1 | ||
|
|
33eb777d4f | ||
|
|
9c0c039a0f | ||
|
|
ebf9c57318 | ||
|
|
e3e5abb1b4 | ||
|
|
c3bd9b44fd | ||
|
|
21bb5586b7 | ||
|
|
06e24586db | ||
|
|
84f6868390 | ||
|
|
b44fba44a3 | ||
|
|
b1a585cec4 | ||
|
|
16a1ad0810 | ||
|
|
160513e82b | ||
|
|
6f6e03bda5 | ||
|
|
19ef1cc6b8 | ||
|
|
a609f1058e | ||
|
|
b0e6a361bb | ||
|
|
9f05114be2 | ||
|
|
bbbe62f880 | ||
|
|
023c74d1c2 | ||
|
|
07da1f5428 | ||
|
|
e3255cb428 | ||
|
|
c81ed9dd2e | ||
|
|
45afd3b36b | ||
|
|
2b5875b146 | ||
|
|
3176bc5c36 | ||
|
|
87efc74920 | ||
|
|
e5fc7a8428 | ||
|
|
aced630093 | ||
|
|
bf28a74155 | ||
|
|
dbf75b8d20 | ||
|
|
a2e351c670 | ||
|
|
bb147d567f | ||
|
|
4e82669f4b | ||
|
|
31a4ae5b9a | ||
|
|
606ad93395 | ||
|
|
b078ba20a4 | ||
|
|
26dd43c2ee | ||
|
|
87d82b3928 | ||
|
|
93771c4551 | ||
|
|
a5d151470e | ||
|
|
7d65700a70 | ||
|
|
5f58e26314 | ||
|
|
4968ea0a40 | ||
|
|
3560edb27b | ||
|
|
67c7e2b80b | ||
|
|
1dc7819162 | ||
|
|
169383519b | ||
|
|
e2a9ea96fd | ||
|
|
20d7f69ef2 | ||
|
|
f68dc3a394 | ||
|
|
502f01f8b2 | ||
|
|
4d47ba66a2 | ||
|
|
25d5e194dc | ||
|
|
4bdbf6340b | ||
|
|
5e5cfd8722 | ||
|
|
2e1337d5d7 | ||
|
|
3ad0cc92b6 | ||
|
|
db52907042 | ||
|
|
7c41127075 | ||
|
|
777e68c869 | ||
|
|
62087ae13b | ||
|
|
e86115740e | ||
|
|
c4b79f37f7 | ||
|
|
18e0b0d00c | ||
|
|
52e3130fea | ||
|
|
59d49e1617 | ||
|
|
85ee9baf51 | ||
|
|
5042cca9f0 | ||
|
|
866041bfb6 | ||
|
|
0723e0e9a1 | ||
|
|
d564252623 | ||
|
|
2124d23cb9 | ||
|
|
7faa482135 | ||
|
|
992d022ba2 | ||
|
|
f22e397de3 | ||
|
|
6d785d4459 | ||
|
|
e96c86b85b | ||
|
|
a530fd04d6 | ||
|
|
220959124a | ||
|
|
1b842094f7 | ||
|
|
96c7e6a99c | ||
|
|
00ca276859 | ||
|
|
0c765703e0 | ||
|
|
7f65834614 | ||
|
|
88a79348c5 | ||
|
|
90882b8e60 | ||
|
|
4ae9de61d7 | ||
|
|
5f7ae2eb30 | ||
|
|
8bcc0fb29d | ||
|
|
cd8a1185f3 | ||
|
|
e4cd30a3e4 | ||
|
|
11c7be61cd | ||
|
|
17089b20c7 | ||
|
|
76dffbd280 | ||
|
|
005006b02a | ||
|
|
de5bca03d9 | ||
|
|
c0a2527284 | ||
|
|
4787a204ab | ||
|
|
9cf58eb3f9 | ||
|
|
6886c9d757 | ||
|
|
4ab088fd34 | ||
|
|
fed101e8ae | ||
|
|
ea5b8d440e | ||
|
|
b3f7f2206f | ||
|
|
4d58545b3b | ||
|
|
4f3d542159 | ||
|
|
e5b9c2e3b2 | ||
|
|
f3e56f32c0 | ||
|
|
2aa6eb8499 | ||
|
|
ed03dca79e | ||
|
|
2e438f12f4 | ||
|
|
28fdc0154d | ||
|
|
60eb19e29f | ||
|
|
f69ba8a7f1 | ||
|
|
0fac03a214 | ||
|
|
dca04b202b | ||
|
|
155cb9e039 | ||
|
|
858b9e0464 | ||
|
|
b04cbfad92 | ||
|
|
762b31108e | ||
|
|
bb430aea37 | ||
|
|
2a541cb643 | ||
|
|
ad3fbd7388 | ||
|
|
ee40992008 | ||
|
|
1beeeb1836 | ||
|
|
e97e811998 | ||
|
|
ffe576d601 | ||
|
|
30def99958 | ||
|
|
76c1ecd71c | ||
|
|
16d62a7e19 | ||
|
|
522330d14d | ||
|
|
7c9d78e875 | ||
|
|
aae10672dd | ||
|
|
3ebac555fa | ||
|
|
75788e478b | ||
|
|
73ba1ca398 | ||
|
|
527ca4df5d | ||
|
|
27b2b9d813 | ||
|
|
ae44cd5574 | ||
|
|
31831e25ce | ||
|
|
ba9fc80f82 | ||
|
|
f45d4df3fe | ||
|
|
5ff9ad6953 | ||
|
|
6eb0055ca5 | ||
|
|
aa009b4352 | ||
|
|
05b69097e9 | ||
|
|
ab204507e7 | ||
|
|
879f3a8805 | ||
|
|
571cde7010 | ||
|
|
6c643b6f95 | ||
|
|
5d686259c7 | ||
|
|
f0ced36c54 | ||
|
|
65341ebc2f | ||
|
|
b873869559 | ||
|
|
967328f494 | ||
|
|
88e8359d2c | ||
|
|
4f76203dcc | ||
|
|
b2002336e1 | ||
|
|
b39dc06298 | ||
|
|
9db513b786 | ||
|
|
4091703afe | ||
|
|
aa17aba53c | ||
|
|
7e7aacfead | ||
|
|
e2f3b6c07d | ||
|
|
0b93b767f2 | ||
|
|
d4286349e9 |
No files matched your search
+31
-4
@@ -1,3 +1,10 @@
|
||||
#################### 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'
|
||||
# Please read the changelog before changing this value
|
||||
# https://github.com/Arthi-chaud/Meelo/releases
|
||||
TAG=
|
||||
#################### Database
|
||||
# The port on the host where meelo will be accessible
|
||||
PORT=
|
||||
@@ -7,6 +14,10 @@ POSTGRES_USER=
|
||||
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=
|
||||
#################### Config
|
||||
# The directory that contains the `settings.json` file (and where the illustrations will be stored) (on the host machine)
|
||||
CONFIG_DIR=
|
||||
@@ -16,17 +27,33 @@ DATA_DIR=
|
||||
# Set to 1 if you want to allow anonymous request
|
||||
# This will not affect front-end behaviour
|
||||
ALLOW_ANONYMOUS=
|
||||
# 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
|
||||
#################### Web app
|
||||
# URL of the server/api that would be accessible to the front app's server or any client
|
||||
# If PORT=5000, it should look like 'http://0.0.0.0:5000/api' (mind the '/api')
|
||||
PUBLIC_SERVER_URL=
|
||||
# Public URL to access Meelo, without trailing slash
|
||||
# Example: http://localhost:5000
|
||||
PUBLIC_URL=
|
||||
#################### Security
|
||||
# Random String used to sign JWT Tokens
|
||||
JWT_SIGNATURE=
|
||||
# Key used to authenticate the Meilisearch Instance
|
||||
# Should be a random string, must be at least 16 bytes
|
||||
MEILI_MASTER_KEY=
|
||||
# A coma-separated list of random strings used for internal authentication
|
||||
# Example: API_KEYS=azertyuip,qwertyuiop
|
||||
API_KEYS=
|
||||
#################### Scrobblers
|
||||
# All values here are optional
|
||||
# Fill in the values according to what scrobblers you want to enable
|
||||
## LastFM:
|
||||
## Values obtained when creating an API account
|
||||
## https://www.last.fm/api/account/create
|
||||
LASTFM_API_KEY=
|
||||
LASTFM_API_SECRET=
|
||||
## ListenBrainz:
|
||||
### No configuration needed
|
||||
#################### Internal
|
||||
# Do not change this
|
||||
INTERNAL_DATA_DIR=/data
|
||||
INTERNAL_CONFIG_DIR=/config
|
||||
INTERNAL_CONFIG_DIR=/config
|
||||
@@ -11,6 +11,6 @@ runs:
|
||||
with:
|
||||
path: |
|
||||
./front/node_modules
|
||||
./front/.next
|
||||
key: front
|
||||
restore-keys: front
|
||||
./front/apps/web/.next
|
||||
key: front-${{ hashFiles('front/yarn.lock') }}
|
||||
restore-keys: front-
|
||||
@@ -0,0 +1,20 @@
|
||||
name: 'Cache (Matcher)'
|
||||
description: 'Cache for Matcher'
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- run: apk add --update --no-progress lsb-release python3 py3-pip tar
|
||||
shell: sh
|
||||
- run: mkdir ./pip-cache
|
||||
shell: sh
|
||||
- name: Cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
./pip-cache
|
||||
key: matcher-${{ hashFiles('matcher/requirements.txt') }}
|
||||
restore-keys: matcher-
|
||||
- run: pip3 install -r matcher/requirements.txt
|
||||
shell: sh
|
||||
env:
|
||||
PIP_CACHE_DIR: ./pip-cache
|
||||
@@ -0,0 +1,16 @@
|
||||
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
|
||||
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: '1.24.1'
|
||||
@@ -12,5 +12,5 @@ runs:
|
||||
path: |
|
||||
./server/node_modules
|
||||
./server/dist
|
||||
key: server
|
||||
restore-keys: server
|
||||
key: server-${{ hashFiles('server/yarn.lock') }}
|
||||
restore-keys: server-
|
||||
@@ -0,0 +1,59 @@
|
||||
name: 'Dockerisation'
|
||||
description: 'Build using Docker, and publish if necessary'
|
||||
inputs:
|
||||
docker-username:
|
||||
description: 'Username to publish Docker image under'
|
||||
required: true
|
||||
docker-token:
|
||||
description: 'Token to publish Docker image'
|
||||
required: true
|
||||
github-token:
|
||||
description: 'GitHub Token'
|
||||
required: true
|
||||
image-suffix:
|
||||
description: "Suffix to 'meelo-'"
|
||||
required: true
|
||||
publish:
|
||||
description: 'Publish to registry'
|
||||
default: 'false'
|
||||
context:
|
||||
description: 'Directory of the Dockerfile'
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ inputs.docker-username }}
|
||||
password: ${{ inputs.docker-token }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ inputs.github-token }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
arthichaud/meelo-${{ inputs.image-suffix }}
|
||||
ghcr.io/${{ github.repository }}-${{ inputs.image-suffix }}
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ${{ inputs.context }}
|
||||
platforms: ${{ (inputs.publish == 'true' || inputs.publish == true) && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
|
||||
push: ${{ inputs.publish == 'true' || inputs.publish == true }}
|
||||
cache-from: type=gha
|
||||
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 }}
|
||||
@@ -1,40 +0,0 @@
|
||||
name: Documentation
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
Changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
docs: ${{ steps.filter.outputs.docs }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
docs:
|
||||
- 'docs/**'
|
||||
- '.github/workflows/documentation.yml'
|
||||
- 'assets/**'
|
||||
Build:
|
||||
needs: changes
|
||||
name: Deploy
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ needs.changes.outputs.docs == 'true' }}
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v3
|
||||
- name: Deploy docs
|
||||
uses: mhausenblas/mkdocs-deploy-gh-pages@master
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CONFIG_FILE: docs/mkdocs.yml
|
||||
REQUIREMENTS: docs/requirements.txt
|
||||
+50
-99
@@ -1,9 +1,9 @@
|
||||
name: "Front"
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main, v2 ]
|
||||
branches: [ main, v3 ]
|
||||
push:
|
||||
branches: [ main, v2 ]
|
||||
branches: [ main, v3 ]
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
@@ -26,8 +26,12 @@ jobs:
|
||||
with:
|
||||
filters: |
|
||||
front:
|
||||
- 'front/**'
|
||||
- 'front/apps/web/**'
|
||||
- 'front/translations/**'
|
||||
- 'front/packages/**'
|
||||
- '.github/workflows/front.yml'
|
||||
- '.github/actions/cache-front/**'
|
||||
- '.github/actions/dockerize/**'
|
||||
Build:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
|
||||
@@ -41,12 +45,15 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: yarn install
|
||||
- name: Build
|
||||
run: yarn build
|
||||
Lint:
|
||||
run: yarn build:web
|
||||
Biome:
|
||||
if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
|
||||
container: node:18-alpine3.18
|
||||
container: node:22-alpine3.20
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Build ]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
@@ -55,118 +62,62 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: yarn install
|
||||
- name: Run Linter
|
||||
run: yarn lint
|
||||
Format:
|
||||
if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
|
||||
container: node:18-alpine3.18
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Build ]
|
||||
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: Check Format
|
||||
run: yarn prettier --check src
|
||||
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@v3
|
||||
with:
|
||||
name: coverage-report-front
|
||||
path: ./front/coverage
|
||||
run: yarn run biome ci .
|
||||
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@v3
|
||||
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/sonarcloud-github-action@master
|
||||
uses: SonarSource/sonarqube-scan-action@master
|
||||
with:
|
||||
projectBaseDir: ./front
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_FRONT }}
|
||||
Dockerize:
|
||||
if: ${{ needs.changes.outputs.front == 'true' && github.event_name == 'pull_request' }}
|
||||
needs: [ Build ]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build Docker
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./front
|
||||
push: false
|
||||
Publish:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.front == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.front == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
arthichaud/meelo-front
|
||||
ghcr.io/${{ github.repository }}-front
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
- uses: ./.github/actions/dockerize
|
||||
with:
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: ./front
|
||||
push: true
|
||||
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-front:edge' }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
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
|
||||
# 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
|
||||
@@ -0,0 +1,91 @@
|
||||
name: "Matcher"
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main, v3 ]
|
||||
push:
|
||||
branches: [ main, v3 ]
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./matcher
|
||||
|
||||
jobs:
|
||||
Changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
matcher: ${{ steps.filter.outputs.matcher }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
matcher:
|
||||
- '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
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-matcher
|
||||
- uses: jakebailey/pyright-action@v2
|
||||
with:
|
||||
working-directory: './matcher/matcher'
|
||||
Lint:
|
||||
if: ${{ needs.changes.outputs.matcher == 'true' && github.event_name == 'pull_request' }}
|
||||
container: python:3.14.0a2-alpine3.21
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Typecheck ]
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-matcher
|
||||
- run: pip install ruff
|
||||
- run: ruff check matcher
|
||||
Tests:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Typecheck ]
|
||||
container: python:3.14.0a2-alpine3.21
|
||||
if: ${{ needs.changes.outputs.matcher == 'true' && always() }}
|
||||
env:
|
||||
GENIUS_ACCESS_TOKEN: "${{ secrets.GENIUS_ACCESS_TOKEN }}"
|
||||
DISCOGS_ACCESS_TOKEN: "${{ secrets.DISCOGS_ACCESS_TOKEN }}"
|
||||
CI: "1"
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-matcher
|
||||
- run: python3 -m tests
|
||||
Dockerize:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.matcher == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dockerize
|
||||
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,115 @@
|
||||
name: "Scanner"
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main, v3 ]
|
||||
push:
|
||||
branches: [ main, v3 ]
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: ./scanner
|
||||
|
||||
jobs:
|
||||
Changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
scanner: ${{ steps.filter.outputs.scanner }}
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
scanner:
|
||||
- 'scanner/**'
|
||||
- '.github/workflows/scanner.yml'
|
||||
- '.github/actions/cache-scanner/**'
|
||||
- '.github/actions/dockerize/**'
|
||||
Build:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.scanner == 'true' && github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/cache-scanner
|
||||
- name: Install Dependencies and Build
|
||||
run: go build -o scanner ./app
|
||||
Format:
|
||||
if: ${{ needs.changes.outputs.scanner == 'true' && github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Build ]
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/cache-scanner
|
||||
- uses: Jerome1337/gofmt-action@v1.0.5
|
||||
with:
|
||||
gofmt-path: './scanner'
|
||||
gofmt-flags: '-l'
|
||||
Tests:
|
||||
runs-on: ubuntu-latest
|
||||
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
|
||||
- name: Install Coverage Report
|
||||
run: go install github.com/jandelgado/gcov2lcov@latest
|
||||
- name: Run tests
|
||||
run: go test ./... -coverprofile=coverage.out
|
||||
- name: Upload Unit Tests artifact
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report-scanner
|
||||
path: ./scanner/coverage.out
|
||||
Analysis:
|
||||
needs: [ Tests ]
|
||||
name: "Static Analysis"
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ needs.changes.outputs.scanner == 'true' && always() }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Downlad Coverage artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-report-scanner
|
||||
path: ./scanner
|
||||
- name: Run SonarCloud Scan
|
||||
uses: SonarSource/sonarqube-scan-action@master
|
||||
with:
|
||||
projectBaseDir: ./scanner
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_SCANNER }}
|
||||
Dockerize:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.scanner == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/dockerize
|
||||
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') }}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
name: "Server"
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main, v2 ]
|
||||
branches: [ main, v3 ]
|
||||
push:
|
||||
branches: [ main, v2 ]
|
||||
branches: [ main, v3 ]
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
@@ -28,10 +28,12 @@ jobs:
|
||||
server:
|
||||
- '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' }}
|
||||
container: node:18-alpine3.18
|
||||
container: node:22-alpine3.20
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
@@ -42,11 +44,14 @@ jobs:
|
||||
run: yarn install
|
||||
- name: Build
|
||||
run: yarn build
|
||||
Lint:
|
||||
Biome:
|
||||
if: ${{ needs.changes.outputs.server == 'true' && github.event_name == 'pull_request' }}
|
||||
container: node:18-alpine3.18
|
||||
container: node:22-alpine3.20
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Build ]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
@@ -55,29 +60,14 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: yarn install
|
||||
- name: Run Linter
|
||||
run: yarn lint
|
||||
Format:
|
||||
if: ${{ needs.changes.outputs.server == 'true' && github.event_name == 'pull_request' }}
|
||||
container: node:18-alpine3.18
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ Build ]
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Restore Dependencies
|
||||
uses: ./.github/actions/cache-server
|
||||
- name: Install Dependencies
|
||||
run: yarn install
|
||||
- name: Check Format
|
||||
run: yarn prettier --check src
|
||||
run: yarn run biome ci .
|
||||
Tests:
|
||||
runs-on: ubuntu-latest
|
||||
container: node:18-alpine3.18
|
||||
container: node:22-alpine3.20
|
||||
needs: [ Build ]
|
||||
if: ${{ needs.changes.outputs.server == 'true' && always() }}
|
||||
env:
|
||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||
REDIS_HOST: "redis"
|
||||
DATABASE_URL: "postgresql://test:test@postgres:5432/test?schema=public"
|
||||
JWT_SIGNATURE: "12345"
|
||||
GENIUS_ACCESS_TOKEN: "${{ secrets.GENIUS_ACCESS_TOKEN }}"
|
||||
@@ -97,15 +87,6 @@ jobs:
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:7.0-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.5
|
||||
ports:
|
||||
@@ -125,7 +106,7 @@ jobs:
|
||||
run: yarn --cwd server test --coverage --ci --no-cache --maxConcurrency=1
|
||||
- name: Upload Unit Tests artifact
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report-server
|
||||
path: ./server/coverage
|
||||
@@ -137,75 +118,32 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Downlad Coverage artifact
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: coverage-report-server
|
||||
path: ./server/coverage
|
||||
- name: Run SonarCloud Scan
|
||||
uses: SonarSource/sonarcloud-github-action@master
|
||||
uses: SonarSource/sonarqube-scan-action@master
|
||||
with:
|
||||
projectBaseDir: ./server
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN_BACK }}
|
||||
Dockerize:
|
||||
if: ${{ needs.changes.outputs.server == 'true' && github.event_name == 'pull_request' }}
|
||||
needs: [ Build ]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build Docker
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: ./server
|
||||
push: false
|
||||
Publish:
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.server == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
if: ${{ github.event_name == 'release' || (needs.changes.outputs.server == 'true' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main'))) }}
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
arthichaud/meelo-server
|
||||
ghcr.io/${{ github.repository }}-server
|
||||
- name: Build and push Docker images
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
- uses: ./.github/actions/dockerize
|
||||
with:
|
||||
docker-username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
docker-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
context: ./server
|
||||
push: true
|
||||
tags: ${{ github.event_name == 'release' && steps.meta.outputs.tags || 'arthichaud/meelo-server:edge' }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
image-suffix: server
|
||||
publish: ${{ github.event_name == 'release' || (needs.changes.outputs.server == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
@@ -1,4 +1,4 @@
|
||||
# <img alt="Icon" src="./front/public/banner.png" width="150px">
|
||||
# <img alt="Icon" src="./front/assets/banner1-white.png" width="150px">
|
||||
|
||||
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:
|
||||
|
||||
@@ -10,8 +10,8 @@ While the software wants to be a flexible as possible, it requires a little bit
|
||||
|
||||
Meelo has all the basic features you might expect from a music player/server (playback, browsing, search, playlists, libraries ...). But it comes with a handful of features that make it unique :star_struck:
|
||||
|
||||
- Music Videos
|
||||
- Access them directly from an album's or an artist's page
|
||||
- Music Videos are first-class citizens
|
||||
- Access them directly from an album's, artist's or song's page
|
||||
- We also differentiate music video from interviews, behind-the-scenes, trailers...
|
||||
- Identifies B-Sides
|
||||
- Meelo shows you any tracks that could be related to an album directly on the album's page!
|
||||
@@ -26,6 +26,7 @@ Meelo has all the basic features you might expect from a music player/server (pl
|
||||
- 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)
|
||||
- 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)
|
||||
@@ -36,7 +37,8 @@ Meelo has all the basic features you might expect from a music player/server (pl
|
||||
- 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
|
||||
- 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)
|
||||
|
||||
@@ -45,7 +47,7 @@ Keep track of the upcoming features [here](https://github.com/Arthi-chaud/Meelo/
|
||||
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.
|
||||
|
||||
Meelo is shipped though Docker images. You might need to know a bit about Regexes.
|
||||
Look over [here](https://arthi-chaud.github.io/Meelo/) for setup instructions.
|
||||
Look over [here](https://github.com/Arthi-chaud/Meelo/wiki) for setup instructions.
|
||||
|
||||
## :desktop_computer: Live Demo
|
||||
|
||||
@@ -56,23 +58,39 @@ We are actively working on a public demo, so that you can try out Meelo. Stay tu
|
||||
<details>
|
||||
<summary>Unfold to see what the web application looks like!</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/48141241-25ec-4570-a4f1-696c0cdfcaaa
|
||||
|
||||
---
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
More screenshots [here](./assets/examples/)
|
||||
|
||||
</details>
|
||||
|
||||
## :handshake: Contributing and Bugs reporting
|
||||
## 🌐 Translation
|
||||
|
||||
:hammer: Would you like to contribute to Meelo? Feel free to [open a pull request](https://github.com/Arthi-chaud/Meelo/compare).
|
||||
Meelo uses [Weblate](https://weblate.org/en-gb/) to handle translations.
|
||||
Feel free to contribute [here](https://hosted.weblate.org/engage/meelo/)!
|
||||
|
||||
[](https://hosted.weblate.org/engage/meelo/)
|
||||
|
||||
Any questions about this? Open a discussion [here](https://github.com/Arthi-chaud/Meelo/discussions/categories/translation)!
|
||||
|
||||
## :handshake: Contributing and Bugs reporting
|
||||
|
||||
:thinking: Is there a feature you would like to have? [Open an issue](https://github.com/Arthi-chaud/Meelo/issues/new/choose), and we will be happy to discuss it!
|
||||
|
||||
:wrench: Need help with the setup process? Open a discussion [here](https://github.com/Arthi-chaud/Meelo/discussions/categories/need-help-with-setting-up).
|
||||
|
||||
:bug: Have you encountered a bug? Ugh we don't like 'em here! Report it by [opening an issue](https://github.com/Arthi-chaud/Meelo/issues/new/choose).
|
||||
|
||||
:hammer: Would you like to contribute to Meelo? Feel free to [open a pull request](https://github.com/Arthi-chaud/Meelo/compare).
|
||||
|
||||
---
|
||||
|
||||
Get ready to make the most out of you music collection!
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.3 MiB |
+55
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"vcs": {
|
||||
"enabled": false,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true,
|
||||
"defaultBranch": "main"
|
||||
},
|
||||
"files": {
|
||||
"ignore": [
|
||||
"**/.yarn/**",
|
||||
"**/dist/**",
|
||||
"**/package.json",
|
||||
"**/.expo/**"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
"indentWidth": 4
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"noParameterAssign": "off"
|
||||
},
|
||||
"performance": {
|
||||
"noAccumulatingSpread": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noBannedTypes": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off",
|
||||
"noConsole": "error"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedImports": "warn"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
},
|
||||
"parser": {
|
||||
"unsafeParameterDecoratorsEnabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
-18
@@ -10,12 +10,20 @@ services:
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
mq:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- localhost:4000"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
volumes:
|
||||
- ./server/src:/app/src
|
||||
- ./server/prisma:/app/prisma
|
||||
- ./server/test:/app/test
|
||||
- ./server/package.json:/app/package.json
|
||||
- ./server/.yarn:/app/.yarn
|
||||
- server_deps:/app/node_modules
|
||||
- ./server/.yarnrc.yml:/app/.yarnrc.yml
|
||||
- ./server/tsconfig.json:/app/tsconfig.json
|
||||
- ./server/tsconfig.build.json:/app/tsconfig.build.json
|
||||
@@ -26,34 +34,75 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
- TRANSCODER_URL=http://transcoder:7666
|
||||
- POSTGRES_HOST=db
|
||||
- MEILI_HOST=http://meilisearch:7700
|
||||
- REDIS_HOST=redis
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?schema=public
|
||||
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public
|
||||
scanner:
|
||||
build:
|
||||
context: ./scanner
|
||||
dockerfile: Dockerfile.dev
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- localhost:8133"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
ports:
|
||||
- "8133:8133"
|
||||
environment:
|
||||
- API_URL=http://server:4000
|
||||
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
|
||||
- INTERNAL_DATA_DIR=${INTERNAL_DATA_DIR}
|
||||
- API_KEYS=${API_KEYS}
|
||||
volumes:
|
||||
- ./scanner:/app
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
|
||||
front:
|
||||
build:
|
||||
context: ./front
|
||||
dockerfile: Dockerfile.dev
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
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
|
||||
- PUBLIC_SERVER_URL=http://localhost:4000
|
||||
- SSR_SERVER_URL=http://server:4000
|
||||
redis:
|
||||
image: redis:7.0-alpine
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli","ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
- PUBLIC_SCANNER_URL=http://localhost:8133
|
||||
- SSR_SCANNER_URL=http://scanner:8133
|
||||
matcher:
|
||||
build:
|
||||
context: ./matcher
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
mq:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- API_URL=http://server:4000
|
||||
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
|
||||
- API_KEYS=${API_KEYS}
|
||||
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
|
||||
volumes:
|
||||
- ./matcher:/app
|
||||
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.5
|
||||
restart: on-failure
|
||||
@@ -64,6 +113,7 @@ services:
|
||||
environment:
|
||||
- MEILI_ENV=development
|
||||
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
|
||||
- MEILI_LOG_LEVEL=WARN
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--spider", "http://0.0.0.0:7700/health"]
|
||||
timeout: 5s
|
||||
@@ -84,7 +134,9 @@ services:
|
||||
volumes:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- transcoder_cache:/cache
|
||||
profiles: ['', 'cpu']
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
db:
|
||||
image: postgres:alpine3.16
|
||||
healthcheck:
|
||||
@@ -94,7 +146,22 @@ services:
|
||||
retries: 5
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
mq:
|
||||
image: rabbitmq:4.0-alpine
|
||||
environment:
|
||||
- RABBITMQ_NODENAME=meelo
|
||||
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
|
||||
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
|
||||
healthcheck:
|
||||
test: rabbitmq-diagnostics -q ping
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
volumes:
|
||||
- rabbitmq_data:/var/lib/rabbitmq
|
||||
volumes:
|
||||
search:
|
||||
transcoder_cache:
|
||||
transcoder_cache:
|
||||
front_deps:
|
||||
server_deps:
|
||||
rabbitmq_data:
|
||||
+65
-14
@@ -1,7 +1,7 @@
|
||||
# This compose allows to setup the whole project in the production environment
|
||||
services:
|
||||
server:
|
||||
image: ghcr.io/arthi-chaud/meelo-server:latest
|
||||
image: arthichaud/meelo-server:${TAG:-latest}
|
||||
expose:
|
||||
- 4000
|
||||
restart: on-failure
|
||||
@@ -10,6 +10,8 @@ services:
|
||||
condition: service_healthy
|
||||
meilisearch:
|
||||
condition: service_healthy
|
||||
mq:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}
|
||||
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}
|
||||
@@ -18,31 +20,62 @@ services:
|
||||
environment:
|
||||
- TRANSCODER_URL=http://transcoder:7666
|
||||
- MEILI_HOST=http://meilisearch:7700
|
||||
- REDIS_HOST=redis
|
||||
- POSTGRES_HOST=db
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?schema=public
|
||||
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- localhost:4000"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
scanner:
|
||||
image: arthichaud/meelo-scanner:${TAG:-latest}
|
||||
expose:
|
||||
- 8133
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- localhost:8133"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
environment:
|
||||
- API_URL=http://server:4000
|
||||
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
|
||||
- INTERNAL_DATA_DIR=${INTERNAL_DATA_DIR}
|
||||
- API_KEYS=${API_KEYS}
|
||||
volumes:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
|
||||
front:
|
||||
image: ghcr.io/arthi-chaud/meelo-front:latest
|
||||
image: arthichaud/meelo-front:${TAG:-latest}
|
||||
expose:
|
||||
- 3000
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
scanner:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- PUBLIC_SERVER_URL=${PUBLIC_SERVER_URL}
|
||||
- PUBLIC_SERVER_URL=${PUBLIC_URL}/api
|
||||
- SSR_SERVER_URL=http://server:4000
|
||||
redis:
|
||||
image: redis:7.0-alpine
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli","ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
- PUBLIC_SCANNER_URL=${PUBLIC_URL}/scanner
|
||||
- SSR_SCANNER_URL=http://scanner:8133
|
||||
matcher:
|
||||
image: arthichaud/meelo-matcher:${TAG:-latest}
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
mq:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- API_URL=http://server:4000
|
||||
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
|
||||
- API_KEYS=${API_KEYS}
|
||||
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
|
||||
volumes:
|
||||
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
|
||||
db:
|
||||
image: postgres:alpine3.14
|
||||
healthcheck:
|
||||
@@ -64,6 +97,7 @@ services:
|
||||
environment:
|
||||
- MEILI_ENV=production
|
||||
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
|
||||
- MEILI_LOG_LEVEL=WARN
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--spider", "http://0.0.0.0:7700/health"]
|
||||
timeout: 5s
|
||||
@@ -84,7 +118,22 @@ services:
|
||||
volumes:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- transcoder_cache:/cache
|
||||
profiles: ['', 'cpu']
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
mq:
|
||||
image: rabbitmq:4.0-alpine
|
||||
environment:
|
||||
- RABBITMQ_NODENAME=meelo
|
||||
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
|
||||
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
|
||||
healthcheck:
|
||||
test: rabbitmq-diagnostics -q ping
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
volumes:
|
||||
- rabbitmq_data:/var/lib/rabbitmq
|
||||
nginx:
|
||||
restart: on-failure
|
||||
image: nginx:1.24.0-alpine
|
||||
@@ -99,6 +148,7 @@ services:
|
||||
- PORT=5000
|
||||
- FRONT_URL=http://front:3000
|
||||
- SERVER_URL=http://server:4000
|
||||
- SCANNER_URL=http://scanner:8133
|
||||
volumes:
|
||||
- ./nginx.conf.template:/etc/nginx/templates/meelo.conf.template:ro
|
||||
volumes:
|
||||
@@ -107,3 +157,4 @@ volumes:
|
||||
data:
|
||||
search:
|
||||
transcoder_cache:
|
||||
rabbitmq_data:
|
||||
+62
-9
@@ -11,6 +11,8 @@ services:
|
||||
condition: service_healthy
|
||||
meilisearch:
|
||||
condition: service_healthy
|
||||
mq:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}
|
||||
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}
|
||||
@@ -19,9 +21,8 @@ services:
|
||||
environment:
|
||||
- TRANSCODER_URL=http://transcoder:7666
|
||||
- MEILI_HOST=http://meilisearch:7700
|
||||
- REDIS_HOST=redis
|
||||
- POSTGRES_HOST=db
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?schema=public
|
||||
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- localhost:4000"]
|
||||
interval: 5s
|
||||
@@ -35,22 +36,59 @@ services:
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
scanner:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- PUBLIC_SERVER_URL=${PUBLIC_SERVER_URL}
|
||||
- PUBLIC_SERVER_URL=${PUBLIC_URL}/api
|
||||
- SSR_SERVER_URL=http://server:4000
|
||||
redis:
|
||||
image: redis:7.0-alpine
|
||||
- PUBLIC_SCANNER_URL=${PUBLIC_URL}/scanner
|
||||
- SSR_SCANNER_URL=http://scanner:8133
|
||||
scanner:
|
||||
build:
|
||||
context: ./scanner
|
||||
expose:
|
||||
- 8133
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli","ping"]
|
||||
test: ["CMD-SHELL", "wget -qO- localhost:8133"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
retries: 10
|
||||
environment:
|
||||
- API_URL=http://server:4000
|
||||
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
|
||||
- INTERNAL_DATA_DIR=${INTERNAL_DATA_DIR}
|
||||
- API_KEYS=${API_KEYS}
|
||||
volumes:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
|
||||
matcher:
|
||||
build:
|
||||
context: ./matcher
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
mq:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- API_URL=http://server:4000
|
||||
- INTERNAL_CONFIG_DIR=${INTERNAL_CONFIG_DIR}
|
||||
- API_KEYS=${API_KEYS}
|
||||
- RABBITMQ_URL=amqp://${RABBITMQ_USER}:${RABBITMQ_PASSWORD}@mq:5672
|
||||
volumes:
|
||||
- ${CONFIG_DIR}:${INTERNAL_CONFIG_DIR}:ro
|
||||
transcoder:
|
||||
image: ghcr.io/zoriya/kyoo_transcoder:master
|
||||
restart: on-failure
|
||||
cpus: 1
|
||||
expose:
|
||||
- 7666
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- GOCODER_SAFE_PATH=${INTERNAL_DATA_DIR}
|
||||
- POSTGRES_USER=${POSTGRES_USER}
|
||||
@@ -61,7 +99,6 @@ services:
|
||||
volumes:
|
||||
- ${DATA_DIR}:${INTERNAL_DATA_DIR}:ro
|
||||
- transcoder_cache:/cache
|
||||
profiles: ['', 'cpu']
|
||||
db:
|
||||
image: postgres:alpine3.14
|
||||
healthcheck:
|
||||
@@ -83,10 +120,24 @@ services:
|
||||
environment:
|
||||
- MEILI_ENV=production
|
||||
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY}
|
||||
- MEILI_LOG_LEVEL=WARN
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--spider", "http://0.0.0.0:7700/health"]
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
mq:
|
||||
image: rabbitmq:4.0-alpine
|
||||
environment:
|
||||
- RABBITMQ_NODENAME=meelo
|
||||
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER}
|
||||
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD}
|
||||
healthcheck:
|
||||
test: rabbitmq-diagnostics -q ping
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
volumes:
|
||||
- rabbitmq_data:/var/lib/rabbitmq
|
||||
nginx:
|
||||
restart: on-failure
|
||||
image: nginx:1.24.0-alpine
|
||||
@@ -101,6 +152,7 @@ services:
|
||||
- PORT=5000
|
||||
- FRONT_URL=http://front:3000
|
||||
- SERVER_URL=http://server:4000
|
||||
- SCANNER_URL=http://scanner:8133
|
||||
volumes:
|
||||
- ./nginx.conf.template:/etc/nginx/templates/meelo.conf.template:ro
|
||||
volumes:
|
||||
@@ -109,3 +161,4 @@ volumes:
|
||||
data:
|
||||
search:
|
||||
transcoder_cache:
|
||||
rabbitmq_data:
|
||||
@@ -1 +0,0 @@
|
||||
site/
|
||||
@@ -1,45 +0,0 @@
|
||||
site_name: Meelo - User Documentation
|
||||
docs_dir: './src'
|
||||
repo_url: https://github.com/Arthi-chaud/Meelo
|
||||
nav:
|
||||
- Setup:
|
||||
- TLDR: setup/tldr.md
|
||||
- Requirements: setup/requirements.md
|
||||
- Prepare Music Library: setup/prepare-music-library.md
|
||||
- Create Configuration Folder: setup/create-conf-folder.md
|
||||
- Prepare Your Settings: setup/settings.md
|
||||
- Configure Providers: setup/providers.md
|
||||
- Environment Variables: setup/env.md
|
||||
- Docker: setup/docker.md
|
||||
- Start: setup/startup.md
|
||||
- Create First Library: setup/create-library.md
|
||||
- Album Types: album-types.md
|
||||
- Song Types: song-types.md
|
||||
- Featuring Artists: featuring-artists.md
|
||||
theme:
|
||||
name: material
|
||||
language: en
|
||||
palette:
|
||||
scheme: default
|
||||
titles_only: true
|
||||
icon:
|
||||
repo: fontawesome/brands/github
|
||||
font:
|
||||
text: Noto Sans
|
||||
logo: ./icon.png
|
||||
favicon: ./favicon.ico
|
||||
features:
|
||||
- navigation.instant
|
||||
- navigation.tracking
|
||||
- navigation.tabs
|
||||
- navigation.tabs.sticky
|
||||
- navigation.sections
|
||||
- navigation.indexes
|
||||
- toc.integrate
|
||||
- navigation.top
|
||||
extra_css:
|
||||
- stylesheets/extra.css
|
||||
plugins:
|
||||
- search
|
||||
markdown_extensions:
|
||||
- mdx_truly_sane_lists
|
||||
@@ -1,2 +0,0 @@
|
||||
mkdocs-material
|
||||
mdx_truly_sane_lists
|
||||
@@ -1,42 +0,0 @@
|
||||
# Meelo's Album Types
|
||||
|
||||
In Music Apps, we usually call any song container an *Album*. While this is technically incorrect, many music streaming services categorizes these *Albums* in sub-categories: Albums, Singles, Live, Compilations, etc.
|
||||
|
||||
## Available Types
|
||||
|
||||
Meelo supports this categorization, and the following album types:
|
||||
|
||||
- Studio Recording
|
||||
- What we would usually call *Albums*
|
||||
- Live Recording
|
||||
- Compilation
|
||||
- Single
|
||||
- Soundtrack
|
||||
- Remix Album
|
||||
- Collections that would mainly have remixes of songs by a single artist, while not being a Single
|
||||
- Video Album
|
||||
- Any kind of collection that would only contain video tracks
|
||||
|
||||
## Type recognition
|
||||
|
||||
Meelo tries to be as independent of user interactions as possible. Therefore, type recognition is done automatically, when a new album is registered.
|
||||
|
||||
For now, Type recognition is solely based on the Album's title. Keywords to recognize an album's type were inspired by the way other music streaming services would categorize an album. Therefore, if your music collection's naming convention work a similar way to Spotify, Apple Music, ..., there might not be a lot to change if you want to fully enjoy this feature. However, if you consider that one of the following ways to recognize a type is incomplete or incorrect, feel free to [open an issue](https://github.com/Arthi-chaud/Meelo/issues).
|
||||
|
||||
### Keywords
|
||||
|
||||
| Album Type | Keywords |
|
||||
|---|---|
|
||||
| Studio Album | See note below |
|
||||
| Compilations | - `best of`<br>- `hits`<br>- `greatest hits`<br>- `singles`<br>- `collection`<br>+ See note below |
|
||||
| Live Recording | - '` tour`' (Notice the leading whitespace)<br>- `live from`<br>- `live at`<br>- `unplugged`<br>- `(live)` |
|
||||
| Single | - `- single`<br>- `- ep`<br>- `(remixes)` |
|
||||
| Video Album | - `music videos`<br>- `the video`<br>- `dvd` |
|
||||
| Remix Album | - `remix album`<br>- `mixes`<br>- `the remixes`<br>- `remixed`<br>- `remixes`<br>- `best mixes` |
|
||||
| Soundtrack | - `soundtrack`<br>- `from the motion picture`<br>- `music from and inspired by` |
|
||||
|
||||
Notes:
|
||||
|
||||
- Keywords are case-insensitive.
|
||||
- Any album that would not fall into the other categories will be considered to be a Compilation IF the album is a 'compilation' album (see metadata)
|
||||
- Any album that would not fall into the other categories will be considered to be a Studio Recording
|
||||
@@ -1 +0,0 @@
|
||||
../../front/public/banner-black.png
|
||||
@@ -1 +0,0 @@
|
||||
../../front/public/favicon.ico
|
||||
@@ -1,53 +0,0 @@
|
||||
# Meelo's Featuring Artist Management
|
||||
|
||||
Meelo is now able to parse and recognise featuring artists on a song, based on:
|
||||
|
||||
- Its name
|
||||
- Its artist field (from the metadata)
|
||||
|
||||
It does not leverage the album artist.
|
||||
|
||||
For example, a song named 'My Song (feat. Another Artist)' by 'Primary Artist & Other Artist' would be parsed to 'My Song' by artist 'Primary artist', featuring 'Other Artist' and 'Another Artist'.
|
||||
|
||||
Therefore, each featuring artist will be added to the database, and treated like any other artist.
|
||||
|
||||
Parsing works with `()`, `[]` and `-` as delimiters.
|
||||
|
||||
## Examples
|
||||
|
||||
In this section, we list examples that would be successfully parsed by Meelo
|
||||
|
||||
- 'My Song (feat. B)' by 'A'
|
||||
- Name: 'My Song'
|
||||
- Artist: 'A'
|
||||
- Featuring: 'B'
|
||||
- 'My Song (Remix) [feat. B]' by 'A'
|
||||
- Name: 'My Song (Remix)'
|
||||
- Artist: 'A'
|
||||
- Featuring: 'B'
|
||||
- 'My Song' by 'A & B'
|
||||
- Name: 'My Song'
|
||||
- Artist: 'A'
|
||||
- Featuring: 'B'
|
||||
- 'My Song' by 'A, B & C'
|
||||
- Name: 'My Song'
|
||||
- Artist: 'A'
|
||||
- Featuring: 'B', 'C'
|
||||
- 'My Song - With D' by 'A, B & C'
|
||||
- Name: 'My Song'
|
||||
- Artist: 'A'
|
||||
- Featuring: 'B', 'C', 'D'
|
||||
- 'My Song - Version With Drums' by 'A'
|
||||
- Name: 'My Song - Version With Drums'
|
||||
- Artist: 'A'
|
||||
- Featuring: None
|
||||
- 'My Song' by 'A Vs. B'
|
||||
- Name: 'My Song'
|
||||
- Artist: 'A'
|
||||
- Featuring: 'B'
|
||||
|
||||
## Special Case: Artist is already known
|
||||
|
||||
By default, we use '&' to find and split a list of artist. However, this behaviour changes if an artist containing '&' already exists.
|
||||
|
||||
For example, if we had an *album* artist named 'A & B' (since album artist are not parsed as much as song artists), a song named 'My Song (feat. A & B)' would be parsed as 'My Song', with featuring artist 'A & B'.
|
||||
@@ -1 +0,0 @@
|
||||
../../front/public/icon.png
|
||||
@@ -1,3 +0,0 @@
|
||||

|
||||
|
||||
# Welcome to Meelo's User Documentation
|
||||
@@ -1,7 +0,0 @@
|
||||
# Create a configuration folder
|
||||
|
||||
Meelo is runnable through a Docker container. But it needs a way to be parameterized and persist some data.
|
||||
|
||||
Therefore, it is recommended that you create a dedicated folder on your computer for Meelo.
|
||||
|
||||
For the sake of clarity, in this documentation, we'll refer to this folder as `Meelo`.
|
||||
@@ -1,8 +0,0 @@
|
||||
# Create your first library
|
||||
|
||||
After creating the admin account (from Web App),
|
||||
Visit the `/settings/libraries`, and click `Create Library` to create your first library.
|
||||
|
||||
Once your library is created, you should run a scan to collect the related files. To do so, click the *Scan* button.
|
||||
|
||||
Your Meelo server is ready to be used! Visit the `/` route on your favourite browser to enjoy your favourite music :).
|
||||
@@ -1,5 +0,0 @@
|
||||
# Docker and Docker-compose
|
||||
|
||||
In your `Meelo` folder, copy this [docker-compose file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/docker-compose.prod.yml) as `docker-compose.yml`. It can be used as-is.
|
||||
|
||||
Additionally, you should copy [nginx configuration file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/nginx.conf.template) as is in your `Meelo` folder.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Environment variables
|
||||
|
||||
In your `Meelo` directory, copy this [`.env.example`](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/.env.example) as `.env` and fill the variables
|
||||
@@ -1,18 +0,0 @@
|
||||
# Prepare your music library
|
||||
|
||||
The toughest part is to have an structured music library. The way files are organised are up to you! But here is a recommended architecture:
|
||||
|
||||
```tree
|
||||
Library's Folder
|
||||
│
|
||||
Artist 1
|
||||
│ │
|
||||
│ └───Album 1
|
||||
│ │ 01 Track 1.m4a
|
||||
│ │ 02 Track 2.m4a
|
||||
| | cover.jpg
|
||||
│ │ ...
|
||||
│ ...
|
||||
```
|
||||
|
||||
Meelo provides two ways of collecting metadata: using embedded tags, or using the path of the file. If a metadata source does not provide a specific field, you can use the other as a fallback. (You will configure this behaviour in the next sections). But before going any further, you should decide which metadata collection method to use.
|
||||
@@ -1,40 +0,0 @@
|
||||
# Providers
|
||||
|
||||
## Enabling Providers
|
||||
|
||||
In the `settings.json`, ff the `provider` object does not contain a provider's settings, this provider will be disabled.
|
||||
|
||||
Providers can also be disabled explicitly by setting the Provider's settings' field `enabled` at `false`. For example:
|
||||
|
||||
```json
|
||||
// In settings.json
|
||||
{
|
||||
//... Other properties
|
||||
"providers": {
|
||||
"provider1": {}, // Provider 1 is enabled
|
||||
// Provider 2 was not specified, it is therefore disabled
|
||||
"provider3": {
|
||||
"enabled": true // Provider 3 is enabled
|
||||
},
|
||||
"provider4": {
|
||||
"enabled": false, // Provider 4 is disabled
|
||||
"apiKey": "" // Other property of provider 4
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If a provider is disabled, no call to its service will be done.
|
||||
|
||||
## Providers' properties and features
|
||||
|
||||
| Provider Name | Key | Additional Properties | Artist Description | Artist Illustration | Album Description | Album Rating | Song Description | Song Lyrics | Release Identification | Album Genres |
|
||||
|---------------|---------------|--------------------------------------------------------------------------------------------|--------------------|---------------------|-------------------|--------------|------------------|-------------|------------------------|--------------|
|
||||
| Genius | `genius` | `apiKey`: Access Token, you can get it [here](https://genius.com/api-clients) | x | x | x | | | x | | |
|
||||
| MusicBrainz | `musicbrainz` | None | | | | | | | | x |
|
||||
| AllMusic | `allmusic` | None | | | x | x | | | | |
|
||||
| MetaCritic | `metacritic` | None | | | x | x | | | | |
|
||||
| Wikipedia | `wikipedia` | None | x | | x | | x | | | |
|
||||
| Discogs | `discogs` | `apiKey`: Access Token, you can get it [here](https://www.discogs.com/settings/developers) | x | | | | | | x | x |
|
||||
|
||||
**Warning**: Meelo heavily relies on Wikidata to optimize the metadata fetching. It is used if, and only if the MusicBrainz Provider is enabled. It is not possible to use one without the other.
|
||||
@@ -1,8 +0,0 @@
|
||||
# Requirements
|
||||
|
||||
Meelo is easy to setup! But you'll need the following things before continuing
|
||||
|
||||
- Have you music library ready (see [*this step*](../prepare-music-library))
|
||||
- Know how `Docker` and `docker-compose` work
|
||||
- Know how to create a `.env` file
|
||||
- Know how to manipulate Regular Expressions (Especially the ECMAScript flavor)
|
||||
@@ -1,32 +0,0 @@
|
||||
# Prepare your settings
|
||||
|
||||
In your `Meelo` folder, download and fill [this `settings.json` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/settings.json)
|
||||
|
||||
All fields are **required**
|
||||
|
||||
- `trackRegex`: Probably the most important setting for Meelo. This field takes an array of strings, each string being a Regular Expression string. The RegExp will be used to find the files to import (It will be matched against the **absolute path** of the file). As mentioned before, the path of the file can be used as a metadata source. Therefore, **the Regular Expression accepts groups to find metadata**:
|
||||
- `AlbumArtist`: The artist of the parent album.
|
||||
- Special Case: If it equals `Compilations`, related album will be considered as compilation albums (with no main artist, like soundtrack albums)
|
||||
- `Artist`: The artist of the track. If it is not present, it will use the `AlbumArtist` field.
|
||||
- `Release`: Usually no present in a standard file architecture; the name of the *release*. When it is not present, the `Album` field will be used.
|
||||
- `Album`: The name of the album of the material. This field will then be parsed to group related album releases.
|
||||
- `Year`: The Year of the release of the material
|
||||
- `Disc`: The Index of the disc the track is on.
|
||||
- `Index`: The index of the track in a playlist/on a disc.
|
||||
- `Track`: The name of the track
|
||||
- `Genre`: The genre of the material
|
||||
- `DiscogsID`: The Discogs ID of the parent release. A Discogs ID should take the form of a digit-only string. This ID can be found in the URL of the release's Discogs page or in the top-right corner of that page (displayed as `[rXXXXXXXX]`)
|
||||
- `metadata`: Configuration of the **metadata extraction** system:
|
||||
- `source`: The **primary metadata source** to use:
|
||||
- `embedded`: Using the embedded tags
|
||||
- `path`: Using the Regular Expression's groups.
|
||||
- `order`: Define the missing metadata fallback behaviour. *Note*: if this method is enabled, it will consider 'cover.*' file in the same directory as the media file as an illustration candidate.
|
||||
- `only`: If a field is missing from the primary metadata source, will not use the other method to get it.
|
||||
- `preferred`: If a field is missing from the primary metadata source, will try to use the other method to get it.
|
||||
- `useExternalProviderGenres`: Set to `true` if you want genres from external metadata providers to be used. These genres will be used alongside with embedded genres (if parsing embedded metadata is enabled)
|
||||
- `providers`: Please refer to [*Providers*](../providers)
|
||||
- `compilations`:
|
||||
- `artists`: Optional field. If an album artist in this list, their albums will be considered to be compilation albums.
|
||||
- `useID3CompTag`: If true, use iTunes' non-standard ID3 tags for compilations.
|
||||
|
||||
*Note*: The provided Regular Expression uses the iTunes file architecture.
|
||||
@@ -1,16 +0,0 @@
|
||||
# Start Meelo
|
||||
|
||||
Once these three files are ready, run the following command from your `Meelo` folder:
|
||||
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Two things can prevent Meelo from starting normally:
|
||||
|
||||
- A bad `settings.json` file
|
||||
- In that case, a message should tell you what went wrong
|
||||
- Connection to database failed
|
||||
- In that case, you should have a red message telling you a query failed. To fix, simply restart the server
|
||||
@@ -1,12 +0,0 @@
|
||||
# TLDR
|
||||
|
||||
Don't feel like reading? Here's how to setup Meelo quickly
|
||||
|
||||
- Download and fill [this `docker-compose` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/docker-compose.prod.yml)
|
||||
- Download and fill [this `nginx` configuration file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/nginx.conf.template)
|
||||
- Download and fill [this `.env` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/.env.example)
|
||||
- Download and fill [this `settings.json` file](https://raw.githubusercontent.com/Arthi-chaud/Meelo/master/settings.json)
|
||||
|
||||
You should be ready to go! If something goes wrong, please take the time to read the `Setup` section of this documentation.
|
||||
|
||||
Enjoy!
|
||||
@@ -1,29 +0,0 @@
|
||||
# Meelo's Album Types
|
||||
|
||||
It is possible to filter songs based on their *type*.
|
||||
|
||||
## Available Types
|
||||
|
||||
Meelo supports the following song types:
|
||||
|
||||
- Original
|
||||
- Live
|
||||
- Acoustic
|
||||
- Remix
|
||||
- Instrumental
|
||||
- Acapella
|
||||
- Edit
|
||||
- Of the original version.
|
||||
- If the song is an edit of a remix, it will be categorised as 'Remix'
|
||||
- Clean
|
||||
- Demo
|
||||
|
||||
## Type recognition
|
||||
|
||||
Songs' type recognition is solely based on keywords present in their name. For example:
|
||||
|
||||
- 'My Song (Remix)' would be typed as 'Remix'
|
||||
- 'My Song (Live)' would be recognised as a Live rendition
|
||||
- 'My Song (Rough Mix)' would be defined as 'Demo'
|
||||
- 'My Song (Instrumental Mix)' would be an 'Instrumental'
|
||||
- 'My Song (Instrumental Break Down Mix)' would be a 'Remix'
|
||||
@@ -1,3 +0,0 @@
|
||||
:root {
|
||||
--md-primary-fg-color: #2F2C2B;
|
||||
}
|
||||
+1
-1
@@ -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'"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.next/
|
||||
node_modules/
|
||||
.yarn/
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"printWidth": 80,
|
||||
"tabWidth": 4,
|
||||
"useTabs": true,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"quoteProps": "as-needed",
|
||||
"jsxSingleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf",
|
||||
"singleAttributePerLine": false
|
||||
}
|
||||
+40
-17
@@ -2,35 +2,58 @@
|
||||
FROM node:18-alpine3.18 AS base
|
||||
FROM base AS builder
|
||||
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
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
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"]
|
||||
+12
-5
@@ -1,12 +1,19 @@
|
||||
FROM node:18-alpine3.18
|
||||
WORKDIR /app
|
||||
COPY ./*.json ./
|
||||
COPY ./*.lock ./
|
||||
COPY ./.yarnrc.yml ./
|
||||
COPY ./.yarn /app/.yarn
|
||||
|
||||
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
|
||||
@@ -1,28 +0,0 @@
|
||||
import userReducer, {
|
||||
setAccessToken,
|
||||
setUserProfile,
|
||||
unsetAccessToken,
|
||||
} from "../../src/state/userSlice";
|
||||
|
||||
describe("User Slice", () => {
|
||||
let state: ReturnType<typeof userReducer> | undefined = undefined;
|
||||
|
||||
it("Should set the access token", () => {
|
||||
state = userReducer({}, setAccessToken("1234"));
|
||||
|
||||
expect(state).toStrictEqual({ accessToken: "1234" });
|
||||
});
|
||||
|
||||
it("Should set the user profile", () => {
|
||||
const user = { id: 1, name: "user", admin: true, enabled: true };
|
||||
state = userReducer(state, setUserProfile(user));
|
||||
|
||||
expect(state).toStrictEqual({ accessToken: "1234", user });
|
||||
});
|
||||
|
||||
it("Should unset the access token, and user profile", () => {
|
||||
state = userReducer(state, unsetAccessToken());
|
||||
|
||||
expect(state.accessToken).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
# 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
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Meelo",
|
||||
"slug": "meelo",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon-black.png",
|
||||
"scheme": "mobile",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"android": {
|
||||
"icon": "./assets/icon-black.png",
|
||||
"edgeToEdgeEnabled": true,
|
||||
"package": "com.arthichaud.meelo"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"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"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
},
|
||||
"extra": {
|
||||
"router": {},
|
||||
"eas": {
|
||||
"projectId": "b8d8dcdb-a905-4674-9c62-db4256e0d94f"
|
||||
}
|
||||
},
|
||||
"ios": {
|
||||
"bundleIdentifier": "com.arthichaud.meelo"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
../../../assets/banner1-black.png
|
||||
@@ -0,0 +1 @@
|
||||
../../../assets/banner1-white.png
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../../assets/icon-black.png
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../../assets/icon-white.png
|
||||
@@ -0,0 +1,15 @@
|
||||
module.exports = (api) => {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
plugins: [
|
||||
[
|
||||
"react-native-unistyles/plugin",
|
||||
{
|
||||
root: "src",
|
||||
},
|
||||
],
|
||||
"react-native-reanimated/plugin", // Always keep last
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 10.2.2"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal"
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal"
|
||||
},
|
||||
"production": {}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import "expo-router/entry";
|
||||
import "./src/theme";
|
||||
@@ -0,0 +1,52 @@
|
||||
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 = {
|
||||
"iconsax-react": "iconsax-react-nativejs",
|
||||
};
|
||||
|
||||
const projectRoot = __dirname;
|
||||
|
||||
function addMonorepoSupport(config) {
|
||||
const workspaceRoot = path.resolve(projectRoot, "../..");
|
||||
|
||||
return {
|
||||
...config,
|
||||
watchFolders: [...config.watchFolders, workspaceRoot],
|
||||
resolver: {
|
||||
...config.resolver,
|
||||
nodeModulesPaths: [
|
||||
...config.resolver.nodeModulesPaths,
|
||||
path.resolve(projectRoot, "node_modules"),
|
||||
path.resolve(workspaceRoot, "node_modules"),
|
||||
],
|
||||
disableHierarchicalLookup: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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,72 @@
|
||||
{
|
||||
"name": "mobile",
|
||||
"main": "index.ts",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/rubik": "^0.3.0",
|
||||
"@shopify/flash-list": "^1.8.3",
|
||||
"@shopify/react-native-skia": "^2.1.1",
|
||||
"@tanstack/react-query": "^5.77.0",
|
||||
"api": "workspace:^",
|
||||
"expo": "~53.0.9",
|
||||
"expo-asset": "~11.1.5",
|
||||
"expo-blur": "^14.1.5",
|
||||
"expo-build-properties": "^0.14.6",
|
||||
"expo-constants": "~17.1.6",
|
||||
"expo-dev-client": "~5.1.8",
|
||||
"expo-font": "~13.3.1",
|
||||
"expo-image": "~2.2.0",
|
||||
"expo-linking": "~7.1.5",
|
||||
"expo-router": "~5.0.7",
|
||||
"expo-splash-screen": "~0.30.8",
|
||||
"expo-status-bar": "~2.2.3",
|
||||
"expo-system-ui": "^5.0.7",
|
||||
"expo-web-browser": "^14.2.0",
|
||||
"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.0.0",
|
||||
"react-hook-form": "^7.58.0",
|
||||
"react-i18next": "^12.2.2",
|
||||
"react-native": "0.79.3",
|
||||
"react-native-blurhash": "^2.1.1",
|
||||
"react-native-edge-to-edge": "^1.6.1",
|
||||
"react-native-fast-image": "^8.6.3",
|
||||
"react-native-keyboard-controller": "^1.17.5",
|
||||
"react-native-mmkv": "^3.3.0",
|
||||
"react-native-nitro-modules": "0.26.2",
|
||||
"react-native-reanimated": "^3.17.3",
|
||||
"react-native-safe-area-context": "^5.4.1",
|
||||
"react-native-screens": "~4.11.1",
|
||||
"react-native-select-dropdown": "^4.0.1",
|
||||
"react-native-svg": "^15.12.0",
|
||||
"react-native-unistyles": "^3.0.0-rc.4",
|
||||
"state": "workspace:^",
|
||||
"toastify-react-native": "^7.2.0",
|
||||
"type-fest": "^4.33.0",
|
||||
"ui": "workspace:^",
|
||||
"utils": "workspace:^"
|
||||
},
|
||||
"installConfig": {
|
||||
"hoistingLimits": "workspaces"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@types/lodash": "^4",
|
||||
"@types/react": "~19.0.10",
|
||||
"typescript": "~5.8.3"
|
||||
},
|
||||
"packageManager": "yarn@4.4.0"
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* 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 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 { useAtom } from "jotai";
|
||||
import { accessTokenAtom, instanceUrlAtom } from "~/state/user";
|
||||
|
||||
export const useAPI = () => {
|
||||
const [accessToken] = useAtom(accessTokenAtom);
|
||||
const [instanceUrl] = useAtom(instanceUrlAtom);
|
||||
return getAPI_(accessToken ?? null, instanceUrl ?? "");
|
||||
};
|
||||
|
||||
// Get API instance using atom to resolve access token
|
||||
export const getAPI = () => {
|
||||
const accessToken = store.get(accessTokenAtom);
|
||||
const instanceUrl = store.get(instanceUrlAtom);
|
||||
return getAPI_(accessToken ?? null, instanceUrl ?? "");
|
||||
};
|
||||
|
||||
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`,
|
||||
});
|
||||
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,76 @@
|
||||
import {
|
||||
AlbumIcon,
|
||||
ArtistIcon,
|
||||
CompilationIcon,
|
||||
type Icon,
|
||||
SongIcon,
|
||||
VideoIcon,
|
||||
} from "@/ui/icons";
|
||||
import { type Href, useRouter } from "expo-router";
|
||||
import { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ListItem } from "~/components/item/list-item";
|
||||
import { SafeView } from "~/components/safe-view";
|
||||
import { Divider } from "~/primitives/divider";
|
||||
|
||||
const tabs: { title: TranslationKey; href: Href; icon: Icon }[] = [
|
||||
{
|
||||
title: "models.artist_plural",
|
||||
href: "/(protected)/(browse)/artists",
|
||||
icon: ArtistIcon,
|
||||
},
|
||||
{
|
||||
title: "models.album_plural",
|
||||
href: "/(protected)/(browse)/albums",
|
||||
icon: AlbumIcon,
|
||||
},
|
||||
|
||||
{
|
||||
title: "models.song_plural",
|
||||
href: "/(protected)/(browse)/songs",
|
||||
icon: SongIcon,
|
||||
},
|
||||
|
||||
{
|
||||
title: "models.video_plural",
|
||||
href: "/(protected)/(browse)/videos",
|
||||
icon: VideoIcon,
|
||||
},
|
||||
{
|
||||
title: "nav.compilations",
|
||||
href: "/(protected)/(browse)/albums?compilations=true",
|
||||
icon: CompilationIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export default function BrowseList() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SafeView style={[styles.root]}>
|
||||
{tabs.map(({ title, href, icon }) => (
|
||||
<Fragment key={title}>
|
||||
<ListItem
|
||||
illustration={null}
|
||||
illustrationProps={{ fallbackIcon: icon }}
|
||||
title={t(title)}
|
||||
subtitle={null}
|
||||
onPress={() => router.push(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",
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* 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 { getAlbums, getArtists, getReleases, getSongs } from "@/api/queries";
|
||||
import type { AlbumSortingKey } from "@/models/album";
|
||||
import type { ArtistSortingKey } from "@/models/artist";
|
||||
import type { SongSortingKey } from "@/models/song";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
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 ReleaseTile from "~/components/item/resource/release";
|
||||
import { Row } from "~/components/row";
|
||||
import { SafeScrollView } from "~/components/safe-view";
|
||||
import { SongGrid } from "~/components/song-grid";
|
||||
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) },
|
||||
}));
|
||||
|
||||
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"]),
|
||||
);
|
||||
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} />;
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* TODO Genres*/}
|
||||
|
||||
<SongGrid
|
||||
seeMore={
|
||||
topSongs.hasNextPage
|
||||
? {
|
||||
pathname: "/songs",
|
||||
params: {
|
||||
sort: "userPlayCount",
|
||||
order: "desc",
|
||||
} satisfies Sorting<SongSortingKey>,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
header={t("home.mostPlayedSongs")}
|
||||
songs={sync(topSongs.data?.pages.at(0)?.items)}
|
||||
subtitle={() => "artists"}
|
||||
style={styles.lastSection}
|
||||
/>
|
||||
</SafeScrollView>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { BackIcon } from "@/ui/icons";
|
||||
import { Stack, useNavigation } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { BlurView } from "~/components/blur-view";
|
||||
import { Icon as MeeloIcon } from "~/components/meelo";
|
||||
import { Icon } from "~/primitives/icon";
|
||||
import { Pressable } from "~/primitives/pressable";
|
||||
|
||||
export const unstable_settings = {
|
||||
home: {
|
||||
initialRouteName: "index",
|
||||
},
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
screen: {
|
||||
backgroundColor: "transparent",
|
||||
flex: 1,
|
||||
},
|
||||
headerBgContainer: {
|
||||
position: "absolute",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
overflow: "hidden",
|
||||
borderBottomLeftRadius: theme.borderRadius,
|
||||
borderBottomRightRadius: theme.borderRadius,
|
||||
},
|
||||
headerBgContent: {
|
||||
flex: 1,
|
||||
},
|
||||
icon: { marginRight: theme.gap(2) },
|
||||
backButtonContainer: { paddingRight: theme.gap(2), paddingLeft: 0 },
|
||||
headerTitle: {
|
||||
color: theme.colors.text.primary,
|
||||
...theme.fontStyles.regular,
|
||||
},
|
||||
}));
|
||||
|
||||
const BackButton = () => {
|
||||
const navigator = useNavigation();
|
||||
return (
|
||||
navigator.canGoBack() && (
|
||||
<Pressable
|
||||
style={styles.backButtonContainer}
|
||||
onPress={() => navigator.goBack()}
|
||||
>
|
||||
<Icon icon={BackIcon} />
|
||||
</Pressable>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
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: "videos/index", options: { headerTitle: "models.video_plural" } },
|
||||
{ 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 = {
|
||||
contentStyle: [styles.screen],
|
||||
// headerStyle: styles.header,
|
||||
headerTitleStyle: styles.headerTitle,
|
||||
headerLeft: () => <BackButton />,
|
||||
headerBackground: () => (
|
||||
<View style={styles.headerBgContainer}>
|
||||
<BlurView style={styles.headerBgContent} />
|
||||
</View>
|
||||
),
|
||||
headerTransparent: true,
|
||||
headerTintColor: styles.headerTitle.color,
|
||||
};
|
||||
switch (segment) {
|
||||
case "(home)": {
|
||||
return (
|
||||
<Stack screenOptions={screenOptions}>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerTitle: t("nav.home"),
|
||||
headerLeft: () => <MeeloIcon style={styles.icon} />,
|
||||
}}
|
||||
/>
|
||||
{SharedRoutes.map(
|
||||
(
|
||||
{ name, options: { headerTitle, ...options } },
|
||||
idx,
|
||||
) => (
|
||||
<Stack.Screen
|
||||
key={idx}
|
||||
name={name}
|
||||
options={{
|
||||
headerTitle: headerTitle
|
||||
? t(headerTitle)
|
||||
: "",
|
||||
...options,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
case "(browse)": {
|
||||
return (
|
||||
<Stack screenOptions={screenOptions}>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerTitle: t("nav.browse"),
|
||||
// TODO IDK why we canGoBack when we are at the browse's index
|
||||
headerLeft: () => <View />,
|
||||
}}
|
||||
/>
|
||||
{SharedRoutes.map(
|
||||
(
|
||||
{ name, options: { headerTitle, ...options } },
|
||||
idx,
|
||||
) => (
|
||||
<Stack.Screen
|
||||
key={idx}
|
||||
name={name}
|
||||
options={{
|
||||
headerTitle: headerTitle
|
||||
? t(headerTitle)
|
||||
: "",
|
||||
...options,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
case "(search)": {
|
||||
return (
|
||||
<Stack screenOptions={screenOptions}>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerTitle: t("nav.search"),
|
||||
// TODO IDK why we canGoBack when we are at the browse's index
|
||||
headerLeft: () => <View />,
|
||||
}}
|
||||
/>
|
||||
{SharedRoutes.map(
|
||||
(
|
||||
{ name, options: { headerTitle, ...options } },
|
||||
idx,
|
||||
) => (
|
||||
<Stack.Screen
|
||||
key={idx}
|
||||
name={name}
|
||||
options={{
|
||||
headerTitle: headerTitle
|
||||
? t(headerTitle)
|
||||
: "",
|
||||
...options,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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 { getAlbums, getArtist } from "@/api/queries";
|
||||
import { AlbumSortingKeys, AlbumType } from "@/models/album";
|
||||
import { albumTypeToTranslationKey } from "@/models/utils";
|
||||
import { Stack, useLocalSearchParams } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "~/api";
|
||||
import { ArtistHeader } from "~/components/artist-header";
|
||||
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 } from "~/components/item/resource/album";
|
||||
import { AlbumTile } from "~/components/item/resource/album";
|
||||
|
||||
export default function AlbumBrowseView() {
|
||||
const { t } = useTranslation();
|
||||
const { compilations, artist: artistId } = useLocalSearchParams<{
|
||||
compilations?: "true";
|
||||
artist?: 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 Item = layout === "list" ? AlbumItem : AlbumTile;
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen
|
||||
options={{
|
||||
headerTitle: 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,
|
||||
},
|
||||
{ sortBy: sort ?? "name", order: order ?? "asc" },
|
||||
["artist", "illustration"],
|
||||
)}
|
||||
render={(album) => (
|
||||
<Item
|
||||
album={album}
|
||||
subtitle={artistId ? "year" : "artistName"}
|
||||
illustrationProps={{ simpleColorPlaceholder: true }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import {
|
||||
getAlbums,
|
||||
getArtist,
|
||||
getArtistExternalMetadata,
|
||||
getSongs,
|
||||
getVideos,
|
||||
} from "@/api/queries";
|
||||
import { type AlbumSortingKey, AlbumType } from "@/models/album";
|
||||
import { albumTypeToTranslationKey } from "@/models/utils";
|
||||
import { VideoTypeIsExtra } from "@/models/video";
|
||||
import type { VideoSortingKey } from "@/models/video";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useInfiniteQuery, useQuery } from "~/api";
|
||||
import { ArtistHeader } from "~/components/artist-header";
|
||||
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 { 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 { 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]);
|
||||
useSetKeyIllustration(artist ?? undefined);
|
||||
return (
|
||||
<>
|
||||
<SafeScrollView>
|
||||
<ArtistHeader artist={artist} />
|
||||
<SongGrid
|
||||
header={t("artist.topSongs")}
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
|
||||
<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}
|
||||
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}
|
||||
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,60 @@
|
||||
/*
|
||||
* 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 } from "~/components/item/resource/artist";
|
||||
import { 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}
|
||||
illustrationProps={{ simpleColorPlaceholder: true }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import ReleasePage from "~/pages/release";
|
||||
|
||||
export default function Page() {
|
||||
const { id: releaseId } = useLocalSearchParams<{ id: string }>();
|
||||
|
||||
return <ReleasePage releaseId={releaseId} />;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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 { getArtist, getSongs } from "@/api/queries";
|
||||
import {
|
||||
SongSortingKeys,
|
||||
SongType,
|
||||
type SongWithRelations,
|
||||
} from "@/models/song";
|
||||
import { songTypeToTranslationKey } from "@/models/utils";
|
||||
import { useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "~/api";
|
||||
import { ArtistHeader } from "~/components/artist-header";
|
||||
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";
|
||||
|
||||
// TODO Handle Genre Query param
|
||||
// TODO Handle song version filtering
|
||||
// TODO song subtitle: allow it to be album
|
||||
|
||||
export default function SongBrowseView() {
|
||||
const nav = useNavigation();
|
||||
const { t } = useTranslation();
|
||||
const { artist: artistId, rare: rareArtistId } = useLocalSearchParams<{
|
||||
artist?: string;
|
||||
rare?: 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,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (rareArtistId !== undefined) {
|
||||
nav.setOptions({ headerTitle: t("artist.rareSongs") });
|
||||
}
|
||||
}, [rareArtistId]);
|
||||
const subtitle = useCallback(
|
||||
(song: SongWithRelations<"featuring"> | undefined) => {
|
||||
if (artistId === undefined && rareArtistId === undefined) {
|
||||
return "artists";
|
||||
}
|
||||
if (!song || !artist) {
|
||||
return null;
|
||||
}
|
||||
if (song.featuring.length > 0 || song.artistId !== artist.id) {
|
||||
return "artists";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[artistId, rareArtistId],
|
||||
);
|
||||
return (
|
||||
<InfiniteView
|
||||
layout={"list"}
|
||||
header={
|
||||
(artistId ?? rareArtistId) !== undefined ? (
|
||||
<ArtistHeader artist={artist} />
|
||||
) : undefined
|
||||
}
|
||||
controls={{
|
||||
sort: sortControl,
|
||||
filters: [libraryFilterControl, songTypeFilterControl],
|
||||
}}
|
||||
query={getSongs(
|
||||
{
|
||||
library: libraries,
|
||||
type: types,
|
||||
artist: artistId,
|
||||
rare: rareArtistId,
|
||||
},
|
||||
{ sortBy: sort ?? "name", order: order ?? "asc" },
|
||||
["artist", "illustration", "featuring"],
|
||||
)}
|
||||
render={(song) => (
|
||||
<SongItem
|
||||
song={song}
|
||||
subtitle={subtitle(song)}
|
||||
illustrationProps={{ simpleColorPlaceholder: true }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* 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 { getArtist, getVideos } from "@/api/queries";
|
||||
import { videoTypeToTranslationKey } from "@/models/utils";
|
||||
import { VideoSortingKeys, VideoType } from "@/models/video";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "~/api";
|
||||
import { ArtistHeader } from "~/components/artist-header";
|
||||
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 } from "~/components/item/resource/video";
|
||||
import { VideoTile } from "~/components/item/resource/video";
|
||||
|
||||
export default function VideoBrowseView() {
|
||||
const { artist: artistId } = useLocalSearchParams<{ artist?: 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,
|
||||
);
|
||||
return (
|
||||
<InfiniteView
|
||||
layout={layout}
|
||||
header={artistId ? <ArtistHeader artist={artist} /> : undefined}
|
||||
controls={{
|
||||
layout: layoutControl,
|
||||
sort: sortControl,
|
||||
filters: [libraryFilterControl, albumTypeFilterControl],
|
||||
}}
|
||||
query={getVideos(
|
||||
{ library: libraries, type: types, artist: artistId },
|
||||
{ sortBy: sort ?? "name", order: order ?? "asc" },
|
||||
["artist", "illustration", "master"],
|
||||
)}
|
||||
render={(video) => (
|
||||
<Item
|
||||
video={video}
|
||||
subtitle={artistId ? "duration" : "artistName"}
|
||||
illustrationProps={{
|
||||
simpleColorPlaceholder: true,
|
||||
normalizedThumbnail: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* 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 { getSearchHistory, 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 { useMutation } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useAPI, useQueryClient } from "~/api";
|
||||
import { InfiniteView } from "~/components/infinite/view";
|
||||
import { SearchResultItem } from "~/components/item/resource/search-result";
|
||||
import { TextInput } from "~/primitives/text_input";
|
||||
|
||||
//TODO Allow filtering by type (artist, album, etc.)
|
||||
|
||||
export default function SearchView() {
|
||||
const timeoutRef = useRef<number>(null);
|
||||
const api = useAPI();
|
||||
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 = useMemo(() => {
|
||||
if (searchValue.trim() === "") {
|
||||
return searchResultQueryToResourceQuery(getSearchHistory());
|
||||
}
|
||||
return searchResultQueryToResourceQuery(searchAll(searchValue));
|
||||
}, [searchValue]);
|
||||
|
||||
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: <explanation>
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<InfiniteView
|
||||
header={
|
||||
<View style={styles.searchHeader}>
|
||||
<TextInput
|
||||
containerStyle={{ backgroundColor: "transparent" }}
|
||||
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={{}}
|
||||
render={(item) => (
|
||||
<SearchResultItem
|
||||
searchResult={item}
|
||||
onPress={() => item && saveSearch.mutate(item)}
|
||||
/>
|
||||
)}
|
||||
layout="list"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type IllustratedSearchResultWithId = {
|
||||
id: number;
|
||||
illustration: Illustration | null;
|
||||
} & SearchResult;
|
||||
|
||||
const searchResultQueryToResourceQuery = (query: Query<SearchResult[]>) => {
|
||||
return transformPage(toInfiniteQuery(query), (item) => {
|
||||
return {
|
||||
...item,
|
||||
id: item.song
|
||||
? `song-${item.song.id}`
|
||||
: item.album
|
||||
? `album-${item.album.id}`
|
||||
: item.video
|
||||
? `video-${item.video.id}`
|
||||
: `artist-${item.artist.id}`,
|
||||
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,150 @@
|
||||
/*
|
||||
* 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 { getCurrentUserStatus } from "@/api/queries";
|
||||
import { toTanStackQuery } from "@/api/query";
|
||||
import {
|
||||
BrowseIcon,
|
||||
HomeIcon,
|
||||
type Icon as IconType,
|
||||
SearchIcon,
|
||||
SettingsIcon,
|
||||
} from "@/ui/icons";
|
||||
import { useQuery as useTanStackQuery } from "@tanstack/react-query";
|
||||
import { Redirect, Tabs } from "expo-router";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TouchableOpacity, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useAPI } from "~/api";
|
||||
import { BlurView } from "~/components/blur-view";
|
||||
import { Icon } from "~/primitives/icon";
|
||||
import { accessTokenAtom, instanceUrlAtom } 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
|
||||
|
||||
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 },
|
||||
tabBar: {
|
||||
position: "absolute",
|
||||
paddingTop: theme.gap(1),
|
||||
borderTopWidth: 0,
|
||||
},
|
||||
tabBarBackground: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
borderTopLeftRadius: theme.borderRadius,
|
||||
borderTopRightRadius: theme.borderRadius,
|
||||
overflow: "hidden",
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
}));
|
||||
|
||||
const TabIcon = ({ icon, focused }: { icon: IconType; focused: boolean }) => {
|
||||
return <Icon icon={icon} variant={focused ? "Bold" : "Outline"} />;
|
||||
};
|
||||
|
||||
// Note: if we use our pressable, there is a delay between when the button become bold and when opacity gets back to 1
|
||||
const TabButton = (props: any) => <TouchableOpacity {...props} />;
|
||||
|
||||
export default function ProtectedLayout() {
|
||||
const accessToken = useAtomValue(accessTokenAtom);
|
||||
const instanceUrl = useAtomValue(instanceUrlAtom);
|
||||
const api = useAPI();
|
||||
const user = useTanStackQuery({
|
||||
...toTanStackQuery(api, getCurrentUserStatus),
|
||||
enabled: !!(accessToken && instanceUrl),
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
if (!accessToken || !instanceUrl || user.error) {
|
||||
return <Redirect href="/auth" />;
|
||||
}
|
||||
//TODO Proper handling of when user is loading
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
animation: "none",
|
||||
headerShown: false,
|
||||
sceneStyle: styles.screen,
|
||||
tabBarShowLabel: false,
|
||||
tabBarStyle: styles.tabBar,
|
||||
tabBarBackground: () => (
|
||||
<BlurView style={styles.tabBarBackground} />
|
||||
),
|
||||
tabBarButton: TabButton,
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="(home)"
|
||||
options={{
|
||||
title: t("nav.home"),
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<TabIcon icon={HomeIcon} focused={focused} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="(browse)"
|
||||
options={{
|
||||
title: t("nav.browse"),
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<TabIcon icon={BrowseIcon} focused={focused} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="(search)"
|
||||
options={{
|
||||
title: t("nav.search"),
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<TabIcon icon={SearchIcon} focused={focused} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
headerShown: true,
|
||||
headerTransparent: true,
|
||||
headerTitleStyle: styles.headerTitle,
|
||||
headerBackground: () => (
|
||||
<View style={styles.headerBackgroundContainer}>
|
||||
<BlurView style={styles.headerBackgroundContent} />
|
||||
</View>
|
||||
),
|
||||
title: t("nav.settings"),
|
||||
tabBarIcon: ({ focused }) => (
|
||||
<TabIcon icon={SettingsIcon} focused={focused} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* 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 { CheckIcon, ExpandMoreIcon, UncheckIcon } from "@/ui/icons";
|
||||
import i18next from "i18next";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Dropdown } from "~/components/dropdown";
|
||||
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 { colorSchemePreference } from "~/state/color-scheme";
|
||||
import { languagePreference } from "~/state/lang";
|
||||
import { accessTokenAtom, instanceUrlAtom } from "~/state/user";
|
||||
import { Languages } from "../../../../../translations";
|
||||
|
||||
// TODO When setting dark/light mode using settings (not the auto mode)
|
||||
// header text color is not updated
|
||||
|
||||
export default function SettingsView() {
|
||||
const { t } = useTranslation();
|
||||
const setAccessToken = useSetAtom(accessTokenAtom);
|
||||
const setInstanceUrl = useSetAtom(instanceUrlAtom);
|
||||
const [colorSchemePref, setColorSchemePref] = useAtom(
|
||||
colorSchemePreference,
|
||||
);
|
||||
const [lng, setLng] = useAtom(languagePreference);
|
||||
|
||||
const actualColorScheme = useColorScheme();
|
||||
return (
|
||||
<SafeScrollView style={styles.root}>
|
||||
<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" />
|
||||
<Dropdown
|
||||
values={Languages}
|
||||
isSelected={(item) => lng === item}
|
||||
buttonProps={{
|
||||
title: t(
|
||||
`settings.ui.lang.${i18next.language as "en"}`,
|
||||
),
|
||||
icon: ExpandMoreIcon,
|
||||
iconPosition: "right",
|
||||
size: "small",
|
||||
}}
|
||||
onSelect={(selected) => {
|
||||
setLng(selected);
|
||||
}}
|
||||
formatItem={(item) =>
|
||||
t(`settings.ui.lang.${item}`, {
|
||||
lng: item,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Divider h />
|
||||
<View style={styles.section}>
|
||||
<SectionHeader
|
||||
style={styles.sectionHeader}
|
||||
content={t("actions.logout")}
|
||||
skeletonWidth={0}
|
||||
/>
|
||||
<Button
|
||||
title={t("actions.logout")}
|
||||
containerStyle={{ alignItems: "center" }}
|
||||
labelStyle={styles.logoutButtonStyle}
|
||||
onPress={() => {
|
||||
setAccessToken(null);
|
||||
setInstanceUrl(null);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</SafeScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: { paddingHorizontal: theme.gap(1) },
|
||||
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 },
|
||||
}));
|
||||
@@ -0,0 +1,173 @@
|
||||
/*
|
||||
* 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 { store } from "@/state/store";
|
||||
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 ToastManager from "toastify-react-native";
|
||||
import "intl-pluralrules";
|
||||
import { DefaultQueryOptions } from "@/api/query";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { UnistylesRuntime } from "react-native-unistyles";
|
||||
import type { ToastConfigParams } from "toastify-react-native/utils/interfaces";
|
||||
import { BackgroundGradient } from "~/components/background-gradient";
|
||||
import { useColorScheme } from "~/hooks/color-scheme";
|
||||
import { Toast as MeeloToast } 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) {
|
||||
SplashScreen.hideAsync();
|
||||
}
|
||||
}, [loaded, error]);
|
||||
|
||||
if (!loaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<ColorSchemeProvider>
|
||||
<KeyboardProvider>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
animation: "none",
|
||||
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>
|
||||
</KeyboardProvider>
|
||||
<BackgroundGradient />
|
||||
</ColorSchemeProvider>
|
||||
</Provider>
|
||||
<ToastManager
|
||||
theme={actualColorScheme}
|
||||
custom={MeeloToast}
|
||||
config={{
|
||||
success: (p: ToastConfigParams) => (
|
||||
<MeeloToast {...p} variant={"success"} />
|
||||
),
|
||||
error: (p: ToastConfigParams) => (
|
||||
<MeeloToast {...p} variant={"error"} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const ColorSchemeProvider = ({ children }: any) => {
|
||||
const colorScheme = useColorScheme();
|
||||
useEffect(() => {
|
||||
UnistylesRuntime.setTheme(colorScheme);
|
||||
}, [colorScheme]);
|
||||
return children;
|
||||
};
|
||||
@@ -0,0 +1,318 @@
|
||||
/*
|
||||
* 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 { ErrorIcon } from "@/ui/icons";
|
||||
import { router } from "expo-router";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import {
|
||||
KeyboardAwareScrollView,
|
||||
KeyboardToolbar,
|
||||
} from "react-native-keyboard-controller";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Toast } from "toastify-react-native";
|
||||
import { getAPI_ } from "~/api";
|
||||
import { Banner } from "~/components/meelo";
|
||||
import { Button } from "~/primitives/button";
|
||||
import { Divider } from "~/primitives/divider";
|
||||
import { Text } from "~/primitives/text";
|
||||
import { TextInput } from "~/primitives/text_input";
|
||||
import { accessTokenAtom, instanceUrlAtom } from "~/state/user";
|
||||
|
||||
const styles = StyleSheet.create((theme, rt) => ({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
marginBottom: theme.gap(10),
|
||||
justifyContent: "space-evenly",
|
||||
},
|
||||
// The padding is a bit much, but it allows making the next form field visible
|
||||
// It would be nice to just center the focused field when the keyboard is up
|
||||
keyboard: { paddingTop: theme.gap(20) },
|
||||
banner: {
|
||||
height: 300,
|
||||
maxHeight: rt.screen.height / 4,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
errorContainer: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.gap(1),
|
||||
},
|
||||
errorMsg: {
|
||||
maxWidth: "80%",
|
||||
color: theme.colors.error,
|
||||
textAlign: "center",
|
||||
},
|
||||
formContainer: {
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: theme.gap(3),
|
||||
},
|
||||
}));
|
||||
|
||||
function timeout(ms: number) {
|
||||
return new Promise<any>((_, reject) => {
|
||||
setTimeout(() => reject(new Error("Request timed out")), ms);
|
||||
});
|
||||
}
|
||||
|
||||
export default function AuthenticationScreen() {
|
||||
const { t } = useTranslation();
|
||||
const defaultValues = {
|
||||
url: "https://",
|
||||
username: "",
|
||||
password: "",
|
||||
confirm: "",
|
||||
};
|
||||
const setAccessToken = useSetAtom(accessTokenAtom);
|
||||
const setInstanceUrl = useSetAtom(instanceUrlAtom);
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const [errorMsg, setErrorMessage] = useState<string>();
|
||||
|
||||
const [formType, setFormType] = useState<"login" | "signup">("login");
|
||||
const safeAreaStyle = useSafeAreaInsets();
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm({ defaultValues });
|
||||
const onSubmit = (data: typeof defaultValues) => {
|
||||
const instanceUrl = data.url.replace(/\/$/, "");
|
||||
const api = getAPI_(null, instanceUrl);
|
||||
setLoading(true);
|
||||
setErrorMessage(undefined);
|
||||
if (formType === "signup") {
|
||||
Promise.race([
|
||||
timeout(3000),
|
||||
api.register({
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
}),
|
||||
])
|
||||
.then(() => {
|
||||
Toast.success(t("toasts.auth.accountCreated"));
|
||||
})
|
||||
.catch((e) => {
|
||||
setErrorMessage(e.message ?? e.toString());
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
Promise.race([
|
||||
timeout(3000),
|
||||
api.login({
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
}),
|
||||
])
|
||||
.then(({ access_token }) => {
|
||||
setAccessToken(access_token);
|
||||
setInstanceUrl(instanceUrl);
|
||||
router.replace("/");
|
||||
})
|
||||
.catch((e) => {
|
||||
setErrorMessage(e.message ?? e.toString());
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<KeyboardAwareScrollView
|
||||
bottomOffset={styles.keyboard.paddingTop}
|
||||
contentContainerStyle={[styles.root, safeAreaStyle]}
|
||||
>
|
||||
<Banner style={styles.banner} />
|
||||
<View style={styles.formContainer}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="url"
|
||||
rules={{
|
||||
required: {
|
||||
value: true,
|
||||
message: t("form.auth.instanceUrlIsRequired"),
|
||||
},
|
||||
}}
|
||||
render={({ field: { onChange, onBlur, value } }) => (
|
||||
<TextInput
|
||||
placeholder={t("form.auth.instanceUrl")}
|
||||
textContentType="URL"
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
onBlur={onBlur}
|
||||
onChangeText={onChange}
|
||||
error={errors.url?.message}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="username"
|
||||
rules={{
|
||||
required: {
|
||||
value: true,
|
||||
message: t("form.auth.usernameTooShort"),
|
||||
},
|
||||
minLength: {
|
||||
value: 4,
|
||||
message: t("form.auth.usernameTooShort"),
|
||||
},
|
||||
}}
|
||||
render={({ field: { onChange, onBlur, value } }) => (
|
||||
<TextInput
|
||||
placeholder={t("form.auth.username")}
|
||||
textContentType="username"
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
onBlur={onBlur}
|
||||
onChangeText={onChange}
|
||||
value={value}
|
||||
autoComplete={
|
||||
formType === "login"
|
||||
? "username"
|
||||
: undefined
|
||||
}
|
||||
error={errors.username?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="password"
|
||||
rules={{
|
||||
required: {
|
||||
value: true,
|
||||
message: t("form.auth.passwordIsRequired"),
|
||||
},
|
||||
minLength: {
|
||||
value: 6,
|
||||
message: t("form.auth.passwordTooShort"),
|
||||
},
|
||||
}}
|
||||
render={({ field: { onChange, onBlur, value } }) => (
|
||||
<TextInput
|
||||
placeholder={t("form.auth.password")}
|
||||
textContentType={
|
||||
formType === "login"
|
||||
? "password"
|
||||
: "newPassword"
|
||||
}
|
||||
onBlur={onBlur}
|
||||
onChangeText={onChange}
|
||||
value={value}
|
||||
autoComplete={
|
||||
formType === "login"
|
||||
? "password"
|
||||
: undefined
|
||||
}
|
||||
error={errors.password?.message}
|
||||
secureTextEntry
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{formType === "signup" && (
|
||||
<Controller
|
||||
control={control}
|
||||
name="confirm"
|
||||
rules={{
|
||||
required: {
|
||||
value: true,
|
||||
message: t("form.auth.pleaseConfirm"),
|
||||
},
|
||||
minLength: {
|
||||
value: 6,
|
||||
message: t("form.auth.passwordTooShort"),
|
||||
},
|
||||
validate: (confirmValue, form) => {
|
||||
if (confirmValue !== form.password) {
|
||||
return t(
|
||||
"form.auth.passwordsAreDifferent",
|
||||
);
|
||||
}
|
||||
},
|
||||
}}
|
||||
render={({
|
||||
field: { onChange, onBlur, value },
|
||||
}) => (
|
||||
<TextInput
|
||||
placeholder={t(
|
||||
"form.auth.confirmPasswordField",
|
||||
)}
|
||||
onBlur={onBlur}
|
||||
onChangeText={onChange}
|
||||
value={value}
|
||||
textContentType="newPassword"
|
||||
secureTextEntry
|
||||
error={errors.confirm?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
disabled={isLoading}
|
||||
onPress={handleSubmit(onSubmit)}
|
||||
title={t(
|
||||
formType === "login"
|
||||
? "auth.loginButton"
|
||||
: "auth.signupButton",
|
||||
)}
|
||||
/>
|
||||
{errorMsg && (
|
||||
<View style={styles.errorContainer}>
|
||||
<ErrorIcon style={styles.errorMsg} />
|
||||
<Text style={styles.errorMsg}>{errorMsg}</Text>
|
||||
</View>
|
||||
)}
|
||||
<Divider h withInsets />
|
||||
<Button
|
||||
variant="outlined"
|
||||
onPress={() => {
|
||||
setFormType(
|
||||
formType === "login" ? "signup" : "login",
|
||||
);
|
||||
setErrorMessage(undefined);
|
||||
}}
|
||||
title={t(
|
||||
formType === "login"
|
||||
? "auth.signup"
|
||||
: "auth.signin",
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</KeyboardAwareScrollView>
|
||||
<KeyboardToolbar />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ArtistWithRelations } from "@/models/artist";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Illustration } from "./illustration";
|
||||
import { LoadableText } from "./loadable_text";
|
||||
|
||||
type Props = { artist: ArtistWithRelations<"illustration"> | undefined };
|
||||
|
||||
// TODO Handle wrap when the artist name is a single word and is larger than view
|
||||
|
||||
export const ArtistHeader = ({ artist }: Props) => {
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<View style={styles.avatar}>
|
||||
<Illustration
|
||||
illustration={artist?.illustration}
|
||||
quality="medium"
|
||||
variant="circle"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.text}>
|
||||
<LoadableText
|
||||
content={artist?.name}
|
||||
skeletonWidth={10}
|
||||
numberOfLines={2}
|
||||
variant="h2"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
width: "100%",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
padding: theme.gap(2),
|
||||
gap: theme.gap(3),
|
||||
display: "flex",
|
||||
},
|
||||
avatar: {
|
||||
flex: 1,
|
||||
aspectRatio: 1,
|
||||
},
|
||||
text: {
|
||||
flex: { xs: 2, sm: 3, md: 5, xl: 12 },
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,188 @@
|
||||
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 {
|
||||
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 { useInfiniteQuery } from "~/api";
|
||||
import { accessTokenAtom } 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 accessToken = useAtomValue(accessTokenAtom);
|
||||
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 (!accessToken) {
|
||||
currentColor1.value = "transparent";
|
||||
currentColor2.value = "transparent";
|
||||
currentColor3.value = "transparent";
|
||||
currentColor4.value = "transparent";
|
||||
currentColor5.value = "transparent";
|
||||
}
|
||||
}, [accessToken]);
|
||||
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,
|
||||
),
|
||||
})}
|
||||
colors={gradientColors[index]}
|
||||
/>
|
||||
</FullScreenRect>
|
||||
);
|
||||
})}
|
||||
</StyledCanvas>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const GradientEllipse = withUnistyles(RadialGradient, (_, rt) => {
|
||||
const r = Math.max(rt.screen.width, rt.screen.height) * 0.55;
|
||||
return {
|
||||
r,
|
||||
};
|
||||
});
|
||||
|
||||
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,98 @@
|
||||
import { CheckIcon, type Icon as IconType } from "@/ui/icons";
|
||||
import { type ComponentProps, useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import SelectDropdown from "react-native-select-dropdown";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Button } from "~/primitives/button";
|
||||
import { Divider } from "~/primitives/divider";
|
||||
import { Icon } from "~/primitives/icon";
|
||||
import { Text } from "~/primitives/text";
|
||||
|
||||
type Props<T> = {
|
||||
values: readonly T[];
|
||||
isSelected: (item: T) => boolean;
|
||||
onSelect: (item: T) => void;
|
||||
formatItem: (item: T) => string;
|
||||
checkIcon?: IconType;
|
||||
buttonProps: Omit<
|
||||
ComponentProps<typeof Button>,
|
||||
"propagateToParent" | "onPress"
|
||||
>;
|
||||
};
|
||||
|
||||
export const Dropdown = <T,>({
|
||||
values,
|
||||
onSelect,
|
||||
formatItem,
|
||||
buttonProps,
|
||||
isSelected,
|
||||
checkIcon,
|
||||
}: Props<T>) => {
|
||||
const notReadonlyArray = useMemo(() => [...values], [values]);
|
||||
return (
|
||||
<SelectDropdown
|
||||
data={notReadonlyArray}
|
||||
onSelect={onSelect}
|
||||
dropdownStyle={styles.dropdownContainer}
|
||||
renderItem={(item: T) => (
|
||||
<View style={styles.dropdownItem}>
|
||||
<View style={styles.dropdownItemRow}>
|
||||
<View style={styles.dropdownItemIconContainer}>
|
||||
{isSelected(item) ? (
|
||||
<Icon
|
||||
icon={checkIcon ?? CheckIcon}
|
||||
style={styles.dropdownItemIcon}
|
||||
/>
|
||||
) : undefined}
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={styles.dropdownItemLabel}
|
||||
content={formatItem(item)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Divider h />
|
||||
</View>
|
||||
)}
|
||||
statusBarTranslucent
|
||||
dropdownOverlayColor="transparent"
|
||||
renderButton={() => (
|
||||
<View>
|
||||
<Button {...buttonProps} propagateToParent />
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
dropdownContainer: {
|
||||
borderRadius: theme.borderRadius,
|
||||
//TODO Can overflow
|
||||
width: 150,
|
||||
gap: theme.gap(2),
|
||||
},
|
||||
dropdownItem: { backgroundColor: theme.colors.background },
|
||||
dropdownItemRow: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.gap(1),
|
||||
padding: theme.gap(1),
|
||||
},
|
||||
dropdownItemIconContainer: {
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
dropdownItemIcon: {
|
||||
size: theme.fontSize.rem(1),
|
||||
} as any,
|
||||
dropdownItemLabel: {
|
||||
flex: 5,
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,32 @@
|
||||
import { EmptyStateIcon, type Icon } from "@/ui/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Text } from "~/primitives/text";
|
||||
|
||||
type Props = Partial<{ icon: Icon; text: TranslationKey }>;
|
||||
|
||||
export const EmptyState = ({ icon, text }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const IconComponent = withUnistyles(icon ?? EmptyStateIcon, (theme) => ({
|
||||
color: theme.colors.text.secondary,
|
||||
}));
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<IconComponent />
|
||||
<Text
|
||||
style={styles.text}
|
||||
content={t(text ?? "emptyState.default")}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
justifyContent: "space-evenly",
|
||||
alignItems: "center",
|
||||
},
|
||||
text: { color: theme.colors.text.secondary, ...theme.fontStyles.medium },
|
||||
}));
|
||||
@@ -0,0 +1,209 @@
|
||||
import type {
|
||||
AlbumExternalMetadata,
|
||||
ArtistExternalMetadata,
|
||||
ExternalMetadataSource,
|
||||
} from "@/models/external-metadata";
|
||||
import { ExpandLessIcon, ExpandMoreIcon } from "@/ui/icons";
|
||||
import { generateArray } from "@/utils/gen-list";
|
||||
import { Image } from "expo-image";
|
||||
import { openBrowserAsync } from "expo-web-browser";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollView, View, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useAPI } from "~/api";
|
||||
import { Icon } from "~/primitives/icon";
|
||||
import { Pressable } from "~/primitives/pressable";
|
||||
import { Text } from "~/primitives/text";
|
||||
import { TextSkeleton } from "~/primitives/text";
|
||||
import { LoadableText } from "./loadable_text";
|
||||
import { SectionHeader } from "./section-header";
|
||||
|
||||
type Props = {
|
||||
externalMetadata:
|
||||
| AlbumExternalMetadata
|
||||
| ArtistExternalMetadata
|
||||
| undefined;
|
||||
style?: ViewStyle;
|
||||
};
|
||||
|
||||
const DescriptionLineCount = 10;
|
||||
|
||||
// Generic section for external metadata.
|
||||
export const ExternalMetadataDescriptionSection = ({
|
||||
externalMetadata,
|
||||
style,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const heading = useMemo(() => t("browsing.sections.about"), [t]);
|
||||
// If null, no need to be able to expand.
|
||||
// If boolean, reflect whether the line clamp is enabled
|
||||
const [isExpanded, expand] = useState<boolean | null>(null);
|
||||
if (externalMetadata?.description === null) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View style={[styles.root, style]}>
|
||||
<SectionHeader
|
||||
skeletonWidth={heading.length}
|
||||
content={externalMetadata === undefined ? undefined : heading}
|
||||
trailing={
|
||||
isExpanded !== null ? (
|
||||
<Pressable
|
||||
onPress={() => expand((expanded) => !expanded)}
|
||||
>
|
||||
<Icon
|
||||
icon={
|
||||
isExpanded ? ExpandLessIcon : ExpandMoreIcon
|
||||
}
|
||||
/>
|
||||
</Pressable>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<View style={styles.descriptionContainer}>
|
||||
{externalMetadata?.description === undefined ? (
|
||||
<>
|
||||
{generateArray(DescriptionLineCount).map((_, idx) => (
|
||||
<TextSkeleton
|
||||
key={idx}
|
||||
width={"100%"}
|
||||
variant="body"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
externalMetadata.description !== null && (
|
||||
<Text
|
||||
content={externalMetadata.description}
|
||||
variant="body"
|
||||
style={styles.descriptionText}
|
||||
numberOfLines={
|
||||
isExpanded ? undefined : DescriptionLineCount
|
||||
}
|
||||
onTextLayout={(e) => {
|
||||
if (
|
||||
e.nativeEvent.lines.length >
|
||||
DescriptionLineCount
|
||||
) {
|
||||
expand(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Generic section for external metadata.
|
||||
// Will render empty view if no sources
|
||||
export const ExternalMetadataSourcesSection = ({
|
||||
externalMetadata,
|
||||
style,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const api = useAPI();
|
||||
const heading = useMemo(() => t("models.externalLink_plural"), [t]);
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
if (externalMetadata && externalMetadata.sources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View style={[styles.root, style]}>
|
||||
<SectionHeader
|
||||
skeletonWidth={heading.length}
|
||||
onPress={() =>
|
||||
scrollRef.current?.scrollTo({ x: 0, animated: true })
|
||||
}
|
||||
content={externalMetadata === undefined ? undefined : heading}
|
||||
/>
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
horizontal
|
||||
contentContainerStyle={styles.externalLinks}
|
||||
>
|
||||
{(externalMetadata?.sources ?? generateArray(2)).map(
|
||||
(source: ExternalMetadataSource | undefined, idx) => (
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
source && openBrowserAsync(source.url)
|
||||
}
|
||||
key={source?.providerId ?? idx}
|
||||
style={styles.externalLink}
|
||||
>
|
||||
{source ? (
|
||||
<Image
|
||||
contentFit="contain"
|
||||
source={{
|
||||
uri: source
|
||||
? api.getIllustrationURL(
|
||||
source.providerIcon,
|
||||
"original",
|
||||
)
|
||||
: undefined,
|
||||
headers: {
|
||||
Authorization: `Bearer ${api.accessToken}`,
|
||||
},
|
||||
}}
|
||||
style={styles.externalLinkIcon}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
styles.externalLinkIcon,
|
||||
styles.externalLinkIconPlaceholder,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<LoadableText
|
||||
content={source?.providerName}
|
||||
variant="body"
|
||||
skeletonWidth={10}
|
||||
/>
|
||||
</Pressable>
|
||||
),
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
width: "100%",
|
||||
flex: 1,
|
||||
},
|
||||
externalLinks: {
|
||||
gap: theme.gap(2),
|
||||
paddingHorizontal: theme.gap(1.5),
|
||||
marginBottom: theme.gap(1),
|
||||
},
|
||||
externalLinkIcon: {
|
||||
aspectRatio: 1,
|
||||
height: "100%",
|
||||
},
|
||||
|
||||
externalLinkIconPlaceholder: {
|
||||
backgroundColor: theme.colors.skeleton,
|
||||
borderRadius: theme.borderRadius,
|
||||
},
|
||||
externalLink: {
|
||||
borderWidth: 1,
|
||||
gap: theme.gap(1),
|
||||
borderColor: theme.colors.text.secondary,
|
||||
borderRadius: theme.borderRadius,
|
||||
padding: theme.gap(1),
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
descriptionText: {
|
||||
lineHeight: theme.gap(2.5),
|
||||
},
|
||||
descriptionContainer: {
|
||||
flex: 1,
|
||||
paddingHorizontal: theme.gap(1.5),
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,235 @@
|
||||
/*
|
||||
* 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 type IllustrationModel from "@/models/illustration";
|
||||
import type { IllustrationQuality } from "@/models/illustration";
|
||||
import type { Icon } from "@/ui/icons";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { View, type ViewStyle } from "react-native";
|
||||
import { Blurhash } from "react-native-blurhash";
|
||||
import Image from "react-native-fast-image";
|
||||
import { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useAPI } from "~/api";
|
||||
|
||||
type Props = {
|
||||
illustration: IllustrationModel | undefined | null;
|
||||
fallbackIcon?: Icon;
|
||||
quality: IllustrationQuality;
|
||||
normalizedThumbnail?: true;
|
||||
variant?: "fill" | "circle" | "center";
|
||||
// If true, the blurhash will not be ecoded/displayed.
|
||||
// Instead it will use the color from the illustration model as a place holder
|
||||
// To enhance performance in infinite lists
|
||||
simpleColorPlaceholder?: true;
|
||||
style?: ViewStyle;
|
||||
};
|
||||
|
||||
export const Illustration = ({
|
||||
illustration,
|
||||
fallbackIcon,
|
||||
quality,
|
||||
variant,
|
||||
simpleColorPlaceholder,
|
||||
normalizedThumbnail,
|
||||
style,
|
||||
}: Props) => {
|
||||
const api = useAPI();
|
||||
const innerAspectRatio = useMemo(
|
||||
() =>
|
||||
normalizedThumbnail
|
||||
? 16 / 9
|
||||
: variant === "fill" || variant === "circle"
|
||||
? 1
|
||||
: (illustration?.aspectRatio ?? 1),
|
||||
[illustration],
|
||||
);
|
||||
styles.useVariants({
|
||||
imageType: innerAspectRatio > 1 ? "wide" : "tall",
|
||||
shape: variant === "circle" ? "circle" : undefined,
|
||||
align: variant === "center" ? "center" : "bottom",
|
||||
normalizedThumbnail: normalizedThumbnail ?? false,
|
||||
});
|
||||
const [loadStatus, setLoadStatus] = useState<"done" | "loading" | "error">(
|
||||
"loading",
|
||||
);
|
||||
const imageOpacity = useSharedValue(0);
|
||||
const blurhashOpacity = useSharedValue(0);
|
||||
const fallbackOpacity = useSharedValue(0);
|
||||
|
||||
const imageOpacityStyle = useAnimatedStyle(() => ({
|
||||
opacity: imageOpacity.value,
|
||||
}));
|
||||
const blurhashOpacityStyle = useAnimatedStyle(() => ({
|
||||
opacity: blurhashOpacity.value,
|
||||
}));
|
||||
const fallbackOpacityStyle = useAnimatedStyle(() => ({
|
||||
opacity: fallbackOpacity.value,
|
||||
}));
|
||||
|
||||
const FallbackIcon = withUnistyles(
|
||||
fallbackIcon ?? View,
|
||||
(theme) =>
|
||||
({
|
||||
color: theme.colors.text.secondary,
|
||||
}) as any,
|
||||
);
|
||||
useEffect(() => {
|
||||
// Whatever the new illustration props is,
|
||||
// we need to reset the blurhash and the image
|
||||
blurhashOpacity.value = 0;
|
||||
imageOpacity.value = 0;
|
||||
if (illustration === null) {
|
||||
fallbackOpacity.value = 1;
|
||||
} else {
|
||||
fallbackOpacity.value = 0;
|
||||
}
|
||||
setLoadStatus("loading");
|
||||
}, [illustration]);
|
||||
return (
|
||||
<View style={[styles.outerContainer, style]}>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
aspectRatio: innerAspectRatio,
|
||||
},
|
||||
styles.innerContainer(
|
||||
simpleColorPlaceholder
|
||||
? illustration?.colors.at(0)
|
||||
: undefined,
|
||||
),
|
||||
]}
|
||||
>
|
||||
{illustration && loadStatus !== "error" && (
|
||||
<>
|
||||
{!simpleColorPlaceholder && loadStatus !== "done" && (
|
||||
<View style={[styles.slot]}>
|
||||
<Blurhash
|
||||
decodeAsync
|
||||
style={[
|
||||
blurhashOpacityStyle,
|
||||
styles.slotContent,
|
||||
styles.blurhash,
|
||||
]}
|
||||
onLoadEnd={() => {
|
||||
blurhashOpacity.value = 1;
|
||||
}}
|
||||
blurhash={illustration.blurhash}
|
||||
decodeWidth={16}
|
||||
decodeHeight={16}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[imageOpacityStyle, styles.slot]}>
|
||||
<Image
|
||||
style={[styles.slotContent]}
|
||||
onLoad={() => {
|
||||
imageOpacity.value = 1;
|
||||
setLoadStatus("done");
|
||||
}}
|
||||
resizeMode={
|
||||
variant === "circle" || normalizedThumbnail
|
||||
? "cover"
|
||||
: "contain"
|
||||
}
|
||||
onError={() => {
|
||||
setLoadStatus("error");
|
||||
}}
|
||||
source={{
|
||||
headers: {
|
||||
Authorization: `Bearer ${api.accessToken}`,
|
||||
},
|
||||
uri: api.getIllustrationURL(
|
||||
illustration.url,
|
||||
quality,
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
{(!illustration || loadStatus === "error") && (
|
||||
<View
|
||||
style={[
|
||||
fallbackOpacityStyle,
|
||||
styles.fallbackContainer,
|
||||
styles.slot,
|
||||
]}
|
||||
>
|
||||
<FallbackIcon />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
outerContainer: {
|
||||
overflow: "hidden",
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
variants: {
|
||||
normalizedThumbnail: {
|
||||
true: { aspectRatio: 16 / 9 },
|
||||
false: { aspectRatio: 1 },
|
||||
},
|
||||
align: {
|
||||
center: { justifyContent: "center" },
|
||||
bottom: {
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
innerContainer: (illustrationColor: string | undefined) => ({
|
||||
backgroundColor: illustrationColor ?? theme.colors.skeleton,
|
||||
borderRadius: theme.borderRadius,
|
||||
variants: {
|
||||
imageType: {
|
||||
tall: { height: "100%", width: undefined },
|
||||
wide: { height: undefined, width: "100%" },
|
||||
},
|
||||
shape: {
|
||||
circle: { borderRadius: 99999, overflow: "hidden" },
|
||||
default: {},
|
||||
},
|
||||
},
|
||||
}),
|
||||
blurhash: {
|
||||
backgroundColor: theme.colors.skeleton,
|
||||
},
|
||||
slot: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
overflow: "hidden",
|
||||
borderRadius: theme.borderRadius,
|
||||
},
|
||||
slotContent: {
|
||||
flex: 1,
|
||||
},
|
||||
fallbackContainer: {
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { FilterControl } from "@/infinite-controls/filters/control";
|
||||
import type { LayoutControl } from "@/infinite-controls/layout";
|
||||
import type { SortControl } from "@/infinite-controls/sort";
|
||||
import { AscIcon, DescIcon, GridIcon, ListIcon } from "@/ui/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Dropdown } from "~/components/dropdown";
|
||||
import { Button } from "~/primitives/button";
|
||||
|
||||
//TODO Actions
|
||||
//TODO Height of buttons is not consistent
|
||||
|
||||
type Props<SortingKey extends string> = {
|
||||
layout?: LayoutControl;
|
||||
sort?: SortControl<SortingKey>;
|
||||
filters?: FilterControl<any>[];
|
||||
};
|
||||
|
||||
export const Controls = <S extends string>({
|
||||
layout,
|
||||
sort,
|
||||
filters,
|
||||
}: Props<S>) => {
|
||||
const { t } = useTranslation();
|
||||
const OrderIcon = withUnistyles(
|
||||
sort?.selected.order === "desc" ? DescIcon : AscIcon,
|
||||
(theme) => ({
|
||||
color: theme.colors.text.primary,
|
||||
size: theme.fontSize.rem(1),
|
||||
}),
|
||||
);
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
{filters?.map((filter, idx) => (
|
||||
<Dropdown
|
||||
key={idx}
|
||||
buttonProps={{
|
||||
title: t(filter.buttonLabel),
|
||||
icon: filter.buttonIcon as any,
|
||||
iconPosition: "right",
|
||||
}}
|
||||
values={filter.values ?? []}
|
||||
isSelected={(item) =>
|
||||
filter.multipleChoice
|
||||
? filter.selected.includes(item)
|
||||
: filter.selected === item
|
||||
}
|
||||
formatItem={(item) => t(filter.formatItem(item))}
|
||||
onSelect={(selected) => {
|
||||
if (filter.multipleChoice) {
|
||||
const selectedKeys = filter.selected.includes(
|
||||
selected,
|
||||
)
|
||||
? filter.selected.filter((k) => k !== selected)
|
||||
: [selected, ...filter.selected];
|
||||
filter.onUpdate(selectedKeys);
|
||||
} else {
|
||||
filter.onUpdate(
|
||||
filter.selected === selected ? null : selected,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{sort && (
|
||||
<Dropdown
|
||||
values={sort.sortingKeys}
|
||||
formatItem={(item) => t(sort.formatItem(item))}
|
||||
buttonProps={{
|
||||
title: t(sort.formatItem(sort.selected.sort)),
|
||||
icon:
|
||||
sort.selected.order === "asc" ? AscIcon : DescIcon,
|
||||
iconPosition: "left",
|
||||
}}
|
||||
isSelected={(item) => sort.selected.sort === item}
|
||||
checkIcon={OrderIcon}
|
||||
onSelect={(selected: S) => {
|
||||
if (selected === sort.selected.sort) {
|
||||
sort.onUpdate({
|
||||
sort: selected,
|
||||
order:
|
||||
sort.selected.order === "asc"
|
||||
? "desc"
|
||||
: "asc",
|
||||
});
|
||||
} else {
|
||||
sort.onUpdate({ sort: selected, order: "asc" });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{layout && (
|
||||
<Button
|
||||
icon={layout.layout === "list" ? GridIcon : ListIcon}
|
||||
containerStyle={styles.button}
|
||||
onPress={() => {
|
||||
layout.onUpdate({
|
||||
layout: layout.layout === "list" ? "grid" : "list",
|
||||
itemSize: layout.itemSize,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.gap(1),
|
||||
},
|
||||
button: { elevation: 10 },
|
||||
}));
|
||||
@@ -0,0 +1,102 @@
|
||||
import { getLibraries } from "@/api/queries";
|
||||
import { toTanStackInfiniteQuery } from "@/api/query";
|
||||
import {
|
||||
useTypeFilterControl as useTypeFilterControlBase,
|
||||
useTypeFiltersControl as useTypeFiltersControlBase,
|
||||
} from "@/infinite-controls/filters/resource-type";
|
||||
|
||||
import {
|
||||
useLibraryFilterControl as useLibraryFilterControlBase,
|
||||
useLibraryFiltersControl as useLibraryFiltersControlBase,
|
||||
} from "@/infinite-controls/filters/library";
|
||||
import { useInfiniteQuery as useReactInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Toast } from "toastify-react-native";
|
||||
import { useAPI } from "~/api";
|
||||
|
||||
const useLibraryFilterControl = () => {
|
||||
const { t } = useTranslation();
|
||||
const api = useAPI();
|
||||
const query = toTanStackInfiniteQuery(api, getLibraries);
|
||||
const librariesQuery = useReactInfiniteQuery({
|
||||
...query,
|
||||
throwOnError: false,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (librariesQuery.isError) {
|
||||
Toast.error(t("toasts.library.loadFail"));
|
||||
}
|
||||
}, [librariesQuery.isError]);
|
||||
const libraries = librariesQuery.data?.pages.at(0)?.items;
|
||||
return useLibraryFilterControlBase({
|
||||
hook: () => null,
|
||||
onUpdate: () => {},
|
||||
libraries,
|
||||
});
|
||||
};
|
||||
|
||||
const useLibraryFiltersControl = () => {
|
||||
const { t } = useTranslation();
|
||||
const api = useAPI();
|
||||
const query = toTanStackInfiniteQuery(api, getLibraries);
|
||||
const librariesQuery = useReactInfiniteQuery({
|
||||
...query,
|
||||
throwOnError: false,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (librariesQuery.isError) {
|
||||
Toast.error(t("toasts.library.loadFail"));
|
||||
}
|
||||
}, [librariesQuery.isError]);
|
||||
const libraries = librariesQuery.data?.pages.at(0)?.items;
|
||||
return useLibraryFiltersControlBase({
|
||||
hook: () => [],
|
||||
onUpdate: () => {},
|
||||
libraries,
|
||||
});
|
||||
};
|
||||
|
||||
const useTypeFiltersControl = <FilterKey extends string>({
|
||||
types: filterKeys,
|
||||
translate,
|
||||
}: {
|
||||
types: readonly FilterKey[];
|
||||
translate: (s: FilterKey) => TranslationKey;
|
||||
}) => {
|
||||
return useTypeFiltersControlBase({
|
||||
types: filterKeys,
|
||||
translate,
|
||||
hook: () => {
|
||||
const { type } = useLocalSearchParams<{ type?: FilterKey }>();
|
||||
return type ? [type] : [];
|
||||
},
|
||||
onUpdate: () => {},
|
||||
});
|
||||
};
|
||||
|
||||
const useTypeFilterControl = <FilterKey extends string>({
|
||||
types,
|
||||
translate,
|
||||
}: {
|
||||
types: readonly FilterKey[];
|
||||
translate: (s: FilterKey) => TranslationKey;
|
||||
}) => {
|
||||
return useTypeFilterControlBase({
|
||||
types,
|
||||
translate,
|
||||
hook: () => {
|
||||
const { type } = useLocalSearchParams<{ type?: FilterKey }>();
|
||||
return type ?? null;
|
||||
},
|
||||
onUpdate: () => {},
|
||||
});
|
||||
};
|
||||
|
||||
export {
|
||||
useTypeFiltersControl,
|
||||
useTypeFilterControl,
|
||||
useLibraryFilterControl,
|
||||
useLibraryFiltersControl,
|
||||
};
|
||||
+16
-17
@@ -16,24 +16,23 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { useConfirm } from "material-ui-confirm";
|
||||
import { Translator } from "../i18n/i18n";
|
||||
import { useLayoutControl as useLayoutControlBase } from "@/infinite-controls/layout";
|
||||
import type { LayoutOption } from "@/models/layout";
|
||||
|
||||
const confirmDownloadAction = (
|
||||
confirm: ReturnType<typeof useConfirm>,
|
||||
downloadUrl: string,
|
||||
t: Translator,
|
||||
) => {
|
||||
confirm({
|
||||
title: t("warning"),
|
||||
description: t("downloadWarning"),
|
||||
confirmationText: t("download"),
|
||||
confirmationButtonProps: {
|
||||
color: "error",
|
||||
variant: "outlined",
|
||||
href: downloadUrl,
|
||||
// Hook to get Layout data to pass to Controls
|
||||
export const useLayoutControl = ({
|
||||
defaultLayout,
|
||||
enableToggle,
|
||||
}: {
|
||||
defaultLayout: LayoutOption;
|
||||
enableToggle: boolean;
|
||||
}) => {
|
||||
return useLayoutControlBase({
|
||||
hook: () => {
|
||||
return null;
|
||||
},
|
||||
defaultLayout,
|
||||
enableToggle,
|
||||
onUpdate: () => {},
|
||||
});
|
||||
};
|
||||
|
||||
export default confirmDownloadAction;
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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 { useSortControl as useSortControlBase } from "@/infinite-controls/sort";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import type { Sorting } from "~/utils/sorting";
|
||||
|
||||
export const useSortControl = <SortingKey extends string>({
|
||||
sortingKeys,
|
||||
translate,
|
||||
}: {
|
||||
sortingKeys: readonly SortingKey[];
|
||||
translate: (s: SortingKey) => TranslationKey;
|
||||
}) => {
|
||||
return useSortControlBase({
|
||||
hook: () => {
|
||||
let { sort, order } = useLocalSearchParams<Sorting<SortingKey>>();
|
||||
sort ??= sortingKeys[0];
|
||||
order ??= "asc";
|
||||
return { sort, order };
|
||||
},
|
||||
sortingKeys,
|
||||
translate,
|
||||
onUpdate: () => {},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { InfiniteQuery } from "@/api/query";
|
||||
import type { IllustratedResource } from "@/models/illustration";
|
||||
import type { LayoutOption } from "@/models/layout";
|
||||
import type Resource from "@/models/resource";
|
||||
import { generateArray } from "@/utils/gen-list";
|
||||
import { type ContentStyle, FlashList } from "@shopify/flash-list";
|
||||
import type React from "react";
|
||||
import { type ComponentProps, useMemo, useState } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useInfiniteQuery } from "~/api";
|
||||
import { useRootViewStyle } from "~/hooks/root-view-style";
|
||||
import { Divider } from "~/primitives/divider";
|
||||
import { breakpoints } from "~/theme";
|
||||
import { useSetKeyIllustrationFromInfiniteQuery } from "../background-gradient";
|
||||
import { EmptyState } from "../empty-state";
|
||||
import { Controls } from "./controls/component";
|
||||
|
||||
//TODO Grid: The breakpoint's type from rt does not seem to use the type defined in the theme
|
||||
//It should be typesafe
|
||||
|
||||
//TODO Button to scroll back up
|
||||
|
||||
// Notes:
|
||||
// the navigation header being positioned using 'absolute'
|
||||
// We need to rely on the root view style hook to get its height
|
||||
//
|
||||
// The controls (libraries, types, etc.) are positioned absolutely, with padding to avoid overlap with header
|
||||
//
|
||||
// The scroll view itself has a top padding where the controls should end up being layed out in
|
||||
|
||||
type Props<T, T1, Sort extends string> = {
|
||||
query: InfiniteQuery<T, T1>;
|
||||
layout: LayoutOption;
|
||||
header?: React.ReactElement;
|
||||
controls: Omit<ComponentProps<typeof Controls<Sort>>, "style">;
|
||||
render: (item: T1 | undefined) => React.ReactElement;
|
||||
ignoreTabBar?: true;
|
||||
};
|
||||
|
||||
export const InfiniteView = <
|
||||
T extends Resource,
|
||||
T1 extends IllustratedResource,
|
||||
Sort extends string,
|
||||
>(
|
||||
props: Props<T, T1, Sort>,
|
||||
) => {
|
||||
styles.useVariants({ layout: props.layout });
|
||||
const { paddingBottom, paddingTop } = useRootViewStyle();
|
||||
const queryRes = useInfiniteQuery(() => props.query);
|
||||
const [controlsHeight, setControlsHeight] = useState(null as number | null);
|
||||
const firstPage = queryRes.data?.pages.at(0)?.items;
|
||||
const itemList = useMemo(() => {
|
||||
const itemCount = queryRes.items?.length ?? 0;
|
||||
const trailingSkeletons =
|
||||
(queryRes.isFetching && !itemCount) || queryRes.isFetchingNextPage
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
return [
|
||||
...(queryRes.items ?? []),
|
||||
...generateArray(trailingSkeletons, undefined),
|
||||
];
|
||||
}, [queryRes.items, queryRes.isFetching, queryRes.isFetchingNextPage]);
|
||||
const ScrollView = props.layout === "list" ? List : Grid;
|
||||
useSetKeyIllustrationFromInfiniteQuery(props.query);
|
||||
return (
|
||||
<View style={[styles.rootStyle]}>
|
||||
{props.header && (
|
||||
<View style={[{ paddingTop }, styles.optionalHeader]}>
|
||||
{props.header}
|
||||
<Divider h />
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.body}>
|
||||
<View
|
||||
style={styles.controls(props.header ? 0 : paddingTop)}
|
||||
onLayout={(e) =>
|
||||
setControlsHeight(e.nativeEvent.layout.height)
|
||||
}
|
||||
>
|
||||
<Controls {...props.controls} />
|
||||
</View>
|
||||
|
||||
{firstPage?.length === 0 ? (
|
||||
<View
|
||||
style={[
|
||||
styles.emptyState,
|
||||
styles.scrollViewTopPadding(controlsHeight),
|
||||
]}
|
||||
>
|
||||
<EmptyState />
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView
|
||||
data={itemList}
|
||||
refreshing={queryRes.isRefetching}
|
||||
contentContainerStyle={
|
||||
[
|
||||
styles.scrollView,
|
||||
{ paddingBottom },
|
||||
] as ContentStyle
|
||||
}
|
||||
onRefresh={() => queryRes.refetch()}
|
||||
onEndReachedThreshold={0.5}
|
||||
onEndReached={() => queryRes.fetchNextPage()}
|
||||
ListHeaderComponent={
|
||||
<View
|
||||
style={styles.scrollViewTopPadding(
|
||||
controlsHeight,
|
||||
)}
|
||||
/>
|
||||
}
|
||||
renderItem={({ item }) => {
|
||||
return (
|
||||
<View style={styles.itemContainer}>
|
||||
{props.render(item as T1 | undefined)}
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
{...(props.layout === "grid"
|
||||
? {
|
||||
uniProps: (theme, rt) => ({
|
||||
keyExtractor: (_, idx) => {
|
||||
const item =
|
||||
queryRes.items?.at(idx);
|
||||
const columnCount =
|
||||
// @ts-expect-error
|
||||
theme.layout.grid.columnCount[
|
||||
rt.breakpoint!
|
||||
];
|
||||
if (!item) {
|
||||
return `skeleton-${idx}-cc:${columnCount}`;
|
||||
}
|
||||
return `item-${item.id}-cc:${columnCount}`;
|
||||
},
|
||||
}),
|
||||
}
|
||||
: {
|
||||
keyExtractor: (_, idx) => {
|
||||
const item = queryRes.items?.at(idx);
|
||||
if (!item) {
|
||||
return `skeleton-${idx}`;
|
||||
}
|
||||
return `item-${item.id}`;
|
||||
},
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const Grid = withUnistyles(FlashList, (theme, rt) => {
|
||||
// @ts-expect-error
|
||||
const colCount = theme.layout.grid.columnCount[rt.breakpoint!];
|
||||
return {
|
||||
onEndReachedThreshold: rt.screen.height,
|
||||
estimatedItemSize: rt.screen.width / colCount,
|
||||
numColumns: colCount,
|
||||
};
|
||||
});
|
||||
|
||||
const List = withUnistyles(FlashList, (_, rt) => ({
|
||||
numColumns: 1,
|
||||
onEndReachedThreshold: rt.screen.height,
|
||||
estimatedItemSize: 70, // TODO
|
||||
ItemSeparatorComponent: () => <Divider h withInsets />,
|
||||
}));
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
rootStyle: {
|
||||
flex: 1,
|
||||
position: "relative",
|
||||
height: "100%",
|
||||
maxWidth: breakpoints.xl,
|
||||
width: "100%",
|
||||
},
|
||||
body: { flex: 1 },
|
||||
optionalHeader: {},
|
||||
emptyState: { height: 300 },
|
||||
controls: (paddingTop: number) => ({
|
||||
paddingBottom: theme.gap(1.5),
|
||||
paddingTop: theme.gap(1.5) + paddingTop,
|
||||
paddingHorizontal: theme.gap(1),
|
||||
position: "absolute",
|
||||
zIndex: 1,
|
||||
width: "100%",
|
||||
}),
|
||||
scrollView: {
|
||||
paddingHorizontal: theme.gap(1),
|
||||
},
|
||||
//approximate so that the list start below controls
|
||||
//putting the actual controls in the ListHeaderComponent causes the dropdown to be misplaced
|
||||
scrollViewTopPadding: (controlsHeight: number | null) => ({
|
||||
paddingTop: controlsHeight == null ? theme.gap(8.25) : controlsHeight,
|
||||
}),
|
||||
itemContainer: {
|
||||
variants: {
|
||||
layout: {
|
||||
// TODO Gap between columns?
|
||||
grid: { width: "100%" },
|
||||
list: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,105 @@
|
||||
import type Illustration from "@/models/illustration";
|
||||
import { type Href, useRouter } from "expo-router";
|
||||
import type { ComponentProps } from "react";
|
||||
import { Pressable, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { RequireAtLeastOne } from "type-fest";
|
||||
import { Illustration as IllustrationComponent } from "~/components/illustration";
|
||||
import { LoadableText } from "../loadable_text";
|
||||
|
||||
type Props = {
|
||||
title: string | undefined;
|
||||
subtitle: string | null | undefined;
|
||||
illustration: Illustration | null | undefined;
|
||||
illustrationProps?: Omit<
|
||||
ComponentProps<typeof IllustrationComponent>,
|
||||
"illustration" | "quality"
|
||||
>;
|
||||
} & RequireAtLeastOne<{ href: Href | null; onPress: (() => void) | null }>;
|
||||
|
||||
//TODO Ripple or visual feedback on press
|
||||
|
||||
export const ListItem = ({
|
||||
title,
|
||||
subtitle,
|
||||
illustration,
|
||||
illustrationProps,
|
||||
href,
|
||||
onPress,
|
||||
}: Props) => {
|
||||
const router = useRouter();
|
||||
styles.useVariants({
|
||||
normalizedThumbnail: illustrationProps?.normalizedThumbnail ?? false,
|
||||
});
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
onPress?.();
|
||||
if (href) {
|
||||
router.push(href);
|
||||
}
|
||||
}}
|
||||
style={[styles.root]}
|
||||
>
|
||||
<View style={styles.illustration}>
|
||||
<IllustrationComponent
|
||||
illustration={illustration}
|
||||
{...illustrationProps}
|
||||
variant={illustrationProps?.variant ?? "center"}
|
||||
quality="low"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.textContainer}>
|
||||
<LoadableText
|
||||
content={title}
|
||||
skeletonWidth={15}
|
||||
variant="h6"
|
||||
numberOfLines={1}
|
||||
/>
|
||||
{subtitle !== null && (
|
||||
<LoadableText
|
||||
content={subtitle}
|
||||
skeletonWidth={10}
|
||||
variant="body"
|
||||
numberOfLines={1}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
borderRadius: theme.borderRadius,
|
||||
overflow: "hidden",
|
||||
width: "100%",
|
||||
justifyContent: "flex-start",
|
||||
gap: theme.gap(2),
|
||||
padding: theme.gap(1),
|
||||
},
|
||||
illustration: {
|
||||
variants: {
|
||||
normalizedThumbnail: {
|
||||
false: {
|
||||
aspectRatio: 1,
|
||||
width: 56,
|
||||
},
|
||||
true: {
|
||||
marginTop: theme.gap(0.5),
|
||||
marginBottom: theme.gap(0.5),
|
||||
aspectRatio: 16 / 9,
|
||||
width: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
textContainer: {
|
||||
display: "flex",
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
gap: theme.gap(1),
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { AlbumWithRelations } from "@/models/album";
|
||||
import { getYear } from "@/utils/date";
|
||||
import { type ComponentProps, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ListItem } from "../list-item";
|
||||
import { Tile } from "../tile";
|
||||
|
||||
type Props = {
|
||||
album: AlbumWithRelations<"artist" | "illustration"> | undefined;
|
||||
subtitle: "artistName" | "year";
|
||||
illustrationProps?: ComponentProps<typeof Tile>["illustrationProps"];
|
||||
onPress?: () => void;
|
||||
formatSubtitle?: (s: string) => string;
|
||||
};
|
||||
|
||||
export const AlbumItem = ({
|
||||
album,
|
||||
illustrationProps,
|
||||
subtitle,
|
||||
formatSubtitle,
|
||||
onPress,
|
||||
}: Props) => {
|
||||
const formattedSubtitle = useFormattedSubtitle({
|
||||
subtitle,
|
||||
formatSubtitle,
|
||||
album,
|
||||
});
|
||||
return (
|
||||
<ListItem
|
||||
title={album?.name}
|
||||
subtitle={formattedSubtitle}
|
||||
href={album?.masterId ? `/releases/${album.masterId}` : null}
|
||||
onPress={onPress}
|
||||
illustration={album?.illustration}
|
||||
illustrationProps={illustrationProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const AlbumTile = ({
|
||||
album,
|
||||
illustrationProps,
|
||||
subtitle,
|
||||
formatSubtitle,
|
||||
}: Props) => {
|
||||
const formattedSubtitle = useFormattedSubtitle({
|
||||
subtitle,
|
||||
formatSubtitle,
|
||||
album,
|
||||
});
|
||||
return (
|
||||
<Tile
|
||||
illustration={album?.illustration}
|
||||
illustrationProps={illustrationProps}
|
||||
title={album?.name}
|
||||
href={album?.masterId ? `/releases/${album.masterId}` : null}
|
||||
subtitle={formattedSubtitle}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormattedSubtitle = ({
|
||||
formatSubtitle,
|
||||
album,
|
||||
subtitle,
|
||||
}: Pick<Props, "formatSubtitle" | "album" | "subtitle">) => {
|
||||
const { t } = useTranslation();
|
||||
return useMemo(() => {
|
||||
const f = formatSubtitle ?? ((e: string) => e);
|
||||
if (album === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (subtitle === "year") {
|
||||
return f(getYear(album.releaseDate)?.toString() ?? "");
|
||||
}
|
||||
if (subtitle === "artistName") {
|
||||
return f(album.artist?.name ?? t("compilationArtistLabel"));
|
||||
}
|
||||
return null;
|
||||
}, [subtitle, formatSubtitle, album]);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ArtistWithRelations } from "@/models/artist";
|
||||
import type { ComponentProps } from "react";
|
||||
import { ListItem } from "../list-item";
|
||||
import { Tile } from "../tile";
|
||||
|
||||
type Props = {
|
||||
artist: ArtistWithRelations<"illustration"> | undefined;
|
||||
illustrationProps?: ComponentProps<typeof Tile>["illustrationProps"];
|
||||
onPress?: () => void;
|
||||
};
|
||||
|
||||
export const ArtistTile = ({ artist, illustrationProps, onPress }: Props) => {
|
||||
return (
|
||||
<Tile
|
||||
illustration={artist?.illustration}
|
||||
title={artist?.name}
|
||||
subtitle={null}
|
||||
onPress={onPress}
|
||||
href={artist ? `/artists/${artist.id}` : null}
|
||||
illustrationProps={{ ...illustrationProps, variant: "circle" }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ArtistItem = ({ artist, illustrationProps, onPress }: Props) => {
|
||||
return (
|
||||
<ListItem
|
||||
title={artist?.name}
|
||||
subtitle={null}
|
||||
href={artist ? `/artists/${artist.id}` : null}
|
||||
onPress={onPress}
|
||||
illustration={artist?.illustration}
|
||||
illustrationProps={{ ...illustrationProps, variant: "circle" }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ReleaseWithRelations } from "@/models/release";
|
||||
import { getYear } from "@/utils/date";
|
||||
import { type ComponentProps, useMemo } from "react";
|
||||
import { Tile } from "../tile";
|
||||
|
||||
const ReleaseTile = (props: {
|
||||
release: ReleaseWithRelations<"illustration"> | undefined;
|
||||
illustrationProps?: ComponentProps<typeof Tile>["illustrationProps"];
|
||||
}) => {
|
||||
const subtitle = useMemo(() => {
|
||||
if (!props.release) {
|
||||
return undefined;
|
||||
}
|
||||
const extensions = props.release.extensions;
|
||||
const yearFormat = props.release
|
||||
? (getYear(props.release.releaseDate)?.toString() ?? "")
|
||||
: "";
|
||||
if (extensions.length > 0 && yearFormat.length > 0) {
|
||||
return [extensions[0], yearFormat].join(" - ");
|
||||
}
|
||||
if (extensions.length > 0) {
|
||||
return extensions[0];
|
||||
}
|
||||
return yearFormat;
|
||||
}, [props.release]);
|
||||
|
||||
return (
|
||||
<Tile
|
||||
title={props.release?.name}
|
||||
subtitle={subtitle}
|
||||
href={props.release ? `/releases/${props.release.id}` : null}
|
||||
illustration={props.release?.illustration}
|
||||
illustrationProps={props.illustrationProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReleaseTile;
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { SearchResult } from "@/models/search";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View } from "react-native";
|
||||
import { AlbumItem } from "./album";
|
||||
import { ArtistItem } from "./artist";
|
||||
import { SongItem } from "./song";
|
||||
import { VideoItem } from "./video";
|
||||
|
||||
export const SearchResultItem = ({
|
||||
searchResult,
|
||||
onPress,
|
||||
}: { searchResult: SearchResult | undefined; onPress: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
if (!searchResult || searchResult.song) {
|
||||
return (
|
||||
<SongItem
|
||||
song={searchResult?.song}
|
||||
illustrationProps={{ simpleColorPlaceholder: true }}
|
||||
subtitle="artists"
|
||||
onPress={onPress}
|
||||
formatSubtitle={(s) => `${t("models.song")} • ${s}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (searchResult.album) {
|
||||
return (
|
||||
<AlbumItem
|
||||
album={searchResult.album}
|
||||
illustrationProps={{ simpleColorPlaceholder: true }}
|
||||
subtitle="artistName"
|
||||
onPress={onPress}
|
||||
formatSubtitle={(s) => `${t("models.album")} • ${s}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (searchResult.artist) {
|
||||
return (
|
||||
<ArtistItem
|
||||
artist={searchResult.artist}
|
||||
illustrationProps={{ simpleColorPlaceholder: true }}
|
||||
onPress={onPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (searchResult.video) {
|
||||
return (
|
||||
<VideoItem
|
||||
video={searchResult.video}
|
||||
illustrationProps={{ simpleColorPlaceholder: true }}
|
||||
onPress={onPress}
|
||||
subtitle="artistName"
|
||||
formatSubtitle={(s) => `${t("models.video")} • ${s}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// biome-ignore lint/suspicious/noConsole: <explanation>
|
||||
console.error("Unexpected search result");
|
||||
// biome-ignore lint/suspicious/noConsole: <explanation>
|
||||
console.error(searchResult);
|
||||
return <View />;
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { SongWithRelations } from "@/models/song";
|
||||
import formatArtists from "@/utils/format-artists";
|
||||
import { type ComponentProps, useMemo } from "react";
|
||||
import { ListItem } from "../list-item";
|
||||
|
||||
type Props = {
|
||||
song:
|
||||
| SongWithRelations<"illustration" | "artist" | "featuring">
|
||||
| undefined;
|
||||
illustrationProps: ComponentProps<typeof ListItem>["illustrationProps"];
|
||||
subtitle: "artists" | null | undefined;
|
||||
formatSubtitle?: (s: string) => string;
|
||||
onPress?: () => void;
|
||||
};
|
||||
|
||||
export const SongItem = ({
|
||||
song,
|
||||
illustrationProps,
|
||||
subtitle,
|
||||
formatSubtitle,
|
||||
onPress,
|
||||
}: Props) => {
|
||||
const formattedSubtitle = useMemo(() => {
|
||||
const f = formatSubtitle ?? ((e: string) => e);
|
||||
if (subtitle === null) {
|
||||
return null;
|
||||
}
|
||||
if (song === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (subtitle === "artists") {
|
||||
return f(formatArtists(song.artist, song.featuring));
|
||||
}
|
||||
return null;
|
||||
}, [subtitle, formatSubtitle, song]);
|
||||
return (
|
||||
<ListItem
|
||||
title={song?.name}
|
||||
subtitle={formattedSubtitle}
|
||||
onPress={() => {
|
||||
onPress?.();
|
||||
}} // TODO Launch playback
|
||||
illustration={song?.illustration}
|
||||
illustrationProps={illustrationProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { VideoWithRelations } from "@/models/video";
|
||||
import formatDuration from "@/utils/format-duration";
|
||||
import { type ComponentProps, useMemo } from "react";
|
||||
import { ListItem } from "../list-item";
|
||||
import { Tile } from "../tile";
|
||||
type Props = {
|
||||
illustrationProps?: ComponentProps<typeof Tile>["illustrationProps"];
|
||||
formatSubtitle?: (s: string) => string;
|
||||
onPress?: () => void;
|
||||
} & (
|
||||
| {
|
||||
subtitle: "duration";
|
||||
video: VideoWithRelations<"illustration" | "master"> | undefined;
|
||||
}
|
||||
| {
|
||||
subtitle: "artistName";
|
||||
|
||||
video: VideoWithRelations<"artist" | "illustration"> | undefined;
|
||||
}
|
||||
);
|
||||
|
||||
export const VideoTile = (props: Props) => {
|
||||
const formattedSubtitle = useFormattedSubtitle(props);
|
||||
return (
|
||||
<Tile
|
||||
illustration={props.video?.illustration}
|
||||
illustrationProps={props.illustrationProps}
|
||||
title={props.video?.name}
|
||||
onPress={() => {
|
||||
props.onPress?.();
|
||||
}} // TODO Launch playback
|
||||
subtitle={formattedSubtitle}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const VideoItem = (props: Props) => {
|
||||
const formattedSubtitle = useFormattedSubtitle(props);
|
||||
return (
|
||||
<ListItem
|
||||
title={props.video?.name}
|
||||
subtitle={formattedSubtitle}
|
||||
onPress={() => {
|
||||
props.onPress?.();
|
||||
}} // TODO Launch playback
|
||||
illustration={props.video?.illustration}
|
||||
illustrationProps={props.illustrationProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const useFormattedSubtitle = ({ formatSubtitle, video, subtitle }: Props) => {
|
||||
return useMemo(() => {
|
||||
const f = formatSubtitle ?? ((e: string) => e);
|
||||
if (video === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (subtitle === "duration") {
|
||||
return f(formatDuration(video.master.duration));
|
||||
}
|
||||
if (subtitle === "artistName") {
|
||||
return f(video.artist.name);
|
||||
}
|
||||
return null;
|
||||
}, [subtitle, formatSubtitle, video]);
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import type IllustrationModel from "@/models/illustration";
|
||||
import { type Href, useRouter } from "expo-router";
|
||||
import type { ComponentProps } from "react";
|
||||
import { Pressable, View, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { RequireAtLeastOne } from "type-fest";
|
||||
import { Illustration } from "~/components/illustration";
|
||||
import { LoadableText } from "~/components/loadable_text";
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
padding: theme.gap(0.5),
|
||||
height: "auto",
|
||||
width: "100%",
|
||||
borderRadius: theme.borderRadius,
|
||||
overflow: "hidden",
|
||||
},
|
||||
imageContainer: {
|
||||
variants: {
|
||||
normalizedThumbnail: {
|
||||
true: { aspectRatio: 16 / 9 },
|
||||
false: { aspectRatio: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
textColumn: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
paddingHorizontal: theme.gap(0.5),
|
||||
paddingVertical: theme.gap(1),
|
||||
gap: theme.gap(0.5),
|
||||
variants: {
|
||||
normalizedThumbnail: {
|
||||
true: {},
|
||||
false: {},
|
||||
},
|
||||
hasSubtitle: {
|
||||
true: { alignItems: "flex-start" },
|
||||
false: { alignItems: "center" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
type Props = {
|
||||
illustration: IllustrationModel | null | undefined;
|
||||
illustrationProps?: Omit<
|
||||
ComponentProps<typeof Illustration>,
|
||||
"illustration" | "quality"
|
||||
>;
|
||||
title: string | undefined;
|
||||
subtitle: string | undefined | null;
|
||||
containerStyle?: ViewStyle;
|
||||
} & RequireAtLeastOne<{ href: Href | null; onPress: (() => void) | null }>;
|
||||
|
||||
export const Tile = ({
|
||||
illustration,
|
||||
title,
|
||||
subtitle,
|
||||
href,
|
||||
onPress,
|
||||
...props
|
||||
}: Props) => {
|
||||
styles.useVariants({
|
||||
hasSubtitle: subtitle !== null,
|
||||
normalizedThumbnail:
|
||||
props.illustrationProps?.normalizedThumbnail ?? false,
|
||||
});
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
onPress?.();
|
||||
if (href) {
|
||||
router.push(href);
|
||||
}
|
||||
}}
|
||||
style={[styles.container, props.containerStyle]}
|
||||
>
|
||||
<View style={styles.imageContainer}>
|
||||
<Illustration
|
||||
illustration={illustration}
|
||||
{...props.illustrationProps}
|
||||
quality="medium"
|
||||
/>
|
||||
</View>
|
||||
<View style={[styles.textColumn]}>
|
||||
<LoadableText
|
||||
variant="h6"
|
||||
numberOfLines={1}
|
||||
skeletonWidth={10}
|
||||
content={title}
|
||||
/>
|
||||
{subtitle !== null && (
|
||||
<LoadableText
|
||||
variant="body"
|
||||
skeletonWidth={8}
|
||||
numberOfLines={1}
|
||||
content={subtitle}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* 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 type { ComponentProps } from "react";
|
||||
import { View } from "react-native";
|
||||
import { Text, TextSkeleton } from "~/primitives/text";
|
||||
|
||||
type Props = Omit<ComponentProps<typeof Text>, "content" | "children"> & {
|
||||
content: string | undefined;
|
||||
skeletonWidth: ComponentProps<typeof TextSkeleton>["width"];
|
||||
};
|
||||
|
||||
export const LoadableText = ({ content, ...props }: Props) => {
|
||||
return (
|
||||
<View>
|
||||
{content === undefined ? (
|
||||
<TextSkeleton
|
||||
variant={props.variant}
|
||||
width={props.skeletonWidth}
|
||||
/>
|
||||
) : (
|
||||
<Text {...props}>{content}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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 { Image } from "expo-image";
|
||||
import type { ImageProps } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
|
||||
const styles = StyleSheet.create((_theme) => ({
|
||||
image: {
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
},
|
||||
icon: { aspectRatio: 1, height: "100%" },
|
||||
}));
|
||||
|
||||
const BannerBase = withUnistyles(
|
||||
Image,
|
||||
(theme) =>
|
||||
({
|
||||
priority: "high",
|
||||
contentFit: "contain",
|
||||
source:
|
||||
theme.name === "dark"
|
||||
? require("../../assets/banner1_white.png")
|
||||
: require("../../assets/banner1_black.png"),
|
||||
}) as const,
|
||||
);
|
||||
|
||||
export const Banner = (props: Pick<ImageProps, "style">) => {
|
||||
return <BannerBase style={[styles.image, props.style]} />;
|
||||
};
|
||||
|
||||
const IconBase = withUnistyles(
|
||||
Image,
|
||||
(theme) =>
|
||||
({
|
||||
priority: "high",
|
||||
contentFit: "contain",
|
||||
source:
|
||||
theme.name === "light"
|
||||
? require("../../assets/icon-black.png")
|
||||
: require("../../assets/icon-white.png"),
|
||||
}) as const,
|
||||
);
|
||||
|
||||
export const Icon = (props: Pick<ImageProps, "style">) => {
|
||||
return <IconBase style={[styles.icon, props.style]} />;
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { StarIcon } from "@/ui/icons";
|
||||
import { generateArray } from "@/utils/gen-list";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Icon } from "~/primitives/icon";
|
||||
import { TextSkeleton } from "~/primitives/text";
|
||||
|
||||
export const Rating = ({
|
||||
rating,
|
||||
}: {
|
||||
// Out of 100
|
||||
rating: number | undefined;
|
||||
}) => {
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
{rating ? (
|
||||
generateArray(5, undefined).map((_, idx) => (
|
||||
<Icon
|
||||
key={idx}
|
||||
icon={StarIcon}
|
||||
style={styles.star(rating ?? 0, idx)}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<TextSkeleton width={11} />
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flexDirection: "row",
|
||||
gap: theme.gap(0.25),
|
||||
justifyContent: "center",
|
||||
},
|
||||
star: (rating: number, starIndex: number) => ({
|
||||
size: theme.fontSize.rem(1),
|
||||
color:
|
||||
Math.ceil(rating / 20) >= starIndex + 1
|
||||
? theme.colors.text.primary
|
||||
: theme.colors.text.secondary,
|
||||
}),
|
||||
}));
|
||||
@@ -0,0 +1,132 @@
|
||||
import type Resource from "@/models/resource";
|
||||
import type { Href } from "expo-router";
|
||||
import type React from "react";
|
||||
import { createRef, useMemo } from "react";
|
||||
import { FlatList, View, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { EmptyState } from "~/components/empty-state";
|
||||
import { SectionHeader } from "./section-header";
|
||||
|
||||
type Props<T> = {
|
||||
items?: (T | undefined)[];
|
||||
header?: string;
|
||||
style?: ViewStyle;
|
||||
seeMore?: Href;
|
||||
render: (item: T | undefined) => React.ReactElement;
|
||||
onEndReached?: () => void;
|
||||
hideIfEmpty?: true;
|
||||
};
|
||||
|
||||
export const Row = <T extends Resource>({ items, ...props }: Props<T>) => {
|
||||
const itemList = useMemo(() => {
|
||||
return items ?? [undefined];
|
||||
}, [items]);
|
||||
|
||||
return <RowBase {...props} items={itemList} />;
|
||||
};
|
||||
|
||||
const RowBase = <T,>({
|
||||
items,
|
||||
header,
|
||||
hideIfEmpty,
|
||||
style,
|
||||
render,
|
||||
onEndReached,
|
||||
seeMore,
|
||||
}: Props<T>) => {
|
||||
const flatListRef = createRef<FlatList<unknown>>();
|
||||
return (
|
||||
(!hideIfEmpty || (items && items.length > 0)) && (
|
||||
<View style={[styles.root, style]}>
|
||||
{header && (
|
||||
<SectionHeader
|
||||
seeMore={seeMore}
|
||||
onPress={() =>
|
||||
flatListRef.current?.scrollToIndex({
|
||||
animated: true,
|
||||
index: 0,
|
||||
})
|
||||
}
|
||||
skeletonWidth={header.length}
|
||||
content={
|
||||
items === undefined || items[0] === undefined
|
||||
? undefined
|
||||
: header
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{items?.length !== 0 ? (
|
||||
<ResponsiveFlatList
|
||||
horizontal
|
||||
snapToAlignment="start"
|
||||
decelerationRate={"normal"}
|
||||
ref={flatListRef}
|
||||
data={items}
|
||||
onEndReached={onEndReached}
|
||||
contentContainerStyle={styles.row}
|
||||
renderItem={({ item, index }) => {
|
||||
return (
|
||||
<View style={[styles.item(index)]}>
|
||||
{render(item as T | undefined)}
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.emptyState}>
|
||||
<EmptyState />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme, rt) => ({
|
||||
root: { display: "flex", alignItems: "flex-start", width: "100%" },
|
||||
emptyState: {
|
||||
aspectRatio: 2.5, // TODO this an approximate, would be nice to compute this correctly
|
||||
width: "100%",
|
||||
},
|
||||
row: { gap: theme.gap(0.5), paddingRight: theme.gap(1) },
|
||||
item: (itemIndex: number) => ({
|
||||
marginLeft: itemIndex === 0 ? theme.gap(1) : 0,
|
||||
// @ts-expect-error
|
||||
width: rt.screen.width / theme.layout.grid.columnCount[rt.breakpoint!],
|
||||
}),
|
||||
}));
|
||||
|
||||
const ResponsiveFlatList = withUnistyles(FlatList, (theme, rt) => ({
|
||||
// @ts-expect-error
|
||||
initialNumToRender: theme.layout.grid.columnCount[rt.breakpoint!],
|
||||
snapToInterval:
|
||||
// TODO The interval is not correct,
|
||||
// the further we scroll the more the snap position is shifted to the left
|
||||
theme.gap(0.5) +
|
||||
// @ts-expect-error
|
||||
rt.screen.width / theme.layout.grid.columnCount[rt.breakpoint!],
|
||||
}));
|
||||
|
||||
// export const InfiniteRow = <T0 extends Resource, T extends Resource>({
|
||||
// query,
|
||||
// ...props
|
||||
// }: Props<T0, T>) => {
|
||||
// const { items, isFetching, isFetchingNextPage, fetchNextPage } =
|
||||
// useInfiniteQuery(() => query);
|
||||
//
|
||||
// const itemList = useMemo(() => {
|
||||
// const itemCount = items?.length ?? 0;
|
||||
// if ((isFetching && !itemCount) || isFetchingNextPage) {
|
||||
// return [...(items ?? []), undefined];
|
||||
// }
|
||||
// return items ?? [undefined];
|
||||
// }, [items, isFetching, isFetchingNextPage]);
|
||||
// return (
|
||||
// <RowBase
|
||||
// {...props}
|
||||
// items={itemList}
|
||||
// onEndReached={() => fetchNextPage()}
|
||||
// />
|
||||
// );
|
||||
// };
|
||||
//
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
ScrollView,
|
||||
type ScrollViewProps,
|
||||
View,
|
||||
type ViewProps,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
|
||||
import { useRootViewStyle } from "~/hooks/root-view-style";
|
||||
|
||||
// ScrollView that adds padding at the top and bottom to avoid header + tabbar
|
||||
export const SafeScrollView = (
|
||||
props: Omit<ScrollViewProps, "contentContainerStyle"> & {
|
||||
contentContainerStyle?: ViewStyle[];
|
||||
},
|
||||
) => {
|
||||
const { paddingTop, ...rootStyle } = useRootViewStyle();
|
||||
return (
|
||||
<ScrollView
|
||||
{...props}
|
||||
contentContainerStyle={[
|
||||
...(props.contentContainerStyle ?? []),
|
||||
rootStyle,
|
||||
{
|
||||
paddingTop: mergePaddingTop(
|
||||
paddingTop,
|
||||
props.contentContainerStyle,
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// View that adds padding at the top and bottom to avoid header + tabbar
|
||||
export const SafeView = (
|
||||
props: Omit<ViewProps, "style"> & {
|
||||
style?: ViewStyle[];
|
||||
},
|
||||
) => {
|
||||
const { paddingTop, ...rootStyle } = useRootViewStyle();
|
||||
return (
|
||||
<View
|
||||
{...props}
|
||||
style={[
|
||||
...(props.style ?? []),
|
||||
rootStyle,
|
||||
{
|
||||
paddingTop: mergePaddingTop(paddingTop, props.style),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const mergePaddingTop = (
|
||||
paddingTop: number,
|
||||
styles: ViewStyle[] | undefined,
|
||||
) => {
|
||||
return (
|
||||
paddingTop +
|
||||
(styles
|
||||
?.map(
|
||||
(s) =>
|
||||
((s.padding ??
|
||||
s.paddingVertical ??
|
||||
s.paddingTop) as number) || 0,
|
||||
)
|
||||
?.reduce((p, s) => p + s) ?? 0)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { MoreIcon } from "@/ui/icons";
|
||||
import { type Href, useRouter } from "expo-router";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { Button } from "~/primitives/button";
|
||||
import { Pressable } from "~/primitives/pressable";
|
||||
import { LoadableText } from "./loadable_text";
|
||||
|
||||
type Props = {
|
||||
onPress?: () => void;
|
||||
style?: ViewStyle;
|
||||
seeMore?: Href;
|
||||
trailing?: React.ReactElement;
|
||||
} & Omit<ComponentProps<typeof LoadableText>, "variant">;
|
||||
|
||||
//TODO Avoid text to be too large
|
||||
|
||||
export const SectionHeader = ({
|
||||
onPress,
|
||||
style,
|
||||
seeMore,
|
||||
trailing,
|
||||
...textProps
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<View style={[styles.root, style]}>
|
||||
<Pressable onPress={() => onPress?.()} disabled={!onPress}>
|
||||
<LoadableText {...textProps} variant="h4" numberOfLines={1} />
|
||||
</Pressable>
|
||||
{trailing ||
|
||||
(seeMore !== undefined && (
|
||||
<Button
|
||||
containerStyle={styles.button(
|
||||
textProps.content === undefined,
|
||||
)}
|
||||
size="small"
|
||||
title={t("browsing.seeAll")}
|
||||
icon={MoreIcon}
|
||||
iconPosition="right"
|
||||
onPress={() => router.push(seeMore)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
width: "100%",
|
||||
paddingHorizontal: theme.gap(1),
|
||||
paddingRight: theme.gap(2),
|
||||
marginBottom: theme.gap(1),
|
||||
},
|
||||
button: (isLoading: boolean) => ({
|
||||
opacity: isLoading ? 0 : 1,
|
||||
}),
|
||||
}));
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { SongWithRelations } from "@/models/song";
|
||||
import { generateArray } from "@/utils/gen-list";
|
||||
import type { Href } from "expo-router";
|
||||
import { type ComponentProps, Fragment, createRef, useMemo } from "react";
|
||||
import { ScrollView, View, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { ListItem } from "~/components/item/list-item";
|
||||
import { SongItem } from "~/components/item/resource/song";
|
||||
import { SectionHeader } from "~/components/section-header";
|
||||
import { Divider } from "~/primitives/divider";
|
||||
import { breakpoints } from "~/theme";
|
||||
|
||||
type Song = SongWithRelations<
|
||||
"artist" | "featuring" | "master" | "illustration"
|
||||
>;
|
||||
|
||||
type Props = {
|
||||
songs: Song[] | undefined;
|
||||
style?: ViewStyle;
|
||||
seeMore?: Href;
|
||||
header?: string;
|
||||
hideIfEmpty?: true;
|
||||
illustrationProps?: ComponentProps<typeof ListItem>["illustrationProps"];
|
||||
subtitle: null | ((song: Song) => "artists" | null);
|
||||
};
|
||||
|
||||
const ItemsPerColumn = 4;
|
||||
|
||||
export const SongGrid = ({
|
||||
songs,
|
||||
subtitle,
|
||||
header,
|
||||
illustrationProps,
|
||||
hideIfEmpty,
|
||||
seeMore,
|
||||
...props
|
||||
}: Props) => {
|
||||
const scrollViewRef = createRef<ScrollView>();
|
||||
const chunks = useMemo(() => {
|
||||
if (songs === undefined) {
|
||||
return [generateArray(ItemsPerColumn, undefined)];
|
||||
}
|
||||
const res: Song[][] = [];
|
||||
for (let i = 0; i < songs.length; i += ItemsPerColumn) {
|
||||
res.push(songs.slice(i, i + ItemsPerColumn));
|
||||
}
|
||||
return res;
|
||||
}, [songs]);
|
||||
return (
|
||||
(songs === undefined ||
|
||||
(!hideIfEmpty && songs.length === 0) ||
|
||||
songs.length > 0) && (
|
||||
<View style={[styles.root, props.style]}>
|
||||
{header && (
|
||||
<SectionHeader
|
||||
seeMore={seeMore}
|
||||
onPress={() =>
|
||||
scrollViewRef.current?.scrollTo({
|
||||
x: 0,
|
||||
animated: true,
|
||||
})
|
||||
}
|
||||
skeletonWidth={header.length}
|
||||
content={songs === undefined ? undefined : header}
|
||||
/>
|
||||
)}
|
||||
<SnappyScrollView
|
||||
horizontal
|
||||
snapToAlignment="start"
|
||||
decelerationRate={"normal"}
|
||||
ref={scrollViewRef}
|
||||
style={styles.scrollView}
|
||||
>
|
||||
{chunks.map((chunk, chunkIdx) => (
|
||||
<View
|
||||
key={chunkIdx}
|
||||
style={styles.column(
|
||||
chunkIdx,
|
||||
chunkIdx === chunks.length - 1,
|
||||
)}
|
||||
>
|
||||
{chunk.map((item, idx) => (
|
||||
<Fragment
|
||||
key={
|
||||
item?.id.toString() ?? `skeleton-${idx}`
|
||||
}
|
||||
>
|
||||
<SongItem
|
||||
song={item}
|
||||
subtitle={
|
||||
subtitle === null
|
||||
? null
|
||||
: item
|
||||
? subtitle(item)
|
||||
: undefined
|
||||
}
|
||||
illustrationProps={illustrationProps}
|
||||
/>
|
||||
|
||||
{idx !== chunk.length - 1 && (
|
||||
<Divider h withInsets />
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</SnappyScrollView>
|
||||
</View>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const ColumnWidthRatio = 0.9;
|
||||
|
||||
const styles = StyleSheet.create((theme, rt) => ({
|
||||
root: { display: "flex", alignItems: "flex-start" },
|
||||
scrollView: {},
|
||||
column: (idx: number, isLast: boolean) => ({
|
||||
paddingRight: isLast ? theme.gap(4) : theme.gap(1),
|
||||
width: rt.screen.width * (!isLast ? ColumnWidthRatio : 1),
|
||||
maxWidth: breakpoints.sm * ColumnWidthRatio,
|
||||
marginLeft: idx === 0 && !isLast ? theme.gap(1) : 0,
|
||||
// Preventing single-col grids to be scrollable
|
||||
paddingLeft: idx === 0 && isLast ? theme.gap(1) : 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
const SnappyScrollView = withUnistyles(ScrollView, (_, rt) => ({
|
||||
//TODO Check on other screen sizes
|
||||
snapToInterval:
|
||||
(rt.screen.width >= breakpoints.sm ? breakpoints.sm : rt.screen.width) *
|
||||
ColumnWidthRatio,
|
||||
}));
|
||||
Loaded 100 of 823 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user