mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-31 11:38:47 -05:00
Compare commits
909 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e81b3461b2 | ||
|
|
9345cb3934 | ||
|
|
eb36a0b3dd | ||
|
|
7e442ecb3d | ||
|
|
f07c5eb725 | ||
|
|
a486be92cb | ||
|
|
4d84060036 | ||
|
|
fc503691fe | ||
|
|
c80dd43a3e | ||
|
|
a4a62e0c18 | ||
|
|
2f98cb9b6d | ||
|
|
91dc6eebb0 | ||
|
|
d72e0a4418 | ||
|
|
2c8ebd43cc | ||
|
|
9f561aa296 | ||
|
|
930bacd45d | ||
|
|
45c97a778d | ||
|
|
6ebc64f73b | ||
|
|
52807d0d49 | ||
|
|
a5e18e99bc | ||
|
|
f545b3e745 | ||
|
|
e0877803e3 | ||
|
|
4916887c8d | ||
|
|
20eb573897 | ||
|
|
8ff7b6b6e6 | ||
|
|
06eaee8909 | ||
|
|
8f9487ba70 | ||
|
|
eca51457b7 | ||
|
|
15c6fce648 | ||
|
|
6c872263c6 | ||
|
|
4d3b3d1740 | ||
|
|
bba8920855 | ||
|
|
f56b9487ff | ||
|
|
1946d8296b | ||
|
|
41e5d7f820 | ||
|
|
2507568103 | ||
|
|
19733798fa | ||
|
|
427d6da360 | ||
|
|
2b67d3d1c5 | ||
|
|
6926a40ad6 | ||
|
|
7a8da5bf3a | ||
|
|
fc8fa17c6f | ||
|
|
0a88659a9f | ||
|
|
9967858c44 | ||
|
|
e2ce388f90 | ||
|
|
f31649f1d2 | ||
|
|
a55c167dde | ||
|
|
642cf232ba | ||
|
|
164b4525c4 | ||
|
|
39c26d2bee | ||
|
|
2a69955cc1 | ||
|
|
4a5345dd5d | ||
|
|
1e6dd0e3e0 | ||
|
|
91cca2e358 | ||
|
|
816a9be618 | ||
|
|
9eb0ec76fe | ||
|
|
49054d5239 | ||
|
|
787c4e45a8 | ||
|
|
34cb7a4d02 | ||
|
|
006241163b | ||
|
|
03818fadee | ||
|
|
897c3ea625 | ||
|
|
73e4293f04 | ||
|
|
6f5ffcb1f8 | ||
|
|
ed70f3af83 | ||
|
|
73196f9be8 | ||
|
|
a77f4e9d77 | ||
|
|
294490f814 | ||
|
|
6183001fca | ||
|
|
3ac604c665 | ||
|
|
e342b07cd0 | ||
|
|
b524cbd1b3 | ||
|
|
88693d73bd | ||
|
|
2c453a34ee | ||
|
|
3d2b2e43b1 | ||
|
|
c3f3fca896 | ||
|
|
dedf6e5d4b | ||
|
|
6c379fc3a7 | ||
|
|
329e9c9eb2 | ||
|
|
ee53086444 | ||
|
|
43d6c6678f | ||
|
|
82f136ba79 | ||
|
|
e40d3dd64d | ||
|
|
a5897fd64b | ||
|
|
e786e3c057 | ||
|
|
d347645475 | ||
|
|
215b78c162 | ||
|
|
ee271519f9 | ||
|
|
b350277bbc | ||
|
|
604ae080ac | ||
|
|
a191dab359 | ||
|
|
3223011b13 | ||
|
|
f746e246e4 | ||
|
|
0476b68585 | ||
|
|
ec395bed72 | ||
|
|
bff56220c2 | ||
|
|
3006405a52 | ||
|
|
9cd0ac80b1 | ||
|
|
da51d38ba2 | ||
|
|
5ba6459069 | ||
|
|
75899242fd | ||
|
|
7faf42d892 | ||
|
|
10f5f331d7 | ||
|
|
b1414388e1 | ||
|
|
eb0f5b2e1b | ||
|
|
7af02ad2e2 | ||
|
|
8330dabc46 | ||
|
|
dbc7ad0b3b | ||
|
|
c0fd24770e | ||
|
|
4289fe4990 | ||
|
|
e925e9b23f | ||
|
|
71cd86fdd5 | ||
|
|
03be947ad6 | ||
|
|
96f9084f2e | ||
|
|
bbccfcbd12 | ||
|
|
9a697f48db | ||
|
|
37ad1cced2 | ||
|
|
26db20f63d | ||
|
|
ff788e3591 | ||
|
|
4b482488de | ||
|
|
e230b6640f | ||
|
|
2bc949fae3 | ||
|
|
b1bc472205 | ||
|
|
5c7a38c292 | ||
|
|
bbd6c51eb6 | ||
|
|
d17f9b0687 | ||
|
|
4d2bdb6eee | ||
|
|
b6a1014c72 | ||
|
|
b99885c806 | ||
|
|
f422c9b820 | ||
|
|
0befe91360 | ||
|
|
da671e3fd5 | ||
|
|
fec94c18aa | ||
|
|
11c6fc7d90 | ||
|
|
7ea5e7dc95 | ||
|
|
2a98e2c361 | ||
|
|
7fb499b301 | ||
|
|
af9aee76cf | ||
|
|
075ec15f02 | ||
|
|
1c650473f8 | ||
|
|
0efdf50821 | ||
|
|
df65ef2191 | ||
|
|
bc3b1d9565 | ||
|
|
2998d3ba6a | ||
|
|
ea11153032 | ||
|
|
733f61075f | ||
|
|
618e69775c | ||
|
|
eabfa90121 | ||
|
|
43b7ccd61a | ||
|
|
b6875a44cf | ||
|
|
c0004dd532 | ||
|
|
0ee3b89760 | ||
|
|
c5e60d30e1 | ||
|
|
acaf1ac196 | ||
|
|
8dc4538c95 | ||
|
|
e224fd2595 | ||
|
|
f0a1ea4d6d | ||
|
|
10cb8ebf3b | ||
|
|
8c4afa1866 | ||
|
|
eb5af47bbf | ||
|
|
4fd93ce64c | ||
|
|
7ba4e9e66d | ||
|
|
e2e5449d25 | ||
|
|
abc76ca155 | ||
|
|
0fc84a8684 | ||
|
|
a76600e53b | ||
|
|
e55cf30705 | ||
|
|
2c65b8fd2b | ||
|
|
20b8e35132 | ||
|
|
8007225a41 | ||
|
|
63a6da6680 | ||
|
|
93114b2181 | ||
|
|
f6dd3de8e7 | ||
|
|
0918391636 | ||
|
|
972b4f7388 | ||
|
|
af92ae4d51 | ||
|
|
3bc6426cc7 | ||
|
|
acfbbd5aec | ||
|
|
9b677be12e | ||
|
|
2f2ec2ec1f | ||
|
|
e05ab14ad2 | ||
|
|
9074e9ed88 | ||
|
|
1e5cb09ada | ||
|
|
b0f1827e3c | ||
|
|
ae7713bacc | ||
|
|
b6c185eebe | ||
|
|
5114be0773 | ||
|
|
9a4c5a16ef | ||
|
|
e6b1acfb44 | ||
|
|
1e5787c60d | ||
|
|
928b080677 | ||
|
|
3764ef14a9 | ||
|
|
92aae736c4 | ||
|
|
31c8cb476a | ||
|
|
3a2f786517 | ||
|
|
7c0b4e35d7 | ||
|
|
0461b57e6c | ||
|
|
a1688488e5 | ||
|
|
4d24817ced | ||
|
|
d46de541d6 | ||
|
|
37f62d22b6 | ||
|
|
79bd6a25d9 | ||
|
|
0042604e6d | ||
|
|
b01ef1c691 | ||
|
|
277ff8a5a5 | ||
|
|
d5f991ae4a | ||
|
|
54f2bb1092 | ||
|
|
6b6df619f5 | ||
|
|
fed5ff4863 | ||
|
|
43217657d7 | ||
|
|
fa1518cb1d | ||
|
|
6d14ed8a72 | ||
|
|
b8e17de8b4 | ||
|
|
e60a91379a | ||
|
|
046bf52d88 | ||
|
|
bfc3c7e7c9 | ||
|
|
dd1d2b7c92 | ||
|
|
8bdee51798 | ||
|
|
5858b64fc6 | ||
|
|
4baa89c8e1 | ||
|
|
1b015beba4 | ||
|
|
ebaec23648 | ||
|
|
d5e00c8bbd | ||
|
|
4732ca8119 | ||
|
|
134c2580c9 | ||
|
|
8e286a6070 | ||
|
|
d7ace4d1dc | ||
|
|
a21b1f3b16 | ||
|
|
c309856f74 | ||
|
|
31146082f0 | ||
|
|
6fbbc65edf | ||
|
|
c1349e586a | ||
|
|
8985ebebe2 | ||
|
|
394a004ff5 | ||
|
|
33e6ad4ad6 | ||
|
|
05a0793a9c | ||
|
|
3a5e9cd865 | ||
|
|
a7cd79850d | ||
|
|
386edb0427 | ||
|
|
6c1e25e964 | ||
|
|
a6a956fc28 | ||
|
|
fb7d6807e2 | ||
|
|
e9f8ca1c14 | ||
|
|
c669ca5be1 | ||
|
|
6dd0fb4225 | ||
|
|
709f9a65fa | ||
|
|
3c888d2876 | ||
|
|
aca39011bb | ||
|
|
f6fc53d7d8 | ||
|
|
599623570b | ||
|
|
67b47785a0 | ||
|
|
56c0124c13 | ||
|
|
f9e270e4be | ||
|
|
8cadaa57f6 | ||
|
|
042035051d | ||
|
|
12ce3a6147 | ||
|
|
9bf4bd9bfa | ||
|
|
2819317924 | ||
|
|
e06ab594e1 | ||
|
|
04a65648a3 | ||
|
|
2673742d8d | ||
|
|
090c02079d | ||
|
|
514fb5f7da | ||
|
|
f541bc2159 | ||
|
|
d70810364c | ||
|
|
09d7880779 | ||
|
|
c69e6bff10 | ||
|
|
b49c2e7b82 | ||
|
|
d012b2107d | ||
|
|
9294521632 | ||
|
|
7d05317357 | ||
|
|
2843a3b6d7 | ||
|
|
635f22ddfe | ||
|
|
903b685e1a | ||
|
|
09bcc1191f | ||
|
|
d6eae9b43e | ||
|
|
f95d9bd0e9 | ||
|
|
e52b695f7e | ||
|
|
72c1407aa7 | ||
|
|
2ec49cbdb1 | ||
|
|
331d7a41ab | ||
|
|
8498cab842 | ||
|
|
c170cb3132 | ||
|
|
0c58c9060e | ||
|
|
e3c3903c71 | ||
|
|
7bc70effb0 | ||
|
|
991da2870f | ||
|
|
52b632d810 | ||
|
|
33531ff73b | ||
|
|
391a777dde | ||
|
|
85e7b63532 | ||
|
|
b02429cf55 | ||
|
|
9e064e670a | ||
|
|
61b3785038 | ||
|
|
a75ad5d659 | ||
|
|
516a3858c5 | ||
|
|
364787db72 | ||
|
|
b2562ede55 | ||
|
|
c441d83d39 | ||
|
|
08c6cc674b | ||
|
|
9c34e4bd14 | ||
|
|
9b159fc1e6 | ||
|
|
bcc2fa409e | ||
|
|
360d54847c | ||
|
|
b25314b4bd | ||
|
|
c87f2a571e | ||
|
|
8be02303f9 | ||
|
|
c6b4694b22 | ||
|
|
4762cdb7d8 | ||
|
|
fe2a07bf4b | ||
|
|
9f80900717 | ||
|
|
6b001ad7a1 | ||
|
|
4241544aaf | ||
|
|
80bcc71c72 | ||
|
|
253095dcd6 | ||
|
|
0e4109a7c2 | ||
|
|
629741db92 | ||
|
|
79236dd67d | ||
|
|
bdfb7b9af3 | ||
|
|
665244f1b2 | ||
|
|
b74f13bbd7 | ||
|
|
d1ee3af2d9 | ||
|
|
38fa4d4169 | ||
|
|
56d3ed5a8e | ||
|
|
cadef9b023 | ||
|
|
34b340f179 | ||
|
|
b89bbd2187 | ||
|
|
d6438590d7 | ||
|
|
baf5f7fbc3 | ||
|
|
e6a2555f05 | ||
|
|
36425e1fab | ||
|
|
18efd95759 | ||
|
|
f682a7a283 | ||
|
|
cb968ef4ca | ||
|
|
a6c5732693 | ||
|
|
7bbdc945d5 | ||
|
|
b37431dfaa | ||
|
|
a333ebe5b0 | ||
|
|
4affcd0d89 | ||
|
|
9d5e6351a4 | ||
|
|
91c25918f1 | ||
|
|
bb88b5d861 | ||
|
|
11818a3576 | ||
|
|
f3de134980 | ||
|
|
9fa5db6976 | ||
|
|
5e9043e5fa | ||
|
|
84e275174c | ||
|
|
ae90dd358e | ||
|
|
0cfd153694 | ||
|
|
bf99d3d506 | ||
|
|
9e055831fe | ||
|
|
a349784da9 | ||
|
|
40f9e0f669 | ||
|
|
c253a95127 | ||
|
|
d70d49b9da | ||
|
|
16c5e4a398 | ||
|
|
d53d16c551 | ||
|
|
312be0f639 | ||
|
|
0246dcc10d | ||
|
|
5aa1b14695 | ||
|
|
2ee24c1ded | ||
|
|
700afeacf0 | ||
|
|
e9453d4f6c | ||
|
|
661db2af26 | ||
|
|
efd205716b | ||
|
|
84144bb32a | ||
|
|
74a094c6df | ||
|
|
aa89aca632 | ||
|
|
8ac9a0d7c0 | ||
|
|
0119d7fcff | ||
|
|
be513fde4f | ||
|
|
715199d88b | ||
|
|
34942a3857 | ||
|
|
d67e916c66 | ||
|
|
e3e2d4ff99 | ||
|
|
699615f2f3 | ||
|
|
6d267cac0d | ||
|
|
7d719d94ba | ||
|
|
4bf410fd3e | ||
|
|
16cd05e187 | ||
|
|
c7dcaa0316 | ||
|
|
09cf502e70 | ||
|
|
78ac7c2a28 | ||
|
|
57acda5592 | ||
|
|
d52a168582 | ||
|
|
97a9782f31 | ||
|
|
11d8669426 | ||
|
|
2bceb6654a | ||
|
|
6feea6a1b0 | ||
|
|
139919ab20 | ||
|
|
234234cc5c | ||
|
|
fcd74ae17b | ||
|
|
c2897f819d | ||
|
|
a018374d26 | ||
|
|
ee501f70ed | ||
|
|
e9e9a8ba75 | ||
|
|
5da4861716 | ||
|
|
9c7569fa7a | ||
|
|
c8892c3725 | ||
|
|
ef05e37a04 | ||
|
|
065aae9a7e | ||
|
|
06202811b4 | ||
|
|
3ef189ed4a | ||
|
|
5f8066e601 | ||
|
|
ace490712e | ||
|
|
265cd75691 | ||
|
|
f43969e429 | ||
|
|
9adfdda7da | ||
|
|
0715de8147 | ||
|
|
9c33446449 | ||
|
|
651601adf6 | ||
|
|
2186603039 | ||
|
|
2b5c7fb519 | ||
|
|
82dcd2d6fb | ||
|
|
3f2925029c | ||
|
|
4da4cf2885 | ||
|
|
ae412f2a57 | ||
|
|
95506bc638 | ||
|
|
4b7b10a901 | ||
|
|
800cdc129d | ||
|
|
fb86b4fc84 | ||
|
|
941f3248d8 | ||
|
|
6edbab863a | ||
|
|
a9a317a378 | ||
|
|
3fd290c518 | ||
|
|
b0924e4ce8 | ||
|
|
24adc8f66f | ||
|
|
964ef910b6 | ||
|
|
ba6a88a5bf | ||
|
|
1576164218 | ||
|
|
94400f7794 | ||
|
|
41e1b02f3a | ||
|
|
1337c60cde | ||
|
|
e9b4e07bd8 | ||
|
|
607fdffc18 | ||
|
|
216139119b | ||
|
|
19cbd1f8de | ||
|
|
bf893a56c9 | ||
|
|
3a2f680a51 | ||
|
|
ce7f891b9b | ||
|
|
8ec9da143f | ||
|
|
7f28fbb330 | ||
|
|
3111d1860a | ||
|
|
bd3dce26d9 | ||
|
|
db9ee301e3 | ||
|
|
7d8fb3bb10 | ||
|
|
6fa49e0aab | ||
|
|
30d3e41542 | ||
|
|
c58d613949 | ||
|
|
38ba7fbec2 | ||
|
|
6fad4521d4 | ||
|
|
2f72300636 | ||
|
|
b9cb54db71 | ||
|
|
aaaa314761 | ||
|
|
4e40dbc3a5 | ||
|
|
ba6a4f1224 | ||
|
|
524ed9b677 | ||
|
|
5bbcb9cac3 | ||
|
|
ff169f3fd0 | ||
|
|
cf7b08c993 | ||
|
|
d99a77837b | ||
|
|
23dcf684d9 | ||
|
|
9c2ed279df | ||
|
|
700d7fe68e | ||
|
|
69833db819 | ||
|
|
ab2026ecea | ||
|
|
811fd9018a | ||
|
|
6d89721371 | ||
|
|
ab3a137db9 | ||
|
|
a11cf7a90e | ||
|
|
c995816076 | ||
|
|
94e7fc6434 | ||
|
|
3916bfe833 | ||
|
|
3080ada35f | ||
|
|
4cddc597c1 | ||
|
|
ec07bfa940 | ||
|
|
d20d4bf8c1 | ||
|
|
09e26a9e56 | ||
|
|
ef74919f12 | ||
|
|
6462a50713 | ||
|
|
8c6c43657c | ||
|
|
b8ed56e91e | ||
|
|
dc0eaa32c9 | ||
|
|
60fc4e20e6 | ||
|
|
6f43b32214 | ||
|
|
5e8ae79d71 | ||
|
|
34718aa95d | ||
|
|
d731ad1bd7 | ||
|
|
e7fa698645 | ||
|
|
851d298916 | ||
|
|
1a27e2bef7 | ||
|
|
d64860001b | ||
|
|
b82ac3d536 | ||
|
|
91be9eb0fc | ||
|
|
d61bb0bea0 | ||
|
|
911d72971e | ||
|
|
b244cc8d41 | ||
|
|
8cc3bfa95e | ||
|
|
ba3d59c645 | ||
|
|
e416958b01 | ||
|
|
05c1ced65c | ||
|
|
057bc1a0c0 | ||
|
|
32fc224600 | ||
|
|
fcecd415c8 | ||
|
|
e384527b67 | ||
|
|
672672dd2a | ||
|
|
fd22a6f51d | ||
|
|
c674042319 | ||
|
|
a668921e29 | ||
|
|
04ed4810fd | ||
|
|
941c798d78 | ||
|
|
7f12c71eca | ||
|
|
f62d10746d | ||
|
|
13afa12456 | ||
|
|
4e1406f612 | ||
|
|
ce98bcc989 | ||
|
|
ff5cbae059 | ||
|
|
04a7f24bac | ||
|
|
68bfcb2e6e | ||
|
|
4bd7e21a51 | ||
|
|
37932f664a | ||
|
|
0081525ed3 | ||
|
|
7e13cb6ecf | ||
|
|
721dd14c1f | ||
|
|
047c8ec017 | ||
|
|
fa5d2b2020 | ||
|
|
dfe6505af0 | ||
|
|
b0e33970b8 | ||
|
|
d9f828c717 | ||
|
|
15ca3307bd | ||
|
|
fa3b7e2f60 | ||
|
|
a6de76a983 | ||
|
|
724e06e9d2 | ||
|
|
bf3db1dae0 | ||
|
|
410801347c | ||
|
|
5041f80cb0 | ||
|
|
7229cfce84 | ||
|
|
cb1ebd4a17 | ||
|
|
7929f3dc42 | ||
|
|
95cdb23efb | ||
|
|
182527bfa8 | ||
|
|
2eb19d46d5 | ||
|
|
10e7f142ec | ||
|
|
c55988102d | ||
|
|
d488b17869 | ||
|
|
ff27c0b58b | ||
|
|
2bd532eb9a | ||
|
|
e5fe31fe26 | ||
|
|
ec83eb0a27 | ||
|
|
6236f53b4f | ||
|
|
1b2cf50633 | ||
|
|
3ab638ed61 | ||
|
|
bd1309b680 | ||
|
|
00bc50c02d | ||
|
|
e8bb92826a | ||
|
|
a0cc42b385 | ||
|
|
7edc7ce861 | ||
|
|
0302ed986e | ||
|
|
babfb6978a | ||
|
|
2cb53fafd7 | ||
|
|
8dbe35e5aa | ||
|
|
bd06b6c716 | ||
|
|
8b27c726d5 | ||
|
|
68418c1d3b | ||
|
|
a8af6db3d6 | ||
|
|
af856ce1ec | ||
|
|
aae8e7535a | ||
|
|
359a2752d8 | ||
|
|
9102a0045f | ||
|
|
b124d61826 | ||
|
|
8e6ead59ce | ||
|
|
f74d741821 | ||
|
|
0498d8cb83 | ||
|
|
15f83986e7 | ||
|
|
a57fe42dff | ||
|
|
b03198abd9 | ||
|
|
ad30977781 | ||
|
|
129da51f76 | ||
|
|
dbe10382fd | ||
|
|
e5bababeae | ||
|
|
9b332f0e66 | ||
|
|
a49c5afa46 | ||
|
|
f0caf1a933 | ||
|
|
9e1c907591 | ||
|
|
d638a328d8 | ||
|
|
f597798839 | ||
|
|
303ef6b7c5 | ||
|
|
0f7c99d989 | ||
|
|
60c65008dc | ||
|
|
c4fd4ff9de | ||
|
|
29fc503503 | ||
|
|
bca49616e1 | ||
|
|
cb49c17fc5 | ||
|
|
9e1686232b | ||
|
|
f702358bbd | ||
|
|
9a0b8de354 | ||
|
|
6ed6fff6bd | ||
|
|
75007bb371 | ||
|
|
df9da095ef | ||
|
|
64c98722c3 | ||
|
|
36c1a8b2df | ||
|
|
710d6af4b3 | ||
|
|
cd7ecb9933 | ||
|
|
f75f0b8cc8 | ||
|
|
e60d2a9858 | ||
|
|
04993dd63d | ||
|
|
41af913280 | ||
|
|
8dc0f2c67c | ||
|
|
fc196180b3 | ||
|
|
4a127d35b9 | ||
|
|
1525fdf4f6 | ||
|
|
8a29c998da | ||
|
|
f56d9f128f | ||
|
|
c5785e9c20 | ||
|
|
0ca91ecfff | ||
|
|
304d0f6d43 | ||
|
|
6c9a811472 | ||
|
|
116a7fb994 | ||
|
|
8e46181ba0 | ||
|
|
a336686e42 | ||
|
|
c8957fe373 | ||
|
|
ca7eaf9750 | ||
|
|
74dd24febf | ||
|
|
7b856474af | ||
|
|
c7ac12a67a | ||
|
|
3264359771 | ||
|
|
c7cc994532 | ||
|
|
afe40be957 | ||
|
|
a9c9c447f1 | ||
|
|
aa1aeacc09 | ||
|
|
fc595bd799 | ||
|
|
a5d7a81519 | ||
|
|
7e8fd91fc5 | ||
|
|
c2ed0b7d3d | ||
|
|
aefda8bd51 | ||
|
|
93bec282d2 | ||
|
|
1396a432a4 | ||
|
|
90e1283058 | ||
|
|
8cd50d5684 | ||
|
|
50bd2648aa | ||
|
|
33254654d5 | ||
|
|
617b8f4487 | ||
|
|
f9b95bb003 | ||
|
|
740640884f | ||
|
|
86fea5c667 | ||
|
|
33e4b51aee | ||
|
|
1cf0bd0f01 | ||
|
|
8ce5a5cdbd | ||
|
|
fc26b7af0a | ||
|
|
2d68fa2c27 | ||
|
|
f241cb2280 | ||
|
|
125346bb5c | ||
|
|
b60f62cebf | ||
|
|
51ff62356d | ||
|
|
f827aa97f8 | ||
|
|
68276fe30b | ||
|
|
961533765f | ||
|
|
c1bbec22f0 | ||
|
|
7d0eb215d6 | ||
|
|
ff5226fa93 | ||
|
|
8d7530254c | ||
|
|
6957b4baf6 | ||
|
|
01c8d42291 | ||
|
|
1e21847852 | ||
|
|
1bee082720 | ||
|
|
b0a9bed15a | ||
|
|
1d7434cbbb | ||
|
|
1646f0ebc2 | ||
|
|
50330b0a60 | ||
|
|
f661e0835c | ||
|
|
9511122bae | ||
|
|
56f1bfef50 | ||
|
|
8e5b7504ae | ||
|
|
0a0006f949 | ||
|
|
5b836dfa28 | ||
|
|
8396900178 | ||
|
|
8f80948211 | ||
|
|
4ad09ec3d8 | ||
|
|
be4eb28b21 | ||
|
|
f938fca2c7 | ||
|
|
d562f6a69f | ||
|
|
166454ef43 | ||
|
|
d5c854d606 | ||
|
|
eace46bf55 | ||
|
|
b9ffce166e | ||
|
|
9713e94aed | ||
|
|
d71bc89c9d | ||
|
|
a2b2a2d060 | ||
|
|
752268effb | ||
|
|
9e3b3f3e12 | ||
|
|
88f9533b37 | ||
|
|
630ece82ad | ||
|
|
5777184cae | ||
|
|
a76da14fb0 | ||
|
|
0c612b4836 | ||
|
|
a1af672c7c | ||
|
|
5fcd23409a | ||
|
|
99f0799a11 | ||
|
|
316aeba1b0 | ||
|
|
bfd4a378f3 | ||
|
|
521db90ae0 | ||
|
|
d02fc2debe | ||
|
|
e6c21c5be1 | ||
|
|
91248b496e | ||
|
|
f7ae7783bd | ||
|
|
ae395497a5 | ||
|
|
8826d3af62 | ||
|
|
65153fae9d | ||
|
|
d4c1bc5dfc | ||
|
|
d6f13513ae | ||
|
|
2584c3b432 | ||
|
|
b54421412d | ||
|
|
e2451a3281 | ||
|
|
dbf4bd5c3d | ||
|
|
2a722ab163 | ||
|
|
c83399c7b5 | ||
|
|
a814e45150 | ||
|
|
29e9216bb1 | ||
|
|
94d1732b0d | ||
|
|
7610084627 | ||
|
|
d840905a97 | ||
|
|
7b1b448795 | ||
|
|
77559d29bb | ||
|
|
c14f9accaf | ||
|
|
76a1f48c62 | ||
|
|
ae0a9bcf86 | ||
|
|
9e44fe5524 | ||
|
|
727dad7e19 | ||
|
|
0c2de91097 | ||
|
|
450fa45360 | ||
|
|
e0dddae2c2 | ||
|
|
daa9fccc14 | ||
|
|
ad45dadc15 | ||
|
|
0e8148001e | ||
|
|
fa71f9db2e | ||
|
|
0d9d2fa4be | ||
|
|
c34e9cde05 | ||
|
|
b934a755b5 | ||
|
|
a5772f6b66 | ||
|
|
153f149d58 | ||
|
|
e50b06183e | ||
|
|
305689d513 | ||
|
|
4dd140585d | ||
|
|
cd60d0219f | ||
|
|
8ec18e8d7b | ||
|
|
15545654ea | ||
|
|
8a0fab2b20 | ||
|
|
6e8c6aa740 | ||
|
|
5005aabe5e | ||
|
|
abc2d28617 | ||
|
|
7569a14510 | ||
|
|
b52341dbcf | ||
|
|
b4eed3bad2 | ||
|
|
4fe672f09d | ||
|
|
49af7eb7b0 | ||
|
|
c93c863d82 | ||
|
|
763bb1b829 | ||
|
|
79d32274aa | ||
|
|
987842ed04 | ||
|
|
d2b006b909 | ||
|
|
f4a19e48ad | ||
|
|
38f12f4795 | ||
|
|
7a4f4b1586 | ||
|
|
20ec54e085 | ||
|
|
655bebfec4 | ||
|
|
71e1abd263 | ||
|
|
72172dcb33 | ||
|
|
def2988e12 | ||
|
|
b47793c365 | ||
|
|
3a99cc56b7 | ||
|
|
24c35dede5 | ||
|
|
8c4400dff1 | ||
|
|
af8dffaa33 | ||
|
|
4a36a3c8e6 | ||
|
|
e6735e042e | ||
|
|
c799379a54 | ||
|
|
d8b9f08e5a | ||
|
|
608b25de45 | ||
|
|
2db8869908 | ||
|
|
9500737bbe | ||
|
|
def2b6425b | ||
|
|
5e8f247e84 | ||
|
|
761a2ff0bf | ||
|
|
e368ffe29f | ||
|
|
0f4b11494e | ||
|
|
46448ce1e9 | ||
|
|
fbe12b393f | ||
|
|
ccf59b2c1a | ||
|
|
d7af3b7788 | ||
|
|
682aca0b2a | ||
|
|
3328ffe1b9 | ||
|
|
c07b7840e2 | ||
|
|
9f848b2c64 | ||
|
|
3d66ec0761 | ||
|
|
f50920be69 | ||
|
|
d31add9d5a | ||
|
|
a4dcb4f92e | ||
|
|
2c589c1dbd | ||
|
|
60ea386c6d | ||
|
|
24be1a0ec5 | ||
|
|
e71a14756b | ||
|
|
85fecbd1b9 | ||
|
|
335d39f317 | ||
|
|
973a18d346 | ||
|
|
a43b93d796 | ||
|
|
acf75abdf1 | ||
|
|
58598bfcf2 | ||
|
|
7a570439db | ||
|
|
6e769d1c20 | ||
|
|
d9e7f5d133 | ||
|
|
a119b05d85 | ||
|
|
7bf7b6bcf9 | ||
|
|
e47ea98cdd | ||
|
|
bf66e13377 | ||
|
|
d7aba5629e | ||
|
|
a5c200ac79 | ||
|
|
fdc1fc1b2a | ||
|
|
42a4b762bd | ||
|
|
180c328ed1 | ||
|
|
2ec52a7a45 | ||
|
|
aacf37e32b | ||
|
|
52323b7eb5 | ||
|
|
5b5613a762 | ||
|
|
de6df0c029 | ||
|
|
e180b3c171 | ||
|
|
1364b79cbf | ||
|
|
ef96f3102f | ||
|
|
06ce3b08f7 | ||
|
|
a13217dddf | ||
|
|
ce528d4012 | ||
|
|
89207b6d2a | ||
|
|
e9591caf81 | ||
|
|
24f1aae6b6 | ||
|
|
04fbc9a22b | ||
|
|
14e31d5690 | ||
|
|
a9e9808183 | ||
|
|
af7cb2432b | ||
|
|
e0c1364916 | ||
|
|
04d16fc535 | ||
|
|
44135b3fed | ||
|
|
6111e8f0da | ||
|
|
4e3e7b10ce | ||
|
|
ce7f81d676 | ||
|
|
0cf2f8885e | ||
|
|
ddf4b2646c | ||
|
|
fe1e0749a2 | ||
|
|
2093468c92 | ||
|
|
19af7454f2 | ||
|
|
d24427aad8 | ||
|
|
e2bb0cfb7c | ||
|
|
2ebdb44826 | ||
|
|
432e25565e | ||
|
|
ebe511404a | ||
|
|
e0a79fb86c | ||
|
|
295ca3d9a2 | ||
|
|
dbad8bdb96 | ||
|
|
8c703859a0 | ||
|
|
bedb260b00 | ||
|
|
b49592301f | ||
|
|
c6c67078b8 | ||
|
|
9e45ad10f1 | ||
|
|
24da859975 | ||
|
|
0b6a8a9641 | ||
|
|
e43c4f082e | ||
|
|
0b334cf957 | ||
|
|
ae387ab397 | ||
|
|
056e62dce8 | ||
|
|
47999214bd | ||
|
|
68473ee345 | ||
|
|
455f27d443 | ||
|
|
ba996c3b55 | ||
|
|
d43a1109c8 | ||
|
|
c3ba7daa16 | ||
|
|
82048cd4f3 | ||
|
|
71b0a5cc81 | ||
|
|
edb5ff1e33 | ||
|
|
d4ed6348ee | ||
|
|
f12ac685e8 | ||
|
|
b9ec4068ee | ||
|
|
02aabb8f97 | ||
|
|
dcec2154c0 | ||
|
|
bbc1d20396 | ||
|
|
e682213681 | ||
|
|
0153c0faae | ||
|
|
87ebf4722b | ||
|
|
3906dca04e | ||
|
|
399ba314a3 | ||
|
|
70827727aa | ||
|
|
73c21242b4 | ||
|
|
19e1803633 | ||
|
|
06391b9b37 | ||
|
|
71048c7ff0 | ||
|
|
7f350279fa | ||
|
|
4c9b2ad08b | ||
|
|
79c34d0638 | ||
|
|
6ef4944d89 | ||
|
|
3b531144cf | ||
|
|
6ca684603c | ||
|
|
cf85d66b2f | ||
|
|
81020ff34d | ||
|
|
fea78898a5 | ||
|
|
1be34564f2 | ||
|
|
56eff7a236 | ||
|
|
12c6a1baa0 | ||
|
|
5ea423072b | ||
|
|
08a41e37b4 | ||
|
|
8027c4a06f | ||
|
|
4e6b75d650 | ||
|
|
679bdf36b1 |
@@ -1,5 +1,5 @@
|
||||
# [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 18, 16, 14, 18-bullseye, 16-bullseye, 14-bullseye, 18-buster, 16-buster, 14-buster
|
||||
ARG VARIANT=16
|
||||
ARG VARIANT=20
|
||||
FROM mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT} as base
|
||||
|
||||
# Setup the node environment
|
||||
@@ -10,6 +10,3 @@ RUN apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends \
|
||||
curl tzdata ffmpeg && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Move tone executable to appropriate directory
|
||||
COPY --from=sandreas/tone:v0.1.5 /usr/local/bin/tone /usr/local/bin/
|
||||
|
||||
@@ -5,5 +5,6 @@ module.exports.config = {
|
||||
ConfigPath: Path.resolve('config'),
|
||||
MetadataPath: Path.resolve('metadata'),
|
||||
FFmpegPath: '/usr/bin/ffmpeg',
|
||||
FFProbePath: '/usr/bin/ffprobe'
|
||||
}
|
||||
FFProbePath: '/usr/bin/ffprobe',
|
||||
SkipBinariesCheck: false
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// Append -bullseye or -buster to pin to an OS version.
|
||||
// Use -bullseye variants on local arm64/Apple Silicon.
|
||||
"args": {
|
||||
"VARIANT": "16"
|
||||
"VARIANT": "20"
|
||||
}
|
||||
},
|
||||
"mounts": [
|
||||
|
||||
8
.editorconfig
Normal file
8
.editorconfig
Normal file
@@ -0,0 +1,8 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
charset = utf-8
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
74
.github/ISSUE_TEMPLATE/bug.yaml
vendored
74
.github/ISSUE_TEMPLATE/bug.yaml
vendored
@@ -1,40 +1,50 @@
|
||||
name: 🐞 Bug Report
|
||||
description: File a bug/issue
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
description: File a bug/issue and help us improve Audiobookshelf
|
||||
title: '[Bug]: '
|
||||
labels: ['bug', 'triage']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "### Please first search for your issue and check the [docs](https://audiobookshelf.org/docs)."
|
||||
value: 'Thank you for filing a bug report! 🐛'
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "### Mobile app issues report [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)."
|
||||
value: 'Please first search for your issue and check the [docs](https://audiobookshelf.org/docs).'
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "### Join the [discord server](https://discord.gg/HQgCbd6E75) for questions or if you are not sure about a bug."
|
||||
value: 'Report issues with the mobile app [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose).'
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "## Be as descriptive as you can. Include screenshots, error logs, browser, file types, everything you can think of that might be relevant."
|
||||
value: 'Join the [discord server](https://discord.gg/HQgCbd6E75) for questions or if you are not sure about a bug.'
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: Describe the issue
|
||||
description: What happened & what did you expect to happen
|
||||
label: What happened?
|
||||
placeholder: Tell us what you see!
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-was-expected
|
||||
attributes:
|
||||
label: What did you expect to happen?
|
||||
placeholder: Tell us what you expected to see! Be as descriptive as you can and include screenshots if applicable.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: Steps to reproduce the issue
|
||||
value: "1. "
|
||||
value: '1. '
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: '## Install Environment'
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Audiobookshelf version
|
||||
description: Do not put 'Latest version', please put the actual version here
|
||||
placeholder: "e.g. v1.6.60"
|
||||
placeholder: 'e.g. v1.6.60'
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
@@ -44,7 +54,45 @@ body:
|
||||
options:
|
||||
- Docker
|
||||
- Debian/PPA
|
||||
- Windows Tray App
|
||||
- Built from source
|
||||
- Other
|
||||
- Other (list in "Additional Notes" box)
|
||||
validations:
|
||||
required: true
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: server-os
|
||||
attributes:
|
||||
label: What OS is your Audiobookshelf server hosted from?
|
||||
options:
|
||||
- Windows
|
||||
- macOS
|
||||
- Linux
|
||||
- Other (list in "Additional Notes" box)
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: desktop-browsers
|
||||
attributes:
|
||||
label: If the issue is being seen in the UI, what browsers are you seeing the problem on?
|
||||
options:
|
||||
- Chrome
|
||||
- Firefox
|
||||
- Safari
|
||||
- Edge
|
||||
- Firefox for Android
|
||||
- Chrome for Android
|
||||
- Safari on iOS
|
||||
- Other (list in "Additional Notes" box)
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: Please include any relevant logs here. This field is automatically formatted into code, so you do not need to include any backticks.
|
||||
placeholder: Paste logs here
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: additional-notes
|
||||
attributes:
|
||||
label: Additional Notes
|
||||
description: Anything else you want to add?
|
||||
placeholder: 'e.g. I have tried X, Y, and Z.'
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/config.yml
vendored
3
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -3,6 +3,3 @@ contact_links:
|
||||
- name: Discord
|
||||
url: https://discord.gg/HQgCbd6E75
|
||||
about: Ask questions, get help troubleshooting, and join the Abs community here.
|
||||
- name: Matrix
|
||||
url: https://matrix.to/#/#audiobookshelf:matrix.org
|
||||
about: Ask questions, get help troubleshooting, and join the Abs community here.
|
||||
|
||||
56
.github/ISSUE_TEMPLATE/feature.yml
vendored
56
.github/ISSUE_TEMPLATE/feature.yml
vendored
@@ -1,17 +1,63 @@
|
||||
name: 🚀 Feature Request
|
||||
description: Request a feature/enhancement
|
||||
title: "[Enhancement]: "
|
||||
labels: ["enhancement"]
|
||||
title: '[Enhancement]: '
|
||||
labels: ['enhancement']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "### Please first search in both issues & discussions for your enhancement."
|
||||
value: '#### *Mobile app features should be [requested here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)*.'
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "### Mobile app features should be requested [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)."
|
||||
value: '## Web/Server Feature Request Description'
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: 'Please first search in both issues & discussions for your enhancement.'
|
||||
- type: dropdown
|
||||
id: enhancment-type
|
||||
attributes:
|
||||
label: Type of Enhancement
|
||||
options:
|
||||
- Server Backend
|
||||
- Web Interface/Frontend
|
||||
- Documentation
|
||||
- type: textarea
|
||||
id: describe
|
||||
attributes:
|
||||
label: Describe the feature/enhancement
|
||||
label: Describe the Feature/Enhancement
|
||||
description: Please help us understand what you want.
|
||||
placeholder: What is your vision?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: the-why
|
||||
attributes:
|
||||
label: Why would this be helpful?
|
||||
description: Please help us understand why this would enhance your experience.
|
||||
placeholder: Explain the "why" or "use case".
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: image
|
||||
attributes:
|
||||
label: Future Implementation (Screenshot)
|
||||
description: Please help us visualize by including a doodle or screenshot.
|
||||
placeholder: How could this look?
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: '## Web/Server Current Implementation'
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Audiobookshelf Server Version
|
||||
description: Do not put 'Latest version', please put your current version number here
|
||||
placeholder: 'e.g. v1.6.60'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: current-image
|
||||
attributes:
|
||||
label: Current Implementation (Screenshot)
|
||||
description: What page were you looking at when you thought of this enhancement?
|
||||
placeholder: If an image is not applicable, please explain why.
|
||||
|
||||
20
.github/workflows/close-issues-on-release.yml
vendored
Normal file
20
.github/workflows/close-issues-on-release.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
name: Close fixed issues on release.
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close issues marked as fixed upon a release.
|
||||
uses: gcampbell-msft/fixed-pending-release@7fa1b75a0c04bcd4b375110522878e5f6100cff5
|
||||
with:
|
||||
label: 'awaiting release'
|
||||
removeLabel: true
|
||||
applyToAll: true
|
||||
message: Fixed in [${releaseTag}](${releaseUrl}).
|
||||
65
.github/workflows/codeql.yml
vendored
Normal file
65
.github/workflows/codeql.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ 'master' ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ 'master' ]
|
||||
schedule:
|
||||
- cron: '16 5 * * 4'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Use only 'java' to analyze code written in Java, Kotlin or both
|
||||
# Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
2
.github/workflows/docker-build.yml
vendored
2
.github/workflows/docker-build.yml
vendored
@@ -71,7 +71,7 @@ jobs:
|
||||
with:
|
||||
tags: ${{ github.event.inputs.tags || steps.meta.outputs.tags }}
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
cache-from: type=local,src=/tmp/.buildx-cache
|
||||
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
||||
|
||||
30
.github/workflows/i18n-integration.yml
vendored
Normal file
30
.github/workflows/i18n-integration.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Verify all i18n files are alphabetized
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- client/strings/** # Should only check if any strings changed
|
||||
push:
|
||||
paths:
|
||||
- client/strings/** # Should only check if any strings changed
|
||||
|
||||
jobs:
|
||||
update_translations:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Check out the repository
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Set up node to run the javascript
|
||||
- name: Set up node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
# The only argument is the `directory`, which is where the i18n files are
|
||||
# stored.
|
||||
- name: Run Update JSON Files action
|
||||
uses: audiobookshelf/audiobookshelf-i18n-updater@v1.3.0
|
||||
with:
|
||||
directory: 'client/strings/' # Adjust the directory path as needed
|
||||
10
.github/workflows/integration-test.yml
vendored
10
.github/workflows/integration-test.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
pull_request:
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'dependabot/**' # Don't run dependabot branches, as they are already covered by pull requests
|
||||
- 'dependabot/**' # Don't run dependabot branches, as they are already covered by pull requests
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -16,10 +16,10 @@ jobs:
|
||||
- name: setup nade
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 20
|
||||
|
||||
- name: install pkg
|
||||
run: npm install -g pkg
|
||||
- name: install pkg (using yao-pkg fork for targetting node20)
|
||||
run: npm install -g @yao-pkg/pkg
|
||||
|
||||
- name: get client dependencies
|
||||
working-directory: client
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
run: npm ci --only=production
|
||||
|
||||
- name: build binary
|
||||
run: pkg -t node18-linux-x64 -o audiobookshelf .
|
||||
run: pkg -t node20-linux-x64 -o audiobookshelf .
|
||||
|
||||
- name: run audiobookshelf
|
||||
run: |
|
||||
|
||||
30
.github/workflows/lint-openapi.yml
vendored
Normal file
30
.github/workflows/lint-openapi.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: API linting
|
||||
|
||||
# Run on pull requests or pushes when there is a change to the OpenAPI file
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- docs/
|
||||
pull_request:
|
||||
paths:
|
||||
- docs/
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Check out the repository
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
# Set up node to run the javascript
|
||||
- name: Set up node
|
||||
uses: actions/setup-node@v4
|
||||
# Install Redocly CLI
|
||||
- name: Install Redocly CLI
|
||||
run: npm install -g @redocly/cli@latest
|
||||
# Perform linting for exploded spec
|
||||
- name: Run linting for exploded spec
|
||||
run: redocly lint docs/root.yaml --format=github-actions
|
||||
# Perform linting for bundled spec
|
||||
- name: Run linting for bundled spec
|
||||
run: redocly lint docs/openapi.json --format=github-actions
|
||||
17
.github/workflows/notify-abs-windows.yml
vendored
Normal file
17
.github/workflows/notify-abs-windows.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Dispatch an abs-windows event
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
abs-windows-dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send a remote repository dispatch event
|
||||
uses: peter-evans/repository-dispatch@v3
|
||||
with:
|
||||
token: ${{ secrets.ABS_WINDOWS_PAT }}
|
||||
repository: mikiher/audiobookshelf-windows
|
||||
event-type: build-windows
|
||||
37
.github/workflows/unit-tests.yml
vendored
Normal file
37
.github/workflows/unit-tests.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: Run Unit Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Branch/Tag/SHA to test'
|
||||
required: true
|
||||
pull_request:
|
||||
push:
|
||||
|
||||
jobs:
|
||||
run-unit-tests:
|
||||
name: Run Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout (push/pull request)
|
||||
uses: actions/checkout@v4
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
|
||||
- name: Checkout (workflow_dispatch)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -15,7 +15,9 @@
|
||||
/.nyc_output/
|
||||
/ffmpeg*
|
||||
/ffprobe*
|
||||
/unicode*
|
||||
|
||||
sw.*
|
||||
.DS_STORE
|
||||
.idea/*
|
||||
tailwind.compiled.css
|
||||
|
||||
17
.prettierrc
Normal file
17
.prettierrc
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 400,
|
||||
"proseWrap": "never",
|
||||
"trailingComma": "none",
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.html"],
|
||||
"options": {
|
||||
"singleQuote": false,
|
||||
"wrapAttributes": false,
|
||||
"sortAttributes": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
7
.vscode/extensions.json
vendored
Normal file
7
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"EditorConfig.EditorConfig",
|
||||
"esbenp.prettier-vscode",
|
||||
"octref.vetur"
|
||||
]
|
||||
}
|
||||
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@@ -17,5 +17,11 @@
|
||||
"editor.formatOnSave": true,
|
||||
"editor.detectIndentation": true,
|
||||
"editor.tabSize": 2,
|
||||
"javascript.format.semicolons": "remove"
|
||||
"javascript.format.semicolons": "remove",
|
||||
"[javascript][json][jsonc]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[vue]": {
|
||||
"editor.defaultFormatter": "octref.vetur"
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
### STAGE 0: Build client ###
|
||||
FROM node:16-alpine AS build
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /client
|
||||
COPY /client /client
|
||||
RUN npm ci && npm cache clean --force
|
||||
RUN npm run generate
|
||||
|
||||
### STAGE 1: Build server ###
|
||||
FROM sandreas/tone:v0.1.5 AS tone
|
||||
FROM node:16-alpine
|
||||
FROM node:20-alpine
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@@ -17,11 +16,11 @@ RUN apk update && \
|
||||
tzdata \
|
||||
ffmpeg \
|
||||
make \
|
||||
gcompat \
|
||||
python3 \
|
||||
g++ \
|
||||
tini
|
||||
|
||||
COPY --from=tone /usr/local/bin/tone /usr/local/bin/
|
||||
COPY --from=build /client/dist /client/dist
|
||||
COPY index.js package* /
|
||||
COPY server server
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
FFMPEG_INSTALL_DIR="/usr/lib/audiobookshelf-ffmpeg"
|
||||
DEFAULT_DATA_DIR="/usr/share/audiobookshelf"
|
||||
CONFIG_PATH="/etc/default/audiobookshelf"
|
||||
DEFAULT_PORT=13378
|
||||
@@ -46,43 +45,11 @@ add_group() {
|
||||
fi
|
||||
}
|
||||
|
||||
install_ffmpeg() {
|
||||
echo "Starting FFMPEG Install"
|
||||
|
||||
WGET="wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz --output-document=ffmpeg-git-amd64-static.tar.xz"
|
||||
WGET_TONE="wget https://github.com/sandreas/tone/releases/download/v0.1.5/tone-0.1.5-linux-x64.tar.gz --output-document=tone-0.1.5-linux-x64.tar.gz"
|
||||
|
||||
if ! cd "$FFMPEG_INSTALL_DIR"; then
|
||||
echo "Creating ffmpeg install dir at $FFMPEG_INSTALL_DIR"
|
||||
mkdir "$FFMPEG_INSTALL_DIR"
|
||||
chown -R 'audiobookshelf:audiobookshelf' "$FFMPEG_INSTALL_DIR"
|
||||
cd "$FFMPEG_INSTALL_DIR"
|
||||
fi
|
||||
|
||||
$WGET
|
||||
tar xvf ffmpeg-git-amd64-static.tar.xz --strip-components=1 --no-same-owner
|
||||
rm ffmpeg-git-amd64-static.tar.xz
|
||||
|
||||
# Temp downloading tone library to the ffmpeg dir
|
||||
echo "Getting tone.."
|
||||
$WGET_TONE
|
||||
tar xvf tone-0.1.5-linux-x64.tar.gz --strip-components=1 --no-same-owner
|
||||
rm tone-0.1.5-linux-x64.tar.gz
|
||||
|
||||
echo "Good to go on Ffmpeg (& tone)... hopefully"
|
||||
}
|
||||
|
||||
setup_config() {
|
||||
if [ -f "$CONFIG_PATH" ]; then
|
||||
echo "Existing config found."
|
||||
cat $CONFIG_PATH
|
||||
|
||||
# TONE_PATH variable added in 2.1.6, if it doesnt exist then add it
|
||||
if ! grep -q "TONE_PATH" "$CONFIG_PATH"; then
|
||||
echo "Adding TONE_PATH to existing config"
|
||||
echo "TONE_PATH=$FFMPEG_INSTALL_DIR/tone" >> "$CONFIG_PATH"
|
||||
fi
|
||||
|
||||
else
|
||||
|
||||
if [ ! -d "$DEFAULT_DATA_DIR" ]; then
|
||||
@@ -96,9 +63,6 @@ setup_config() {
|
||||
|
||||
config_text="METADATA_PATH=$DEFAULT_DATA_DIR/metadata
|
||||
CONFIG_PATH=$DEFAULT_DATA_DIR/config
|
||||
FFMPEG_PATH=$FFMPEG_INSTALL_DIR/ffmpeg
|
||||
FFPROBE_PATH=$FFMPEG_INSTALL_DIR/ffprobe
|
||||
TONE_PATH=$FFMPEG_INSTALL_DIR/tone
|
||||
PORT=$DEFAULT_PORT
|
||||
HOST=$DEFAULT_HOST"
|
||||
|
||||
@@ -115,5 +79,3 @@ add_group 'audiobookshelf' ''
|
||||
add_user 'audiobookshelf' '' 'audiobookshelf' 'audiobookshelf user-daemon' '/bin/false'
|
||||
|
||||
setup_config
|
||||
|
||||
install_ffmpeg
|
||||
|
||||
@@ -48,11 +48,10 @@ Description: $DESCRIPTION"
|
||||
echo "$controlfile" > dist/debian/DEBIAN/control;
|
||||
|
||||
# Package debian
|
||||
pkg -t node16-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
|
||||
pkg -t node20-linux-x64 -o dist/debian/usr/share/audiobookshelf/audiobookshelf .
|
||||
|
||||
fakeroot dpkg-deb --build dist/debian
|
||||
fakeroot dpkg-deb -Zxz --build dist/debian
|
||||
|
||||
mv dist/debian.deb "dist/$OUTPUT_FILE"
|
||||
chmod +x "dist/$OUTPUT_FILE"
|
||||
|
||||
echo "Finished! Filename: $OUTPUT_FILE"
|
||||
|
||||
@@ -30,8 +30,7 @@
|
||||
}
|
||||
|
||||
.bookshelf-row {
|
||||
/* Sidebar width + scrollbar width */
|
||||
width: calc(100vw - 88px);
|
||||
width: calc(100vw - (100vw - 100%));
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -217,36 +216,6 @@ Bookshelf Label
|
||||
filter: blur(20px);
|
||||
}
|
||||
|
||||
|
||||
.episode-subtitle {
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
line-height: 16px;
|
||||
/* fallback */
|
||||
max-height: 32px;
|
||||
/* fallback */
|
||||
-webkit-line-clamp: 2;
|
||||
/* number of lines to show */
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.episode-subtitle-long {
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
line-height: 16px;
|
||||
/* fallback */
|
||||
max-height: 72px;
|
||||
/* fallback */
|
||||
-webkit-line-clamp: 6;
|
||||
/* number of lines to show */
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
|
||||
/* Padding for toastification toasts in the top right to not cover appbar/toolbar */
|
||||
.app-bar-and-toolbar .Vue-Toastification__container.top-right {
|
||||
padding-top: 104px;
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
@font-face {
|
||||
font-family: 'Material Icons';
|
||||
font-family: 'Material Symbols Rounded';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(~static/fonts/MaterialIcons.woff2) format('woff2');
|
||||
src: url(~static/fonts/MaterialSymbolsRounded[FILL,GRAD,opsz,wght].woff2) format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Material Icons Outlined';
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(~static/fonts/MaterialIconsOutlined.woff2) format('woff2');
|
||||
src: url(~static/fonts/MaterialSymbolsOutlined[FILL,GRAD,opsz,wght].woff2) format('woff2');
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-family: 'Material Icons';
|
||||
.material-symbols {
|
||||
font-family: 'Material Symbols Rounded';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
@@ -24,14 +24,16 @@
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.material-icons:not([class*="text-"]) {
|
||||
font-size: 1.5rem;
|
||||
.material-symbols.fill {
|
||||
font-variation-settings:
|
||||
'FILL' 1
|
||||
}
|
||||
|
||||
.material-icons-outlined {
|
||||
font-family: 'Material Icons Outlined';
|
||||
.material-symbols-outlined {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
@@ -42,10 +44,12 @@
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.material-icons-outlined:not([class*="text-"]) {
|
||||
font-size: 1.5rem;
|
||||
.material-symbols-outlined.fill {
|
||||
font-variation-settings:
|
||||
'FILL' 1
|
||||
}
|
||||
|
||||
/* cyrillic-ext */
|
||||
@@ -316,4 +320,4 @@
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('truetype');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
}
|
||||
|
||||
3
client/assets/tailwind.css
Normal file
3
client/assets/tailwind.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
|
||||
<span class="material-icons-outlined text-2xl text-warning text-opacity-50"> cast </span>
|
||||
<span class="material-symbols-outlined text-2xl text-warning text-opacity-50"> cast </span>
|
||||
</ui-tooltip>
|
||||
<div v-if="isChromecastInitialized" class="w-6 min-w-6 h-6 ml-2 mr-1 sm:mx-2 cursor-pointer">
|
||||
<google-cast-launcher></google-cast-launcher>
|
||||
@@ -26,19 +26,19 @@
|
||||
|
||||
<nuxt-link v-if="currentLibrary" to="/config/stats" class="hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
|
||||
<ui-tooltip :text="$strings.HeaderYourStats" direction="bottom" class="flex items-center">
|
||||
<span class="material-icons text-2xl" aria-label="User Stats" role="button">equalizer</span>
|
||||
<span class="material-symbols text-2xl" aria-label="User Stats" role="button">equalizer</span>
|
||||
</ui-tooltip>
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="userCanUpload && currentLibrary" to="/upload" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<ui-tooltip :text="$strings.ButtonUpload" direction="bottom" class="flex items-center">
|
||||
<span class="material-icons text-2xl" aria-label="Upload Media" role="button">upload</span>
|
||||
<span class="material-symbols text-2xl" aria-label="Upload Media" role="button">upload</span>
|
||||
</ui-tooltip>
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="userIsAdminOrUp" to="/config" class="hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<ui-tooltip :text="$strings.HeaderSettings" direction="bottom" class="flex items-center">
|
||||
<span class="material-icons text-2xl" aria-label="System Settings" role="button">settings</span>
|
||||
<span class="material-symbols text-2xl" aria-label="System Settings" role="button">settings</span>
|
||||
</ui-tooltip>
|
||||
</nuxt-link>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<span class="block truncate">{{ username }}</span>
|
||||
</span>
|
||||
<span class="h-full md:ml-3 md:absolute inset-y-0 md:right-0 flex items-center justify-center md:pr-2 pointer-events-none">
|
||||
<span class="material-icons text-xl text-gray-100">person</span>
|
||||
<span class="material-symbols text-xl text-gray-100">person</span>
|
||||
</span>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
@@ -55,7 +55,7 @@
|
||||
<h1 class="text-lg md:text-2xl px-4">{{ $getString('MessageItemsSelected', [numMediaItemsSelected]) }}</h1>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="!isPodcastLibrary && selectedMediaItemsArePlayable" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="playSelectedItems">
|
||||
<span class="material-icons text-2xl -ml-2 pr-1 text-white">play_arrow</span>
|
||||
<span class="material-symbols fill text-2xl -ml-2 pr-1 text-white">play_arrow</span>
|
||||
{{ $strings.ButtonPlay }}
|
||||
</ui-btn>
|
||||
<ui-tooltip v-if="isBookLibrary" :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
|
||||
@@ -76,7 +76,7 @@
|
||||
<ui-context-menu-dropdown v-if="contextMenuItems.length && !processingBatch" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
|
||||
|
||||
<ui-tooltip :text="$strings.LabelDeselectAll" direction="bottom" class="flex items-center">
|
||||
<span class="material-icons text-3xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatch ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
||||
<span class="material-symbols text-3xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatch ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,13 +170,13 @@ export default {
|
||||
|
||||
if (!this.isPodcastLibrary && this.selectedMediaItemsArePlayable) {
|
||||
options.push({
|
||||
text: 'Quick Embed Metadata',
|
||||
text: this.$strings.ButtonQuickEmbedMetadata,
|
||||
action: 'quick-embed'
|
||||
})
|
||||
}
|
||||
|
||||
options.push({
|
||||
text: 'Re-Scan',
|
||||
text: this.$strings.ButtonReScan,
|
||||
action: 'rescan'
|
||||
})
|
||||
|
||||
|
||||
@@ -1,41 +1,29 @@
|
||||
<template>
|
||||
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
|
||||
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative" :style="{ fontSize: sizeMultiplier + 'rem' }">
|
||||
<!-- Cover size widget -->
|
||||
<widgets-cover-size-widget class="fixed right-4 z-50" :style="{ bottom: streamLibraryItem ? '181px' : '16px' }" />
|
||||
|
||||
<div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl mb-4 py-4">{{ libraryName }} Library is empty!</p>
|
||||
<p class="text-center text-2xl mb-4 py-4">{{ $getString('MessageXLibraryIsEmpty', [libraryName]) }}</p>
|
||||
<div v-if="userIsAdminOrUp" class="flex">
|
||||
<ui-btn to="/config" color="primary" class="w-52 mr-2">Configure Scanner</ui-btn>
|
||||
<ui-btn color="success" class="w-52" @click="scan">Scan Library</ui-btn>
|
||||
<ui-btn to="/config" color="primary" class="w-52 mr-2">{{ $strings.ButtonConfigureScanner }}</ui-btn>
|
||||
<ui-btn color="success" class="w-52" :loading="isScanningLibrary || tempIsScanning" @click="scan">{{ $strings.ButtonScanLibrary }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="loaded && !shelves.length && search" class="w-full h-40 flex items-center justify-center">
|
||||
<p class="text-center text-xl py-4">No results for query</p>
|
||||
<p class="text-center text-xl py-4">{{ $strings.MessageBookshelfNoResultsForQuery }}</p>
|
||||
</div>
|
||||
<!-- Alternate plain view -->
|
||||
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
|
||||
<template v-for="(shelf, index) in shelves">
|
||||
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
|
||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24e">
|
||||
<template v-for="(shelf, index) in supportedShelves">
|
||||
<widgets-item-slider :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :type="shelf.type" class="bookshelf-row pl-8e my-6e" @selectEntity="(payload) => selectEntity(payload, index)">
|
||||
<p class="font-semibold text-gray-100">{{ $strings[shelf.labelStringKey] }}</p>
|
||||
</widgets-item-slider>
|
||||
<widgets-episode-slider v-else-if="shelf.type === 'episode'" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
|
||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||
</widgets-episode-slider>
|
||||
<widgets-series-slider v-else-if="shelf.type === 'series'" :key="index + '.'" :items="shelf.entities" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||
</widgets-series-slider>
|
||||
<widgets-authors-slider v-else-if="shelf.type === 'authors'" :key="index + '.'" :items="shelf.entities" :height="192 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||
</widgets-authors-slider>
|
||||
<widgets-narrators-slider v-else-if="shelf.type === 'narrators'" :key="index + '.'" :items="shelf.entities" :height="100 * sizeMultiplier" class="bookshelf-row pl-8 my-6">
|
||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||
</widgets-narrators-slider>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Regular bookshelf view -->
|
||||
<div v-else class="w-full">
|
||||
<template v-for="(shelf, index) in shelves">
|
||||
<template v-for="(shelf, index) in supportedShelves">
|
||||
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" @selectEntity="(payload) => selectEntity(payload, index)" />
|
||||
</template>
|
||||
</div>
|
||||
@@ -58,10 +46,14 @@ export default {
|
||||
scannerParseSubtitle: false,
|
||||
wrapperClientWidth: 0,
|
||||
shelves: [],
|
||||
lastItemIndexSelected: -1
|
||||
lastItemIndexSelected: -1,
|
||||
tempIsScanning: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
supportedShelves() {
|
||||
return this.shelves.filter((shelf) => ['book', 'podcast', 'episode', 'series', 'authors', 'narrators'].includes(shelf.type))
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
@@ -89,14 +81,16 @@ export default {
|
||||
return this.coverAspectRatio == 1
|
||||
},
|
||||
sizeMultiplier() {
|
||||
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||
return this.bookCoverWidth / baseSize
|
||||
return this.$store.getters['user/getSizeMultiplier']
|
||||
},
|
||||
selectedMediaItems() {
|
||||
return this.$store.state.globals.selectedMediaItems || []
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
isScanningLibrary() {
|
||||
return !!this.$store.getters['tasks/getRunningLibraryScanTask'](this.currentLibraryId)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -173,8 +167,19 @@ export default {
|
||||
this.loaded = true
|
||||
},
|
||||
async fetchCategories() {
|
||||
// Sets the limit for the number of items to be displayed based on the viewport width.
|
||||
const viewportWidth = window.innerWidth
|
||||
let limit
|
||||
if (viewportWidth >= 3240) {
|
||||
limit = 15
|
||||
} else if (viewportWidth >= 2880 && viewportWidth < 3240) {
|
||||
limit = 12
|
||||
}
|
||||
|
||||
const limitQuery = limit ? `&limit=${limit}` : ''
|
||||
|
||||
const categories = await this.$axios
|
||||
.$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed,numEpisodesIncomplete`)
|
||||
.$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed,numEpisodesIncomplete,share${limitQuery}`)
|
||||
.then((data) => {
|
||||
return data
|
||||
})
|
||||
@@ -273,14 +278,15 @@ export default {
|
||||
this.shelves = shelves
|
||||
},
|
||||
scan() {
|
||||
this.tempIsScanning = true
|
||||
this.$store
|
||||
.dispatch('libraries/requestLibraryScan', { libraryId: this.$store.state.libraries.currentLibraryId })
|
||||
.then(() => {
|
||||
this.$toast.success('Library scan started')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to start scan', error)
|
||||
this.$toast.error('Failed to start scan')
|
||||
this.$toast.error(this.$strings.ToastLibraryScanFailedToStart)
|
||||
})
|
||||
.finally(() => {
|
||||
this.tempIsScanning = false
|
||||
})
|
||||
},
|
||||
userUpdated(user) {
|
||||
@@ -413,6 +419,36 @@ export default {
|
||||
}
|
||||
})
|
||||
},
|
||||
shareOpen(mediaItemShare) {
|
||||
this.shelves.forEach((shelf) => {
|
||||
if (shelf.type == 'book') {
|
||||
shelf.entities = shelf.entities.map((ent) => {
|
||||
if (ent.media.id === mediaItemShare.mediaItemId) {
|
||||
return {
|
||||
...ent,
|
||||
mediaItemShare
|
||||
}
|
||||
}
|
||||
return ent
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
shareClosed(mediaItemShare) {
|
||||
this.shelves.forEach((shelf) => {
|
||||
if (shelf.type == 'book') {
|
||||
shelf.entities = shelf.entities.map((ent) => {
|
||||
if (ent.media.id === mediaItemShare.mediaItemId) {
|
||||
return {
|
||||
...ent,
|
||||
mediaItemShare: null
|
||||
}
|
||||
}
|
||||
return ent
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
initListeners() {
|
||||
if (this.$root.socket) {
|
||||
this.$root.socket.on('user_updated', this.userUpdated)
|
||||
@@ -424,6 +460,8 @@ export default {
|
||||
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
|
||||
this.$root.socket.on('items_added', this.libraryItemsAdded)
|
||||
this.$root.socket.on('episode_added', this.episodeAdded)
|
||||
this.$root.socket.on('share_open', this.shareOpen)
|
||||
this.$root.socket.on('share_closed', this.shareClosed)
|
||||
} else {
|
||||
console.error('Error socket not initialized')
|
||||
}
|
||||
@@ -439,6 +477,8 @@ export default {
|
||||
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
|
||||
this.$root.socket.off('items_added', this.libraryItemsAdded)
|
||||
this.$root.socket.off('episode_added', this.episodeAdded)
|
||||
this.$root.socket.off('share_open', this.shareOpen)
|
||||
this.$root.socket.off('share_closed', this.shareClosed)
|
||||
} else {
|
||||
console.error('Error socket not initialized')
|
||||
}
|
||||
|
||||
@@ -1,67 +1,53 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div ref="shelf" class="w-full max-w-full bookshelf-row categorizedBookshelfRow relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft * sizeMultiplier + 'rem', height: shelfHeight + 'px' }" @scroll="scrolled">
|
||||
<div class="w-full h-full pt-6">
|
||||
<div ref="shelf" class="w-full max-w-full bookshelf-row categorizedBookshelfRow relative overflow-x-scroll no-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft + 'em' }" @scroll="scrolled">
|
||||
<div class="w-full h-full pt-6e">
|
||||
<div v-if="shelf.type === 'book' || shelf.type === 'podcast'" class="flex items-center">
|
||||
<template v-for="(entity, index) in shelf.entities">
|
||||
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" :continue-listening-shelf="continueListeningShelf" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editItem" />
|
||||
<cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :book-mount="entity" :continue-listening-shelf="continueListeningShelf" class="relative mx-2e" @hook:updated="updatedBookCard" @select="selectItem" @edit="editItem" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'episode'" class="flex items-center">
|
||||
<template v-for="(entity, index) in shelf.entities">
|
||||
<cards-lazy-book-card
|
||||
:key="entity.recentEpisode.id"
|
||||
:ref="`shelf-episode-${entity.recentEpisode.id}`"
|
||||
:index="index"
|
||||
:width="bookCoverWidth"
|
||||
:height="bookCoverHeight"
|
||||
:book-cover-aspect-ratio="bookCoverAspectRatio"
|
||||
:book-mount="entity"
|
||||
:continue-listening-shelf="continueListeningShelf"
|
||||
class="relative mx-2"
|
||||
@hook:updated="updatedBookCard"
|
||||
@select="selectItem"
|
||||
@editPodcast="editItem"
|
||||
@edit="editEpisode"
|
||||
/>
|
||||
<cards-lazy-book-card :key="entity.recentEpisode.id" :ref="`shelf-episode-${entity.recentEpisode.id}`" :index="index" :book-mount="entity" :continue-listening-shelf="continueListeningShelf" class="relative mx-2e" @hook:updated="updatedBookCard" @select="selectItem" @editPodcast="editItem" @edit="editEpisode" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'series'" class="flex items-center">
|
||||
<template v-for="entity in shelf.entities">
|
||||
<cards-lazy-series-card :key="entity.name" :series-mount="entity" :height="bookCoverHeight" :width="bookCoverWidth * 2" :book-cover-aspect-ratio="bookCoverAspectRatio" class="relative mx-2" @hook:updated="updatedBookCard" />
|
||||
<cards-lazy-series-card :key="entity.name" :series-mount="entity" class="relative mx-2e" @hook:updated="updatedBookCard" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'tags'" class="flex items-center">
|
||||
<template v-for="entity in shelf.entities">
|
||||
<cards-group-card :key="entity.name" :group="entity" :height="bookCoverHeight" :width="bookCoverWidth * 2" :book-cover-aspect-ratio="bookCoverAspectRatio" class="relative mx-2" @hook:updated="updatedBookCard" />
|
||||
<cards-group-card :key="entity.name" :group="entity" class="relative mx-2e" @hook:updated="updatedBookCard" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'authors'" class="flex items-center">
|
||||
<template v-for="entity in shelf.entities">
|
||||
<cards-author-card :key="entity.id" :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />
|
||||
<cards-author-card :key="entity.id" :author="entity" @hook:updated="updatedBookCard" class="mx-2e" @edit="editAuthor" />
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="shelf.type === 'narrators'" class="flex items-center">
|
||||
<template v-for="entity in shelf.entities">
|
||||
<cards-narrator-card :key="entity.name" :width="150" :height="100" :narrator="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" />
|
||||
<cards-narrator-card :key="entity.name" :narrator="entity" @hook:updated="updatedBookCard" class="mx-2e" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute text-center categoryPlacard transform z-30 bottom-px left-4 md:left-8 w-44 rounded-md" style="height: 22px">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border">
|
||||
<p class="transform text-sm">{{ $strings[shelf.labelStringKey] }}</p>
|
||||
<div class="relative">
|
||||
<div class="relative text-center categoryPlacard transform z-30 top-0 left-4e md:left-8e w-44e rounded-md">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
|
||||
<p :style="{ fontSize: 0.9 + 'em' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bookshelfDividerCategorized h-6 w-full absolute bottom-0 left-0 right-0 z-20"></div>
|
||||
|
||||
<div v-show="canScrollLeft && !isScrolling" class="hidden sm:flex absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollLeft">
|
||||
<span class="material-icons text-6xl text-white">chevron_left</span>
|
||||
<div class="bookshelfDividerCategorized h-6e w-full absolute top-0 left-0 right-0 z-20"></div>
|
||||
</div>
|
||||
<div v-show="canScrollRight && !isScrolling" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-30" @click="scrollRight">
|
||||
<span class="material-icons text-6xl text-white">chevron_right</span>
|
||||
<div v-show="canScrollLeft && !isScrolling" class="hidden sm:flex absolute top-0 left-0 w-32 pr-8 bg-black book-shelf-arrow-left items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollLeft">
|
||||
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_left</span>
|
||||
</div>
|
||||
<div v-show="canScrollRight && !isScrolling" class="hidden sm:flex absolute top-0 right-0 w-32 pl-8 bg-black book-shelf-arrow-right items-center justify-center cursor-pointer opacity-0 hover:opacity-100 z-40" @click="scrollRight">
|
||||
<span class="material-symbols text-white" :style="{ fontSize: 3.75 + 'em' }">chevron_right</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -74,9 +60,6 @@ export default {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
sizeMultiplier: Number,
|
||||
bookCoverWidth: Number,
|
||||
bookCoverAspectRatio: Number,
|
||||
continueListeningShelf: Boolean
|
||||
},
|
||||
data() {
|
||||
@@ -89,12 +72,8 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookCoverHeight() {
|
||||
return this.bookCoverWidth * this.bookCoverAspectRatio
|
||||
},
|
||||
shelfHeight() {
|
||||
if (this.shelf.type === 'narrators') return 148
|
||||
return this.bookCoverHeight + 48
|
||||
sizeMultiplier() {
|
||||
return this.$store.getters['user/getSizeMultiplier']
|
||||
},
|
||||
paddingLeft() {
|
||||
if (window.innerWidth < 768) return 1
|
||||
@@ -218,13 +197,13 @@ export default {
|
||||
}
|
||||
|
||||
.book-shelf-arrow-right {
|
||||
height: calc(100% - 24px);
|
||||
height: calc(100% - 1.5em);
|
||||
background: rgb(48, 48, 48);
|
||||
background: linear-gradient(90deg, rgba(48, 48, 48, 0) 0%, rgba(25, 25, 25, 0.25) 8%, rgba(17, 17, 17, 0.4) 28%, rgba(17, 17, 17, 0.6) 71%, rgba(10, 10, 10, 0.6) 86%, rgba(0, 0, 0, 0.7) 100%);
|
||||
}
|
||||
.book-shelf-arrow-left {
|
||||
height: calc(100% - 24px);
|
||||
height: calc(100% - 1.5em);
|
||||
background: rgb(48, 48, 48);
|
||||
background: linear-gradient(-90deg, rgba(48, 48, 48, 0) 0%, rgba(25, 25, 25, 0.25) 8%, rgba(17, 17, 17, 0.4) 28%, rgba(17, 17, 17, 0.6) 71%, rgba(10, 10, 10, 0.6) 86%, rgba(0, 0, 0, 0.7) 100%);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -24,11 +24,11 @@
|
||||
</nuxt-link>
|
||||
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="flex-grow h-full flex justify-center items-center" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p v-if="isPlaylistsPage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonPlaylists }}</p>
|
||||
<span v-else class="material-icons-outlined text-lg">queue_music</span>
|
||||
<span v-else class="material-symbols-outlined text-lg">queue_music</span>
|
||||
</nuxt-link>
|
||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="flex-grow h-full flex justify-center items-center" :class="isCollectionsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p v-if="isCollectionsPage" class="text-sm">{{ $strings.ButtonCollections }}</p>
|
||||
<span v-else class="material-icons-outlined text-lg">collections_bookmark</span>
|
||||
<span v-else class="material-symbols-outlined text-lg">collections_bookmark</span>
|
||||
</nuxt-link>
|
||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="flex-grow h-full flex justify-center items-center" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p v-if="isAuthorsPage" class="text-sm">{{ $strings.ButtonAuthors }}</p>
|
||||
@@ -53,7 +53,6 @@
|
||||
<span class="font-mono">{{ numShowing }}</span>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<ui-checkbox v-if="!isBatchSelecting" v-model="settings.collapseBookSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseBookSeries" />
|
||||
|
||||
<!-- RSS feed -->
|
||||
<ui-tooltip v-if="seriesRssFeed" :text="$strings.LabelOpenRSSFeed" direction="top">
|
||||
@@ -68,9 +67,6 @@
|
||||
|
||||
<div class="flex-grow hidden sm:inline-block" />
|
||||
|
||||
<!-- collapse series checkbox -->
|
||||
<ui-checkbox v-if="isLibraryPage && isBookLibrary && !isBatchSelecting" v-model="settings.collapseSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
|
||||
|
||||
<!-- library filter select -->
|
||||
<controls-library-filter-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.filterBy" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateFilter" />
|
||||
|
||||
@@ -93,11 +89,20 @@
|
||||
<div class="flex-grow" />
|
||||
<p>{{ $strings.MessageSearchResultsFor }} "{{ searchQuery }}"</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
|
||||
</template>
|
||||
<!-- authors page -->
|
||||
<template v-else-if="page === 'authors'">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="userCanUpdate && authors && authors.length && !isBatchSelecting" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">{{ $strings.ButtonMatchAllAuthors }}</ui-btn>
|
||||
<ui-btn v-if="userCanUpdate && authors?.length && !isBatchSelecting" :loading="processingAuthors" color="primary" small @click="matchAllAuthors">{{ $strings.ButtonMatchAllAuthors }}</ui-btn>
|
||||
|
||||
<!-- author sort select -->
|
||||
<controls-sort-select v-if="authors?.length" v-model="settings.authorSortBy" :descending.sync="settings.authorSortDesc" :items="authorSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateAuthorSort" />
|
||||
</template>
|
||||
<!-- home page -->
|
||||
<template v-else-if="isHome">
|
||||
<div class="flex-grow" />
|
||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,11 +153,13 @@ export default {
|
||||
|
||||
if (this.isSeriesRemovedFromContinueListening) {
|
||||
items.push({
|
||||
text: 'Re-Add Series to Continue Listening',
|
||||
text: this.$strings.LabelReAddSeriesToContinueListening,
|
||||
action: 're-add-to-continue-listening'
|
||||
})
|
||||
}
|
||||
|
||||
this.addSubtitlesMenuItem(items)
|
||||
|
||||
return items
|
||||
},
|
||||
seriesSortItems() {
|
||||
@@ -180,6 +187,34 @@ export default {
|
||||
{
|
||||
text: this.$strings.LabelTotalDuration,
|
||||
value: 'totalDuration'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelRandomly,
|
||||
value: 'random'
|
||||
}
|
||||
]
|
||||
},
|
||||
authorSortItems() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelAuthorFirstLast,
|
||||
value: 'name'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAuthorLastFirst,
|
||||
value: 'lastFirst'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelNumberOfBooks,
|
||||
value: 'numBooks'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAddedAt,
|
||||
value: 'addedAt'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelUpdatedAt,
|
||||
value: 'updatedAt'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -291,11 +326,14 @@ export default {
|
||||
|
||||
if (this.isPodcastLibrary && this.isLibraryPage && this.userCanDownload) {
|
||||
items.push({
|
||||
text: 'Export OPML',
|
||||
text: this.$strings.LabelExportOPML,
|
||||
action: 'export-opml'
|
||||
})
|
||||
}
|
||||
|
||||
this.addSubtitlesMenuItem(items)
|
||||
this.addCollapseSeriesMenuItem(items)
|
||||
|
||||
return items
|
||||
},
|
||||
showPlaylists() {
|
||||
@@ -303,9 +341,70 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addSubtitlesMenuItem(items) {
|
||||
if (this.isBookLibrary && (!this.page || this.page === 'search')) {
|
||||
if (this.settings.showSubtitles) {
|
||||
items.push({
|
||||
text: this.$strings.LabelHideSubtitles,
|
||||
action: 'hide-subtitles'
|
||||
})
|
||||
} else {
|
||||
items.push({
|
||||
text: this.$strings.LabelShowSubtitles,
|
||||
action: 'show-subtitles'
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
addCollapseSeriesMenuItem(items) {
|
||||
if (this.isLibraryPage && this.isBookLibrary && !this.isBatchSelecting) {
|
||||
if (this.settings.collapseSeries) {
|
||||
items.push({
|
||||
text: this.$strings.LabelExpandSeries,
|
||||
action: 'expand-series'
|
||||
})
|
||||
} else {
|
||||
items.push({
|
||||
text: this.$strings.LabelCollapseSeries,
|
||||
action: 'collapse-series'
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
handleSubtitlesAction(action) {
|
||||
if (action === 'show-subtitles') {
|
||||
this.settings.showSubtitles = true
|
||||
this.updateShowSubtitles()
|
||||
return true
|
||||
}
|
||||
if (action === 'hide-subtitles') {
|
||||
this.settings.showSubtitles = false
|
||||
this.updateShowSubtitles()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
handleCollapseSeriesAction(action) {
|
||||
if (action === 'collapse-series') {
|
||||
this.settings.collapseSeries = true
|
||||
this.updateCollapseSeries()
|
||||
return true
|
||||
}
|
||||
if (action === 'expand-series') {
|
||||
this.settings.collapseSeries = false
|
||||
this.updateCollapseSeries()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
contextMenuAction({ action }) {
|
||||
if (action === 'export-opml') {
|
||||
this.exportOPML()
|
||||
return
|
||||
} else if (this.handleSubtitlesAction(action)) {
|
||||
return
|
||||
} else if (this.handleCollapseSeriesAction(action)) {
|
||||
return
|
||||
}
|
||||
},
|
||||
exportOPML() {
|
||||
@@ -326,6 +425,8 @@ export default {
|
||||
return
|
||||
}
|
||||
this.markSeriesFinished()
|
||||
} else if (this.handleSubtitlesAction(action)) {
|
||||
return
|
||||
}
|
||||
},
|
||||
showOpenSeriesRSSFeed() {
|
||||
@@ -455,6 +556,12 @@ export default {
|
||||
updateCollapseBookSeries() {
|
||||
this.saveSettings()
|
||||
},
|
||||
updateShowSubtitles() {
|
||||
this.saveSettings()
|
||||
},
|
||||
updateAuthorSort() {
|
||||
this.saveSettings()
|
||||
},
|
||||
saveSettings() {
|
||||
this.$store.dispatch('user/updateUserSettings', this.settings)
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<div class="w-44 fixed left-0 top-16 bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform mb-12 overflow-y-auto" :class="wrapperClass + ' ' + (streamLibraryItem ? 'h-[calc(100%-270px)]' : 'h-[calc(100%-110px)]')" v-click-outside="clickOutside">
|
||||
<div v-show="isMobilePortrait" class="flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
|
||||
<span class="material-icons text-2xl">arrow_back</span>
|
||||
<span class="material-symbols text-2xl">arrow_back</span>
|
||||
</div>
|
||||
|
||||
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-3 h-12 border-b border-primary border-opacity-30 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
||||
@@ -10,7 +10,7 @@
|
||||
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version" />
|
||||
<modals-changelog-view-modal v-model="showChangelogModal" :versionData="versionData" />
|
||||
</div>
|
||||
|
||||
<div class="w-44 h-12 px-4 border-t bg-bg border-black border-opacity-20 fixed left-0 flex flex-col justify-center" :class="wrapperClass" :style="{ bottom: streamLibraryItem ? '160px' : '0px' }">
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
<p class="text-xs text-gray-300 italic">{{ Source }}</p>
|
||||
</div>
|
||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a>
|
||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ $config.version }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -114,9 +114,9 @@ export default {
|
||||
|
||||
if (this.currentLibraryId) {
|
||||
configRoutes.push({
|
||||
id: 'config-library-stats',
|
||||
id: 'library-stats',
|
||||
title: this.$strings.HeaderLibraryStats,
|
||||
path: '/config/library-stats'
|
||||
path: `/library/${this.currentLibraryId}/stats`
|
||||
})
|
||||
configRoutes.push({
|
||||
id: 'config-stats',
|
||||
@@ -156,15 +156,9 @@ export default {
|
||||
hasUpdate() {
|
||||
return !!this.versionData.hasUpdate
|
||||
},
|
||||
latestVersion() {
|
||||
return this.versionData.latestVersion
|
||||
},
|
||||
githubTagUrl() {
|
||||
return this.versionData.githubTagUrl
|
||||
},
|
||||
currentVersionChangelog() {
|
||||
return this.versionData.currentVersionChangelog || 'No Changelog Available'
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
}
|
||||
@@ -182,4 +176,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div id="bookshelf" class="w-full overflow-y-auto">
|
||||
<div id="bookshelf" ref="bookshelf" class="w-full overflow-y-auto" :style="{ fontSize: sizeMultiplier + 'rem' }">
|
||||
<template v-for="shelf in totalShelves">
|
||||
<div :key="shelf" :id="`shelf-${shelf - 1}`" class="w-full px-4 sm:px-8 relative" :class="{ bookshelfRow: !isAlternativeBookshelfView }" :style="{ height: shelfHeight + 'px' }">
|
||||
<div v-if="!isAlternativeBookshelfView" class="bookshelfDivider w-full absolute bottom-0 left-0 right-0 z-20" :class="`h-${shelfDividerHeightIndex}`" />
|
||||
<div :key="shelf" :id="`shelf-${shelf - 1}`" class="w-full px-4e sm:px-8e relative" :class="{ bookshelfRow: !isAlternativeBookshelfView }" :style="{ height: shelfHeight + 'px' }">
|
||||
<div v-if="!isAlternativeBookshelfView" class="bookshelfDivider w-full absolute bottom-0 left-0 right-0 z-20 h-6e" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<p class="text-center text-2xl mb-4 py-4">{{ $getString('MessageXLibraryIsEmpty', [libraryName]) }}</p>
|
||||
<div v-if="userIsAdminOrUp" class="flex">
|
||||
<ui-btn to="/config" color="primary" class="w-52 mr-2">{{ $strings.ButtonConfigureScanner }}</ui-btn>
|
||||
<ui-btn color="success" class="w-52" @click="scan">{{ $strings.ButtonScanLibrary }}</ui-btn>
|
||||
<ui-btn color="success" class="w-52" :loading="isScanningLibrary || tempIsScanning" @click="scan">{{ $strings.ButtonScanLibrary }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!totalShelves && initialized" class="w-full py-16">
|
||||
@@ -49,10 +49,9 @@ export default {
|
||||
entityIndexesMounted: [],
|
||||
entityComponentRefs: {},
|
||||
currentBookWidth: 0,
|
||||
pageLoadQueue: [],
|
||||
isFetchingEntities: false,
|
||||
scrollTimeout: null,
|
||||
booksPerFetch: 100,
|
||||
booksPerFetch: 0,
|
||||
totalShelves: 0,
|
||||
bookshelfMarginLeft: 0,
|
||||
isSelectionMode: false,
|
||||
@@ -62,7 +61,11 @@ export default {
|
||||
currScrollTop: 0,
|
||||
resizeTimeout: null,
|
||||
mountWindowWidth: 0,
|
||||
lastItemIndexSelected: -1
|
||||
lastItemIndexSelected: -1,
|
||||
tempIsScanning: false,
|
||||
cardWidth: 0,
|
||||
cardHeight: 0,
|
||||
resizeObserver: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -159,55 +162,46 @@ export default {
|
||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||
},
|
||||
bookWidth() {
|
||||
const coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||
if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return coverSize * 1.6
|
||||
return coverSize
|
||||
return this.cardWidth
|
||||
},
|
||||
bookHeight() {
|
||||
if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return this.bookWidth
|
||||
return this.bookWidth * 1.6
|
||||
return this.cardHeight
|
||||
},
|
||||
shelfPadding() {
|
||||
if (this.bookshelfWidth < 640) return 32
|
||||
return 64
|
||||
if (this.bookshelfWidth < 640) return 32 * this.sizeMultiplier
|
||||
return 64 * this.sizeMultiplier
|
||||
},
|
||||
totalPadding() {
|
||||
return this.shelfPadding * 2
|
||||
},
|
||||
entityWidth() {
|
||||
if (this.entityName === 'series' || this.entityName === 'collections') {
|
||||
if (this.bookWidth * 2 > this.bookshelfWidth - this.shelfPadding) return this.bookWidth * 1.6
|
||||
return this.bookWidth * 2
|
||||
}
|
||||
return this.bookWidth
|
||||
return this.cardWidth
|
||||
},
|
||||
entityHeight() {
|
||||
return this.bookHeight
|
||||
return this.cardHeight
|
||||
},
|
||||
shelfDividerHeightIndex() {
|
||||
return 6
|
||||
shelfPaddingHeight() {
|
||||
return 16
|
||||
},
|
||||
shelfHeight() {
|
||||
if (this.isAlternativeBookshelfView) {
|
||||
const isItemEntity = this.entityName === 'series-books' || this.entityName === 'items'
|
||||
const extraTitleSpace = isItemEntity ? 80 : this.entityName === 'albums' ? 60 : 40
|
||||
return this.entityHeight + extraTitleSpace * this.sizeMultiplier
|
||||
}
|
||||
return this.entityHeight + 40
|
||||
const dividerHeight = this.isAlternativeBookshelfView ? 0 : 24 // h-6
|
||||
return this.cardHeight + (this.shelfPaddingHeight + dividerHeight) * this.sizeMultiplier
|
||||
},
|
||||
totalEntityCardWidth() {
|
||||
// Includes margin
|
||||
return this.entityWidth + 24
|
||||
return this.entityWidth + 24 * this.sizeMultiplier
|
||||
},
|
||||
selectedMediaItems() {
|
||||
return this.$store.state.globals.selectedMediaItems || []
|
||||
},
|
||||
sizeMultiplier() {
|
||||
const baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||
return this.entityWidth / baseSize
|
||||
return this.$store.getters['user/getSizeMultiplier']
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
isScanningLibrary() {
|
||||
return !!this.$store.getters['tasks/getRunningLibraryScanTask'](this.currentLibraryId)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -318,7 +312,7 @@ export default {
|
||||
|
||||
let entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
|
||||
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
||||
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed,numEpisodesIncomplete`
|
||||
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed,numEpisodesIncomplete,share`
|
||||
|
||||
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
|
||||
console.error('failed to fetch items', error)
|
||||
@@ -432,10 +426,14 @@ export default {
|
||||
rebuild() {
|
||||
this.initSizeData()
|
||||
|
||||
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
|
||||
var lastBookIndex = Math.min(this.totalEntities, this.booksPerFetch)
|
||||
this.entityIndexesMounted = []
|
||||
for (let i = 0; i < lastBookIndex; i++) {
|
||||
this.entityIndexesMounted.push(i)
|
||||
if (!this.entities[i]) {
|
||||
const page = Math.floor(i / this.booksPerFetch)
|
||||
this.loadPage(page)
|
||||
}
|
||||
}
|
||||
var bookshelfEl = document.getElementById('bookshelf')
|
||||
if (bookshelfEl) {
|
||||
@@ -497,7 +495,8 @@ export default {
|
||||
this.resetEntities()
|
||||
}
|
||||
},
|
||||
settingsUpdated(settings) {
|
||||
async settingsUpdated(settings) {
|
||||
await this.cardsHelpers.setCardSize()
|
||||
const wasUpdated = this.checkUpdateSearchParams()
|
||||
if (wasUpdated) {
|
||||
this.resetEntities()
|
||||
@@ -602,6 +601,44 @@ export default {
|
||||
this.executeRebuild()
|
||||
}
|
||||
},
|
||||
shareOpen(mediaItemShare) {
|
||||
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||
var indexOf = this.entities.findIndex((ent) => ent?.media?.id === mediaItemShare.mediaItemId)
|
||||
if (indexOf >= 0) {
|
||||
if (this.entityComponentRefs[indexOf]) {
|
||||
const libraryItem = { ...this.entityComponentRefs[indexOf].libraryItem }
|
||||
libraryItem.mediaItemShare = mediaItemShare
|
||||
this.entityComponentRefs[indexOf].setEntity?.(libraryItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
shareClosed(mediaItemShare) {
|
||||
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||
var indexOf = this.entities.findIndex((ent) => ent?.media?.id === mediaItemShare.mediaItemId)
|
||||
if (indexOf >= 0) {
|
||||
if (this.entityComponentRefs[indexOf]) {
|
||||
const libraryItem = { ...this.entityComponentRefs[indexOf].libraryItem }
|
||||
libraryItem.mediaItemShare = null
|
||||
this.entityComponentRefs[indexOf].setEntity?.(libraryItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
updatePagesLoaded() {
|
||||
let numPages = Math.ceil(this.totalEntities / this.booksPerFetch)
|
||||
for (let page = 0; page < numPages; page++) {
|
||||
let numEntities = Math.min(this.totalEntities - page * this.booksPerFetch, this.booksPerFetch)
|
||||
this.pagesLoaded[page] = true
|
||||
for (let i = 0; i < numEntities; i++) {
|
||||
const index = page * this.booksPerFetch + i
|
||||
if (!this.entities[index]) {
|
||||
this.pagesLoaded[page] = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
initSizeData(_bookshelf) {
|
||||
var bookshelf = _bookshelf || document.getElementById('bookshelf')
|
||||
if (!bookshelf) {
|
||||
@@ -618,6 +655,13 @@ export default {
|
||||
this.entitiesPerShelf = Math.max(1, Math.floor((this.bookshelfWidth - this.shelfPadding) / this.totalEntityCardWidth))
|
||||
this.shelvesPerPage = Math.ceil(this.bookshelfHeight / this.shelfHeight) + 2
|
||||
this.bookshelfMarginLeft = (this.bookshelfWidth - this.entitiesPerShelf * this.totalEntityCardWidth) / 2
|
||||
const booksPerFetch = this.entitiesPerShelf * this.shelvesPerPage
|
||||
if (booksPerFetch !== this.booksPerFetch) {
|
||||
this.booksPerFetch = booksPerFetch
|
||||
if (this.totalEntities) {
|
||||
this.updatePagesLoaded()
|
||||
}
|
||||
}
|
||||
|
||||
this.currentBookWidth = this.bookWidth
|
||||
if (this.totalEntities) {
|
||||
@@ -626,13 +670,8 @@ export default {
|
||||
return entitiesPerShelfBefore < this.entitiesPerShelf // Books per shelf has changed
|
||||
},
|
||||
async init(bookshelf) {
|
||||
if (this.entityName === 'series') {
|
||||
this.booksPerFetch = 50
|
||||
} else {
|
||||
this.booksPerFetch = 100
|
||||
}
|
||||
this.checkUpdateSearchParams()
|
||||
this.initSizeData(bookshelf)
|
||||
this.checkUpdateSearchParams()
|
||||
|
||||
this.pagesLoaded[0] = true
|
||||
await this.fetchEntites(0)
|
||||
@@ -688,6 +727,8 @@ export default {
|
||||
this.$root.socket.on('playlist_added', this.playlistAdded)
|
||||
this.$root.socket.on('playlist_updated', this.playlistUpdated)
|
||||
this.$root.socket.on('playlist_removed', this.playlistRemoved)
|
||||
this.$root.socket.on('share_open', this.shareOpen)
|
||||
this.$root.socket.on('share_closed', this.shareClosed)
|
||||
} else {
|
||||
console.error('Bookshelf - Socket not initialized')
|
||||
}
|
||||
@@ -715,6 +756,8 @@ export default {
|
||||
this.$root.socket.off('playlist_added', this.playlistAdded)
|
||||
this.$root.socket.off('playlist_updated', this.playlistUpdated)
|
||||
this.$root.socket.off('playlist_removed', this.playlistRemoved)
|
||||
this.$root.socket.off('share_open', this.shareOpen)
|
||||
this.$root.socket.off('share_closed', this.shareClosed)
|
||||
} else {
|
||||
console.error('Bookshelf - Socket not initialized')
|
||||
}
|
||||
@@ -727,18 +770,20 @@ export default {
|
||||
}
|
||||
},
|
||||
scan() {
|
||||
this.tempIsScanning = true
|
||||
this.$store
|
||||
.dispatch('libraries/requestLibraryScan', { libraryId: this.currentLibraryId })
|
||||
.then(() => {
|
||||
this.$toast.success('Library scan started')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to start scan', error)
|
||||
this.$toast.error('Failed to start scan')
|
||||
this.$toast.error(this.$strings.ToastLibraryScanFailedToStart)
|
||||
})
|
||||
.finally(() => {
|
||||
this.tempIsScanning = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
async mounted() {
|
||||
await this.cardsHelpers.setCardSize()
|
||||
this.initListeners()
|
||||
|
||||
this.routeFullPath = window.location.pathname + (window.location.search || '')
|
||||
@@ -773,6 +818,6 @@ export default {
|
||||
.bookshelfDivider {
|
||||
background: rgb(149, 119, 90);
|
||||
background: var(--bookshelf-divider-bg);
|
||||
box-shadow: 2px 14px 8px #111111aa;
|
||||
box-shadow: 0.125em 0.875em 0.5em #111111aa;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,45 +1,47 @@
|
||||
<template>
|
||||
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 md:h-40 z-50 bg-primary px-2 md:px-4 pb-1 md:pb-4 pt-2">
|
||||
<div v-if="streamLibraryItem" id="mediaPlayerContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 lg:h-40 z-50 bg-primary px-2 lg:px-4 pb-1 lg:pb-4 pt-2">
|
||||
<div id="videoDock" />
|
||||
<div class="absolute left-2 top-2 md:left-4 cursor-pointer">
|
||||
<div class="absolute left-2 top-2 lg:left-4 cursor-pointer">
|
||||
<covers-book-cover expand-on-click :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
||||
</div>
|
||||
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
|
||||
<div class="min-w-0">
|
||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
|
||||
{{ title }}
|
||||
</nuxt-link>
|
||||
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center">
|
||||
<span class="material-icons text-sm">person</span>
|
||||
<div class="flex items-center">
|
||||
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</div>
|
||||
<div v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</div>
|
||||
<div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base">
|
||||
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
</div>
|
||||
<div v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">{{ $strings.LabelUnknown }}</div>
|
||||
<widgets-explicit-indicator :explicit="isExplicit"></widgets-explicit-indicator>
|
||||
<div class="flex items-start mb-6 lg:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'">
|
||||
<div class="min-w-0 w-full">
|
||||
<div class="flex items-center">
|
||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate">
|
||||
{{ title }}
|
||||
</nuxt-link>
|
||||
<widgets-explicit-indicator v-if="isExplicit" />
|
||||
</div>
|
||||
<div v-if="!playerHandler.isVideo" class="text-gray-400 flex items-center w-1/2 sm:w-4/5 lg:w-2/5">
|
||||
<span class="material-symbols text-sm">person</span>
|
||||
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</div>
|
||||
<div v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</div>
|
||||
<div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base truncate">
|
||||
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
</div>
|
||||
<div v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">{{ $strings.LabelUnknown }}</div>
|
||||
</div>
|
||||
|
||||
<div class="text-gray-400 flex items-center">
|
||||
<span class="material-icons text-xs">schedule</span>
|
||||
<span class="material-symbols text-xs">schedule</span>
|
||||
<p class="font-mono text-xs sm:text-sm pl-1 sm:pl-1.5 pb-px">{{ totalDurationPretty }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<ui-tooltip direction="top" :text="$strings.LabelClosePlayer">
|
||||
<span class="material-icons sm:px-2 py-1 md:p-4 cursor-pointer text-xl sm:text-2xl" @click="closePlayer">close</span>
|
||||
<button :aria-label="$strings.LabelClosePlayer" class="material-symbols sm:px-2 py-1 lg:p-4 cursor-pointer text-xl sm:text-2xl" @click="closePlayer">close</button>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<player-ui
|
||||
ref="audioPlayer"
|
||||
:chapters="chapters"
|
||||
:current-chapter="currentChapter"
|
||||
:paused="!isPlaying"
|
||||
:loading="playerLoading"
|
||||
:bookmarks="bookmarks"
|
||||
:sleep-timer-set="sleepTimerSet"
|
||||
:sleep-timer-remaining="sleepTimerRemaining"
|
||||
:sleep-timer-type="sleepTimerType"
|
||||
:is-podcast="isPodcast"
|
||||
@playPause="playPause"
|
||||
@jumpForward="jumpForward"
|
||||
@@ -51,13 +53,16 @@
|
||||
@showBookmarks="showBookmarks"
|
||||
@showSleepTimer="showSleepTimerModal = true"
|
||||
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
|
||||
@showPlayerSettings="showPlayerSettingsModal = true"
|
||||
/>
|
||||
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
|
||||
|
||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-time="sleepTimerTime" :remaining="sleepTimerRemaining" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-type="sleepTimerType" :remaining="sleepTimerRemaining" :has-chapters="!!chapters.length" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
|
||||
|
||||
<modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" :library-item-id="libraryItemId" />
|
||||
|
||||
<modals-player-settings-modal v-model="showPlayerSettingsModal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -76,19 +81,18 @@ export default {
|
||||
currentTime: 0,
|
||||
showSleepTimerModal: false,
|
||||
showPlayerQueueItemsModal: false,
|
||||
showPlayerSettingsModal: false,
|
||||
sleepTimerSet: false,
|
||||
sleepTimerTime: 0,
|
||||
sleepTimerRemaining: 0,
|
||||
sleepTimerType: null,
|
||||
sleepTimer: null,
|
||||
displayTitle: null,
|
||||
currentPlaybackRate: 1,
|
||||
syncFailedToast: null
|
||||
syncFailedToast: null,
|
||||
coverAspectRatio: 1
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
isSquareCover() {
|
||||
return this.coverAspectRatio === 1
|
||||
},
|
||||
@@ -138,7 +142,7 @@ export default {
|
||||
return this.streamLibraryItem?.mediaType === 'music'
|
||||
},
|
||||
isExplicit() {
|
||||
return this.mediaMetadata.explicit || false
|
||||
return !!this.mediaMetadata.explicit
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
@@ -147,6 +151,9 @@ export default {
|
||||
if (this.streamEpisode) return this.streamEpisode.chapters || []
|
||||
return this.media.chapters || []
|
||||
},
|
||||
currentChapter() {
|
||||
return this.chapters.find((chapter) => chapter.start <= this.currentTime && this.currentTime < chapter.end)
|
||||
},
|
||||
title() {
|
||||
if (this.playerHandler.displayTitle) return this.playerHandler.displayTitle
|
||||
return this.mediaMetadata.title || 'No Title'
|
||||
@@ -206,14 +213,18 @@ export default {
|
||||
this.$store.commit('setIsPlaying', isPlaying)
|
||||
this.updateMediaSessionPlaybackState()
|
||||
},
|
||||
setSleepTimer(seconds) {
|
||||
setSleepTimer(time) {
|
||||
this.sleepTimerSet = true
|
||||
this.sleepTimerTime = seconds
|
||||
this.sleepTimerRemaining = seconds
|
||||
this.runSleepTimer()
|
||||
this.showSleepTimerModal = false
|
||||
|
||||
this.sleepTimerType = time.timerType
|
||||
if (this.sleepTimerType === this.$constants.SleepTimerTypes.COUNTDOWN) {
|
||||
this.runSleepTimer(time)
|
||||
}
|
||||
},
|
||||
runSleepTimer() {
|
||||
runSleepTimer(time) {
|
||||
this.sleepTimerRemaining = time.seconds
|
||||
|
||||
var lastTick = Date.now()
|
||||
clearInterval(this.sleepTimer)
|
||||
this.sleepTimer = setInterval(() => {
|
||||
@@ -222,12 +233,23 @@ export default {
|
||||
this.sleepTimerRemaining -= elapsed / 1000
|
||||
|
||||
if (this.sleepTimerRemaining <= 0) {
|
||||
this.clearSleepTimer()
|
||||
this.playerHandler.pause()
|
||||
this.$toast.info('Sleep Timer Done.. zZzzZz')
|
||||
this.sleepTimerEnd()
|
||||
}
|
||||
}, 1000)
|
||||
},
|
||||
checkChapterEnd(time) {
|
||||
if (!this.currentChapter) return
|
||||
const chapterEndTime = this.currentChapter.end
|
||||
const tolerance = 0.75
|
||||
if (time >= chapterEndTime - tolerance) {
|
||||
this.sleepTimerEnd()
|
||||
}
|
||||
},
|
||||
sleepTimerEnd() {
|
||||
this.clearSleepTimer()
|
||||
this.playerHandler.pause()
|
||||
this.$toast.info('Sleep Timer Done.. zZzzZz')
|
||||
},
|
||||
cancelSleepTimer() {
|
||||
this.showSleepTimerModal = false
|
||||
this.clearSleepTimer()
|
||||
@@ -237,6 +259,7 @@ export default {
|
||||
this.sleepTimerRemaining = 0
|
||||
this.sleepTimer = null
|
||||
this.sleepTimerSet = false
|
||||
this.sleepTimerType = null
|
||||
},
|
||||
incrementSleepTimer(amount) {
|
||||
if (!this.sleepTimerSet) return
|
||||
@@ -277,6 +300,10 @@ export default {
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.setCurrentTime(time)
|
||||
}
|
||||
|
||||
if (this.sleepTimerType === this.$constants.SleepTimerTypes.CHAPTER && this.sleepTimerSet) {
|
||||
this.checkChapterEnd(time)
|
||||
}
|
||||
},
|
||||
setDuration(duration) {
|
||||
this.totalDuration = duration
|
||||
@@ -380,7 +407,7 @@ export default {
|
||||
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === data.stream) {
|
||||
if (!data.numSegments) return
|
||||
var chunks = data.chunks
|
||||
console.log(`[StreamContainer] Stream Progress ${data.percent}`)
|
||||
console.log(`[MediaPlayerContainer] Stream Progress ${data.percent}`)
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
|
||||
} else {
|
||||
@@ -397,17 +424,17 @@ export default {
|
||||
this.playerHandler.prepareOpenSession(session, this.currentPlaybackRate)
|
||||
},
|
||||
streamOpen(session) {
|
||||
console.log(`[StreamContainer] Stream session open`, session)
|
||||
console.log(`[MediaPlayerContainer] Stream session open`, session)
|
||||
},
|
||||
streamClosed(streamId) {
|
||||
// Stream was closed from the server
|
||||
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
|
||||
console.warn('[StreamContainer] Closing stream due to request from server')
|
||||
console.warn('[MediaPlayerContainer] Closing stream due to request from server')
|
||||
this.playerHandler.closePlayer()
|
||||
}
|
||||
},
|
||||
streamReady() {
|
||||
console.log(`[StreamContainer] Stream Ready`)
|
||||
console.log(`[MediaPlayerContainer] Stream Ready`)
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.setStreamReady()
|
||||
} else {
|
||||
@@ -417,7 +444,7 @@ export default {
|
||||
streamError(streamId) {
|
||||
// Stream had critical error from the server
|
||||
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === streamId) {
|
||||
console.warn('[StreamContainer] Closing stream due to stream error from server')
|
||||
console.warn('[MediaPlayerContainer] Closing stream due to stream error from server')
|
||||
this.playerHandler.closePlayer()
|
||||
}
|
||||
},
|
||||
@@ -457,6 +484,9 @@ export default {
|
||||
episodeId,
|
||||
queueItems: payload.queueItems || []
|
||||
})
|
||||
// Set cover aspect ratio for this item's library since the library may change
|
||||
this.coverAspectRatio = this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
|
||||
})
|
||||
@@ -496,7 +526,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#streamContainer {
|
||||
#mediaPlayerContainer {
|
||||
box-shadow: 0px -6px 8px #1111113f;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-8">
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-2 sm:p-4 mb-8">
|
||||
<div class="flex items-center mb-2">
|
||||
<slot name="header-prefix"></slot>
|
||||
<h1 class="text-xl">{{ headerText }}</h1>
|
||||
|
||||
<slot name="header-items"></slot>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="material-icons text-2xl">format_list_bulleted</span>
|
||||
<span class="material-symbols text-2xl">format_list_bulleted</span>
|
||||
|
||||
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLatest }}</p>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="material-icons-outlined text-2xl">collections_bookmark</span>
|
||||
<span class="material-symbols-outlined text-2xl">collections_bookmark</span>
|
||||
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonCollections }}</p>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="material-icons text-2.5xl">queue_music</span>
|
||||
<span class="material-symbols text-2.5xl">queue_music</span>
|
||||
|
||||
<p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
|
||||
|
||||
@@ -72,13 +72,21 @@
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/narrators`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isNarratorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="material-icons text-2xl">record_voice_over</span>
|
||||
<span class="material-symbols text-2xl">record_voice_over</span>
|
||||
|
||||
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.LabelNarrators }}</p>
|
||||
|
||||
<div v-show="isNarratorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isBookLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/stats`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isStatsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="material-symbols text-2xl">monitoring</span>
|
||||
|
||||
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonStats }}</p>
|
||||
|
||||
<div v-show="isStatsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="abs-icons icon-podcast text-xl"></span>
|
||||
|
||||
@@ -88,7 +96,7 @@
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isMusicLibrary" :to="`/library/${currentLibraryId}/bookshelf/albums`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isMusicAlbumsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="material-icons-outlined text-xl">album</span>
|
||||
<span class="material-symbols-outlined text-xl">album</span>
|
||||
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">Albums</p>
|
||||
|
||||
@@ -96,15 +104,15 @@
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<span class="material-icons text-2xl">file_download</span>
|
||||
<span class="material-symbols text-2xl">file_download</span>
|
||||
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonDownloadQueue }}</p>
|
||||
|
||||
<div v-show="isPodcastDownloadQueuePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'">
|
||||
<span class="material-icons text-2xl">warning</span>
|
||||
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : 'bg-error bg-opacity-20'">
|
||||
<span class="material-symbols text-2xl">warning</span>
|
||||
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 1rem">{{ $strings.ButtonIssues }}</p>
|
||||
|
||||
@@ -121,7 +129,7 @@
|
||||
<p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p>
|
||||
</div>
|
||||
|
||||
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version" />
|
||||
<modals-changelog-view-modal v-model="showChangelogModal" :versionData="versionData" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -194,6 +202,9 @@ export default {
|
||||
isPlaylistsPage() {
|
||||
return this.paramId === 'playlists'
|
||||
},
|
||||
isStatsPage() {
|
||||
return this.$route.name === 'library-library-stats'
|
||||
},
|
||||
libraryBookshelfPage() {
|
||||
return this.$route.name === 'library-library-bookshelf-id'
|
||||
},
|
||||
@@ -219,9 +230,6 @@ export default {
|
||||
githubTagUrl() {
|
||||
return this.versionData.githubTagUrl
|
||||
},
|
||||
currentVersionChangelog() {
|
||||
return this.versionData.currentVersionChangelog || 'No Changelog Available'
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
@@ -245,4 +253,4 @@ export default {
|
||||
#siderail-buttons-container.player-open {
|
||||
max-height: calc(100vh - 64px - 48px - 160px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,38 +1,40 @@
|
||||
<template>
|
||||
<nuxt-link :to="`/author/${author.id}`">
|
||||
<div @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||
<!-- Image or placeholder -->
|
||||
<covers-author-image :author="author" />
|
||||
<div :style="{ minWidth: cardWidth + 'px', maxWidth: cardWidth + 'px' }">
|
||||
<nuxt-link :to="`/author/${author.id}`">
|
||||
<div cy-id="card" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div cy-id="imageArea" :style="{ height: cardHeight + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||
<!-- Image or placeholder -->
|
||||
<covers-author-image :author="author" />
|
||||
|
||||
<!-- Author name & num books overlay -->
|
||||
<div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
|
||||
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} {{ $strings.LabelBooks }}</p>
|
||||
</div>
|
||||
<!-- Author name & num books overlay -->
|
||||
<div cy-id="textInline" v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1e bg-black bg-opacity-60 px-2e">
|
||||
<p class="text-center font-semibold truncate" :style="{ fontSize: 0.75 + 'em' }">{{ name }}</p>
|
||||
<p class="text-center text-gray-200" :style="{ fontSize: 0.65 + 'em' }">{{ numBooks }} {{ $strings.LabelBooks }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Search icon btn -->
|
||||
<div v-show="!searching && isHovering && userCanUpdate" class="absolute top-0 left-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="searchAuthor">
|
||||
<ui-tooltip :text="$strings.ButtonQuickMatch" direction="bottom">
|
||||
<span class="material-icons text-lg">search</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div v-show="isHovering && !searching && userCanUpdate" class="absolute top-0 right-0 p-2 cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="$emit('edit', author)">
|
||||
<ui-tooltip :text="$strings.LabelEdit" direction="bottom">
|
||||
<span class="material-icons text-lg">edit</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<!-- Search icon btn -->
|
||||
<div cy-id="match" v-show="!searching && isHovering && userCanUpdate" class="absolute top-0 left-0 p-2e cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="searchAuthor">
|
||||
<ui-tooltip :text="$strings.ButtonQuickMatch" direction="bottom">
|
||||
<span class="material-symbols" :style="{ fontSize: 1.125 + 'em' }">search</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div cy-id="edit" v-show="isHovering && !searching && userCanUpdate" class="absolute top-0 right-0 p-2e cursor-pointer hover:text-white text-gray-200 transform hover:scale-125 duration-150" @click.prevent.stop="$emit('edit', author)">
|
||||
<ui-tooltip :text="$strings.LabelEdit" direction="bottom">
|
||||
<span class="material-symbols" :style="{ fontSize: 1.125 + 'em' }">edit</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Loading spinner -->
|
||||
<div v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<widgets-loading-spinner size="" />
|
||||
<!-- Loading spinner -->
|
||||
<div cy-id="spinner" v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<widgets-loading-spinner size="" />
|
||||
</div>
|
||||
</div>
|
||||
<div cy-id="nameBelow" v-show="nameBelow" class="w-full py-1e px-2e">
|
||||
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: 0.75 + 'em' }">{{ name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="nameBelow" class="w-full py-1 px-2">
|
||||
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -43,12 +45,14 @@ export default {
|
||||
default: () => {}
|
||||
},
|
||||
width: Number,
|
||||
height: Number,
|
||||
sizeMultiplier: {
|
||||
height: {
|
||||
type: Number,
|
||||
default: 1
|
||||
default: 192
|
||||
},
|
||||
nameBelow: Boolean
|
||||
nameBelow: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -57,6 +61,12 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
cardWidth() {
|
||||
return this.width || this.cardHeight * 0.8
|
||||
},
|
||||
cardHeight() {
|
||||
return this.height * this.sizeMultiplier
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
@@ -83,6 +93,9 @@ export default {
|
||||
},
|
||||
libraryProvider() {
|
||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return this.$store.getters['user/getSizeMultiplier']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -128,4 +141,4 @@ export default {
|
||||
this.$eventBus.$off(`searching-author-${this.authorId}`, this.setSearching)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
</div>
|
||||
<div class="flex-grow px-2 authorSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ name }}</p>
|
||||
<p class="text-xs text-gray-400">{{ $getString('LabelXBooks', [numBooks]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -23,6 +24,9 @@ export default {
|
||||
computed: {
|
||||
name() {
|
||||
return this.author.name
|
||||
},
|
||||
numBooks() {
|
||||
return this.author.numBooks
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
@@ -33,9 +37,9 @@ export default {
|
||||
<style>
|
||||
.authorSearchCardContent {
|
||||
width: calc(100% - 80px);
|
||||
height: 40px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative pointer-events-none" :style="{ width: standardWidth * 0.8 * 1.1 * scale + 'px', height: standardHeight * 1.1 * scale + 'px', marginBottom: 20 + 'px', marginTop: 15 + 'px' }">
|
||||
<div ref="card" class="wrap absolute origin-center transform duration-200" :style="{ transform: `scale(${scale * scaleMultiplier}) translateY(${hover2 ? '-40%' : '-50%'})` }">
|
||||
<div class="perspective">
|
||||
<div class="book-wrap transform duration-100 pointer-events-auto" :class="hover2 ? 'z-80' : 'rotate'" @mouseover="hover = true" @mouseout="hover = false">
|
||||
<div class="book book-1 box-shadow-book3d" ref="front"></div>
|
||||
<div class="title book-1 pointer-events-none" ref="left"></div>
|
||||
<div class="bottom book-1 pointer-events-none" ref="bottom"></div>
|
||||
<div class="book-back book-1 pointer-events-none">
|
||||
<div class="text pointer-events-none">
|
||||
<h3 class="mb-4">Book Back</h3>
|
||||
<p>
|
||||
<span>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Sunt earum doloremque aliquam culpa dolor nostrum consequatur quas dicta? Molestias repellendus minima pariatur libero vel, reiciendis optio magnam rerum, labore corporis.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
src: String,
|
||||
width: {
|
||||
type: Number,
|
||||
default: 200
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hover: false,
|
||||
hover2: false,
|
||||
standardWidth: 200,
|
||||
standardHeight: 320,
|
||||
isAttached: true,
|
||||
pageX: 0,
|
||||
pageY: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
src(newVal) {
|
||||
this.setCover()
|
||||
},
|
||||
width(newVal) {
|
||||
this.init()
|
||||
},
|
||||
hover(newVal) {
|
||||
if (newVal) {
|
||||
this.unattach()
|
||||
} else {
|
||||
this.attach()
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.hover2 = newVal
|
||||
}, 100)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
scaleMultiplier() {
|
||||
return this.hover2 ? 1.25 : 1
|
||||
},
|
||||
scale() {
|
||||
var scale = this.width / this.standardWidth
|
||||
return scale
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
unattach() {
|
||||
if (this.$refs.card && this.isAttached) {
|
||||
var bookshelf = document.getElementById('bookshelf')
|
||||
if (bookshelf) {
|
||||
var pos = this.$refs.wrapper.getBoundingClientRect()
|
||||
|
||||
this.pageX = pos.x
|
||||
this.pageY = pos.y
|
||||
document.body.appendChild(this.$refs.card)
|
||||
this.$refs.card.style.left = this.pageX + 'px'
|
||||
this.$refs.card.style.top = this.pageY + 'px'
|
||||
this.$refs.card.style.zIndex = 50
|
||||
this.isAttached = false
|
||||
} else if (bookshelf) {
|
||||
console.log(this.pageX, this.pageY)
|
||||
this.isAttached = false
|
||||
}
|
||||
}
|
||||
},
|
||||
attach() {
|
||||
if (this.$refs.card && !this.isAttached) {
|
||||
if (this.$refs.wrapper) {
|
||||
this.isAttached = true
|
||||
|
||||
this.$refs.wrapper.appendChild(this.$refs.card)
|
||||
this.$refs.card.style.left = '0px'
|
||||
this.$refs.card.style.top = '0px'
|
||||
}
|
||||
} else {
|
||||
console.log('Is attached already', this.isAttached)
|
||||
}
|
||||
},
|
||||
init() {
|
||||
var standardWidth = this.standardWidth
|
||||
document.documentElement.style.setProperty('--book-w', standardWidth + 'px')
|
||||
document.documentElement.style.setProperty('--book-wx', standardWidth + 1 + 'px')
|
||||
document.documentElement.style.setProperty('--book-h', standardWidth * 1.6 + 'px')
|
||||
document.documentElement.style.setProperty('--book-d', 40 + 'px')
|
||||
},
|
||||
setElBg(el) {
|
||||
el.style.backgroundImage = `url("${this.src}")`
|
||||
el.style.backgroundSize = 'cover'
|
||||
el.style.backgroundPosition = 'center center'
|
||||
el.style.backgroundRepeat = 'no-repeat'
|
||||
},
|
||||
setCover() {
|
||||
if (this.$refs.front) {
|
||||
this.setElBg(this.$refs.front)
|
||||
}
|
||||
if (this.$refs.bottom) {
|
||||
this.setElBg(this.$refs.bottom)
|
||||
this.$refs.bottom.style.backgroundSize = '2000%'
|
||||
this.$refs.bottom.style.filter = 'blur(1px)'
|
||||
}
|
||||
if (this.$refs.left) {
|
||||
this.setElBg(this.$refs.left)
|
||||
this.$refs.left.style.backgroundSize = '2000%'
|
||||
this.$refs.left.style.filter = 'blur(1px)'
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.setCover()
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* :root {
|
||||
--book-w: 200px;
|
||||
--book-h: 320px;
|
||||
--book-d: 30px;
|
||||
--book-wx: 201px;
|
||||
} */
|
||||
/*
|
||||
.wrap {
|
||||
width: calc(1.1 * var(--book-w));
|
||||
height: calc(1.1 * var(--book-h));
|
||||
margin: 0 auto;
|
||||
}
|
||||
.perspective {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
perspective: 600px;
|
||||
transform-style: preserve-3d;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.book-wrap {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
transform-style: preserve-3d;
|
||||
transition: 'all ease-out 0.6s';
|
||||
}
|
||||
|
||||
.book {
|
||||
width: var(--book-w);
|
||||
height: var(--book-h);
|
||||
background: url(https://covers.openlibrary.org/b/id/8303020-L.jpg) no-repeat center center;
|
||||
background-size: cover;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
.title {
|
||||
content: '';
|
||||
height: var(--book-h);
|
||||
width: var(--book-d);
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: calc(var(--book-wx) * -1);
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
background: #444;
|
||||
transform: rotateY(-80deg) translateX(-14px);
|
||||
|
||||
background: url(https://covers.openlibrary.org/b/id/8303020-L.jpg) no-repeat center center;
|
||||
background-size: 5000%;
|
||||
filter: blur(1px);
|
||||
}
|
||||
|
||||
.bottom {
|
||||
content: '';
|
||||
height: var(--book-d);
|
||||
width: var(--book-w);
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: var(--book-h);
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: auto;
|
||||
background: #444;
|
||||
transform: rotateY(0deg) rotateX(90deg) translateY(-15px) translateX(-2.5px) skewX(10deg);
|
||||
|
||||
background: url(https://covers.openlibrary.org/b/id/8303020-L.jpg) no-repeat center center;
|
||||
background-size: 5000%;
|
||||
filter: blur(1px);
|
||||
}
|
||||
|
||||
.book-back {
|
||||
width: var(--book-w);
|
||||
height: var(--book-h);
|
||||
background-color: #444;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
cursor: pointer;
|
||||
transform: rotate(180deg) translateZ(-30px) translateX(5px);
|
||||
}
|
||||
.book-back .text {
|
||||
transform: rotateX(180deg);
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
padding: 20px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
}
|
||||
.book-back .text h3 {
|
||||
color: #fff;
|
||||
}
|
||||
.book-back .text span {
|
||||
display: block;
|
||||
margin-bottom: 20px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.book-wrap.rotate {
|
||||
transform: rotateY(30deg) rotateX(0deg);
|
||||
}
|
||||
.book-wrap.flip {
|
||||
transform: rotateY(180deg);
|
||||
} */
|
||||
</style>
|
||||
@@ -13,9 +13,9 @@
|
||||
<div class="flex-grow" />
|
||||
<p class="text-sm md:text-base">{{ book.publishedYear }}</p>
|
||||
</div>
|
||||
<p v-if="book.author" class="text-gray-300 text-xs md:text-sm">by {{ book.author }}</p>
|
||||
<p v-if="book.narrator" class="text-gray-400 text-xs">Narrated by {{ book.narrator }}</p>
|
||||
<p v-if="book.duration" class="text-gray-400 text-xs">Runtime: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}</p>
|
||||
<p v-if="book.author" class="text-gray-300 text-xs md:text-sm">{{ $getString('LabelByAuthor', [book.author]) }}</p>
|
||||
<p v-if="book.narrator" class="text-gray-400 text-xs">{{ $strings.LabelNarrators }}: {{ book.narrator }}</p>
|
||||
<p v-if="book.duration" class="text-gray-400 text-xs">{{ $strings.LabelDuration }}: {{ $elapsedPrettyExtended(bookDuration, false) }} {{ bookDurationComparison }}</p>
|
||||
<div v-if="book.series?.length" class="flex py-1 -mx-1">
|
||||
<div v-for="(series, index) in book.series" :key="index" class="bg-white bg-opacity-10 rounded-full px-1 py-0.5 mx-1">
|
||||
<p class="leading-3 text-xs text-gray-400">
|
||||
@@ -29,9 +29,9 @@
|
||||
</div>
|
||||
<div v-else class="px-4 flex-grow">
|
||||
<h1>
|
||||
<div class="flex items-center">{{ book.title }}<widgets-explicit-indicator :explicit="book.explicit" /></div>
|
||||
<div class="flex items-center">{{ book.title }}<widgets-explicit-indicator v-if="book.explicit" /></div>
|
||||
</h1>
|
||||
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ book.author }}</p>
|
||||
<p class="text-base text-gray-300 whitespace-nowrap truncate">{{ $getString('LabelByAuthor', [book.author]) }}</p>
|
||||
<p v-if="book.genres" class="text-xs text-gray-400 leading-5">{{ book.genres.join(', ') }}</p>
|
||||
<p class="text-xs text-gray-400 leading-5">{{ book.trackCount }} Episodes</p>
|
||||
</div>
|
||||
@@ -75,11 +75,11 @@ export default {
|
||||
let differenceInMinutes = currentBookDurationMinutes - this.book.duration
|
||||
if (differenceInMinutes < 0) {
|
||||
differenceInMinutes = Math.abs(differenceInMinutes)
|
||||
return `(${this.$elapsedPrettyExtended(differenceInMinutes * 60, false, false)} shorter)`
|
||||
return this.$getString('LabelDurationComparisonLonger', [this.$elapsedPrettyExtended(differenceInMinutes * 60, false, false)])
|
||||
} else if (differenceInMinutes > 0) {
|
||||
return `(${this.$elapsedPrettyExtended(differenceInMinutes * 60, false, false)} longer)`
|
||||
return this.$getString('LabelDurationComparisonShorter', [this.$elapsedPrettyExtended(differenceInMinutes * 60, false, false)])
|
||||
}
|
||||
return '(exact match)'
|
||||
return this.$strings.LabelDurationComparisonExactMatch
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
36
client/components/cards/GenreSearchCard.vue
Normal file
36
client/components/cards/GenreSearchCard.vue
Normal file
@@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<div class="w-10 h-10 flex items-center justify-center">
|
||||
<span class="material-symbols text-2xl text-gray-200">category</span>
|
||||
</div>
|
||||
<div class="flex-grow px-2 tagSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ genre }}</p>
|
||||
<p class="text-xs text-gray-400">{{ $getString('LabelXItems', [numItems]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
genre: String,
|
||||
numItems: Number
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.tagSearchCardContent {
|
||||
width: calc(100% - 40px);
|
||||
height: 44px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +1,15 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div class="rounded-sm h-full relative" :style="{ width: width + 'px', height: height + 'px' }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
|
||||
<div class="rounded-sm h-full relative" :style="{ width: cardWidth + 'px', height: cardHeight + 'px' }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
|
||||
<nuxt-link :to="groupTo" class="cursor-pointer">
|
||||
<div class="w-full h-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'">
|
||||
<covers-group-cover ref="groupcover" :id="groupEncode" :name="groupName" :type="groupType" :book-items="bookItems" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-group-cover ref="groupcover" :id="groupEncode" :name="groupName" :type="groupType" :book-items="bookItems" :width="cardWidth" :height="cardHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
|
||||
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity z-30" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ groupName }}</p>
|
||||
</div>
|
||||
|
||||
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ bookItems.length }}</div>
|
||||
<div class="absolute z-10 top-1.5e right-1.5e rounded-md leading-3e p-1e font-semibold text-white flex items-center justify-center" :style="{ fontSize: 0.8 + 'em' }" style="background-color: #cd9d49dd">{{ bookItems.length }}</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
@@ -24,8 +24,10 @@ export default {
|
||||
default: () => null
|
||||
},
|
||||
width: Number,
|
||||
height: Number,
|
||||
bookCoverAspectRatio: Number
|
||||
height: {
|
||||
type: Number,
|
||||
default: 192
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -33,6 +35,15 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
cardWidth() {
|
||||
return this.width || this.cardHeight * 2
|
||||
},
|
||||
cardHeight() {
|
||||
return this.height * this.sizeMultiplier
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
@@ -46,8 +57,7 @@ export default {
|
||||
return `/library/${this.currentLibraryId}/bookshelf?filter=${this.filter}`
|
||||
},
|
||||
sizeMultiplier() {
|
||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||
return this.width / 240
|
||||
return this.$store.getters['user/getSizeMultiplier']
|
||||
},
|
||||
bookItems() {
|
||||
return this._group.books || []
|
||||
@@ -78,4 +88,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -2,15 +2,9 @@
|
||||
<div class="flex items-center h-full px-1 overflow-hidden">
|
||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div class="flex-grow px-2 audiobookSearchCardContent">
|
||||
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
|
||||
<p v-else class="truncate text-sm" v-html="matchHtml" />
|
||||
|
||||
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300" v-html="matchHtml" />
|
||||
|
||||
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
|
||||
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
|
||||
|
||||
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode' || matchKey === 'narrators'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
||||
<p class="truncate text-sm">{{ title }}</p>
|
||||
<p v-if="subtitle" class="truncate text-xs text-gray-300">{{ subtitle }}</p>
|
||||
<p class="text-xs text-gray-200 truncate">{{ $getString('LabelByAuthor', [authorName]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -21,10 +15,7 @@ export default {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
search: String,
|
||||
matchKey: String,
|
||||
matchText: String
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
@@ -58,23 +49,6 @@ export default {
|
||||
authorName() {
|
||||
if (this.isPodcast) return this.mediaMetadata.author || 'Unknown'
|
||||
return this.mediaMetadata.authorName || 'Unknown'
|
||||
},
|
||||
matchHtml() {
|
||||
if (!this.matchText || !this.search) return ''
|
||||
|
||||
// This used to highlight the part of the search found
|
||||
// but with removing commas periods etc this is no longer plausible
|
||||
const html = this.matchText
|
||||
|
||||
if (this.matchKey === 'episode') return `<p class="truncate">${this.$strings.LabelEpisode}: ${html}</p>`
|
||||
if (this.matchKey === 'tags') return `<p class="truncate">${this.$strings.LabelTags}: ${html}</p>`
|
||||
if (this.matchKey === 'subtitle') return `<p class="truncate">${html}</p>`
|
||||
if (this.matchKey === 'authors') return `by ${html}`
|
||||
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
||||
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
|
||||
if (this.matchKey === 'series') return `<p class="truncate">${this.$strings.LabelSeries}: ${html}</p>`
|
||||
if (this.matchKey === 'narrators') return `<p class="truncate">${this.$strings.LabelNarrator}: ${html}</p>`
|
||||
return `${html}`
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
@@ -90,4 +64,4 @@ export default {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex items-center px-1 overflow-hidden">
|
||||
<div class="w-8 flex items-center justify-center">
|
||||
<span v-if="isFinished" :class="taskIconStatus" class="material-icons text-base">{{ actionIcon }}</span>
|
||||
<span v-if="isFinished" :class="taskIconStatus" class="material-symbols text-base">{{ actionIcon }}</span>
|
||||
<widgets-loading-spinner v-else />
|
||||
</div>
|
||||
<div class="flex-grow px-2 taskRunningCardContent">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="!processing && !uploadFailed && !uploadSuccess" class="absolute -top-3 -right-3 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-error cursor-pointer" @click="$emit('remove')">
|
||||
<span class="text-base text-white text-opacity-80 font-mono material-icons">close</span>
|
||||
<span class="text-base text-white text-opacity-80 font-mono material-symbols">close</span>
|
||||
</div>
|
||||
|
||||
<template v-if="!uploadSuccess && !uploadFailed">
|
||||
@@ -21,15 +21,16 @@
|
||||
<div v-if="!isPodcast" class="flex items-end">
|
||||
<ui-text-input-with-label v-model.trim="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" />
|
||||
<ui-tooltip :text="$strings.LabelUploaderItemFetchMetadataHelp">
|
||||
<div
|
||||
class="ml-2 mb-1 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-primary cursor-pointer"
|
||||
@click="fetchMetadata">
|
||||
<span class="text-base text-white text-opacity-80 font-mono material-icons">sync</span>
|
||||
<div class="ml-2 mb-1 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-primary cursor-pointer" @click="fetchMetadata">
|
||||
<span class="text-base text-white text-opacity-80 font-mono material-symbols">sync</span>
|
||||
</div>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div v-else class="w-full">
|
||||
<p class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></p>
|
||||
<p class="px-1 text-sm font-semibold">
|
||||
{{ $strings.LabelDirectory }}
|
||||
<em class="font-normal text-xs pl-2">(auto)</em>
|
||||
</p>
|
||||
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,7 +41,10 @@
|
||||
</div>
|
||||
<div class="w-1/2 px-2">
|
||||
<div class="w-full">
|
||||
<label class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></label>
|
||||
<label class="px-1 text-sm font-semibold">
|
||||
{{ $strings.LabelDirectory }}
|
||||
<em class="font-normal text-xs pl-2">(auto)</em>
|
||||
</label>
|
||||
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs h-10" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,10 +55,10 @@
|
||||
<tables-uploaded-files-table v-if="item.ignoredFiles.length" :title="$strings.HeaderIgnoredFiles" :files="item.ignoredFiles" />
|
||||
</template>
|
||||
<widgets-alert v-if="uploadSuccess" type="success">
|
||||
<p class="text-base">{{ $strings.MessageUploaderItemSuccess }}</p>
|
||||
<p class="text-base">"{{ itemData.title }}" {{ $strings.MessageUploaderItemSuccess }}</p>
|
||||
</widgets-alert>
|
||||
<widgets-alert v-if="uploadFailed" type="error">
|
||||
<p class="text-base">{{ $strings.MessageUploaderItemFailed }}</p>
|
||||
<p class="text-base">"{{ itemData.title }}" {{ $strings.MessageUploaderItemFailed }}</p>
|
||||
</widgets-alert>
|
||||
|
||||
<div v-if="isNonInteractable" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20">
|
||||
@@ -70,7 +74,7 @@ export default {
|
||||
props: {
|
||||
item: {
|
||||
type: Object,
|
||||
default: () => { }
|
||||
default: () => {}
|
||||
},
|
||||
mediaType: String,
|
||||
processing: Boolean,
|
||||
@@ -99,7 +103,7 @@ export default {
|
||||
if (this.isPodcast) return this.itemData.title
|
||||
|
||||
const outputPathParts = [this.itemData.author, this.itemData.series, this.itemData.title]
|
||||
const cleanedOutputPathParts = outputPathParts.filter(Boolean).map(part => this.$sanitizeFilename(part))
|
||||
const cleanedOutputPathParts = outputPathParts.filter(Boolean).map((part) => this.$sanitizeFilename(part))
|
||||
|
||||
return Path.join(...cleanedOutputPathParts)
|
||||
},
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
<template>
|
||||
<div ref="card" :id="`album-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||
<covers-preview-cover ref="cover" :src="coverSrc" :width="width" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
|
||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
<div ref="card" :id="`album-card-${index}`" :style="{ width: cardWidth + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div class="relative" :style="{ height: coverHeight + 'px' }">
|
||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||
<covers-preview-cover ref="cover" :src="coverSrc" :width="cardWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ artist || ' ' }}</p>
|
||||
|
||||
<div class="relative w-full">
|
||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center" :style="{ width: Math.min(200, cardWidth) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em ${0.5}em` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8e h-8e py-1e rounded-md text-center">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ title }}</p>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ artist || ' ' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -22,8 +26,10 @@ export default {
|
||||
props: {
|
||||
index: Number,
|
||||
width: Number,
|
||||
height: Number,
|
||||
bookCoverAspectRatio: Number,
|
||||
height: {
|
||||
type: Number,
|
||||
default: 192
|
||||
},
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 0
|
||||
@@ -42,6 +48,29 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
cardWidth() {
|
||||
return this.width || this.coverHeight
|
||||
},
|
||||
coverHeight() {
|
||||
return this.height * this.sizeMultiplier
|
||||
},
|
||||
/*
|
||||
cardHeight() {
|
||||
return this.coverHeight + this.bottomTextHeight
|
||||
},
|
||||
bottomTextHeight() {
|
||||
if (!this.isAlternativeBookshelfView) return 0
|
||||
const lineHeight = 1.5
|
||||
const remSize = 16
|
||||
const baseHeight = this.sizeMultiplier * lineHeight * remSize
|
||||
const titleHeight = this.labelFontSize * baseHeight
|
||||
const paddingHeight = 4 * 2 * this.sizeMultiplier // py-1
|
||||
return titleHeight + paddingHeight
|
||||
},
|
||||
*/
|
||||
coverSrc() {
|
||||
const config = this.$config || this.$nuxt.$config
|
||||
if (!this.album || !this.album.libraryItemId) return `${config.routerBasePath}/book_placeholder.jpg`
|
||||
@@ -49,11 +78,10 @@ export default {
|
||||
},
|
||||
labelFontSize() {
|
||||
if (this.width < 160) return 0.75
|
||||
return 0.875
|
||||
return 0.9
|
||||
},
|
||||
sizeMultiplier() {
|
||||
const baseSize = this.bookCoverAspectRatio === 1 ? 192 : 120
|
||||
return this.width / baseSize
|
||||
return this.store.getters['user/getSizeMultiplier']
|
||||
},
|
||||
title() {
|
||||
return this.album ? this.album.title : ''
|
||||
@@ -111,4 +139,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,129 +1,142 @@
|
||||
<template>
|
||||
<div ref="card" :id="`book-card-${index}`" :style="{ minWidth: width + 'px', maxWidth: width + 'px', height: height + 'px' }" class="rounded-sm z-10 bg-primary cursor-pointer box-shadow-book" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<!-- When cover image does not fill -->
|
||||
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
|
||||
<div class="absolute cover-bg" ref="coverBg" />
|
||||
<div ref="card" :id="`book-card-${index}`" :style="{ minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }" class="absolute rounded-sm z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div :id="`cover-area-${index}`" class="relative w-full top-0 left-0 rounded overflow-hidden z-10 bg-primary box-shadow-book" :style="{ height: coverHeight + 'px ' }">
|
||||
<!-- When cover image does not fill -->
|
||||
<div cy-id="coverBg" v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
|
||||
<div class="absolute cover-bg" ref="coverBg" />
|
||||
</div>
|
||||
|
||||
<div cy-id="seriesSequenceList" v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #78350f">
|
||||
<p :style="{ fontSize: 0.8 + 'em' }">#{{ seriesSequenceList }}</p>
|
||||
</div>
|
||||
<div cy-id="booksInSeries" v-else-if="booksInSeries" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #cd9d49dd">
|
||||
<p :style="{ fontSize: 0.8 + 'em' }">{{ booksInSeries }}</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
|
||||
<div cy-id="titleImageNotReady" v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: 0.5 + 'em' }">
|
||||
<p :style="{ fontSize: 0.8 + 'em' }" class="text-gray-300 text-center">{{ title }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Cover Image -->
|
||||
<img cy-id="coverImage" v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="relative w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
|
||||
|
||||
<!-- Placeholder Cover Title & Author -->
|
||||
<div cy-id="placeholderTitle" v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em' }">
|
||||
<div>
|
||||
<p cy-id="placeholderTitleText" class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'em' }">{{ titleCleaned }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div cy-id="placeholderAuthor" v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'em', bottom: authorBottom + 'em' }">
|
||||
<p cy-id="placeholderAuthorText" class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'em' }">{{ authorCleaned }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }" style="background-color: #78350f">
|
||||
<p :style="{ fontSize: 0.8 + 'em' }">#{{ seriesSequenceList }}</p>
|
||||
</div>
|
||||
<div v-else-if="booksInSeries" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }" style="background-color: #cd9d49dd">
|
||||
<p :style="{ fontSize: 0.8 + 'em' }">{{ booksInSeries }}</p>
|
||||
</div>
|
||||
|
||||
<!-- No progress shown for podcasts (unless showing podcast episode) -->
|
||||
<div cy-id="progressBar" v-if="!isPodcast || episodeProgress" class="absolute bottom-0 left-0 h-1e shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * userProgressPercent + 'px' }"></div>
|
||||
|
||||
<!-- Overlay is not shown if collapsing series in library -->
|
||||
<div cy-id="overlay" v-show="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen) && !processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded md:block" :class="overlayWrapperClasslist">
|
||||
<div cy-id="playButton" v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none">
|
||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play">
|
||||
<span class="material-symbols fill" :style="{ fontSize: playIconFontSize + 'em' }">play_arrow</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div cy-id="readButton" v-show="showReadButton" class="h-full flex items-center justify-center pointer-events-none">
|
||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="clickReadEBook">
|
||||
<span class="material-symbols" :style="{ fontSize: playIconFontSize + 'em' }">auto_stories</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div cy-id="editButton" v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-150 top-0 right-0" :style="{ padding: 0.375 + 'em' }" @click.stop.prevent="editClick">
|
||||
<span class="material-symbols" :style="{ fontSize: 1 + 'em' }">edit</span>
|
||||
</div>
|
||||
|
||||
<!-- Radio button -->
|
||||
<div cy-id="selectedRadioButton" v-if="!isAuthorBookshelfView" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-100" :style="{ top: 0.375 + 'em', left: 0.375 + 'em' }" @click.stop.prevent="selectBtnClick">
|
||||
<span class="material-symbols" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 + 'em' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- More Menu Icon -->
|
||||
<div cy-id="moreButton" ref="moreIcon" v-show="!isSelectionMode && moreMenuItems.length" class="md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-150" :style="{ bottom: 0.375 + 'em', right: 0.375 + 'em' }" @click.stop.prevent="clickShowMore">
|
||||
<span class="material-symbols" :style="{ fontSize: 1.2 + 'em' }">more_vert</span>
|
||||
</div>
|
||||
|
||||
<div cy-id="ebookFormat" v-if="ebookFormat" class="absolute" :style="{ bottom: 0.375 + 'em', left: 0.375 + 'em' }">
|
||||
<span class="text-white/80" :style="{ fontSize: 0.8 + 'em' }">{{ ebookFormat }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing/loading spinner overlay -->
|
||||
<div cy-id="loadingSpinner" v-if="processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 rounded flex items-center justify-center">
|
||||
<widgets-loading-spinner size="la-lg" />
|
||||
</div>
|
||||
|
||||
<!-- Series name overlay -->
|
||||
<div cy-id="seriesNameOverlay" v-if="booksInSeries && libraryItem && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: 1 + 'em' }">
|
||||
<p v-if="seriesName" class="text-gray-200 text-center" :style="{ fontSize: 1.1 + 'em' }">{{ seriesName }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Error widget -->
|
||||
<ui-tooltip cy-id="ErrorTooltip" v-if="showError" :text="errorText" class="absolute bottom-4e left-0 z-10">
|
||||
<div :style="{ height: 1.5 + 'em', width: 2.5 + 'em' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
|
||||
<span class="material-symbols text-red-100 pr-1e" :style="{ fontSize: 0.875 + 'em' }">priority_high</span>
|
||||
</div>
|
||||
</ui-tooltip>
|
||||
|
||||
<!-- rss feed icon -->
|
||||
<div cy-id="rssFeed" v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 + 'em' }">
|
||||
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">rss_feed</span>
|
||||
</div>
|
||||
<!-- media item shared icon -->
|
||||
<div cy-id="mediaItemShare" v-if="mediaItemShare && !isSelectionMode && !isHovering" class="absolute text-success left-0 z-10" :style="{ padding: 0.375 + 'em', top: rssFeed ? '2em' : '0px' }">
|
||||
<span class="material-symbols" :style="{ fontSize: 1.5 + 'em' }">public</span>
|
||||
</div>
|
||||
|
||||
<!-- Series sequence -->
|
||||
<div cy-id="seriesSequence" v-if="seriesSequence && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }">
|
||||
<p :style="{ fontSize: 0.8 + 'em' }">#{{ seriesSequence }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Episode # -->
|
||||
<div cy-id="podcastEpisodeNumber" v-if="recentEpisodeNumber !== null && !isHovering && !isSelectionMode && !processing" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `${0.1}em ${0.25}em` }">
|
||||
<p :style="{ fontSize: 0.8 + 'em' }">
|
||||
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Num Episodes -->
|
||||
<div cy-id="numEpisodes" v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', width: 1.25 + 'em', height: 1.25 + 'em' }">
|
||||
<p :style="{ fontSize: 0.8 + 'em' }">{{ numEpisodes }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Num Episodes -->
|
||||
<div cy-id="numEpisodesIncomplete" v-else-if="numEpisodesIncomplete && !isHovering && !isSelectionMode" class="absolute rounded-full bg-yellow-400 text-black font-semibold box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', width: 1.25 + 'em', height: 1.25 + 'em' }">
|
||||
<p :style="{ fontSize: 0.8 + 'em' }">{{ numEpisodesIncomplete }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alternative bookshelf title/author/sort -->
|
||||
<div v-if="isAlternativeBookshelfView || isAuthorBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
||||
<div :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||
<ui-tooltip :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
||||
<p ref="displayTitle" class="truncate">{{ displayTitle }}</p>
|
||||
<widgets-explicit-indicator :explicit="isExplicit" />
|
||||
<div cy-id="detailBottom" :id="`description-area-${index}`" v-if="isAlternativeBookshelfView || isAuthorBookshelfView" dir="auto" class="relative mt-2e mb-2e left-0 z-50 w-full">
|
||||
<div :style="{ fontSize: 0.9 + 'em' }">
|
||||
<ui-tooltip v-if="displayTitle" :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
||||
<p cy-id="title" ref="displayTitle" class="truncate">{{ displayTitle }}</p>
|
||||
<widgets-explicit-indicator cy-id="explicitIndicator" v-if="isExplicit" />
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || ' ' }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }" style="background-color: #78350f">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequenceList }}</p>
|
||||
</div>
|
||||
<div v-else-if="booksInSeries" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }" style="background-color: #cd9d49dd">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ booksInSeries }}</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
|
||||
<div v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="text-gray-300 text-center">{{ title }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Cover Image -->
|
||||
<img v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
|
||||
|
||||
<!-- Placeholder Cover Title & Author -->
|
||||
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||
<div>
|
||||
<p class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">
|
||||
{{ titleCleaned }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
|
||||
<p class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No progress shown for collapsed series in library and podcasts (unless showing podcast episode) -->
|
||||
<div v-if="!booksInSeries && (!isPodcast || episodeProgress)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
<!-- Finished progress bar for collapsed series -->
|
||||
<div v-else-if="booksInSeries && seriesIsFinished" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b bg-success" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
|
||||
<!-- Overlay is not shown if collapsing series in library -->
|
||||
<div v-show="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen) && !processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist">
|
||||
<div v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none">
|
||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play">
|
||||
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="showReadButton" class="h-full flex items-center justify-center pointer-events-none">
|
||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="clickReadEBook">
|
||||
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">auto_stories</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-150 top-0 right-0" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
|
||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
|
||||
</div>
|
||||
|
||||
<!-- Radio button -->
|
||||
<div v-if="!isAuthorBookshelfView" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-100" :style="{ top: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="selectBtnClick">
|
||||
<span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- More Menu Icon -->
|
||||
<div ref="moreIcon" v-show="!isSelectionMode && moreMenuItems.length" class="hidden md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-150" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
|
||||
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ebookFormat" class="absolute" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }">
|
||||
<span class="text-white/80" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ ebookFormat }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing/loading spinner overlay -->
|
||||
<div v-if="processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 rounded flex items-center justify-center">
|
||||
<widgets-loading-spinner size="la-lg" />
|
||||
</div>
|
||||
|
||||
<!-- Series name overlay -->
|
||||
<div v-if="booksInSeries && libraryItem && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
|
||||
<p v-if="seriesName" class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ seriesName }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Error widget -->
|
||||
<ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0 z-10">
|
||||
<div :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
|
||||
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
|
||||
</div>
|
||||
</ui-tooltip>
|
||||
|
||||
<div v-if="rssFeed && !isSelectionMode && !isHovering" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }">
|
||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.5 + 'rem' }">rss_feed</span>
|
||||
</div>
|
||||
|
||||
<!-- Series sequence -->
|
||||
<div v-if="seriesSequence && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Episode # -->
|
||||
<div v-if="recentEpisodeNumber !== null && !isHovering && !isSelectionMode && !processing" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">
|
||||
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Num Episodes -->
|
||||
<div v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Num Episodes -->
|
||||
<div v-else-if="numEpisodesIncomplete && !isHovering && !isSelectionMode" class="absolute rounded-full bg-yellow-400 text-black font-semibold box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodesIncomplete }}</p>
|
||||
<ui-tooltip v-if="showSubtitles" :text="displaySubtitle" :disabled="!displaySubtitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center">
|
||||
<p cy-id="subtitle" class="truncate" ref="displaySubtitle" :style="{ fontSize: 0.6 + 'em' }">{{ displaySubtitle }}</p>
|
||||
</ui-tooltip>
|
||||
<p cy-id="line2" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displayLineTwo || ' ' }}</p>
|
||||
<p cy-id="line3" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -135,15 +148,11 @@ import MoreMenu from '@/components/widgets/MoreMenu'
|
||||
export default {
|
||||
props: {
|
||||
index: Number,
|
||||
width: {
|
||||
type: Number,
|
||||
default: 120
|
||||
},
|
||||
width: Number,
|
||||
height: {
|
||||
type: Number,
|
||||
default: 192
|
||||
},
|
||||
bookCoverAspectRatio: Number,
|
||||
bookshelfView: Number,
|
||||
bookMount: {
|
||||
// Book can be passed as prop or set with setEntity()
|
||||
@@ -165,6 +174,7 @@ export default {
|
||||
selected: false,
|
||||
isSelectionMode: false,
|
||||
displayTitleTruncated: false,
|
||||
displaySubtitleTruncated: false,
|
||||
showCoverBg: false
|
||||
}
|
||||
},
|
||||
@@ -178,6 +188,39 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
coverWidth() {
|
||||
return this.width || this.coverHeight / this.bookCoverAspectRatio
|
||||
},
|
||||
coverHeight() {
|
||||
return this.height * this.sizeMultiplier
|
||||
},
|
||||
cardWidth() {
|
||||
// This method returns immediately without waiting for the DOM to update
|
||||
return this.coverWidth
|
||||
},
|
||||
/*
|
||||
cardHeight() {
|
||||
// This method returns immediately without waiting for the DOM to update
|
||||
return this.coverHeight + this.detailsHeight
|
||||
},
|
||||
detailsHeight() {
|
||||
if (!this.isAlternativeBookshelfView) return 0
|
||||
const lineHeight = 1.5
|
||||
const remSize = 16
|
||||
const baseHeight = this.sizeMultiplier * lineHeight * remSize
|
||||
const titleHeight = 0.9 * baseHeight
|
||||
const line2Height = 0.8 * baseHeight
|
||||
const line3Height = this.displaySortLine ? 0.8 * baseHeight : 0
|
||||
const marginHeight = 8 * 2 * this.sizeMultiplier // py-2
|
||||
return titleHeight + line2Height + line3Height + marginHeight
|
||||
},
|
||||
*/
|
||||
sizeMultiplier() {
|
||||
return this.store.getters['user/getSizeMultiplier']
|
||||
},
|
||||
dateFormat() {
|
||||
return this.store.state.serverSettings.dateFormat
|
||||
},
|
||||
@@ -198,7 +241,7 @@ export default {
|
||||
return this._libraryItem.mediaType
|
||||
},
|
||||
isPodcast() {
|
||||
return this.mediaType === 'podcast'
|
||||
return this.mediaType === 'podcast' || this.store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
|
||||
},
|
||||
isMusic() {
|
||||
return this.mediaType === 'music'
|
||||
@@ -277,10 +320,6 @@ export default {
|
||||
squareAspectRatio() {
|
||||
return this.bookCoverAspectRatio === 1
|
||||
},
|
||||
sizeMultiplier() {
|
||||
const baseSize = this.squareAspectRatio ? 192 : 120
|
||||
return this.width / baseSize
|
||||
},
|
||||
title() {
|
||||
return this.mediaMetadata.title || ''
|
||||
},
|
||||
@@ -302,7 +341,14 @@ export default {
|
||||
if (this.recentEpisode) return this.recentEpisode.title
|
||||
const ignorePrefix = this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix
|
||||
if (this.collapsedSeries) return ignorePrefix ? this.collapsedSeries.nameIgnorePrefix : this.collapsedSeries.name
|
||||
return ignorePrefix ? this.mediaMetadata.titleIgnorePrefix : this.title
|
||||
return ignorePrefix ? this.mediaMetadata.titleIgnorePrefix || '\u00A0' : this.title || '\u00A0'
|
||||
},
|
||||
displaySubtitle() {
|
||||
if (!this.libraryItem) return '\u00A0'
|
||||
if (this.collapsedSeries) return this.collapsedSeries.numBooks === 1 ? '1 book' : `${this.collapsedSeries.numBooks} books`
|
||||
if (this.mediaMetadata.subtitle) return this.mediaMetadata.subtitle
|
||||
if (this.mediaMetadata.seriesName) return this.mediaMetadata.seriesName
|
||||
return ''
|
||||
},
|
||||
displayLineTwo() {
|
||||
if (this.recentEpisode) return this.title
|
||||
@@ -323,7 +369,10 @@ export default {
|
||||
if (this.orderBy === 'media.duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
|
||||
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
|
||||
if (this.orderBy === 'media.numTracks') return `${this.numEpisodes} Episodes`
|
||||
if (this.orderBy === 'media.metadata.publishedYear' && this.mediaMetadata.publishedYear) return 'Published ' + this.mediaMetadata.publishedYear
|
||||
if (this.orderBy === 'media.metadata.publishedYear') {
|
||||
if (this.mediaMetadata.publishedYear) return 'Published ' + this.mediaMetadata.publishedYear
|
||||
return '\u00A0'
|
||||
}
|
||||
return null
|
||||
},
|
||||
episodeProgress() {
|
||||
@@ -343,11 +392,22 @@ export default {
|
||||
if (!this.userProgress || this.userProgress.progress) return false
|
||||
return this.userProgress.ebookProgress > 0
|
||||
},
|
||||
seriesProgressPercent() {
|
||||
if (!this.libraryItemIdsInSeries.length) return 0
|
||||
let progressPercent = 0
|
||||
const useEBookProgress = this.useEBookProgress
|
||||
this.libraryItemIdsInSeries.forEach((lid) => {
|
||||
const progress = this.store.getters['user/getUserMediaProgress'](lid)
|
||||
if (progress) progressPercent += progress.isFinished ? 1 : useEBookProgress ? progress.ebookProgress || 0 : progress.progress || 0
|
||||
})
|
||||
return progressPercent / this.libraryItemIdsInSeries.length
|
||||
},
|
||||
userProgressPercent() {
|
||||
if (this.useEBookProgress) return Math.max(Math.min(1, this.userProgress.ebookProgress), 0)
|
||||
return this.userProgress ? Math.max(Math.min(1, this.userProgress.progress), 0) || 0 : 0
|
||||
let progressPercent = this.itemIsFinished ? 1 : this.booksInSeries ? this.seriesProgressPercent : this.useEBookProgress ? this.userProgress?.ebookProgress || 0 : this.userProgress?.progress || 0
|
||||
return Math.max(Math.min(1, progressPercent), 0)
|
||||
},
|
||||
itemIsFinished() {
|
||||
if (this.booksInSeries) return this.seriesIsFinished
|
||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||
},
|
||||
seriesIsFinished() {
|
||||
@@ -358,7 +418,7 @@ export default {
|
||||
},
|
||||
showError() {
|
||||
if (this.recentEpisode) return false // Dont show podcast error on episode card
|
||||
return this.numInvalidAudioFiles || this.numMissingParts || this.isMissing || this.isInvalid
|
||||
return this.isMissing || this.isInvalid
|
||||
},
|
||||
libraryItemIdStreaming() {
|
||||
return this.store.getters['getLibraryItemIdStreaming']
|
||||
@@ -388,29 +448,13 @@ export default {
|
||||
isInvalid() {
|
||||
return this._libraryItem.isInvalid
|
||||
},
|
||||
numMissingParts() {
|
||||
if (this.isPodcast) return 0
|
||||
return this.media.numMissingParts
|
||||
},
|
||||
numInvalidAudioFiles() {
|
||||
if (this.isPodcast) return 0
|
||||
return this.media.numInvalidAudioFiles
|
||||
},
|
||||
errorText() {
|
||||
if (this.isMissing) return 'Item directory is missing!'
|
||||
else if (this.isInvalid) {
|
||||
if (this.isPodcast) return 'Podcast has no episodes'
|
||||
return 'Item has no audio tracks & ebook'
|
||||
}
|
||||
let txt = ''
|
||||
if (this.numMissingParts) {
|
||||
txt += `${this.numMissingParts} missing parts.`
|
||||
}
|
||||
if (this.numInvalidAudioFiles) {
|
||||
if (txt) txt += ' '
|
||||
txt += `${this.numInvalidAudioFiles} invalid audio files.`
|
||||
}
|
||||
return txt || 'Unknown Error'
|
||||
return 'Unknown Error'
|
||||
},
|
||||
overlayWrapperClasslist() {
|
||||
const classes = []
|
||||
@@ -495,6 +539,12 @@ export default {
|
||||
func: 'openPlaylists',
|
||||
text: this.$strings.LabelAddToPlaylist
|
||||
})
|
||||
if (this.userIsAdminOrUp) {
|
||||
items.push({
|
||||
func: 'openShare',
|
||||
text: this.$strings.LabelShare
|
||||
})
|
||||
}
|
||||
}
|
||||
if (this.ebookFormat && this.store.state.libraries.ereaderDevices?.length) {
|
||||
items.push({
|
||||
@@ -566,16 +616,16 @@ export default {
|
||||
return this.$root.socket || this.$nuxt.$root.socket
|
||||
},
|
||||
titleFontSize() {
|
||||
return 0.75 * this.sizeMultiplier
|
||||
return 0.75
|
||||
},
|
||||
authorFontSize() {
|
||||
return 0.6 * this.sizeMultiplier
|
||||
return 0.6
|
||||
},
|
||||
placeholderCoverPadding() {
|
||||
return 0.8 * this.sizeMultiplier
|
||||
return 0.8
|
||||
},
|
||||
authorBottom() {
|
||||
return 0.75 * this.sizeMultiplier
|
||||
return 0.75
|
||||
},
|
||||
titleCleaned() {
|
||||
if (!this.title) return ''
|
||||
@@ -599,14 +649,15 @@ export default {
|
||||
const constants = this.$constants || this.$nuxt.$constants
|
||||
return this.bookshelfView === constants.BookshelfView.AUTHOR
|
||||
},
|
||||
titleDisplayBottomOffset() {
|
||||
if (!this.isAlternativeBookshelfView && !this.isAuthorBookshelfView) return 0
|
||||
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
|
||||
return 4.25 * this.sizeMultiplier
|
||||
},
|
||||
rssFeed() {
|
||||
if (this.booksInSeries) return null
|
||||
return this._libraryItem.rssFeed || null
|
||||
},
|
||||
mediaItemShare() {
|
||||
return this._libraryItem.mediaItemShare || null
|
||||
},
|
||||
showSubtitles() {
|
||||
return !this.isPodcast && this.store.getters['user/getUserSetting']('showSubtitles')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -648,6 +699,9 @@ export default {
|
||||
if (this.$refs.displayTitle) {
|
||||
this.displayTitleTruncated = this.$refs.displayTitle.scrollWidth > this.$refs.displayTitle.clientWidth
|
||||
}
|
||||
if (this.$refs.displaySubtitle) {
|
||||
this.displaySubtitleTruncated = this.$refs.displaySubtitle.scrollWidth > this.$refs.displaySubtitle.clientWidth
|
||||
}
|
||||
})
|
||||
},
|
||||
clickCard(e) {
|
||||
@@ -849,6 +903,10 @@ export default {
|
||||
this.store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.recentEpisode }])
|
||||
this.store.commit('globals/setShowPlaylistsModal', true)
|
||||
},
|
||||
openShare() {
|
||||
this.store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||
this.store.commit('globals/setShareModal', this.mediaItemShare)
|
||||
},
|
||||
deleteLibraryItem() {
|
||||
const payload = {
|
||||
message: this.$strings.MessageConfirmDeleteLibraryItem,
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
<template>
|
||||
<div ref="card" :id="`collection-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
<div v-show="isHovering && userCanUpdate" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
||||
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
|
||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
|
||||
<div ref="card" :id="`collection-card-${index}`" :style="{ width: cardWidth + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div class="relative" :style="{ height: coverHeight + 'px' }">
|
||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||
<covers-collection-cover ref="cover" :book-items="books" :width="cardWidth" :height="coverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
<div v-show="isHovering && userCanUpdate" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
||||
<div class="absolute pointer-events-auto" :style="{ top: 0.5 + 'em', right: 0.5 + 'em' }" @click.stop.prevent="clickEdit">
|
||||
<span class="material-symbols text-white text-opacity-75 hover:text-opacity-100" :style="{ fontSize: 1.25 + 'em' }">edit</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span v-if="!isHovering && rssFeed" class="absolute z-10 material-symbols text-success" :style="{ top: 0.5 + 'em', left: 0.5 + 'em', fontSize: 1.5 + 'em' }">rss_feed</span>
|
||||
</div>
|
||||
|
||||
<span v-if="!isHovering && rssFeed" class="absolute z-10 material-icons text-success" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem', fontSize: 1.5 * sizeMultiplier + 'rem' }">rss_feed</span>
|
||||
|
||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center" :style="{ width: Math.min(200, cardWidth) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em ${0.5}em` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||
<div v-else class="relative z-30 left-0 right-0 mx-auto h-8e py-1e rounded-md text-center">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -28,8 +30,10 @@ export default {
|
||||
props: {
|
||||
index: Number,
|
||||
width: Number,
|
||||
height: Number,
|
||||
bookCoverAspectRatio: Number,
|
||||
height: {
|
||||
type: Number,
|
||||
default: 192
|
||||
},
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 0
|
||||
@@ -49,13 +53,33 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
cardWidth() {
|
||||
return this.width || this.coverHeight * 2
|
||||
},
|
||||
coverHeight() {
|
||||
return this.height * this.sizeMultiplier
|
||||
},
|
||||
cardHeight() {
|
||||
return this.coverHeight + this.bottomTextHeight
|
||||
},
|
||||
bottomTextHeight() {
|
||||
if (!this.isAlternativeBookshelfView) return 0 // bottom text appears on top of the divider
|
||||
const lineHeight = 1.5
|
||||
const remSize = 16
|
||||
const baseHeight = this.sizeMultiplier * lineHeight * remSize
|
||||
const titleHeight = this.labelFontSize * baseHeight
|
||||
const paddingHeight = 4 * 2 * this.sizeMultiplier // py-1
|
||||
return titleHeight + paddingHeight
|
||||
},
|
||||
labelFontSize() {
|
||||
if (this.width < 160) return 0.75
|
||||
return 0.875
|
||||
return 0.9
|
||||
},
|
||||
sizeMultiplier() {
|
||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||
return this.width / 240
|
||||
return this.store.getters['user/getSizeMultiplier']
|
||||
},
|
||||
title() {
|
||||
return this.collection ? this.collection.name : ''
|
||||
@@ -119,4 +143,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
<template>
|
||||
<div ref="card" :id="`playlist-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||
<covers-playlist-cover ref="cover" :items="items" :width="width" :height="height" />
|
||||
</div>
|
||||
<div v-show="isHovering && userCanUpdate" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
||||
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
|
||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
|
||||
<div ref="card" :id="`playlist-card-${index}`" :style="{ width: cardWidth + 'px', fontSize: sizeMultiplier + 'rem' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div class="relative" :style="{ height: coverHeight + 'px' }">
|
||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||
<covers-playlist-cover ref="cover" :items="items" :width="cardWidth" :height="coverHeight" />
|
||||
</div>
|
||||
<div v-show="isHovering && userCanUpdate" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
|
||||
<div class="absolute pointer-events-auto" :style="{ top: 0.5 + 'em', right: 0.5 + 'em' }" @click.stop.prevent="clickEdit">
|
||||
<span class="material-symbols text-white text-opacity-75 hover:text-opacity-100" :style="{ fontSize: 1.25 + 'em' }">edit</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
|
||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 -bottom-6e left-0 right-0 mx-auto h-6e rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em ${0.5}em` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||
<div v-else class="relative z-30 left-0 right-0 mx-auto h-8e py-1e rounded-md text-center">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -25,8 +28,10 @@ export default {
|
||||
props: {
|
||||
index: Number,
|
||||
width: Number,
|
||||
height: Number,
|
||||
bookCoverAspectRatio: Number,
|
||||
height: {
|
||||
type: Number,
|
||||
default: 192
|
||||
},
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 0
|
||||
@@ -45,13 +50,21 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
cardWidth() {
|
||||
return this.width || this.coverHeight
|
||||
},
|
||||
coverHeight() {
|
||||
return this.height * this.sizeMultiplier
|
||||
},
|
||||
labelFontSize() {
|
||||
if (this.width < 160) return 0.75
|
||||
return 0.875
|
||||
return 0.9
|
||||
},
|
||||
sizeMultiplier() {
|
||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6)
|
||||
return this.width / 120
|
||||
return this.store.getters['user/getSizeMultiplier']
|
||||
},
|
||||
title() {
|
||||
return this.playlist ? this.playlist.name : ''
|
||||
@@ -112,4 +125,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
<template>
|
||||
<div ref="card" :id="`series-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
|
||||
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="displayTitle" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div cy-id="card" ref="card" :id="`series-card-${index}`" :style="{ width: cardWidth + 'px' }" class="absolute rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<div cy-id="covers-area" class="relative" :style="{ height: coverHeight + 'px' }">
|
||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
|
||||
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="displayTitle" :book-items="books" :width="cardWidth" :height="coverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
|
||||
<div cy-id="seriesLengthMarker" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 + 'em', right: 0.375 + 'em', padding: `0.1em 0.25em` }" style="background-color: #cd9d49dd">
|
||||
<p :style="{ fontSize: 0.8 + 'em' }">{{ books.length }}</p>
|
||||
</div>
|
||||
|
||||
<div cy-id="seriesProgressBar" v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1e shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
|
||||
|
||||
<div cy-id="hoveringDisplayTitle" v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: '1em' }">
|
||||
<p :style="{ fontSize: 1.2 + 'em' }">{{ displayTitle }}</p>
|
||||
</div>
|
||||
|
||||
<span cy-id="rssFeedMarker" v-if="!isHovering && rssFeed" class="absolute z-10 material-symbols text-success" :style="{ top: 0.5 + 'em', left: 0.5 + 'em', fontSize: 1.5 + 'em' }">rss_feed</span>
|
||||
</div>
|
||||
|
||||
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>
|
||||
|
||||
<div v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
|
||||
|
||||
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
|
||||
</div>
|
||||
|
||||
<span v-if="!isHovering && rssFeed" class="absolute z-10 material-icons text-success" :style="{ top: 0.5 * sizeMultiplier + 'rem', left: 0.5 * sizeMultiplier + 'rem', fontSize: 1.5 * sizeMultiplier + 'rem' }">rss_feed</span>
|
||||
|
||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(200, width) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ displayTitle }}</p>
|
||||
<div cy-id="standardBottomText" v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-10 left-0 right-0 mx-auto -bottom-6e h-6e rounded-md text-center" :style="{ width: Math.min(200, cardWidth) + 'px' }">
|
||||
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0em 0.5em` }">
|
||||
<p cy-id="standardBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
<div cy-id="detailBottomText" v-else class="relative z-30 left-0 right-0 mx-auto py-1e rounded-md text-center">
|
||||
<p cy-id="detailBottomDisplayTitle" class="truncate" :style="{ fontSize: labelFontSize + 'em' }">{{ displayTitle }}</p>
|
||||
<p cy-id="detailBottomSortLine" v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 + 'em' }">{{ displaySortLine }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -32,13 +36,14 @@ export default {
|
||||
props: {
|
||||
index: Number,
|
||||
width: Number,
|
||||
height: Number,
|
||||
bookCoverAspectRatio: Number,
|
||||
height: {
|
||||
type: Number,
|
||||
default: 192
|
||||
},
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
isCategorized: Boolean,
|
||||
seriesMount: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
@@ -56,29 +61,37 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
cardWidth() {
|
||||
return this.width || this.coverHeight * 2
|
||||
},
|
||||
coverHeight() {
|
||||
return this.height * this.sizeMultiplier
|
||||
},
|
||||
dateFormat() {
|
||||
return this.store.state.serverSettings.dateFormat
|
||||
},
|
||||
labelFontSize() {
|
||||
if (this.width < 160) return 0.75
|
||||
return 0.875
|
||||
return 0.9
|
||||
},
|
||||
sizeMultiplier() {
|
||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||
return this.width / 240
|
||||
return this.store.getters['user/getSizeMultiplier']
|
||||
},
|
||||
seriesId() {
|
||||
return this.series ? this.series.id : ''
|
||||
return this.series?.id || ''
|
||||
},
|
||||
title() {
|
||||
return this.series ? this.series.name : ''
|
||||
return this.series?.name || ''
|
||||
},
|
||||
nameIgnorePrefix() {
|
||||
return this.series ? this.series.nameIgnorePrefix : ''
|
||||
return this.series?.nameIgnorePrefix || ''
|
||||
},
|
||||
displayTitle() {
|
||||
if (this.sortingIgnorePrefix) return this.nameIgnorePrefix || this.title
|
||||
return this.title
|
||||
if (this.sortingIgnorePrefix) return this.nameIgnorePrefix || this.title || '\u00A0'
|
||||
return this.title || '\u00A0'
|
||||
},
|
||||
displaySortLine() {
|
||||
switch (this.orderBy) {
|
||||
@@ -97,13 +110,13 @@ export default {
|
||||
}
|
||||
},
|
||||
books() {
|
||||
return this.series ? this.series.books || [] : []
|
||||
return this.series?.books || []
|
||||
},
|
||||
addedAt() {
|
||||
return this.series ? this.series.addedAt : 0
|
||||
return this.series?.addedAt || 0
|
||||
},
|
||||
totalDuration() {
|
||||
return this.series ? this.series.totalDuration : 0
|
||||
return this.series?.totalDuration || 0
|
||||
},
|
||||
seriesBookProgress() {
|
||||
return this.books
|
||||
@@ -119,9 +132,13 @@ export default {
|
||||
return this.seriesBookProgress.some((p) => !p.isFinished && p.progress > 0)
|
||||
},
|
||||
seriesPercentInProgress() {
|
||||
let totalFinishedAndInProgress = this.seriesBooksFinished.length
|
||||
if (this.hasSeriesBookInProgress) totalFinishedAndInProgress += 1
|
||||
return Math.min(1, Math.max(0, totalFinishedAndInProgress / this.books.length))
|
||||
if (!this.books.length) return 0
|
||||
let progressPercent = 0
|
||||
this.seriesBookProgress.forEach((progress) => {
|
||||
progressPercent += progress.isFinished ? 1 : progress.progress || 0
|
||||
})
|
||||
progressPercent /= this.books.length
|
||||
return Math.min(1, Math.max(0, progressPercent))
|
||||
},
|
||||
isSeriesFinished() {
|
||||
return this.books.length === this.seriesBooksFinished.length
|
||||
@@ -144,7 +161,7 @@ export default {
|
||||
return this.bookshelfView == constants.BookshelfView.DETAIL
|
||||
},
|
||||
rssFeed() {
|
||||
return this.series ? this.series.rssFeed : null
|
||||
return this.series?.rssFeed
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
<template>
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(narrator.name)}`">
|
||||
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||
<div class="absolute inset-0 w-full h-full flex items-center justify-center pointer-events-none opacity-40">
|
||||
<span class="material-icons-outlined text-[10rem]">record_voice_over</span>
|
||||
</div>
|
||||
<div>
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(name)}`">
|
||||
<div cy-id="card" :style="{ width: cardWidth + 'px', height: cardHeight + 'px', fontSize: sizeMultiplier + 'rem' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||
<div class="absolute inset-0 w-full h-full flex items-center justify-center pointer-events-none opacity-40">
|
||||
<span class="material-symbols-outlined text-[10em]">record_voice_over</span>
|
||||
</div>
|
||||
|
||||
<!-- Narrator name & num books overlay -->
|
||||
<div class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
|
||||
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
||||
<!-- Narrator name & num books overlay -->
|
||||
<div class="absolute bottom-0 left-0 w-full py-1e bg-black bg-opacity-60 px-2e">
|
||||
<p cy-id="name" class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: 0.75 + 'em' }">{{ name }}</p>
|
||||
<p cy-id="numBooks" class="text-center text-gray-200" :style="{ fontSize: 0.65 + 'em' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -22,16 +24,21 @@ export default {
|
||||
default: () => {}
|
||||
},
|
||||
width: Number,
|
||||
height: Number,
|
||||
sizeMultiplier: {
|
||||
height: {
|
||||
type: Number,
|
||||
default: 1
|
||||
default: 100
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
cardWidth() {
|
||||
return this.cardHeight * 1.5
|
||||
},
|
||||
cardHeight() {
|
||||
return this.height * this.sizeMultiplier
|
||||
},
|
||||
name() {
|
||||
return this.narrator?.name || ''
|
||||
},
|
||||
@@ -43,8 +50,11 @@ export default {
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return this.$store.getters['user/getSizeMultiplier']
|
||||
}
|
||||
},
|
||||
methods: {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<div class="w-10 h-10 flex items-center justify-center">
|
||||
<span class="material-icons text-2xl text-gray-200">record_voice_over</span>
|
||||
<span class="material-symbols text-2xl text-gray-200">record_voice_over</span>
|
||||
</div>
|
||||
<div class="flex-grow px-2 narratorSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ narrator }}</p>
|
||||
<p class="text-xs text-gray-400">{{ $getString('LabelXBooks', [numBooks]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -12,7 +13,8 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
narrator: String
|
||||
narrator: String,
|
||||
numBooks: Number
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
@@ -26,9 +28,9 @@ export default {
|
||||
<style scoped>
|
||||
.narratorSearchCardContent {
|
||||
width: calc(100% - 40px);
|
||||
height: 40px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<div class="w-10 h-10 flex items-center justify-center">
|
||||
<span class="material-icons text-2xl text-gray-200">local_offer</span>
|
||||
<span class="material-symbols text-2xl text-gray-200">local_offer</span>
|
||||
</div>
|
||||
<div class="flex-grow px-2 tagSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ tag }}</p>
|
||||
<p class="text-xs text-gray-400">{{ $getString('LabelXItems', [numItems]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -12,7 +13,8 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
tag: String
|
||||
tag: String,
|
||||
numItems: Number
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
@@ -26,9 +28,9 @@ export default {
|
||||
<style>
|
||||
.tagSearchCardContent {
|
||||
width: calc(100% - 40px);
|
||||
height: 40px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -89,6 +89,14 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="language" class="flex py-0.5">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelLanguage }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=languages.${$encode(language)}`" class="hover:underline">{{ language }}</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tracks.length || audioFile || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
|
||||
@@ -182,6 +190,9 @@ export default {
|
||||
narrators() {
|
||||
return this.mediaMetadata.narrators || []
|
||||
},
|
||||
language() {
|
||||
return this.mediaMetadata.language || null
|
||||
},
|
||||
durationPretty() {
|
||||
if (this.isPodcast) return this.$elapsedPrettyExtended(this.totalPodcastDuration)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</svg>
|
||||
</span>
|
||||
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-300" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
|
||||
<span class="material-icons" style="font-size: 1.1rem">close</span>
|
||||
<span class="material-symbols" style="font-size: 1.1rem">close</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
<!-- selected checkmark icon -->
|
||||
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
|
||||
<span class="material-icons text-base text-yellow-400">check</span>
|
||||
<span class="material-symbols text-base text-yellow-400">check</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<template>
|
||||
<div class="sm:w-80 w-full relative">
|
||||
<form @submit.prevent="submitSearch">
|
||||
<ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
||||
</form>
|
||||
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
|
||||
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
|
||||
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
|
||||
<div class="">
|
||||
<div class="w-full relative sm:w-80">
|
||||
<form @submit.prevent="submitSearch">
|
||||
<ui-text-input ref="input" v-model="search" :placeholder="$strings.PlaceholderSearch" @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
||||
</form>
|
||||
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
|
||||
<span v-if="!search" class="material-symbols" style="font-size: 1.2rem">search</span>
|
||||
<span v-else class="material-symbols" style="font-size: 1.2rem">close</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-40 sm:w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
|
||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-40 -mt-px w-full max-w-64 sm:max-w-80 sm:w-80 bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalSearchMenu">
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<li v-if="isTyping" class="py-2 px-2">
|
||||
<p>{{ $strings.MessageThinking }}</p>
|
||||
@@ -23,7 +25,7 @@
|
||||
<template v-for="item in bookResults">
|
||||
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||
<cards-item-search-card :library-item="item.libraryItem" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
@@ -32,7 +34,7 @@
|
||||
<template v-for="item in podcastResults">
|
||||
<li :key="item.libraryItem.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/item/${item.libraryItem.id}`">
|
||||
<cards-item-search-card :library-item="item.libraryItem" :match-key="item.matchKey" :match-text="item.matchText" :search="lastSearch" />
|
||||
<cards-item-search-card :library-item="item.libraryItem" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
@@ -57,9 +59,18 @@
|
||||
|
||||
<p v-if="tagResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelTags }}</p>
|
||||
<template v-for="item in tagResults">
|
||||
<li :key="item.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<li :key="`tag.${item.name}`" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=tags.${$encode(item.name)}`">
|
||||
<cards-tag-search-card :tag="item.name" />
|
||||
<cards-tag-search-card :tag="item.name" :num-items="item.numItems" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<p v-if="genreResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelGenres }}</p>
|
||||
<template v-for="item in genreResults">
|
||||
<li :key="`genre.${item.name}`" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=genres.${$encode(item.name)}`">
|
||||
<cards-genre-search-card :genre="item.name" :num-items="item.numItems" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
@@ -68,7 +79,7 @@
|
||||
<template v-for="narrator in narratorResults">
|
||||
<li :key="narrator.name" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickOption">
|
||||
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=narrators.${$encode(narrator.name)}`">
|
||||
<cards-narrator-search-card :narrator="narrator.name" />
|
||||
<cards-narrator-search-card :narrator="narrator.name" :num-books="narrator.numBooks" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
@@ -93,6 +104,7 @@ export default {
|
||||
authorResults: [],
|
||||
seriesResults: [],
|
||||
tagResults: [],
|
||||
genreResults: [],
|
||||
narratorResults: [],
|
||||
searchTimeout: null,
|
||||
lastSearch: null
|
||||
@@ -103,7 +115,7 @@ export default {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
totalResults() {
|
||||
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.podcastResults.length + this.narratorResults.length
|
||||
return this.bookResults.length + this.seriesResults.length + this.authorResults.length + this.tagResults.length + this.genreResults.length + this.podcastResults.length + this.narratorResults.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -114,7 +126,7 @@ export default {
|
||||
if (!this.search) return
|
||||
var search = this.search
|
||||
this.clearResults()
|
||||
this.$router.push(`/library/${this.currentLibraryId}/search?q=${search}`)
|
||||
this.$router.push(`/library/${this.currentLibraryId}/search?q=${encodeURIComponent(search)}`)
|
||||
},
|
||||
clearResults() {
|
||||
this.search = null
|
||||
@@ -124,6 +136,7 @@ export default {
|
||||
this.authorResults = []
|
||||
this.seriesResults = []
|
||||
this.tagResults = []
|
||||
this.genreResults = []
|
||||
this.narratorResults = []
|
||||
this.showMenu = false
|
||||
this.isFetching = false
|
||||
@@ -153,7 +166,7 @@ export default {
|
||||
}
|
||||
this.isFetching = true
|
||||
|
||||
const searchResults = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=3`).catch((error) => {
|
||||
const searchResults = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${encodeURIComponent(value)}&limit=3`).catch((error) => {
|
||||
console.error('Search error', error)
|
||||
return []
|
||||
})
|
||||
@@ -166,6 +179,7 @@ export default {
|
||||
this.authorResults = searchResults.authors || []
|
||||
this.seriesResults = searchResults.series || []
|
||||
this.tagResults = searchResults.tags || []
|
||||
this.genreResults = searchResults.genres || []
|
||||
this.narratorResults = searchResults.narrators || []
|
||||
|
||||
this.isFetching = false
|
||||
@@ -201,4 +215,4 @@ export default {
|
||||
.globalSearchMenu {
|
||||
max-height: calc(100vh - 75px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</svg>
|
||||
</span>
|
||||
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
|
||||
<span class="material-icons" style="font-size: 1.1rem">close</span>
|
||||
<span class="material-symbols" style="font-size: 1.1rem">close</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -22,11 +22,11 @@
|
||||
<span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span>
|
||||
</div>
|
||||
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
|
||||
<span class="material-icons text-2xl">arrow_right</span>
|
||||
<span class="material-symbols text-2xl">arrow_right</span>
|
||||
</div>
|
||||
<!-- selected checkmark icon -->
|
||||
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
|
||||
<span class="material-icons text-base text-yellow-400">check</span>
|
||||
<span class="material-symbols text-base text-yellow-400">check</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
@@ -34,15 +34,15 @@
|
||||
<ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="option" @click="sublist = null">
|
||||
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
|
||||
<span class="material-icons text-2xl">arrow_left</span>
|
||||
<span class="material-symbols text-2xl">arrow_left</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-normal block truncate">Back</span>
|
||||
<span class="font-normal block truncate">{{ $strings.ButtonBack }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate py-2">No {{ sublist }}</span>
|
||||
<span class="font-normal block truncate py-2">{{ $getString('LabelLibraryFilterSublistEmpty', [selectedSublistText]) }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<template v-for="item in sublistItems">
|
||||
@@ -52,7 +52,7 @@
|
||||
</div>
|
||||
<!-- selected checkmark icon -->
|
||||
<div v-if="`${sublist}.${item.value}` === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
|
||||
<span class="material-icons text-base text-yellow-400">check</span>
|
||||
<span class="material-symbols text-base text-yellow-400">check</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
@@ -89,6 +89,9 @@ export default {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
libraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
@@ -106,31 +109,37 @@ export default {
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelGenre,
|
||||
textPlural: this.$strings.LabelGenres,
|
||||
value: 'genres',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelTag,
|
||||
textPlural: this.$strings.LabelTags,
|
||||
value: 'tags',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAuthor,
|
||||
textPlural: this.$strings.LabelAuthors,
|
||||
value: 'authors',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelNarrator,
|
||||
textPlural: this.$strings.LabelNarrators,
|
||||
value: 'narrators',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelPublisher,
|
||||
textPlural: this.$strings.LabelPublishers,
|
||||
value: 'publishers',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLanguage,
|
||||
textPlural: this.$strings.LabelLanguages,
|
||||
value: 'languages',
|
||||
sublist: true
|
||||
},
|
||||
@@ -142,43 +151,50 @@ export default {
|
||||
]
|
||||
},
|
||||
bookItems() {
|
||||
return [
|
||||
const items = [
|
||||
{
|
||||
text: this.$strings.LabelAll,
|
||||
value: 'all'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelGenre,
|
||||
textPlural: this.$strings.LabelGenres,
|
||||
value: 'genres',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelTag,
|
||||
textPlural: this.$strings.LabelTags,
|
||||
value: 'tags',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelSeries,
|
||||
textPlural: this.$strings.LabelSeries,
|
||||
value: 'series',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAuthor,
|
||||
textPlural: this.$strings.LabelAuthors,
|
||||
value: 'authors',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelNarrator,
|
||||
textPlural: this.$strings.LabelNarrators,
|
||||
value: 'narrators',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelPublisher,
|
||||
textPlural: this.$strings.LabelPublishers,
|
||||
value: 'publishers',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLanguage,
|
||||
textPlural: this.$strings.LabelLanguages,
|
||||
value: 'languages',
|
||||
sublist: true
|
||||
},
|
||||
@@ -218,6 +234,14 @@ export default {
|
||||
sublist: false
|
||||
}
|
||||
]
|
||||
if (this.userIsAdminOrUp) {
|
||||
items.push({
|
||||
text: this.$strings.LabelShareOpen,
|
||||
value: 'share-open',
|
||||
sublist: false
|
||||
})
|
||||
}
|
||||
return items
|
||||
},
|
||||
podcastItems() {
|
||||
return [
|
||||
@@ -227,14 +251,22 @@ export default {
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelGenre,
|
||||
textPlural: this.$strings.LabelGenres,
|
||||
value: 'genres',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelTag,
|
||||
textPlural: this.$strings.LabelTags,
|
||||
value: 'tags',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLanguage,
|
||||
textPlural: this.$strings.LabelLanguages,
|
||||
value: 'languages',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.ButtonIssues,
|
||||
value: 'issues',
|
||||
@@ -250,11 +282,13 @@ export default {
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelGenre,
|
||||
textPlural: this.$strings.LabelGenres,
|
||||
value: 'genres',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelTag,
|
||||
textPlural: this.$strings.LabelTags,
|
||||
value: 'tags',
|
||||
sublist: true
|
||||
},
|
||||
@@ -274,6 +308,13 @@ export default {
|
||||
selectedItemSublist() {
|
||||
return this.selected?.includes('.') ? this.selected.split('.')[0] : null
|
||||
},
|
||||
selectedSublistText() {
|
||||
if (!this.sublist) {
|
||||
return ''
|
||||
}
|
||||
const sublistItem = this.selectItems.find((i) => i.value === this.sublist)
|
||||
return sublistItem?.textPlural || sublistItem?.text || ''
|
||||
},
|
||||
selectedText() {
|
||||
if (!this.selected) return ''
|
||||
const parts = this.selected.split('.')
|
||||
@@ -368,9 +409,17 @@ export default {
|
||||
id: 'ebook',
|
||||
name: this.$strings.LabelHasEbook
|
||||
},
|
||||
{
|
||||
id: 'no-ebook',
|
||||
name: this.$strings.LabelMissingEbook
|
||||
},
|
||||
{
|
||||
id: 'supplementary',
|
||||
name: this.$strings.LabelHasSupplementaryEbook
|
||||
},
|
||||
{
|
||||
id: 'no-supplementary',
|
||||
name: this.$strings.LabelMissingSupplementaryEbook
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -492,4 +541,4 @@ export default {
|
||||
.libraryFilterMenu {
|
||||
max-height: calc(100vh - 125px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<span class="flex items-center justify-between">
|
||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||
<span class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
<span class="material-symbols text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
||||
</div>
|
||||
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
<span class="material-symbols text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
@@ -88,6 +88,10 @@ export default {
|
||||
{
|
||||
text: this.$strings.LabelFileModified,
|
||||
value: 'mtimeMs'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelRandomly,
|
||||
value: 'random'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -128,6 +132,10 @@ export default {
|
||||
{
|
||||
text: this.$strings.LabelFileModified,
|
||||
value: 'mtimeMs'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelRandomly,
|
||||
value: 'random'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -215,4 +223,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<span class="flex items-center justify-between">
|
||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||
<span class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
<span class="material-symbols text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
||||
</div>
|
||||
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
<span class="material-symbols text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="relative" v-click-outside="clickOutside" @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div class="cursor-pointer text-gray-300 hover:text-white" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
|
||||
<span class="material-icons text-2xl sm:text-3xl">{{ volumeIcon }}</span>
|
||||
</div>
|
||||
<button :aria-label="$strings.LabelVolume" class="text-gray-300 hover:text-white" @mousedown.prevent @mouseup.prevent @click="clickVolumeIcon">
|
||||
<span class="material-symbols text-2xl sm:text-3xl">{{ volumeIcon }}</span>
|
||||
</button>
|
||||
<transition name="menux">
|
||||
<div v-show="isOpen" class="volumeMenu h-6 absolute bottom-2 w-28 px-2 bg-bg shadow-sm rounded-lg" style="left: -116px">
|
||||
<div ref="volumeTrack" class="h-1 w-full bg-gray-500 my-2.5 relative cursor-pointer rounded-full" @mousedown="mousedownTrack" @click="clickVolumeTrack">
|
||||
@@ -38,8 +38,8 @@ export default {
|
||||
},
|
||||
set(val) {
|
||||
try {
|
||||
localStorage.setItem("volume", val);
|
||||
} catch(error) {
|
||||
localStorage.setItem('volume', val)
|
||||
} catch (error) {
|
||||
console.error('Failed to store volume', err)
|
||||
}
|
||||
this.$emit('input', val)
|
||||
@@ -146,7 +146,7 @@ export default {
|
||||
if (this.value === 0) {
|
||||
this.isMute = true
|
||||
}
|
||||
const storageVolume = localStorage.getItem("volume")
|
||||
const storageVolume = localStorage.getItem('volume')
|
||||
if (storageVolume) {
|
||||
this.volume = parseFloat(storageVolume)
|
||||
}
|
||||
|
||||
@@ -84,4 +84,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -101,9 +101,14 @@ export default {
|
||||
},
|
||||
fullCoverUrl() {
|
||||
if (!this.libraryItem) return null
|
||||
var store = this.$store || this.$nuxt.$store
|
||||
const store = this.$store || this.$nuxt.$store
|
||||
return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl)
|
||||
},
|
||||
rawCoverUrl() {
|
||||
if (!this.libraryItem) return null
|
||||
const store = this.$store || this.$nuxt.$store
|
||||
return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl, true)
|
||||
},
|
||||
cover() {
|
||||
return this.media.coverPath || this.placeholderUrl
|
||||
},
|
||||
@@ -126,9 +131,6 @@ export default {
|
||||
authorBottom() {
|
||||
return 0.75 * this.sizeMultiplier
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
resolution() {
|
||||
return `${this.naturalWidth}x${this.naturalHeight}px`
|
||||
}
|
||||
@@ -136,7 +138,7 @@ export default {
|
||||
methods: {
|
||||
clickCover() {
|
||||
if (this.expandOnClick && this.libraryItem) {
|
||||
this.$store.commit('globals/setRawCoverPreviewModal', this.libraryItem.id)
|
||||
this.$store.commit('globals/setRawCoverPreviewModal', this.rawCoverUrl)
|
||||
}
|
||||
},
|
||||
setCoverBg() {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
|
||||
|
||||
<a v-if="!imageFailed && showOpenNewTab && isHovering" :href="cover" @click.stop target="_blank" class="absolute bg-primary flex items-center justify-center shadow-sm rounded-full hover:scale-110 transform duration-100" :style="{ top: sizeMultiplier * 0.5 + 'rem', right: sizeMultiplier * 0.5 + 'rem', width: 2.5 * sizeMultiplier + 'rem', height: 2.5 * sizeMultiplier + 'rem' }">
|
||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.75 + 'rem' }">open_in_new</span>
|
||||
<span class="material-symbols" :style="{ fontSize: sizeMultiplier * 1.75 + 'rem' }">open_in_new</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,7 @@ export default {
|
||||
return 0.8 * this.sizeMultiplier
|
||||
},
|
||||
resolution() {
|
||||
return `${this.naturalWidth}x${this.naturalHeight}px`
|
||||
return `${this.naturalWidth}×${this.naturalHeight}px`
|
||||
},
|
||||
placeholderUrl() {
|
||||
const config = this.$config || this.$nuxt.$config
|
||||
|
||||
@@ -10,21 +10,21 @@
|
||||
<div class="w-full p-8">
|
||||
<div class="flex py-2">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="newUser.username" :label="$strings.LabelUsername" />
|
||||
<ui-text-input-with-label v-model.trim="newUser.username" :label="$strings.LabelUsername" />
|
||||
</div>
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? $strings.LabelPassword : $strings.LabelChangePassword" type="password" />
|
||||
<ui-text-input-with-label v-else v-model="newUser.email" :label="$strings.LabelEmail" />
|
||||
<ui-text-input-with-label v-else v-model.trim="newUser.email" :label="$strings.LabelEmail" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="!isEditingRoot" class="flex py-2">
|
||||
<div class="w-1/2 px-2">
|
||||
<ui-text-input-with-label v-model="newUser.email" :label="$strings.LabelEmail" />
|
||||
<ui-text-input-with-label v-model.trim="newUser.email" :label="$strings.LabelEmail" />
|
||||
</div>
|
||||
<div class="px-2 w-52">
|
||||
<ui-dropdown v-model="newUser.type" :label="$strings.LabelAccountType" :disabled="isEditingRoot" :items="accountTypes" small @input="userTypeUpdated" />
|
||||
</div>
|
||||
<!-- <div class="flex-grow" /> -->
|
||||
|
||||
<div class="flex items-center pt-4 px-2">
|
||||
<p class="px-3 font-semibold" id="user-enabled-toggle" :class="isEditingRoot ? 'text-gray-300' : ''">{{ $strings.LabelEnable }}</p>
|
||||
<ui-toggle-switch labeledBy="user-enabled-toggle" v-model="newUser.isActive" :disabled="isEditingRoot" />
|
||||
@@ -111,7 +111,8 @@
|
||||
</div>
|
||||
|
||||
<div class="flex pt-4 px-2">
|
||||
<ui-btn v-if="isEditingRoot" to="/account">{{ $strings.ButtonChangeRootPassword }}</ui-btn>
|
||||
<ui-btn v-if="hasOpenIDLink" small :loading="unlinkingFromOpenID" color="primary" type="button" class="mr-2" @click.stop="unlinkOpenID">Unlink OpenID</ui-btn>
|
||||
<ui-btn v-if="isEditingRoot" small class="flex items-center" to="/account">{{ $strings.ButtonChangeRootPassword }}</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
</div>
|
||||
@@ -136,7 +137,8 @@ export default {
|
||||
newUser: {},
|
||||
isNew: true,
|
||||
tags: [],
|
||||
loadingTags: false
|
||||
loadingTags: false,
|
||||
unlinkingFromOpenID: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -180,7 +182,7 @@ export default {
|
||||
return this.isNew ? this.$strings.HeaderNewAccount : this.$strings.HeaderUpdateAccount
|
||||
},
|
||||
isEditingRoot() {
|
||||
return this.account && this.account.type === 'root'
|
||||
return this.account?.type === 'root'
|
||||
},
|
||||
libraries() {
|
||||
return this.$store.state.libraries.libraries
|
||||
@@ -198,6 +200,9 @@ export default {
|
||||
},
|
||||
tagsSelectionText() {
|
||||
return this.newUser.permissions.selectedTagsNotAccessible ? this.$strings.LabelTagsNotAccessibleToUser : this.$strings.LabelTagsAccessibleToUser
|
||||
},
|
||||
hasOpenIDLink() {
|
||||
return !!this.account?.hasOpenIDLink
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -205,6 +210,31 @@ export default {
|
||||
// Force close when navigating - used in UsersTable
|
||||
if (this.$refs.modal) this.$refs.modal.setHide()
|
||||
},
|
||||
unlinkOpenID() {
|
||||
const payload = {
|
||||
message: 'Are you sure you want to unlink this user from OpenID?',
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.unlinkingFromOpenID = true
|
||||
this.$axios
|
||||
.$patch(`/api/users/${this.account.id}/openid-unlink`)
|
||||
.then(() => {
|
||||
this.$toast.success('User unlinked from OpenID')
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to unlink user from OpenID', error)
|
||||
this.$toast.error('Failed to unlink user from OpenID')
|
||||
})
|
||||
.finally(() => {
|
||||
this.unlinkingFromOpenID = false
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
accessAllTagsToggled(val) {
|
||||
if (val) {
|
||||
if (this.newUser.itemTagsSelected?.length) {
|
||||
|
||||
105
client/components/modals/AddCustomMetadataProviderModal.vue
Normal file
105
client/components/modals/AddCustomMetadataProviderModal.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<modals-modal ref="modal" v-model="show" name="custom-metadata-provider" :width="600" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="text-3xl text-white truncate">Add custom metadata provider</p>
|
||||
</div>
|
||||
</template>
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="px-4 w-full flex items-center text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
<div class="w-full p-8">
|
||||
<div class="flex mb-2">
|
||||
<div class="w-3/4 p-1">
|
||||
<ui-text-input-with-label v-model="newName" :label="$strings.LabelName" />
|
||||
</div>
|
||||
<div class="w-1/4 p-1">
|
||||
<ui-text-input-with-label value="Book" readonly :label="$strings.LabelMediaType" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full mb-2 p-1">
|
||||
<ui-text-input-with-label v-model="newUrl" label="URL" />
|
||||
</div>
|
||||
<div class="w-full mb-2 p-1">
|
||||
<ui-text-input-with-label v-model="newAuthHeaderValue" :label="'Authorization Header Value'" type="password" />
|
||||
</div>
|
||||
<div class="flex px-1 pt-4">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" type="submit">{{ $strings.ButtonAdd }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
newName: '',
|
||||
newUrl: '',
|
||||
newAuthHeaderValue: ''
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submitForm() {
|
||||
if (!this.newName || !this.newUrl) {
|
||||
this.$toast.error('Must add name and url')
|
||||
return
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post('/api/custom-metadata-providers', {
|
||||
name: this.newName,
|
||||
url: this.newUrl,
|
||||
mediaType: 'book', // Currently only supporting book mediaType
|
||||
authHeaderValue: this.newAuthHeaderValue
|
||||
})
|
||||
.then((data) => {
|
||||
this.$emit('added', data.provider)
|
||||
this.$toast.success('New provider added')
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMsg = error.response?.data || 'Unknown error'
|
||||
console.error('Failed to add provider', error)
|
||||
this.$toast.error('Failed to add provider: ' + errorMsg)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.processing = false
|
||||
this.newName = ''
|
||||
this.newUrl = ''
|
||||
this.newAuthHeaderValue = ''
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -91,7 +91,7 @@
|
||||
<ui-textarea-with-label :value="prettyFfprobeData" readonly :rows="30" class="text-xs" />
|
||||
|
||||
<button class="absolute top-4 right-4" :class="copiedToClipboard ? 'text-success' : 'text-white/50 hover:text-white/80'" @click.stop="copyFfprobeData">
|
||||
<span class="material-icons">{{ copiedToClipboard ? 'check' : 'content_copy' }}</span>
|
||||
<span class="material-symbols">{{ copiedToClipboard ? 'check' : 'content_copy' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<ui-tooltip :text="$strings.LabelUpdateCoverHelp">
|
||||
<p class="pl-4">
|
||||
{{ $strings.LabelUpdateCover }}
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
<span class="material-symbols icon-text">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
@@ -28,7 +28,7 @@
|
||||
<ui-tooltip :text="$strings.LabelUpdateDetailsHelp">
|
||||
<p class="pl-4">
|
||||
{{ $strings.LabelUpdateDetails }}
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
<span class="material-symbols icon-text">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<div class="flex-grow px-2">
|
||||
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" />
|
||||
</div>
|
||||
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-icons text-2xl -mt-px">add</span></ui-btn>
|
||||
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-symbols text-2xl -mt-px">add</span></ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="chapters" :width="600" :height="'unset'">
|
||||
<div id="chapter-modal-wrapper" ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<div id="chapter-modal-wrapper" ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<template v-for="chap in chapters">
|
||||
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg relative" :class="chap.id === currentChapterId ? 'bg-yellow-400 bg-opacity-10' : chap.end / _playbackRate <= currentChapterStart ? 'bg-success bg-opacity-5' : 'bg-opacity-20'" @click="clickChapter(chap)">
|
||||
<div :key="chap.id" :id="`chapter-row-${chap.id}`" class="flex items-center px-6 py-3 justify-start cursor-pointer relative" :class="chap.id === currentChapterId ? 'bg-yellow-400/20 hover:bg-yellow-400/10' : chap.end / _playbackRate <= currentChapterStart ? 'bg-success/10 hover:bg-success/5' : 'hover:bg-primary/10'" @click="clickChapter(chap)">
|
||||
<p class="chapter-title truncate text-sm md:text-base">
|
||||
{{ chap.title }}
|
||||
</p>
|
||||
@@ -34,11 +34,6 @@ export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
watch: {
|
||||
value(newVal) {
|
||||
this.$nextTick(this.scrollToChapter)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
@@ -53,7 +48,7 @@ export default {
|
||||
return this.playbackRate
|
||||
},
|
||||
currentChapterId() {
|
||||
return this.currentChapter ? this.currentChapter.id : null
|
||||
return this.currentChapter?.id || null
|
||||
},
|
||||
currentChapterStart() {
|
||||
return (this.currentChapter?.start || 0) / this._playbackRate
|
||||
@@ -74,6 +69,11 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
if (this.value) {
|
||||
this.$nextTick(this.scrollToChapter)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -87,4 +87,4 @@ export default {
|
||||
max-width: calc(100% - 150px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="hidden absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 rounded-lg items-center justify-center" style="z-index: 61" @click="clickClose">
|
||||
<div class="absolute top-3 right-3 md:top-5 md:right-5 h-8 w-8 md:h-12 md:w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300">
|
||||
<span class="material-icons text-2xl md:text-4xl">close</span>
|
||||
<span class="material-symbols text-2xl md:text-4xl">close</span>
|
||||
</div>
|
||||
<div ref="content" class="text-white">
|
||||
<form v-if="selectedSeries" @submit.prevent="submitSeriesForm">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
|
||||
<div class="flex items-center">
|
||||
<p class="text-base text-gray-200">{{ _session.displayTitle }}</p>
|
||||
<p v-if="_session.displayAuthor" class="text-xs text-gray-400 px-4">by {{ _session.displayAuthor }}</p>
|
||||
<p v-if="_session.displayAuthor" class="text-xs text-gray-400 px-4">{{ $getString('LabelByAuthor', [_session.displayAuthor]) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
@@ -80,26 +80,27 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full md:w-1/3">
|
||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">{{ $strings.LabelUser }}</p>
|
||||
<p class="mb-1 text-xs">{{ _session.userId }}</p>
|
||||
<p v-if="!isMediaItemShareSession" class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">{{ $strings.LabelUser }}</p>
|
||||
<p v-if="!isMediaItemShareSession" class="mb-1 text-xs">{{ _session.userId }}</p>
|
||||
|
||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelMediaPlayer }}</p>
|
||||
<p class="mb-1">{{ playMethodName }}</p>
|
||||
<p class="mb-1">{{ _session.mediaPlayer }}</p>
|
||||
|
||||
<p v-if="hasDeviceInfo" class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelDevice }}</p>
|
||||
<p v-if="clientDisplayName" class="mb-1">{{ clientDisplayName }}</p>
|
||||
<p v-if="deviceInfo.ipAddress" class="mb-1">{{ deviceInfo.ipAddress }}</p>
|
||||
<p v-if="osDisplayName" class="mb-1">{{ osDisplayName }}</p>
|
||||
<p v-if="deviceInfo.browserName" class="mb-1">{{ deviceInfo.browserName }}</p>
|
||||
<p v-if="clientDisplayName" class="mb-1">{{ clientDisplayName }}</p>
|
||||
<p v-if="deviceDisplayName" class="mb-1">{{ deviceDisplayName }}</p>
|
||||
<p v-if="deviceInfo.sdkVersion" class="mb-1">SDK {{ $strings.LabelVersion }}: {{ deviceInfo.sdkVersion }}</p>
|
||||
<p v-if="deviceInfo.deviceType" class="mb-1">{{ $strings.LabelType }}: {{ deviceInfo.deviceType }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<ui-btn v-if="!isOpenSession" small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
|
||||
<ui-btn v-else small color="error" @click.stop="closeSessionClick">Close Open Session</ui-btn>
|
||||
<ui-btn v-if="!isOpenSession && !isMediaItemShareSession" small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
|
||||
<ui-btn v-else-if="!isMediaItemShareSession" small color="error" @click.stop="closeSessionClick">Close Open Session</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
@@ -141,10 +142,14 @@ export default {
|
||||
if (!this.deviceInfo.osName) return null
|
||||
return `${this.deviceInfo.osName} ${this.deviceInfo.osVersion}`
|
||||
},
|
||||
clientDisplayName() {
|
||||
deviceDisplayName() {
|
||||
if (!this.deviceInfo.manufacturer || !this.deviceInfo.model) return null
|
||||
return `${this.deviceInfo.manufacturer} ${this.deviceInfo.model}`
|
||||
},
|
||||
clientDisplayName() {
|
||||
if (!this.deviceInfo.clientName) return null
|
||||
return `${this.deviceInfo.clientName} ${this.deviceInfo.clientVersion || ''}`
|
||||
},
|
||||
playMethodName() {
|
||||
const playMethod = this._session.playMethod
|
||||
if (playMethod === this.$constants.PlayMethod.DIRECTPLAY) return 'Direct Play'
|
||||
@@ -161,6 +166,9 @@ export default {
|
||||
},
|
||||
isOpenSession() {
|
||||
return !!this._session.open
|
||||
},
|
||||
isMediaItemShareSession() {
|
||||
return this._session.mediaPlayer === 'web-share'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||
|
||||
<button class="absolute top-4 right-4 landscape:top-4 landscape:right-4 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 inline-flex text-gray-200 hover:text-white" aria-label="Close modal" @click="clickClose">
|
||||
<span class="material-icons text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
|
||||
<span class="material-symbols text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
|
||||
</button>
|
||||
<slot name="outer" />
|
||||
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" aria-modal="true" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
|
||||
|
||||
70
client/components/modals/PlayerSettingsModal.vue
Normal file
70
client/components/modals/PlayerSettingsModal.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="player-settings" :width="500" :height="'unset'">
|
||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-4" style="max-height: 80vh; min-height: 40vh">
|
||||
<h3 class="text-xl font-semibold mb-8">{{ $strings.HeaderPlayerSettings }}</h3>
|
||||
<div class="flex items-center mb-4">
|
||||
<ui-toggle-switch v-model="useChapterTrack" @input="setUseChapterTrack" />
|
||||
<div class="pl-4">
|
||||
<span>{{ $strings.LabelUseChapterTrack }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center mb-4">
|
||||
<ui-select-input v-model="jumpForwardAmount" :label="$strings.LabelJumpForwardAmount" menuMaxHeight="250px" :items="jumpValues" @input="setJumpForwardAmount" />
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<ui-select-input v-model="jumpBackwardAmount" :label="$strings.LabelJumpBackwardAmount" menuMaxHeight="250px" :items="jumpValues" @input="setJumpBackwardAmount" />
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
useChapterTrack: false,
|
||||
jumpValues: [
|
||||
{ text: this.$getString('LabelTimeDurationXSeconds', ['10']), value: 10 },
|
||||
{ text: this.$getString('LabelTimeDurationXSeconds', ['15']), value: 15 },
|
||||
{ text: this.$getString('LabelTimeDurationXSeconds', ['30']), value: 30 },
|
||||
{ text: this.$getString('LabelTimeDurationXSeconds', ['60']), value: 60 },
|
||||
{ text: this.$getString('LabelTimeDurationXMinutes', ['2']), value: 120 },
|
||||
{ text: this.$getString('LabelTimeDurationXMinutes', ['5']), value: 300 }
|
||||
],
|
||||
jumpForwardAmount: 10,
|
||||
jumpBackwardAmount: 10
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setUseChapterTrack() {
|
||||
this.$store.dispatch('user/updateUserSettings', { useChapterTrack: this.useChapterTrack })
|
||||
},
|
||||
setJumpForwardAmount(val) {
|
||||
this.jumpForwardAmount = val
|
||||
this.$store.dispatch('user/updateUserSettings', { jumpForwardAmount: val })
|
||||
},
|
||||
setJumpBackwardAmount(val) {
|
||||
this.jumpBackwardAmount = val
|
||||
this.$store.dispatch('user/updateUserSettings', { jumpBackwardAmount: val })
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack')
|
||||
this.jumpForwardAmount = this.$store.getters['user/getUserSetting']('jumpForwardAmount')
|
||||
this.jumpBackwardAmount = this.$store.getters['user/getUserSetting']('jumpBackwardAmount')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -20,14 +20,11 @@ export default {
|
||||
this.$store.commit('globals/setShowRawCoverPreviewModal', val)
|
||||
}
|
||||
},
|
||||
selectedLibraryItemId() {
|
||||
return this.$store.state.globals.selectedLibraryItemId
|
||||
},
|
||||
rawCoverUrl() {
|
||||
return this.$store.getters['globals/getLibraryItemCoverSrcById'](this.selectedLibraryItemId, null, true)
|
||||
return this.$store.state.globals.selectedRawCoverUrl
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
204
client/components/modals/ShareModal.vue
Normal file
204
client/components/modals/ShareModal.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<modals-modal ref="modal" v-model="show" name="share" :width="600" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="text-3xl text-white truncate">{{ $strings.LabelShare }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="px-6 py-8 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<div class="absolute top-0 right-0 p-4">
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
|
||||
<a href="https://www.audiobookshelf.org/guides/media-item-shares" target="_blank" class="inline-flex">
|
||||
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<template v-if="currentShare">
|
||||
<div class="w-full py-2">
|
||||
<label class="px-1 text-sm font-semibold block">{{ $strings.LabelShareURL }}</label>
|
||||
<ui-text-input v-model="currentShareUrl" show-copy readonly class="text-base h-10" />
|
||||
</div>
|
||||
<div class="w-full py-2 px-1">
|
||||
<p v-if="currentShare.expiresAt" class="text-base">{{ $getString('MessageShareExpiresIn', [currentShareTimeRemaining]) }}</p>
|
||||
<p v-else>{{ $strings.LabelPermanent }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex flex-col sm:flex-row items-center justify-between space-y-4 sm:space-y-0 sm:space-x-4 mb-4">
|
||||
<div class="w-full sm:w-48">
|
||||
<label class="px-1 text-sm font-semibold block">{{ $strings.LabelSlug }}</label>
|
||||
<ui-text-input v-model="newShareSlug" class="text-base h-10" />
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div class="w-full sm:w-80">
|
||||
<label class="px-1 text-sm font-semibold block">{{ $strings.LabelDuration }}</label>
|
||||
<div class="inline-flex items-center space-x-2">
|
||||
<div>
|
||||
<ui-icon-btn icon="remove" :size="10" @click="clickMinus" />
|
||||
</div>
|
||||
<ui-text-input v-model="newShareDuration" type="number" text-center no-spinner class="text-center max-w-12 min-w-12 h-10 text-base" />
|
||||
<div>
|
||||
<ui-icon-btn icon="add" :size="10" @click="clickPlus" />
|
||||
</div>
|
||||
<div class="w-28">
|
||||
<ui-dropdown v-model="shareDurationUnit" :items="durationUnits" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-300 py-1 px-1" v-html="$getString('MessageShareURLWillBe', [demoShareUrl])" />
|
||||
<p class="text-sm text-gray-300 py-1 px-1" v-html="$getString('MessageShareExpirationWillBe', [expirationDateString])" />
|
||||
</template>
|
||||
<div class="flex items-center pt-6">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="currentShare" color="error" small @click="deleteShare">{{ $strings.ButtonDelete }}</ui-btn>
|
||||
<ui-btn v-if="!currentShare" color="success" small @click="openShare">{{ $strings.ButtonShare }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
newShareSlug: '',
|
||||
newShareDuration: 0,
|
||||
currentShare: null,
|
||||
shareDurationUnit: 'minutes',
|
||||
durationUnits: [
|
||||
{
|
||||
text: this.$strings.LabelMinutes,
|
||||
value: 'minutes'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelHours,
|
||||
value: 'hours'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelDays,
|
||||
value: 'days'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.$store.state.globals.showShareModal
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('globals/setShowShareModal', val)
|
||||
}
|
||||
},
|
||||
mediaItemShare() {
|
||||
return this.$store.state.globals.selectedMediaItemShare
|
||||
},
|
||||
libraryItem() {
|
||||
return this.$store.state.selectedLibraryItem
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
demoShareUrl() {
|
||||
return `${window.origin}/share/${this.newShareSlug}`
|
||||
},
|
||||
currentShareUrl() {
|
||||
if (!this.currentShare) return ''
|
||||
return `${window.origin}/share/${this.currentShare.slug}`
|
||||
},
|
||||
currentShareTimeRemaining() {
|
||||
if (!this.currentShare) return 'Error'
|
||||
if (!this.currentShare.expiresAt) return this.$strings.LabelPermanent
|
||||
const msRemaining = new Date(this.currentShare.expiresAt).valueOf() - Date.now()
|
||||
if (msRemaining <= 0) return 'Expired'
|
||||
return this.$elapsedPrettyExtended(msRemaining / 1000, true, false)
|
||||
},
|
||||
expireDurationSeconds() {
|
||||
let shareDuration = Number(this.newShareDuration)
|
||||
if (!shareDuration || isNaN(shareDuration)) return 0
|
||||
return this.newShareDuration * (this.shareDurationUnit === 'minutes' ? 60 : this.shareDurationUnit === 'hours' ? 3600 : 86400)
|
||||
},
|
||||
expirationDateString() {
|
||||
if (!this.expireDurationSeconds) return this.$strings.LabelPermanent
|
||||
const dateMs = Date.now() + this.expireDurationSeconds * 1000
|
||||
return this.$formatDatetime(dateMs, this.$store.state.serverSettings.dateFormat, this.$store.state.serverSettings.timeFormat)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickPlus() {
|
||||
this.newShareDuration++
|
||||
},
|
||||
clickMinus() {
|
||||
if (this.newShareDuration > 0) {
|
||||
this.newShareDuration--
|
||||
}
|
||||
},
|
||||
deleteShare() {
|
||||
if (!this.currentShare) return
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/share/mediaitem/${this.currentShare.id}`)
|
||||
.then(() => {
|
||||
this.currentShare = null
|
||||
this.$emit('removed')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('deleteShare', error)
|
||||
let errorMsg = error.response?.data || 'Failed to delete share'
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
openShare() {
|
||||
if (!this.newShareSlug) {
|
||||
this.$toast.error('Slug is required')
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
slug: this.newShareSlug,
|
||||
mediaItemType: 'book',
|
||||
mediaItemId: this.libraryItem.media.id,
|
||||
expiresAt: this.expireDurationSeconds ? Date.now() + this.expireDurationSeconds * 1000 : 0
|
||||
}
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post(`/api/share/mediaitem`, payload)
|
||||
.then((data) => {
|
||||
this.currentShare = data
|
||||
this.$emit('opened', data)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('openShare', error)
|
||||
let errorMsg = error.response?.data || 'Failed to share item'
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.newShareSlug = this.$randomId(10)
|
||||
if (this.mediaItemShare) {
|
||||
this.currentShare = { ...this.mediaItemShare }
|
||||
} else {
|
||||
this.currentShare = null
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -6,34 +6,36 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div ref="container" class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<div v-if="!timerSet" class="w-full">
|
||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
<div class="w-full">
|
||||
<template v-for="time in sleepTimes">
|
||||
<div :key="time.text" class="flex items-center px-6 py-3 justify-center cursor-pointer hover:bg-bg relative" @click="setTime(time.seconds)">
|
||||
<p class="text-xl text-center">{{ time.text }}</p>
|
||||
<div :key="time.text" class="flex items-center px-6 py-3 justify-center cursor-pointer hover:bg-primary/25 relative" @click="setTime(time)">
|
||||
<p class="text-lg text-center">{{ time.text }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<form class="flex items-center justify-center px-6 py-3" @submit.prevent="submitCustomTime">
|
||||
<ui-text-input v-model="customTime" type="number" step="any" min="0.1" placeholder="Time in minutes" class="w-48" />
|
||||
<ui-text-input v-model="customTime" type="number" step="any" min="0.1" :placeholder="$strings.LabelTimeInMinutes" class="w-48" />
|
||||
<ui-btn color="success" type="submit" :padding-x="0" class="h-9 w-12 flex items-center justify-center ml-1">Set</ui-btn>
|
||||
</form>
|
||||
</div>
|
||||
<div v-else class="w-full p-4">
|
||||
<div class="mb-4 flex items-center justify-center">
|
||||
<ui-btn :padding-x="2" small :disabled="remaining < 30 * 60" class="flex items-center mr-4" @click="decrement(30 * 60)">
|
||||
<span class="material-icons text-lg">remove</span>
|
||||
<span class="pl-1 text-base font-mono">30m</span>
|
||||
<div v-if="timerSet" class="w-full p-4">
|
||||
<div class="mb-4 h-px w-full bg-white/10" />
|
||||
|
||||
<div v-if="timerType === $constants.SleepTimerTypes.COUNTDOWN" class="mb-4 flex items-center justify-center space-x-4">
|
||||
<ui-btn :padding-x="2" small :disabled="remaining < 30 * 60" class="flex items-center h-9" @click="decrement(30 * 60)">
|
||||
<span class="material-symbols text-lg">remove</span>
|
||||
<span class="pl-1 text-sm">30m</span>
|
||||
</ui-btn>
|
||||
|
||||
<ui-icon-btn icon="remove" @click="decrement(60 * 5)" />
|
||||
<ui-icon-btn icon="remove" class="min-w-9" @click="decrement(60 * 5)" />
|
||||
|
||||
<p class="mx-6 text-2xl font-mono">{{ $secondsToTimestamp(remaining) }}</p>
|
||||
<p class="text-2xl font-mono">{{ $secondsToTimestamp(remaining) }}</p>
|
||||
|
||||
<ui-icon-btn icon="add" @click="increment(60 * 5)" />
|
||||
<ui-icon-btn icon="add" class="min-w-9" @click="increment(60 * 5)" />
|
||||
|
||||
<ui-btn :padding-x="2" small class="flex items-center ml-4" @click="increment(30 * 60)">
|
||||
<span class="material-icons text-lg">add</span>
|
||||
<span class="pl-1 text-base font-mono">30m</span>
|
||||
<ui-btn :padding-x="2" small class="flex items-center h-9" @click="increment(30 * 60)">
|
||||
<span class="material-symbols text-lg">add</span>
|
||||
<span class="pl-1 text-sm">30m</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
<ui-btn class="w-full" @click="$emit('cancel')">{{ $strings.ButtonCancel }}</ui-btn>
|
||||
@@ -47,52 +49,13 @@ export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
timerSet: Boolean,
|
||||
timerTime: Number,
|
||||
remaining: Number
|
||||
timerType: String,
|
||||
remaining: Number,
|
||||
hasChapters: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
customTime: null,
|
||||
sleepTimes: [
|
||||
{
|
||||
seconds: 60 * 5,
|
||||
text: '5 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 15,
|
||||
text: '15 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 20,
|
||||
text: '20 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 30,
|
||||
text: '30 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 45,
|
||||
text: '45 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 60,
|
||||
text: '60 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 90,
|
||||
text: '90 minutes'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 120,
|
||||
text: '2 hours'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
if (newVal) {
|
||||
}
|
||||
customTime: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -103,6 +66,54 @@ export default {
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
sleepTimes() {
|
||||
const times = [
|
||||
{
|
||||
seconds: 60 * 5,
|
||||
text: this.$getString('LabelTimeDurationXMinutes', ['5']),
|
||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||
},
|
||||
{
|
||||
seconds: 60 * 15,
|
||||
text: this.$getString('LabelTimeDurationXMinutes', ['15']),
|
||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||
},
|
||||
{
|
||||
seconds: 60 * 20,
|
||||
text: this.$getString('LabelTimeDurationXMinutes', ['20']),
|
||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||
},
|
||||
{
|
||||
seconds: 60 * 30,
|
||||
text: this.$getString('LabelTimeDurationXMinutes', ['30']),
|
||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||
},
|
||||
{
|
||||
seconds: 60 * 45,
|
||||
text: this.$getString('LabelTimeDurationXMinutes', ['45']),
|
||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||
},
|
||||
{
|
||||
seconds: 60 * 60,
|
||||
text: this.$getString('LabelTimeDurationXMinutes', ['60']),
|
||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||
},
|
||||
{
|
||||
seconds: 60 * 90,
|
||||
text: this.$getString('LabelTimeDurationXMinutes', ['90']),
|
||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||
},
|
||||
{
|
||||
seconds: 60 * 120,
|
||||
text: this.$getString('LabelTimeDurationXHours', ['2']),
|
||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||
}
|
||||
]
|
||||
if (this.hasChapters) {
|
||||
times.push({ seconds: -1, text: this.$strings.LabelEndOfChapter, timerType: this.$constants.SleepTimerTypes.CHAPTER })
|
||||
}
|
||||
return times
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -113,10 +124,14 @@ export default {
|
||||
}
|
||||
|
||||
const timeInSeconds = Math.round(Number(this.customTime) * 60)
|
||||
this.setTime(timeInSeconds)
|
||||
const time = {
|
||||
seconds: timeInSeconds,
|
||||
timerType: this.$constants.SleepTimerTypes.COUNTDOWN
|
||||
}
|
||||
this.setTime(time)
|
||||
},
|
||||
setTime(seconds) {
|
||||
this.$emit('set', seconds)
|
||||
setTime(time) {
|
||||
this.$emit('set', time)
|
||||
},
|
||||
increment(amount) {
|
||||
this.$emit('increment', amount)
|
||||
@@ -130,4 +145,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
<div v-if="previewUpload" class="absolute top-0 left-0 w-full h-full z-10 bg-bg p-8">
|
||||
<p class="text-lg">Preview Cover</p>
|
||||
<span class="absolute top-4 right-4 material-icons text-2xl cursor-pointer" @click="resetCoverPreview">close</span>
|
||||
<span class="absolute top-4 right-4 material-symbols text-2xl cursor-pointer" @click="resetCoverPreview">close</span>
|
||||
<div class="flex justify-center py-4">
|
||||
<covers-preview-cover :src="previewUpload" :width="240" />
|
||||
</div>
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
<div class="flex">
|
||||
<div class="w-40 p-2">
|
||||
<div class="w-full h-45 relative">
|
||||
<covers-author-image :author="author" />
|
||||
<covers-author-image :author="authorCopy" />
|
||||
<div v-if="userCanDelete && !processing && author.imagePath" class="absolute top-0 left-0 w-full h-full opacity-0 hover:opacity-100">
|
||||
<span class="absolute top-2 right-2 material-icons text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
|
||||
<span class="absolute top-2 right-2 material-symbols text-error transform hover:scale-125 transition-transform cursor-pointer text-lg" @click="removeCover">delete</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,9 +30,6 @@
|
||||
<ui-text-input-with-label v-model="authorCopy.asin" :disabled="processing" label="ASIN" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="p-2">
|
||||
<ui-text-input-with-label v-model="authorCopy.imagePath" :disabled="processing" :label="$strings.LabelPhotoPathURL" />
|
||||
</div> -->
|
||||
<div class="p-2">
|
||||
<ui-textarea-with-label v-model="authorCopy.description" :disabled="processing" :label="$strings.LabelDescription" :rows="8" />
|
||||
</div>
|
||||
@@ -106,9 +103,9 @@ export default {
|
||||
methods: {
|
||||
init() {
|
||||
this.imageUrl = ''
|
||||
this.authorCopy.name = this.author.name
|
||||
this.authorCopy.asin = this.author.asin
|
||||
this.authorCopy.description = this.author.description
|
||||
this.authorCopy = {
|
||||
...this.author
|
||||
}
|
||||
},
|
||||
removeClick() {
|
||||
const payload = {
|
||||
@@ -171,7 +168,9 @@ export default {
|
||||
.$delete(`/api/authors/${this.authorId}/image`)
|
||||
.then((data) => {
|
||||
this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess)
|
||||
this.$store.commit('globals/showEditAuthorModal', data.author)
|
||||
|
||||
this.authorCopy.updatedAt = data.author.updatedAt
|
||||
this.authorCopy.imagePath = data.author.imagePath
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
@@ -196,7 +195,9 @@ export default {
|
||||
.then((data) => {
|
||||
this.imageUrl = ''
|
||||
this.$toast.success('Author image updated')
|
||||
this.$store.commit('globals/showEditAuthorModal', data.author)
|
||||
|
||||
this.authorCopy.updatedAt = data.author.updatedAt
|
||||
this.authorCopy.imagePath = data.author.imagePath
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
@@ -231,8 +232,11 @@ export default {
|
||||
} else if (response.updated) {
|
||||
if (response.author.imagePath) {
|
||||
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
|
||||
this.$store.commit('globals/showEditAuthorModal', response.author)
|
||||
} else this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)
|
||||
|
||||
this.authorCopy = {
|
||||
...response.author
|
||||
}
|
||||
} else {
|
||||
this.$toast.info('No updates were made for Author')
|
||||
}
|
||||
@@ -242,4 +246,4 @@ export default {
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
<div class="flex-grow pr-2">
|
||||
<ui-text-input v-model="newBookmarkTitle" placeholder="Note" class="w-full" />
|
||||
</div>
|
||||
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-icons text-2xl -mt-px">forward</span></ui-btn>
|
||||
<ui-btn type="submit" color="success" :padding-x="4" class="h-10"><span class="material-symbols text-2xl -mt-px">forward</span></ui-btn>
|
||||
<div class="pl-2 flex items-center">
|
||||
<span class="material-icons text-3xl text-white text-opacity-70 hover:text-opacity-95 cursor-pointer" @click.stop.prevent="cancelEditing">close</span>
|
||||
<span class="material-symbols text-3xl text-white text-opacity-70 hover:text-opacity-95 cursor-pointer" @click.stop.prevent="cancelEditing">close</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -22,8 +22,8 @@
|
||||
<p v-else class="pl-2 pr-2 truncate">{{ bookmark.title }}</p>
|
||||
</div>
|
||||
<div v-if="!isEditing" class="h-full flex items-center justify-end transform" :class="isHovering ? 'transition-transform translate-0 w-16' : 'translate-x-40 w-0'">
|
||||
<span class="material-icons text-xl mr-2 text-gray-200 hover:text-yellow-400" @click.stop="editClick">edit</span>
|
||||
<span class="material-icons text-xl text-gray-200 hover:text-error cursor-pointer" @click.stop="deleteClick">delete</span>
|
||||
<span class="material-symbols text-xl mr-2 text-gray-200 hover:text-yellow-400" @click.stop="editClick">edit</span>
|
||||
<span class="material-symbols text-xl text-gray-200 hover:text-error cursor-pointer" @click.stop="deleteClick">delete</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -6,8 +6,15 @@
|
||||
</div>
|
||||
</template>
|
||||
<div class="px-8 py-6 w-full rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-scroll" style="max-height: 80vh">
|
||||
<p class="text-xl font-bold pb-4">Changelog v{{ currentVersionNumber }}</p>
|
||||
<div class="custom-text" v-html="compiledMarkedown" />
|
||||
<template v-for="release in releasesToShow">
|
||||
<div :key="release.name">
|
||||
<p class="text-xl font-bold pb-4">
|
||||
Changelog <a :href="`https://github.com/advplyr/audiobookshelf/releases/tag/${release.name}`" target="_blank" class="hover:underline">{{ release.name }}</a> ({{ $formatDate(release.pubdate, dateFormat) }})
|
||||
</p>
|
||||
<div class="custom-text" v-html="getChangelog(release)" />
|
||||
</div>
|
||||
<div v-if="release !== releasesToShow[releasesToShow.length - 1]" class="border-b border-black-300 my-8" />
|
||||
</template>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
@@ -18,17 +25,9 @@ import { marked } from '@/static/libs/marked/index.js'
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
changelog: String,
|
||||
currentVersion: String
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
versionData: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -40,15 +39,17 @@ export default {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
compiledMarkedown() {
|
||||
return marked.parse(this.changelog, { gfm: true, breaks: true })
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
currentVersionNumber() {
|
||||
return this.currentVersion
|
||||
releasesToShow() {
|
||||
return this.versionData?.releasesToShow || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {}
|
||||
getChangelog(release) {
|
||||
return marked.parse(release.changelog || 'No Changelog Available', { gfm: true, breaks: true })
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
@@ -57,7 +58,7 @@ export default {
|
||||
<style scoped>
|
||||
/*
|
||||
1. we need to manually define styles to apply to the parsed markdown elements,
|
||||
since we don't have access to the actual elements in this component
|
||||
since we don't have access to the actual elements in this component
|
||||
|
||||
2. v-deep allows these to take effect on the content passed in to the v-html in the div above
|
||||
*/
|
||||
@@ -70,4 +71,4 @@ since we don't have access to the actual elements in this component
|
||||
.custom-text ::v-deep > ul {
|
||||
@apply list-disc list-inside pb-4;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -122,7 +122,7 @@ export default {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to get collections', error)
|
||||
this.$toast.error('Failed to load collections')
|
||||
this.$toast.error(this.$strings.ToastFailedToLoadData)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
<nuxt-link :to="`/collection/${collection.id}`" class="pl-2 pr-2 truncate hover:underline cursor-pointer" @click.native="clickNuxtLink">{{ collection.name }}</nuxt-link>
|
||||
</div>
|
||||
<div class="h-full flex items-center justify-end transform" :class="isHovering ? 'transition-transform translate-0 w-16' : 'translate-x-40 w-0'">
|
||||
<ui-btn v-if="!isBookIncluded" color="success" :padding-x="3" small class="h-9" @click.stop="clickAdd"><span class="material-icons text-2xl pt-px">add</span></ui-btn>
|
||||
<ui-btn v-else color="error" :padding-x="3" class="h-9" small @click.stop="clickRem"><span class="material-icons text-2xl pt-px">remove</span></ui-btn>
|
||||
<ui-btn v-if="!isBookIncluded" color="success" :padding-x="3" small class="h-9" @click.stop="clickAdd"><span class="material-symbols text-2xl pt-px">add</span></ui-btn>
|
||||
<ui-btn v-else color="error" :padding-x="3" class="h-9" small @click.stop="clickRem"><span class="material-symbols text-2xl pt-px">remove</span></ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<template v-else>
|
||||
<div class="flex items-center mb-3">
|
||||
<div class="hover:bg-white hover:bg-opacity-10 cursor-pointer h-11 w-11 flex items-center justify-center rounded-full" @click="showImageUploader = false">
|
||||
<span class="material-icons text-4xl">arrow_back</span>
|
||||
<span class="material-symbols text-4xl">arrow_back</span>
|
||||
</div>
|
||||
<p class="ml-2 text-xl mb-1">Collection Cover Image</p>
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,12 @@ export default {
|
||||
ereaderDevice: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
users: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
loadUsers: Function
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -56,8 +61,7 @@ export default {
|
||||
email: '',
|
||||
availabilityOption: 'adminAndUp',
|
||||
users: []
|
||||
},
|
||||
users: []
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -108,25 +112,13 @@ export default {
|
||||
methods: {
|
||||
availabilityOptionChanged(option) {
|
||||
if (option === 'specificUsers' && !this.users.length) {
|
||||
this.loadUsers()
|
||||
this.callLoadUsers()
|
||||
}
|
||||
},
|
||||
async loadUsers() {
|
||||
async callLoadUsers() {
|
||||
this.processing = true
|
||||
this.users = await this.$axios
|
||||
.$get('/api/users')
|
||||
.then((res) => {
|
||||
return res.users.sort((a, b) => {
|
||||
return a.createdAt - b.createdAt
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return []
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
await this.loadUsers()
|
||||
this.processing = false
|
||||
},
|
||||
submitForm() {
|
||||
this.$refs.ereaderNameInput.blur()
|
||||
@@ -226,10 +218,6 @@ export default {
|
||||
this.newDevice.email = this.ereaderDevice.email
|
||||
this.newDevice.availabilityOption = this.ereaderDevice.availabilityOption || 'adminOrUp'
|
||||
this.newDevice.users = this.ereaderDevice.users || []
|
||||
|
||||
if (this.newDevice.availabilityOption === 'specificUsers' && !this.users.length) {
|
||||
this.loadUsers()
|
||||
}
|
||||
} else {
|
||||
this.newDevice.name = ''
|
||||
this.newDevice.email = ''
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
</div>
|
||||
|
||||
<div v-show="canGoPrev" class="absolute -left-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
|
||||
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goPrevBook" @mousedown.prevent>arrow_back_ios</div>
|
||||
<div class="material-symbols text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goPrevBook" @mousedown.prevent>arrow_back_ios</div>
|
||||
</div>
|
||||
<div v-show="canGoNext" class="absolute -right-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
|
||||
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div>
|
||||
<div class="material-symbols text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextBook" @mousedown.prevent>arrow_forward_ios</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-full max-h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<div class="w-full mb-4">
|
||||
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" keep-open />
|
||||
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" keep-open @close="closeModal" />
|
||||
<div v-if="!chapters.length" class="py-4 text-center">
|
||||
<p class="mb-8 text-xl">{{ $strings.MessageNoChapters }}</p>
|
||||
<ui-btn v-if="userCanUpdate" :to="`/audiobook/${libraryItem.id}/chapters`">{{ $strings.ButtonAddChapters }}</ui-btn>
|
||||
<ui-btn v-if="userCanUpdate" :to="`/audiobook/${libraryItem.id}/chapters`" @click="clickAddChapters">{{ $strings.ButtonAddChapters }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -23,7 +23,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
return this.libraryItem?.media || {}
|
||||
},
|
||||
chapters() {
|
||||
return this.media.chapters || []
|
||||
@@ -32,6 +32,15 @@ export default {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
}
|
||||
},
|
||||
methods: {}
|
||||
methods: {
|
||||
closeModal() {
|
||||
this.$emit('close')
|
||||
},
|
||||
clickAddChapters() {
|
||||
if (this.$route.name === 'audiobook-id-chapters' && this.$route.params?.id === this.libraryItem?.id) {
|
||||
this.closeModal()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
|
||||
<div class="flex flex-wrap mb-4">
|
||||
<div class="relative">
|
||||
<div class="flex flex-col sm:flex-row mb-4">
|
||||
<div class="relative self-center">
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, libraryItemUpdatedAt, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
|
||||
<!-- book cover overlay -->
|
||||
@@ -9,17 +9,17 @@
|
||||
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
|
||||
<div v-if="userCanDelete" class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover">
|
||||
<ui-tooltip direction="top" :text="$strings.LabelRemoveCover">
|
||||
<span class="material-icons text-2xl">delete</span>
|
||||
<span class="material-symbols text-2xl">delete</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-2 md:mt-0">
|
||||
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-6 md:mt-0">
|
||||
<div class="flex items-center">
|
||||
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 md:min-w-32">
|
||||
<ui-file-input ref="fileInput" @change="fileUploadSelected">
|
||||
<span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span>
|
||||
<span class="material-icons text-2xl inline-block md:!hidden">upload</span>
|
||||
<span class="material-symbols text-2xl inline-block md:!hidden">upload</span>
|
||||
</ui-file-input>
|
||||
</div>
|
||||
|
||||
@@ -49,20 +49,20 @@
|
||||
</div>
|
||||
</div>
|
||||
<form @submit.prevent="submitSearchForm">
|
||||
<div class="flex items-center justify-start -mx-1 h-20">
|
||||
<div class="w-48 px-1">
|
||||
<div class="flex flex-wrap sm:flex-nowrap items-center justify-start -mx-1">
|
||||
<div class="w-48 flex-grow p-1">
|
||||
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
|
||||
</div>
|
||||
<div class="w-72 px-1">
|
||||
<div class="w-72 flex-grow p-1">
|
||||
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" :placeholder="$strings.PlaceholderSearch" />
|
||||
</div>
|
||||
<div v-show="provider != 'itunes' && provider != 'audiobookcovers'" class="w-72 px-1">
|
||||
<div v-show="provider != 'itunes' && provider != 'audiobookcovers'" class="w-72 flex-grow p-1">
|
||||
<ui-text-input-with-label v-model="searchAuthor" :label="$strings.LabelAuthor" />
|
||||
</div>
|
||||
<ui-btn class="mt-5 ml-1" type="submit">{{ $strings.ButtonSearch }}</ui-btn>
|
||||
<ui-btn class="mt-5 ml-1 md:min-w-24" :padding-x="4" type="submit">{{ $strings.ButtonSearch }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-80 overflow-y-scroll mt-2 max-w-full">
|
||||
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center sm:max-h-80 sm:overflow-y-scroll mt-2 max-w-full">
|
||||
<p v-if="!coversFound.length">{{ $strings.MessageNoCoversFound }}</p>
|
||||
<template v-for="cover in coversFound">
|
||||
<div :key="cover" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === coverPath ? 'border-yellow-300' : ''" @click="updateCover(cover)">
|
||||
@@ -73,7 +73,7 @@
|
||||
|
||||
<div v-if="previewUpload" class="absolute top-0 left-0 w-full h-full z-10 bg-bg p-8">
|
||||
<p class="text-lg">{{ $strings.HeaderPreviewCover }}</p>
|
||||
<span class="absolute top-4 right-4 material-icons text-2xl cursor-pointer" @click="resetCoverPreview">close</span>
|
||||
<span class="absolute top-4 right-4 material-symbols text-2xl cursor-pointer" @click="resetCoverPreview">close</span>
|
||||
<div class="flex justify-center py-4">
|
||||
<covers-preview-cover :src="previewUpload" :width="240" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="flex -mb-0.5">
|
||||
<p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': checkingNewEpisodes }">{{ $strings.LabelLimit }}</p>
|
||||
<ui-tooltip direction="top" text="Max # of episodes to download. Use 0 for unlimited.">
|
||||
<span class="material-icons text-base">info_outlined</span>
|
||||
<span class="material-symbols text-base">info</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</ui-text-input-with-label>
|
||||
@@ -29,7 +29,7 @@
|
||||
<td class="text-center w-20 min-w-20">
|
||||
<p>{{ episode.episode }}</p>
|
||||
</td>
|
||||
<td>
|
||||
<td dir="auto">
|
||||
{{ episode.title }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
|
||||
@@ -28,11 +28,11 @@
|
||||
<div v-if="selectedMatchOrig" class="absolute top-0 left-0 w-full bg-bg h-full px-2 py-6 md:p-8 max-h-full overflow-y-auto overflow-x-hidden">
|
||||
<div class="flex mb-4">
|
||||
<div class="w-8 h-8 rounded-full hover:bg-white hover:bg-opacity-10 flex items-center justify-center cursor-pointer" @click="clearSelectedMatch">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
<span class="material-symbols text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<p class="text-xl pl-3">{{ $strings.HeaderUpdateDetails }}</p>
|
||||
</div>
|
||||
<ui-checkbox v-model="selectAll" checkbox-bg="bg" @input="selectAllToggled" />
|
||||
<ui-checkbox v-model="selectAll" :label="$strings.LabelSelectAll" checkbox-bg="bg" @input="selectAllToggled" />
|
||||
<form @submit.prevent="submitMatchUpdate">
|
||||
<div v-if="selectedMatchOrig.cover" class="flex flex-wrap md:flex-nowrap items-center justify-center">
|
||||
<div class="flex flex-grow items-center py-2">
|
||||
@@ -42,15 +42,15 @@
|
||||
|
||||
<div class="flex py-2">
|
||||
<div>
|
||||
<p class="text-center text-gray-200">New</p>
|
||||
<p class="text-center text-gray-200">{{ $strings.LabelNew }}</p>
|
||||
<a :href="selectedMatch.cover" target="_blank" class="bg-primary">
|
||||
<covers-preview-cover :src="selectedMatch.cover" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="media.coverPath">
|
||||
<p class="text-center text-gray-200">Current</p>
|
||||
<a :href="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" target="_blank" class="bg-primary">
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div v-if="media.coverPath" class="ml-0.5">
|
||||
<p class="text-center text-gray-200">{{ $strings.LabelCurrent }}</p>
|
||||
<a :href="$store.getters['globals/getLibraryItemCoverSrc'](libraryItem, null, true)" target="_blank" class="bg-primary">
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrc'](libraryItem, null, true)" :width="100" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,49 +59,63 @@
|
||||
<ui-checkbox v-model="selectedMatchUsage.title" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.title" :disabled="!selectedMatchUsage.title" :label="$strings.LabelTitle" />
|
||||
<p v-if="mediaMetadata.title" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.title || '' }}</p>
|
||||
<p v-if="mediaMetadata.title" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('title', mediaMetadata.title)">{{ mediaMetadata.title || '' }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.subtitle" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.subtitle" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.subtitle" :disabled="!selectedMatchUsage.subtitle" :label="$strings.LabelSubtitle" />
|
||||
<p v-if="mediaMetadata.subtitle" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.subtitle || '' }}</p>
|
||||
<p v-if="mediaMetadata.subtitle" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('subtitle', mediaMetadata.subtitle)">{{ mediaMetadata.subtitle }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.author" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.author" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.author" :disabled="!selectedMatchUsage.author" :label="$strings.LabelAuthor" />
|
||||
<p v-if="mediaMetadata.authorName" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.authorName || '' }}</p>
|
||||
<p v-if="mediaMetadata.authorName" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('author', mediaMetadata.authorName)">{{ mediaMetadata.authorName }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.narrator" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.narrator" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.narrator" :disabled="!selectedMatchUsage.narrator" :label="$strings.LabelNarrators" />
|
||||
<p v-if="mediaMetadata.narratorName" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.narratorName || '' }}</p>
|
||||
<ui-multi-select v-model="selectedMatch.narrator" :items="narrators" :disabled="!selectedMatchUsage.narrator" :label="$strings.LabelNarrators" />
|
||||
<p v-if="mediaMetadata.narratorName" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('narrator', mediaMetadata.narrators)">{{ mediaMetadata.narratorName }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.description" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.description" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-textarea-with-label v-model="selectedMatch.description" :rows="3" :disabled="!selectedMatchUsage.description" :label="$strings.LabelDescription" />
|
||||
<p v-if="mediaMetadata.description" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.description.substr(0, 100) + (mediaMetadata.description.length > 100 ? '...' : '') }}</p>
|
||||
<p v-if="mediaMetadata.description" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('description', mediaMetadata.description)">{{ mediaMetadata.description.substr(0, 100) + (mediaMetadata.description.length > 100 ? '...' : '') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.publisher" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.publisher" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.publisher" :disabled="!selectedMatchUsage.publisher" :label="$strings.LabelPublisher" />
|
||||
<p v-if="mediaMetadata.publisher" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.publisher || '' }}</p>
|
||||
<p v-if="mediaMetadata.publisher" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('publisher', mediaMetadata.publisher)">{{ mediaMetadata.publisher }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.publishedYear" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.publishedYear" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.publishedYear" :disabled="!selectedMatchUsage.publishedYear" :label="$strings.LabelPublishYear" />
|
||||
<p v-if="mediaMetadata.publishedYear" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.publishedYear || '' }}</p>
|
||||
<p v-if="mediaMetadata.publishedYear" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('publishedYear', mediaMetadata.publishedYear)">{{ mediaMetadata.publishedYear }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -109,42 +123,54 @@
|
||||
<ui-checkbox v-model="selectedMatchUsage.series" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<widgets-series-input-widget v-model="selectedMatch.series" :disabled="!selectedMatchUsage.series" />
|
||||
<p v-if="mediaMetadata.seriesName" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.seriesName || '' }}</p>
|
||||
<p v-if="mediaMetadata.seriesName" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('series', mediaMetadata.series)">{{ mediaMetadata.seriesName }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.genres && selectedMatchOrig.genres.length" class="flex items-center py-2">
|
||||
<div v-if="selectedMatchOrig.genres?.length" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.genres" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-multi-select v-model="selectedMatch.genres" :items="genres" :disabled="!selectedMatchUsage.genres" :label="$strings.LabelGenres" />
|
||||
<p v-if="mediaMetadata.genres" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.genres.join(', ') }}</p>
|
||||
<p v-if="mediaMetadata.genres?.length" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('genres', mediaMetadata.genres)">{{ mediaMetadata.genres.join(', ') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.tags" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.tags" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.tags" :disabled="!selectedMatchUsage.tags" :label="$strings.LabelTags" />
|
||||
<p v-if="media.tags" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ media.tags.join(', ') }}</p>
|
||||
<ui-multi-select v-model="selectedMatch.tags" :items="tags" :disabled="!selectedMatchUsage.tags" :label="$strings.LabelTags" />
|
||||
<p v-if="media.tags?.length" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('tags', media.tags)">{{ media.tags.join(', ') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.language" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.language" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.language" :disabled="!selectedMatchUsage.language" :label="$strings.LabelLanguage" />
|
||||
<p v-if="mediaMetadata.language" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.language || '' }}</p>
|
||||
<p v-if="mediaMetadata.language" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('language', mediaMetadata.language)">{{ mediaMetadata.language }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.isbn" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.isbn" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.isbn" :disabled="!selectedMatchUsage.isbn" label="ISBN" />
|
||||
<p v-if="mediaMetadata.isbn" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.isbn || '' }}</p>
|
||||
<p v-if="mediaMetadata.isbn" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('isbn', mediaMetadata.isbn)">{{ mediaMetadata.isbn }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.asin" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.asin" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.asin" :disabled="!selectedMatchUsage.asin" label="ASIN" />
|
||||
<p v-if="mediaMetadata.asin" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.asin || '' }}</p>
|
||||
<p v-if="mediaMetadata.asin" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('asin', mediaMetadata.asin)">{{ mediaMetadata.asin }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -152,42 +178,50 @@
|
||||
<ui-checkbox v-model="selectedMatchUsage.itunesId" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.itunesId" type="number" :disabled="!selectedMatchUsage.itunesId" label="iTunes ID" />
|
||||
<p v-if="mediaMetadata.itunesId" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.itunesId || '' }}</p>
|
||||
<p v-if="mediaMetadata.itunesId" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('itunesId', mediaMetadata.itunesId)">{{ mediaMetadata.itunesId }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.feedUrl" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.feedUrl" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.feedUrl" :disabled="!selectedMatchUsage.feedUrl" label="RSS Feed URL" />
|
||||
<p v-if="mediaMetadata.feedUrl" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.feedUrl || '' }}</p>
|
||||
<p v-if="mediaMetadata.feedUrl" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('feedUrl', mediaMetadata.feedUrl)">{{ mediaMetadata.feedUrl }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.itunesPageUrl" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.itunesPageUrl" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.itunesPageUrl" :disabled="!selectedMatchUsage.itunesPageUrl" label="iTunes Page URL" />
|
||||
<p v-if="mediaMetadata.itunesPageUrl" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.itunesPageUrl || '' }}</p>
|
||||
<p v-if="mediaMetadata.itunesPageUrl" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('itunesPageUrl', mediaMetadata.itunesPageUrl)">{{ mediaMetadata.itunesPageUrl }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.releaseDate" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.releaseDate" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4">
|
||||
<ui-text-input-with-label v-model="selectedMatch.releaseDate" :disabled="!selectedMatchUsage.releaseDate" :label="$strings.LabelReleaseDate" />
|
||||
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.releaseDate || '' }}</p>
|
||||
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">
|
||||
{{ $strings.LabelCurrently }} <a title="Click to use current value" class="cursor-pointer hover:underline" @click.stop="setMatchFieldValue('releaseDate', mediaMetadata.releaseDate)">{{ mediaMetadata.releaseDate }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.explicit != null" class="flex items-center pb-2" :class="{ 'pt-2': mediaMetadata.explicit == null }">
|
||||
<ui-checkbox v-model="selectedMatchUsage.explicit" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4" :class="{ 'pt-4': mediaMetadata.explicit != null }">
|
||||
<ui-checkbox v-model="selectedMatch.explicit" :label="$strings.LabelExplicit" :disabled="!selectedMatchUsage.explicit" :checkbox-bg="!selectedMatchUsage.explicit ? 'bg' : 'primary'" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
<p v-if="mediaMetadata.explicit != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.explicit ? 'Explicit (checked)' : 'Not Explicit (unchecked)' }}</p>
|
||||
<p v-if="mediaMetadata.explicit != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.explicit ? $strings.LabelExplicitChecked : $strings.LabelExplicitUnchecked }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.abridged != null" class="flex items-center pb-2" :class="{ 'pt-2': mediaMetadata.abridged == null }">
|
||||
<ui-checkbox v-model="selectedMatchUsage.abridged" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<div class="flex-grow ml-4" :class="{ 'pt-4': mediaMetadata.abridged != null }">
|
||||
<ui-checkbox v-model="selectedMatch.abridged" :label="$strings.LabelAbridged" :disabled="!selectedMatchUsage.abridged" :checkbox-bg="!selectedMatchUsage.abridged ? 'bg' : 'primary'" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
<p v-if="mediaMetadata.abridged != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.abridged ? 'Abridged (checked)' : 'Unabridged (unchecked)' }}</p>
|
||||
<p v-if="mediaMetadata.abridged != null" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.abridged ? $strings.LabelAbridgedChecked : $strings.LabelAbridgedUnchecked }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -280,6 +314,9 @@ export default {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
filterData() {
|
||||
return this.$store.state.libraries.filterData || {}
|
||||
},
|
||||
providers() {
|
||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
@@ -305,14 +342,26 @@ export default {
|
||||
isPodcast() {
|
||||
return this.mediaType == 'podcast'
|
||||
},
|
||||
narrators() {
|
||||
return this.filterData.narrators || []
|
||||
},
|
||||
genres() {
|
||||
const filterData = this.$store.state.libraries.filterData || {}
|
||||
const currentGenres = filterData.genres || []
|
||||
const currentGenres = this.filterData.genres || []
|
||||
const selectedMatchGenres = this.selectedMatch.genres || []
|
||||
return [...new Set([...currentGenres, ...selectedMatchGenres])]
|
||||
},
|
||||
tags() {
|
||||
return this.filterData.tags || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setMatchFieldValue(field, value) {
|
||||
if (Array.isArray(value)) {
|
||||
this.selectedMatch[field] = [...value]
|
||||
} else {
|
||||
this.selectedMatch[field] = value
|
||||
}
|
||||
},
|
||||
selectAllToggled(val) {
|
||||
for (const key in this.selectedMatchUsage) {
|
||||
this.selectedMatchUsage[key] = val
|
||||
@@ -328,6 +377,17 @@ export default {
|
||||
console.error('PersistProvider', error)
|
||||
}
|
||||
},
|
||||
getDefaultBookProvider() {
|
||||
let provider = localStorage.getItem('book-provider')
|
||||
if (!provider) return 'google'
|
||||
// Validate book provider
|
||||
if (!this.$store.getters['scanners/checkBookProviderExists'](provider)) {
|
||||
console.error('Stored book provider does not exist', provider)
|
||||
localStorage.removeItem('book-provider')
|
||||
return 'google'
|
||||
}
|
||||
return provider
|
||||
},
|
||||
getSearchQuery() {
|
||||
if (this.isPodcast) return `term=${encodeURIComponent(this.searchTitle)}`
|
||||
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${encodeURIComponent(this.searchTitle)}`
|
||||
@@ -434,7 +494,9 @@ export default {
|
||||
this.searchTitle = this.libraryItem.media.metadata.title
|
||||
this.searchAuthor = this.libraryItem.media.metadata.authorName || ''
|
||||
if (this.isPodcast) this.provider = 'itunes'
|
||||
else this.provider = localStorage.getItem('book-provider') || 'google'
|
||||
else {
|
||||
this.provider = this.getDefaultBookProvider()
|
||||
}
|
||||
|
||||
// Prefer using ASIN if set and using audible provider
|
||||
if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) {
|
||||
@@ -466,6 +528,12 @@ export default {
|
||||
// match.genres = match.genres.join(',')
|
||||
match.genres = match.genres.split(',').map((g) => g.trim())
|
||||
}
|
||||
if (match.tags && !Array.isArray(match.tags)) {
|
||||
match.tags = match.tags.split(',').map((g) => g.trim())
|
||||
}
|
||||
if (match.narrator && !Array.isArray(match.narrator)) {
|
||||
match.narrator = match.narrator.split(',').map((g) => g.trim())
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Select Match', match)
|
||||
@@ -495,7 +563,10 @@ export default {
|
||||
} else if (key === 'author' && !this.isPodcast) {
|
||||
var authors = this.selectedMatch[key]
|
||||
if (!Array.isArray(authors)) {
|
||||
authors = authors.split(',').map((au) => au.trim())
|
||||
authors = authors
|
||||
.split(',')
|
||||
.map((au) => au.trim())
|
||||
.filter((au) => !!au)
|
||||
}
|
||||
var authorPayload = []
|
||||
authors.forEach((authorName) =>
|
||||
@@ -506,11 +577,11 @@ export default {
|
||||
)
|
||||
updatePayload.metadata.authors = authorPayload
|
||||
} else if (key === 'narrator') {
|
||||
updatePayload.metadata.narrators = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||
updatePayload.metadata.narrators = this.selectedMatch[key]
|
||||
} else if (key === 'genres') {
|
||||
updatePayload.metadata.genres = [...this.selectedMatch[key]]
|
||||
} else if (key === 'tags') {
|
||||
updatePayload.tags = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||
updatePayload.tags = this.selectedMatch[key]
|
||||
} else if (key === 'itunesId') {
|
||||
updatePayload.metadata.itunesId = Number(this.selectedMatch[key])
|
||||
} else {
|
||||
@@ -533,24 +604,11 @@ export default {
|
||||
// Persist in local storage
|
||||
localStorage.setItem('selectedMatchUsage', JSON.stringify(this.selectedMatchUsage))
|
||||
|
||||
if (updatePayload.metadata.cover) {
|
||||
const coverPayload = {
|
||||
url: updatePayload.metadata.cover
|
||||
}
|
||||
const success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, coverPayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
if (success) {
|
||||
this.$toast.success(this.$strings.ToastItemCoverUpdateSuccess)
|
||||
} else {
|
||||
this.$toast.error(this.$strings.ToastItemCoverUpdateFailed)
|
||||
}
|
||||
console.log('Updated cover')
|
||||
delete updatePayload.metadata.cover
|
||||
}
|
||||
|
||||
if (Object.keys(updatePayload).length) {
|
||||
if (updatePayload.metadata.cover) {
|
||||
updatePayload.url = updatePayload.metadata.cover
|
||||
delete updatePayload.metadata.cover
|
||||
}
|
||||
const mediaUpdatePayload = updatePayload
|
||||
const updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
|
||||
@@ -15,16 +15,16 @@
|
||||
<ui-tooltip text="Value of 0 sets no max limit. After a new episode is auto-downloaded this will delete the oldest episode if you have more than X episodes. <br>This will only delete 1 episode per new download.">
|
||||
<p class="pl-4 text-base">
|
||||
Max episodes to keep
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
<span class="material-symbols icon-text">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div v-if="enableAutoDownloadEpisodes" class="flex items-center py-2">
|
||||
<ui-text-input ref="maxEpisodesInput" type="number" v-model="newMaxNewEpisodesToDownload" no-spinner :padding-x="1" text-center class="w-10 text-base" @change="updateMaxNewEpisodesToDownload" />
|
||||
<ui-text-input ref="maxEpisodesToDownloadInput" type="number" v-model="newMaxNewEpisodesToDownload" no-spinner :padding-x="1" text-center class="w-10 text-base" @change="updateMaxNewEpisodesToDownload" />
|
||||
<ui-tooltip text="Value of 0 sets no max limit. When checking for new episodes this is the max number of episodes that will be downloaded.">
|
||||
<p class="pl-4 text-base">
|
||||
Max new episodes to download per check
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
<span class="material-symbols icon-text">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
@@ -129,9 +129,12 @@ export default {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (this.$refs.maxEpisodesInput && this.$refs.maxEpisodesInput.isFocused) {
|
||||
|
||||
if (this.$refs.maxEpisodesInput?.isFocused) {
|
||||
this.$refs.maxEpisodesInput.blur()
|
||||
return
|
||||
}
|
||||
if (this.$refs.maxEpisodesToDownloadInput?.isFocused) {
|
||||
this.$refs.maxEpisodesToDownloadInput.blur()
|
||||
}
|
||||
|
||||
const updatePayload = {
|
||||
@@ -140,9 +143,11 @@ export default {
|
||||
if (this.enableAutoDownloadEpisodes) {
|
||||
updatePayload.autoDownloadSchedule = this.cronExpression
|
||||
}
|
||||
this.newMaxEpisodesToKeep = Number(this.newMaxEpisodesToKeep)
|
||||
if (this.newMaxEpisodesToKeep !== this.maxEpisodesToKeep) {
|
||||
updatePayload.maxEpisodesToKeep = this.newMaxEpisodesToKeep
|
||||
}
|
||||
this.newMaxNewEpisodesToDownload = Number(this.newMaxNewEpisodesToDownload)
|
||||
if (this.newMaxNewEpisodesToDownload !== this.maxNewEpisodesToDownload) {
|
||||
updatePayload.maxNewEpisodesToDownload = this.newMaxNewEpisodesToDownload
|
||||
}
|
||||
|
||||
@@ -2,11 +2,8 @@
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
|
||||
<p class="text-xl font-semibold mb-2">{{ $strings.HeaderAudiobookTools }}</p>
|
||||
|
||||
<!-- alert for windows install -->
|
||||
<widgets-alert v-if="isWindowsInstall" type="warning" class="my-8 text-base">Not supported for the Windows install yet</widgets-alert>
|
||||
|
||||
<!-- Merge to m4b -->
|
||||
<div v-if="showM4bDownload && !isWindowsInstall" class="w-full border border-black-200 p-4 my-8">
|
||||
<div v-if="showM4bDownload" class="w-full border border-black-200 p-4 my-8">
|
||||
<div class="flex flex-wrap items-center">
|
||||
<div>
|
||||
<p class="text-lg">{{ $strings.LabelToolsMakeM4b }}</p>
|
||||
@@ -16,14 +13,14 @@
|
||||
<div>
|
||||
<ui-btn :to="`/audiobook/${libraryItemId}/manage?tool=m4b`" class="flex items-center"
|
||||
>{{ $strings.ButtonOpenManager }}
|
||||
<span class="material-icons text-lg ml-2">launch</span>
|
||||
<span class="material-symbols text-lg ml-2">launch</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Embed Metadata -->
|
||||
<div v-if="mediaTracks.length && !isWindowsInstall" class="w-full border border-black-200 p-4 my-8">
|
||||
<div v-if="mediaTracks.length" class="w-full border border-black-200 p-4 my-8">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<p class="text-lg">{{ $strings.LabelToolsEmbedMetadata }}</p>
|
||||
@@ -33,7 +30,7 @@
|
||||
<div>
|
||||
<ui-btn :to="`/audiobook/${libraryItemId}/manage?tool=embed`" class="flex items-center"
|
||||
>{{ $strings.ButtonOpenManager }}
|
||||
<span class="material-icons text-lg ml-2">launch</span>
|
||||
<span class="material-symbols text-lg ml-2">launch</span>
|
||||
</ui-btn>
|
||||
|
||||
<ui-btn v-if="!isMetadataEmbedQueued && !isEmbedTaskRunning" class="w-full mt-4" small @click.stop="quickEmbed">Quick Embed</ui-btn>
|
||||
@@ -111,12 +108,6 @@ export default {
|
||||
},
|
||||
isEncodeTaskRunning() {
|
||||
return this.encodeTask && !this.encodeTask?.isFinished
|
||||
},
|
||||
isWindowsInstall() {
|
||||
return this.Source == 'windows'
|
||||
},
|
||||
Source() {
|
||||
return this.$store.state.Source
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -141,4 +132,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -19,12 +19,12 @@
|
||||
<div class="folders-container overflow-y-auto w-full py-2 mb-2">
|
||||
<p class="px-1 text-sm font-semibold">{{ $strings.LabelFolders }}</p>
|
||||
<div v-for="(folder, index) in folders" :key="index" class="w-full flex items-center py-1 px-2">
|
||||
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||
<span class="material-symbols fill bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||
<ui-editable-text ref="folderInput" v-model="folder.fullPath" :readonly="!!folder.id" type="text" class="w-full" @blur="existingFolderInputBlurred(folder)" />
|
||||
<span v-show="folders.length > 1" class="material-icons text-2xl ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span>
|
||||
<span v-show="folders.length > 1" class="material-symbols text-2xl ml-2 cursor-pointer hover:text-error" @click="removeFolder(folder)">close</span>
|
||||
</div>
|
||||
<div class="flex py-1 px-2 items-center w-full">
|
||||
<span class="material-icons bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||
<span class="material-symbols fill bg-opacity-50 mr-2 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||
<ui-editable-text ref="newFolderInput" v-model="newFolderPath" :placeholder="$strings.PlaceholderNewFolderPath" type="text" class="w-full" @blur="newFolderInputBlurred" />
|
||||
</div>
|
||||
|
||||
@@ -169,4 +169,4 @@ export default {
|
||||
max-height: calc(80vh - 292px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -127,6 +127,7 @@ export default {
|
||||
skipMatchingMediaWithIsbn: false,
|
||||
autoScanCronExpression: null,
|
||||
hideSingleBookSeries: false,
|
||||
onlyShowLaterBooksInContinueSeries: false,
|
||||
metadataPrecedence: ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full h-full bg-bg absolute top-0 left-0 px-4 py-4 z-10">
|
||||
<div class="flex items-center py-1 mb-2">
|
||||
<span class="material-icons text-3xl cursor-pointer hover:text-gray-300" @click="$emit('back')">arrow_back</span>
|
||||
<span class="material-symbols text-3xl cursor-pointer hover:text-gray-300" @click="$emit('back')">arrow_back</span>
|
||||
<p class="px-4 text-xl">{{ $strings.HeaderChooseAFolder }}</p>
|
||||
</div>
|
||||
<div v-if="rootDirs.length" class="w-full bg-primary bg-opacity-70 py-1 px-4 mb-2">
|
||||
@@ -10,18 +10,18 @@
|
||||
<div v-if="rootDirs.length" class="relative flex bg-primary bg-opacity-50 p-4 folder-container">
|
||||
<div class="w-1/2 border-r border-bg h-full overflow-y-auto">
|
||||
<div v-if="level > 0" class="w-full p-1 cursor-pointer flex items-center hover:bg-white/10" @click="goBack">
|
||||
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||
<span class="material-symbols fill bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||
<p class="text-base font-mono px-2">..</p>
|
||||
</div>
|
||||
<div v-for="dir in _directories" :key="dir.path" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200 hover:bg-white/10" :class="dir.className" @click="selectDir(dir)">
|
||||
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||
<span class="material-symbols fill bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
|
||||
<span v-if="dir.path === selectedPath" class="material-icons" style="font-size: 1.1rem">arrow_right</span>
|
||||
<span v-if="dir.path === selectedPath" class="material-symbols" style="font-size: 1.1rem">arrow_right</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-1/2 h-full overflow-y-auto">
|
||||
<div v-for="dir in _subdirs" :key="dir.path" :class="dir.className" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200 hover:bg-white/10" @click="selectSubDir(dir)">
|
||||
<span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||
<span class="material-symbols fill bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span>
|
||||
<p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,4 +199,4 @@ export default {
|
||||
height: calc(100% - 130px);
|
||||
min-height: calc(100% - 130px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<p class="text-sm text-gray-300 pr-2">{{ $strings.LabelMetadataOrderOfPrecedenceDescription }}</p>
|
||||
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex">
|
||||
<a href="https://www.audiobookshelf.org/guides/book-scanner" target="_blank" class="inline-flex">
|
||||
<span class="material-icons text-xl w-5">help_outline</span>
|
||||
<span class="material-symbols text-xl w-5">help_outline</span>
|
||||
</a>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
@@ -17,7 +17,7 @@
|
||||
<draggable v-model="metadataSourceMapped" v-bind="dragOptions" class="list-group" draggable=".item" handle=".drag-handle" tag="ul" @start="drag = true" @end="drag = false" @update="draggableUpdate">
|
||||
<transition-group type="transition" :name="!drag ? 'flip-list' : null">
|
||||
<li v-for="(source, index) in metadataSourceMapped" :key="source.id" :class="source.include ? 'item' : 'opacity-50'" class="w-full px-2 flex items-center relative border border-white/10">
|
||||
<span class="material-icons drag-handle text-xl text-gray-400 hover:text-gray-50 mr-2 md:mr-4">reorder</span>
|
||||
<span class="material-symbols drag-handle text-xl text-gray-400 hover:text-gray-50 mr-2 md:mr-4">reorder</span>
|
||||
<div class="text-center py-1 w-8 min-w-8">
|
||||
{{ source.include ? getSourceIndex(source.id) : '' }}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<ui-tooltip :text="$strings.LabelSettingsSquareBookCoversHelp">
|
||||
<p class="pl-4 text-base">
|
||||
{{ $strings.LabelSettingsSquareBookCovers }}
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
<span class="material-symbols icon-text text-sm">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
@@ -22,7 +22,7 @@
|
||||
<ui-tooltip :text="$strings.LabelSettingsAudiobooksOnlyHelp">
|
||||
<p class="pl-4 text-base">
|
||||
{{ $strings.LabelSettingsAudiobooksOnly }}
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
<span class="material-symbols icon-text text-sm">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
@@ -44,11 +44,36 @@
|
||||
<ui-tooltip :text="$strings.LabelSettingsHideSingleBookSeriesHelp">
|
||||
<p class="pl-4 text-base">
|
||||
{{ $strings.LabelSettingsHideSingleBookSeries }}
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
<span class="material-symbols icon-text text-sm">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isBookLibrary" class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="onlyShowLaterBooksInContinueSeries" @input="formUpdated" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsOnlyShowLaterBooksInContinueSeriesHelp">
|
||||
<p class="pl-4 text-base">
|
||||
{{ $strings.LabelSettingsOnlyShowLaterBooksInContinueSeries }}
|
||||
<span class="material-symbols icon-text text-sm">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isBookLibrary" class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="epubsAllowScriptedContent" @input="formUpdated" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsEpubsAllowScriptedContentHelp">
|
||||
<p class="pl-4 text-base">
|
||||
{{ $strings.LabelSettingsEpubsAllowScriptedContent }}
|
||||
<span class="material-symbols icon-text text-sm">info</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isPodcastLibrary" class="py-3">
|
||||
<ui-dropdown :label="$strings.LabelPodcastSearchRegion" v-model="podcastSearchRegion" :items="$podcastSearchRegionOptions" small class="max-w-72" menu-max-height="200px" @input="formUpdated" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -69,7 +94,10 @@ export default {
|
||||
skipMatchingMediaWithAsin: false,
|
||||
skipMatchingMediaWithIsbn: false,
|
||||
audiobooksOnly: false,
|
||||
hideSingleBookSeries: false
|
||||
epubsAllowScriptedContent: false,
|
||||
hideSingleBookSeries: false,
|
||||
onlyShowLaterBooksInContinueSeries: false,
|
||||
podcastSearchRegion: 'us'
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -85,6 +113,9 @@ export default {
|
||||
isBookLibrary() {
|
||||
return this.mediaType === 'book'
|
||||
},
|
||||
isPodcastLibrary() {
|
||||
return this.mediaType === 'podcast'
|
||||
},
|
||||
providers() {
|
||||
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
@@ -99,7 +130,10 @@ export default {
|
||||
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
|
||||
skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn,
|
||||
audiobooksOnly: !!this.audiobooksOnly,
|
||||
hideSingleBookSeries: !!this.hideSingleBookSeries
|
||||
epubsAllowScriptedContent: !!this.epubsAllowScriptedContent,
|
||||
hideSingleBookSeries: !!this.hideSingleBookSeries,
|
||||
onlyShowLaterBooksInContinueSeries: !!this.onlyShowLaterBooksInContinueSeries,
|
||||
podcastSearchRegion: this.podcastSearchRegion
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -112,11 +146,14 @@ export default {
|
||||
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
|
||||
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
|
||||
this.audiobooksOnly = !!this.librarySettings.audiobooksOnly
|
||||
this.epubsAllowScriptedContent = !!this.librarySettings.epubsAllowScriptedContent
|
||||
this.hideSingleBookSeries = !!this.librarySettings.hideSingleBookSeries
|
||||
this.onlyShowLaterBooksInContinueSeries = !!this.librarySettings.onlyShowLaterBooksInContinueSeries
|
||||
this.podcastSearchRegion = this.librarySettings.podcastSearchRegion || 'us'
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
<p v-if="isOpenInPlayer" class="text-sm text-right text-gray-400">{{ $strings.ButtonPlaying }}</p>
|
||||
<div v-else-if="isHovering" class="flex items-center justify-end -mx-1">
|
||||
<button class="outline-none mx-1 flex items-center" @click.stop="playClick">
|
||||
<span class="material-icons text-2xl text-success">play_arrow</span>
|
||||
<span class="material-symbols fill text-2xl text-success">play_arrow</span>
|
||||
</button>
|
||||
<button class="outline-none mx-1 flex items-center" @click.stop="removeClick">
|
||||
<span class="material-icons text-2xl text-error">close</span>
|
||||
<span class="material-symbols text-2xl text-error">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="text-gray-400 text-sm text-right">{{ durationPretty }}</p>
|
||||
|
||||
@@ -115,7 +115,7 @@ export default {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to get playlists', error)
|
||||
this.$toast.error('Failed to load user playlists')
|
||||
this.$toast.error(this.$strings.ToastFailedToLoadData)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
<nuxt-link :to="`/playlist/${playlist.id}`" class="pl-2 pr-2 truncate hover:underline cursor-pointer" @click.native="clickNuxtLink">{{ playlist.name }}</nuxt-link>
|
||||
</div>
|
||||
<div class="h-full flex items-center justify-end transform" :class="isHovering ? 'transition-transform translate-0 w-16' : 'translate-x-40 w-0'">
|
||||
<ui-btn v-if="!isItemIncluded" color="success" :padding-x="3" small class="h-9" @click.stop="clickAdd"><span class="material-icons text-2xl pt-px">add</span></ui-btn>
|
||||
<ui-btn v-else color="error" :padding-x="3" class="h-9" small @click.stop="clickRem"><span class="material-icons text-2xl pt-px">remove</span></ui-btn>
|
||||
<ui-btn v-if="!isItemIncluded" color="success" :padding-x="3" small class="h-9" @click.stop="clickAdd"><span class="material-symbols text-2xl pt-px">add</span></ui-btn>
|
||||
<ui-btn v-else color="error" :padding-x="3" class="h-9" small @click.stop="clickRem"><span class="material-symbols text-2xl pt-px">remove</span></ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
</div>
|
||||
|
||||
<div v-show="canGoPrev" class="absolute -left-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
|
||||
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goPrevEpisode" @mousedown.prevent>arrow_back_ios</div>
|
||||
<div class="material-symbols text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goPrevEpisode" @mousedown.prevent>arrow_back_ios</div>
|
||||
</div>
|
||||
<div v-show="canGoNext" class="absolute -right-24 top-0 bottom-0 h-full pointer-events-none flex items-center px-6">
|
||||
<div class="material-icons text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextEpisode" @mousedown.prevent>arrow_forward_ios</div>
|
||||
<div class="material-symbols text-5xl text-white text-opacity-50 hover:text-opacity-90 cursor-pointer pointer-events-auto" @click.stop.prevent="goNextEpisode" @mousedown.prevent>arrow_forward_ios</div>
|
||||
</div>
|
||||
|
||||
<div ref="wrapper" class="p-4 w-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto" style="max-height: 80vh">
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
@click="toggleSelectEpisode(episode)"
|
||||
>
|
||||
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
||||
<span v-if="getIsEpisodeDownloaded(episode)" class="material-icons text-success text-xl">download_done</span>
|
||||
<span v-if="getIsEpisodeDownloaded(episode)" class="material-symbols text-success text-xl">download_done</span>
|
||||
<ui-checkbox v-else v-model="selectedEpisodes[episode.cleanUrl]" small checkbox-bg="primary" border-color="gray-600" />
|
||||
</div>
|
||||
<div class="px-8 py-2">
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="break-words">{{ episode.title }}</div>
|
||||
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
||||
</div>
|
||||
<p v-if="episode.subtitle" class="break-words mb-1 text-sm text-gray-300 episode-subtitle">{{ episode.subtitle }}</p>
|
||||
<p v-if="episode.subtitle" class="mb-1 text-sm text-gray-300 line-clamp-2">{{ episode.subtitle }}</p>
|
||||
<p class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,11 +16,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-lg font-semibold mb-2">{{ $strings.HeaderPodcastsToAdd }}</p>
|
||||
<p class="text-lg font-semibold mb-1">{{ $strings.HeaderPodcastsToAdd }}</p>
|
||||
<p class="text-sm text-gray-300 mb-4">{{ $strings.MessageOpmlPreviewNote }}</p>
|
||||
|
||||
<div class="w-full overflow-y-auto" style="max-height: 50vh">
|
||||
<template v-for="(feed, index) in feedMetadata">
|
||||
<cards-podcast-feed-summary-card :key="index" :feed="feed" :library-folder-path="selectedFolderPath" class="my-1" />
|
||||
<template v-for="(feed, index) in feeds">
|
||||
<div :key="index" class="py-1 flex items-center">
|
||||
<p class="text-lg font-semibold">{{ index + 1 }}.</p>
|
||||
<div class="pl-2">
|
||||
<p v-if="feed.title" class="text-sm font-semibold">{{ feed.title }}</p>
|
||||
<p class="text-xs text-gray-400">{{ feed.feedUrl }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,9 +52,7 @@ export default {
|
||||
return {
|
||||
processing: false,
|
||||
selectedFolderId: null,
|
||||
fullPath: null,
|
||||
autoDownloadEpisodes: false,
|
||||
feedMetadata: []
|
||||
autoDownloadEpisodes: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -96,73 +101,36 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toFeedMetadata(feed) {
|
||||
const metadata = feed.metadata
|
||||
return {
|
||||
title: metadata.title,
|
||||
author: metadata.author,
|
||||
description: metadata.description,
|
||||
releaseDate: '',
|
||||
genres: [...metadata.categories],
|
||||
feedUrl: metadata.feedUrl,
|
||||
imageUrl: metadata.image,
|
||||
itunesPageUrl: '',
|
||||
itunesId: '',
|
||||
itunesArtistId: '',
|
||||
language: '',
|
||||
numEpisodes: feed.numEpisodes
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.feedMetadata = this.feeds.map(this.toFeedMetadata)
|
||||
|
||||
if (this.folderItems[0]) {
|
||||
this.selectedFolderId = this.folderItems[0].value
|
||||
}
|
||||
},
|
||||
async submit() {
|
||||
this.processing = true
|
||||
const newFeedPayloads = this.feedMetadata.map((metadata) => {
|
||||
return {
|
||||
path: `${this.selectedFolderPath}/${this.$sanitizeFilename(metadata.title)}`,
|
||||
folderId: this.selectedFolderId,
|
||||
libraryId: this.currentLibrary.id,
|
||||
media: {
|
||||
metadata: {
|
||||
...metadata
|
||||
},
|
||||
autoDownloadEpisodes: this.autoDownloadEpisodes
|
||||
}
|
||||
}
|
||||
})
|
||||
console.log('New feed payloads', newFeedPayloads)
|
||||
|
||||
for (const podcastPayload of newFeedPayloads) {
|
||||
await this.$axios
|
||||
.$post('/api/podcasts', podcastPayload)
|
||||
.then(() => {
|
||||
this.$toast.success(`${podcastPayload.media.metadata.title}: ${this.$strings.ToastPodcastCreateSuccess}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : this.$strings.ToastPodcastCreateFailed
|
||||
console.error('Failed to create podcast', podcastPayload, error)
|
||||
this.$toast.error(`${podcastPayload.media.metadata.title}: ${errorMsg}`)
|
||||
})
|
||||
const payload = {
|
||||
feeds: this.feeds.map((f) => f.feedUrl),
|
||||
folderId: this.selectedFolderId,
|
||||
libraryId: this.currentLibrary.id,
|
||||
autoDownloadEpisodes: this.autoDownloadEpisodes
|
||||
}
|
||||
this.processing = false
|
||||
this.show = false
|
||||
this.$axios
|
||||
.$post('/api/podcasts/opml/create', payload)
|
||||
.then(() => {
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMsg = error.response?.data || this.$strings.ToastPodcastCreateFailed
|
||||
console.error('Failed to create podcast', payload, error)
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#podcast-wrapper {
|
||||
min-height: 400px;
|
||||
max-height: 80vh;
|
||||
}
|
||||
#episodes-scroll {
|
||||
max-height: calc(80vh - 200px);
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user