mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-31 11:38:47 -05:00
Compare commits
632 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca5f781531 | ||
|
|
53c96b2540 | ||
|
|
9712bdf5f0 | ||
|
|
0678c26627 | ||
|
|
b52e240025 | ||
|
|
2fa73f7a8d | ||
|
|
2cc23b6d6b | ||
|
|
9a617226b3 | ||
|
|
fbfc015d92 | ||
|
|
3e4c94e2b4 | ||
|
|
1da471e136 | ||
|
|
4dba95c000 | ||
|
|
36477a832c | ||
|
|
b4aa8f0c9a | ||
|
|
6a974d5ef0 | ||
|
|
304eda9f8c | ||
|
|
581f2e3d15 | ||
|
|
be2d317325 | ||
|
|
9f6bfeb839 | ||
|
|
f4f5f79af7 | ||
|
|
92bb2fb23d | ||
|
|
3c406c12b4 | ||
|
|
81d4ac3ed2 | ||
|
|
32bdae31a8 | ||
|
|
84c16c4a39 | ||
|
|
b8b3d05f5e | ||
|
|
bac09de23d | ||
|
|
b0bf9604bb | ||
|
|
688531f0a7 | ||
|
|
dfc7877f69 | ||
|
|
e00116a0e3 | ||
|
|
2ab287e2a9 | ||
|
|
1e0da09b2f | ||
|
|
0e7a5649cc | ||
|
|
30009e45da | ||
|
|
f9a668cb41 | ||
|
|
c848f366de | ||
|
|
25daab2f34 | ||
|
|
7170ab7239 | ||
|
|
063b3bb8db | ||
|
|
6eb6a7b115 | ||
|
|
d0972348b9 | ||
|
|
0e70af77c6 | ||
|
|
4efca78602 | ||
|
|
87d10bd6f5 | ||
|
|
0f82aed4ce | ||
|
|
58f10ad7af | ||
|
|
68dcf87aea | ||
|
|
c2f85deb11 | ||
|
|
0dd3a52cc8 | ||
|
|
c07c73c649 | ||
|
|
dbde5f773c | ||
|
|
68bf038205 | ||
|
|
eb7f66c89e | ||
|
|
58ebde2982 | ||
|
|
604a671549 | ||
|
|
5286b53334 | ||
|
|
4db26f9f79 | ||
|
|
ff8a58c7bc | ||
|
|
6f67c7bfa2 | ||
|
|
e9f5bd9bfe | ||
|
|
56e213d654 | ||
|
|
98323de64c | ||
|
|
4a13712b1c | ||
|
|
0387436111 | ||
|
|
7685ead000 | ||
|
|
8665d66923 | ||
|
|
9a808602c4 | ||
|
|
813e553dbb | ||
|
|
be050a7d57 | ||
|
|
065675697d | ||
|
|
8f6832fc2e | ||
|
|
bdb154a6e5 | ||
|
|
f557289274 | ||
|
|
a5627a1b52 | ||
|
|
33f20d54cc | ||
|
|
dadd41cb5c | ||
|
|
35e27e4f61 | ||
|
|
84839bea44 | ||
|
|
1342897858 | ||
|
|
c32efb8db8 | ||
|
|
f9ed412e4e | ||
|
|
6ae3ad508e | ||
|
|
24af702b41 | ||
|
|
a57ff20f35 | ||
|
|
39e710deb1 | ||
|
|
3b6fa73ac0 | ||
|
|
e2dd66d450 | ||
|
|
b1b53a1eae | ||
|
|
6f73345f39 | ||
|
|
c7b4b3bd3e | ||
|
|
98d543e3e5 | ||
|
|
4de4e958a0 | ||
|
|
cc5e92ec8e | ||
|
|
6cb9dfaa85 | ||
|
|
8790166ac1 | ||
|
|
3b97e2146d | ||
|
|
0bb1cf002d | ||
|
|
307c7ebc9d | ||
|
|
cc1b41995d | ||
|
|
730d60575e | ||
|
|
1b96297cc7 | ||
|
|
128c554543 | ||
|
|
1b5ab6c378 | ||
|
|
e4961feffb | ||
|
|
eb5f257b8c | ||
|
|
e271e89835 | ||
|
|
f5009f76f4 | ||
|
|
a3e63e03d2 | ||
|
|
2ae3ea346f | ||
|
|
8542d433a2 | ||
|
|
03984f96d4 | ||
|
|
eab019c577 | ||
|
|
179f11f55d | ||
|
|
5a21e63d0b | ||
|
|
24ef105732 | ||
|
|
589c4f73d2 | ||
|
|
55fdc48d5d | ||
|
|
4d45a902bb | ||
|
|
69bac2ec1e | ||
|
|
122ec140e8 | ||
|
|
6a0adf7433 | ||
|
|
c1b2aaec9f | ||
|
|
a49acdb2e4 | ||
|
|
9b67fbe8d9 | ||
|
|
2d13215f1f | ||
|
|
a77c3aae93 | ||
|
|
164937b454 | ||
|
|
b0a8f3d207 | ||
|
|
77cc0934be | ||
|
|
718890cfad | ||
|
|
418adcf891 | ||
|
|
b96f878d69 | ||
|
|
22b8622c67 | ||
|
|
3dc9416da6 | ||
|
|
5e5b674c17 | ||
|
|
3656eab8bf | ||
|
|
25ca950dd0 | ||
|
|
8fca84e4bd | ||
|
|
56579f440b | ||
|
|
a59311f795 | ||
|
|
042c89039c | ||
|
|
d94482827a | ||
|
|
a8dab5653b | ||
|
|
1d1200a3f2 | ||
|
|
4d110ebe7e | ||
|
|
b300f0d10c | ||
|
|
6dc4dc8f49 | ||
|
|
dfae6cf89f | ||
|
|
d7f18bdd8b | ||
|
|
05b102722b | ||
|
|
ef954ee68f | ||
|
|
dbaea9f87d | ||
|
|
64768ec2f9 | ||
|
|
14ee17de47 | ||
|
|
034b8956a2 | ||
|
|
1a3f0e332e | ||
|
|
fc36e86db7 | ||
|
|
60b4bc1a7e | ||
|
|
9fdc8df8bc | ||
|
|
212b97fa20 | ||
|
|
704fbaced8 | ||
|
|
575a162f8b | ||
|
|
d2e0844493 | ||
|
|
f2baf3fafd | ||
|
|
916fd039ca | ||
|
|
e248b6d8d8 | ||
|
|
936de68622 | ||
|
|
a99257e758 | ||
|
|
c89d77dd06 | ||
|
|
3138865d69 | ||
|
|
4d29ebd647 | ||
|
|
fd58df4729 | ||
|
|
5078818295 | ||
|
|
7181df0479 | ||
|
|
6c618d7760 | ||
|
|
17b8cf19b7 | ||
|
|
e018f8341e | ||
|
|
59b5f8cbbe | ||
|
|
d6108a0722 | ||
|
|
1af7e59d88 | ||
|
|
7b425e9a9d | ||
|
|
596a03900b | ||
|
|
b283644d95 | ||
|
|
808690c137 | ||
|
|
136c347586 | ||
|
|
e81238038e | ||
|
|
fcf6964d7d | ||
|
|
bd75ad4576 | ||
|
|
f970d8e539 | ||
|
|
c49010b4e1 | ||
|
|
146093d81e | ||
|
|
11ccbf1913 | ||
|
|
a4a334a18a | ||
|
|
387a37e4da | ||
|
|
ebad304aa9 | ||
|
|
8b557a0cb9 | ||
|
|
40b808e73d | ||
|
|
a8b57a1ce9 | ||
|
|
35315843f2 | ||
|
|
27b9d3b94f | ||
|
|
0010ac5a40 | ||
|
|
884808f34e | ||
|
|
f75ed07497 | ||
|
|
b707d6f3c9 | ||
|
|
a2d4a4a906 | ||
|
|
434d743d99 | ||
|
|
30f16b05fe | ||
|
|
92a88f4416 | ||
|
|
5c9c122af2 | ||
|
|
620d5ce578 | ||
|
|
363e1cee4b | ||
|
|
93f576772a | ||
|
|
d4612bae92 | ||
|
|
e01af27008 | ||
|
|
657fe0a650 | ||
|
|
9a6ec5548e | ||
|
|
0807509ea7 | ||
|
|
d9d1c4e360 | ||
|
|
2135e5b066 | ||
|
|
b69eb10ae0 | ||
|
|
e1512b6f54 | ||
|
|
1b8e8215d6 | ||
|
|
9b44e36e7b | ||
|
|
db1ca08c2e | ||
|
|
557d3243c3 | ||
|
|
785942b94f | ||
|
|
3df7caa838 | ||
|
|
aef2c52630 | ||
|
|
dccad3055b | ||
|
|
c629923a80 | ||
|
|
b4f1fd5b25 | ||
|
|
267897ce74 | ||
|
|
022bf9d0ef | ||
|
|
61c759e0c4 | ||
|
|
cfb3ce0c60 | ||
|
|
72396c5a98 | ||
|
|
12f231b886 | ||
|
|
6aeed24296 | ||
|
|
d8b6e09bc0 | ||
|
|
d95975cade | ||
|
|
c4208a4690 | ||
|
|
7c7a6df6e4 | ||
|
|
791c058ef8 | ||
|
|
c847aea0a4 | ||
|
|
e56164aa5a | ||
|
|
cfb5e909a9 | ||
|
|
071444a9e7 | ||
|
|
34ac972130 | ||
|
|
97b5cf04f5 | ||
|
|
0d50d730d9 | ||
|
|
3a7fd0bcc9 | ||
|
|
f0edea5d52 | ||
|
|
9c6b07df99 | ||
|
|
caacf461ab | ||
|
|
5bdbc75522 | ||
|
|
0d3e6b1d0a | ||
|
|
a122e25cba | ||
|
|
d7b287bfed | ||
|
|
ba4f585318 | ||
|
|
3f859723a6 | ||
|
|
c820d0e62b | ||
|
|
7a47032a96 | ||
|
|
2db4dd6a40 | ||
|
|
f58e2b6dce | ||
|
|
859a53e79a | ||
|
|
ad0edc6329 | ||
|
|
002fb7a35e | ||
|
|
cc62a20a5d | ||
|
|
ec7e965dfa | ||
|
|
9c3f5406a9 | ||
|
|
f4ec6948d2 | ||
|
|
9a51c3be0f | ||
|
|
b1ee54522a | ||
|
|
c14d13440f | ||
|
|
8c84640484 | ||
|
|
0d8917ced6 | ||
|
|
a006eb489d | ||
|
|
f2941e04d3 | ||
|
|
2728546660 | ||
|
|
eeb7c80518 | ||
|
|
c8c40360ad | ||
|
|
79ab656217 | ||
|
|
5c250da388 | ||
|
|
505e0eb3a2 | ||
|
|
388444e51f | ||
|
|
08d7a9aa14 | ||
|
|
f650ae7f18 | ||
|
|
6d138ae905 | ||
|
|
956678c08c | ||
|
|
911c854365 | ||
|
|
3c5dc17e3c | ||
|
|
e709cc4cb1 | ||
|
|
da7825e3e3 | ||
|
|
4039dc7968 | ||
|
|
e345c4cc9e | ||
|
|
a08cfa436e | ||
|
|
7207efb4da | ||
|
|
481611ff33 | ||
|
|
b67cd37a38 | ||
|
|
6e8547f0c8 | ||
|
|
96930d7ecc | ||
|
|
23f2c8a251 | ||
|
|
c5372d1405 | ||
|
|
dcfbed5f30 | ||
|
|
895ab8d18a | ||
|
|
8b5d05739f | ||
|
|
a8f6202302 | ||
|
|
5d40fdf277 | ||
|
|
f35c96e118 | ||
|
|
8f8d6f81ab | ||
|
|
e195eec1c5 | ||
|
|
33846e46fa | ||
|
|
2ad03bcb9a | ||
|
|
371cd3b2e5 | ||
|
|
b9307143bd | ||
|
|
36e44e902a | ||
|
|
4529fc0124 | ||
|
|
ba07761de3 | ||
|
|
3b7ce69327 | ||
|
|
cf927f61a0 | ||
|
|
61c32d99e7 | ||
|
|
19e39f6321 | ||
|
|
f9e6655359 | ||
|
|
debf0f495d | ||
|
|
3383ec2046 | ||
|
|
b957e1a36b | ||
|
|
c93f17051a | ||
|
|
5983f0262f | ||
|
|
96a8e74d38 | ||
|
|
6f3d488c3d | ||
|
|
74dcc4f9e4 | ||
|
|
8c4d3b93c8 | ||
|
|
c411cf04cc | ||
|
|
d1b25da408 | ||
|
|
08f765fa51 | ||
|
|
337cf90c4b | ||
|
|
17b930e13d | ||
|
|
639b600570 | ||
|
|
7a751b8f91 | ||
|
|
68621e0c07 | ||
|
|
d2512d324a | ||
|
|
5abb02e93a | ||
|
|
573079c5a1 | ||
|
|
5bde320ac7 | ||
|
|
5f63d97e59 | ||
|
|
bf2fe3faea | ||
|
|
ea60f19e7a | ||
|
|
cefc75a4ed | ||
|
|
d8753aafb9 | ||
|
|
ba5ad228cc | ||
|
|
0203f9cc1b | ||
|
|
4770be5a39 | ||
|
|
1bac395bed | ||
|
|
e818f270cd | ||
|
|
c4e2726622 | ||
|
|
74d8a09f31 | ||
|
|
618338165e | ||
|
|
db494001a2 | ||
|
|
a67213fb7b | ||
|
|
5d96b2cc6e | ||
|
|
72d0b097ab | ||
|
|
36d2957fb4 | ||
|
|
b5de517aad | ||
|
|
41db0e3bb1 | ||
|
|
e8d582269b | ||
|
|
80ef8ee890 | ||
|
|
a65859f575 | ||
|
|
5724887785 | ||
|
|
8908aa7a82 | ||
|
|
f83dd29213 | ||
|
|
99d90778f4 | ||
|
|
49279430fc | ||
|
|
030c20b12e | ||
|
|
5e943ef152 | ||
|
|
4ae057f626 | ||
|
|
9ebe4b55dd | ||
|
|
2f7403adec | ||
|
|
2777b496ad | ||
|
|
f7a3dbf209 | ||
|
|
d900093976 | ||
|
|
08250e266e | ||
|
|
da2d1455d7 | ||
|
|
b6c6c4c939 | ||
|
|
22179d82b8 | ||
|
|
343ce312f1 | ||
|
|
10677d6fb0 | ||
|
|
49a8aead9b | ||
|
|
274b0e48be | ||
|
|
4d8ffc5d99 | ||
|
|
4f3029e5b2 | ||
|
|
a1b49f5fcf | ||
|
|
89d497a305 | ||
|
|
9e095a4bc1 | ||
|
|
024d052a7b | ||
|
|
c312979aec | ||
|
|
773e621944 | ||
|
|
ed4f33b565 | ||
|
|
f8a0852dfc | ||
|
|
6dec750d3e | ||
|
|
3c98a5fb24 | ||
|
|
702ee3d350 | ||
|
|
fcc2f3650b | ||
|
|
e4ad622c01 | ||
|
|
458403eec9 | ||
|
|
aaede2752c | ||
|
|
39d8c2cf04 | ||
|
|
dd5c940d36 | ||
|
|
277f024bbc | ||
|
|
59ad1e5e36 | ||
|
|
02c4b21d3f | ||
|
|
33ae5445be | ||
|
|
5ed06871b6 | ||
|
|
e98eb8f1eb | ||
|
|
ebedaeb3b0 | ||
|
|
62aec63d1d | ||
|
|
3c25e87e8d | ||
|
|
08d16ce7c2 | ||
|
|
2cb3808326 | ||
|
|
bdb6f0c0aa | ||
|
|
5255bf13cc | ||
|
|
3588e1e8d3 | ||
|
|
8fa8360e99 | ||
|
|
b305cfd268 | ||
|
|
ff10287d05 | ||
|
|
7a7708403f | ||
|
|
ddabd0ee75 | ||
|
|
5a26704c32 | ||
|
|
7ccf36a896 | ||
|
|
e9a84dd7dd | ||
|
|
b00510855e | ||
|
|
2cd9079692 | ||
|
|
3e4b1652fc | ||
|
|
878330b4fb | ||
|
|
9a85ad1f6b | ||
|
|
f76f9c7f84 | ||
|
|
3426832f2b | ||
|
|
10fd51498c | ||
|
|
49c581ed35 | ||
|
|
f095d89980 | ||
|
|
1609f1a499 | ||
|
|
88bd51e2da | ||
|
|
74388fe0b9 | ||
|
|
7f5356100d | ||
|
|
84d2d00a30 | ||
|
|
31dddfbb60 | ||
|
|
d6da161b13 | ||
|
|
9de7be1cb4 | ||
|
|
5410aae8fc | ||
|
|
86bf6bfc62 | ||
|
|
0807146aab | ||
|
|
591d8a8ab1 | ||
|
|
b1d4e28027 | ||
|
|
44363f05ac | ||
|
|
452af43916 | ||
|
|
70ba2f7850 | ||
|
|
a364fe5031 | ||
|
|
ca6765c8e7 | ||
|
|
6bfa281dc5 | ||
|
|
d8ee61bfab | ||
|
|
c6763dee2d | ||
|
|
0e6b0d3eff | ||
|
|
8bbfee334c | ||
|
|
f806e4cce3 | ||
|
|
209ba308bd | ||
|
|
4cd9088a66 | ||
|
|
ac5e2e5c73 | ||
|
|
f1329d2847 | ||
|
|
27faefc64d | ||
|
|
0fa7e61dc1 | ||
|
|
5a3f14ae51 | ||
|
|
4e61185136 | ||
|
|
6ee06d5dae | ||
|
|
2c344a0bc0 | ||
|
|
315c83e4c3 | ||
|
|
9e4bc582cb | ||
|
|
fc6aa1f91f | ||
|
|
d4bea34423 | ||
|
|
a551a2d288 | ||
|
|
4b0c59b174 | ||
|
|
a0840d2a08 | ||
|
|
308ccf470f | ||
|
|
4021b6eca1 | ||
|
|
061695f922 | ||
|
|
e803dcd325 | ||
|
|
128796bd36 | ||
|
|
775dedc338 | ||
|
|
45c9038954 | ||
|
|
8acf962864 | ||
|
|
c3fc38639e | ||
|
|
b60b75c8da | ||
|
|
0f7edec73b | ||
|
|
321277826f | ||
|
|
6e752af2c0 | ||
|
|
0717ae39db | ||
|
|
7bc5902ea8 | ||
|
|
a28e1ed5e0 | ||
|
|
43d9e129a6 | ||
|
|
b516019ddd | ||
|
|
e4c20d677c | ||
|
|
33e183b802 | ||
|
|
b884f8fe11 | ||
|
|
2cba83f1dd | ||
|
|
a9ee9031c3 | ||
|
|
c3717f6979 | ||
|
|
657d4dd705 | ||
|
|
17356ffd79 | ||
|
|
c4be75b5bd | ||
|
|
57422d0759 | ||
|
|
d2454201b4 | ||
|
|
3a92a69693 | ||
|
|
d733c9ccc6 | ||
|
|
3e15e09c07 | ||
|
|
0592a41d4f | ||
|
|
c32e33f804 | ||
|
|
616ffb8f79 | ||
|
|
bc771a3a44 | ||
|
|
539d1a2d4f | ||
|
|
4d8cea0bb4 | ||
|
|
8b46262e93 | ||
|
|
eb9a077520 | ||
|
|
3d3a224402 | ||
|
|
e1397a6dda | ||
|
|
8f49aae979 | ||
|
|
c0a13f01d4 | ||
|
|
efcebc616c | ||
|
|
902867c3bc | ||
|
|
b7abd372e4 | ||
|
|
147ffc0210 | ||
|
|
1b2ccb6cee | ||
|
|
c58a6b9047 | ||
|
|
b787fb18f3 | ||
|
|
17cce9c914 | ||
|
|
90299e348c | ||
|
|
fe25a1bc54 | ||
|
|
edbe1851b5 | ||
|
|
ad6c5a4f00 | ||
|
|
4971787482 | ||
|
|
56d2ec9c22 | ||
|
|
106ddc9541 | ||
|
|
4d93e39fa9 | ||
|
|
54b41b15c2 | ||
|
|
54ca42a903 | ||
|
|
d7cc8a052a | ||
|
|
5165f11460 | ||
|
|
b47ce4fb24 | ||
|
|
9b1f7f566f | ||
|
|
10295b000a | ||
|
|
c06d734d5e | ||
|
|
49a69193d8 | ||
|
|
7852804a9c | ||
|
|
415dda37a4 | ||
|
|
179d339afd | ||
|
|
858c1a7353 | ||
|
|
0b42b81558 | ||
|
|
f9678dec2f | ||
|
|
82642b295c | ||
|
|
ba3d84a924 | ||
|
|
96e2f934a3 | ||
|
|
a68ade2b3d | ||
|
|
4fcdeda447 | ||
|
|
dc03835742 | ||
|
|
50430e6b27 | ||
|
|
d130dd6d5e | ||
|
|
793cc989de | ||
|
|
27d8c4d67c | ||
|
|
48f493a9f5 | ||
|
|
04992ee3fb | ||
|
|
4d8e2a1279 | ||
|
|
2af7b6b6f1 | ||
|
|
e59351566d | ||
|
|
05d10b73c3 | ||
|
|
41e192c6a5 | ||
|
|
ea42ab7624 | ||
|
|
2d9035d90b | ||
|
|
0ae853c119 | ||
|
|
3c0fdff7b4 | ||
|
|
eede2bbd46 | ||
|
|
5c31687a0f | ||
|
|
6b654d3c2d | ||
|
|
91cbe45839 | ||
|
|
7883d4a97f | ||
|
|
9f4547cff8 | ||
|
|
a98106593d | ||
|
|
c625b3f08c | ||
|
|
9e7f09c21b | ||
|
|
616caecdf1 | ||
|
|
cee19c5128 | ||
|
|
67db41a525 | ||
|
|
3ea3e55d17 | ||
|
|
4959a28485 | ||
|
|
6d2482a98e | ||
|
|
4b23b842bb | ||
|
|
07bebc8808 | ||
|
|
027d7f7a5b | ||
|
|
6baa0fa047 | ||
|
|
8425fac543 | ||
|
|
7b2ac7b9e9 | ||
|
|
bf071be247 | ||
|
|
6c05a0af8a | ||
|
|
0e292c64c4 | ||
|
|
725f8eecdb | ||
|
|
521a673094 | ||
|
|
d917f0e37d | ||
|
|
7ed5b1744f | ||
|
|
c9ab2a242d | ||
|
|
13532cba14 | ||
|
|
3fb2bd3362 | ||
|
|
e80c3a1c5a | ||
|
|
e04d26307e | ||
|
|
b8f74e1c98 | ||
|
|
0851050392 | ||
|
|
b84882d9d1 | ||
|
|
cd37a7618e | ||
|
|
64a7cfac3b | ||
|
|
1ee7ba54f8 | ||
|
|
6bb18f8800 | ||
|
|
b26b854963 | ||
|
|
7d58361ced | ||
|
|
a3723f3d06 | ||
|
|
78d1cd0cfb | ||
|
|
d41366a417 | ||
|
|
a2347150a2 | ||
|
|
d33f23dede | ||
|
|
cfca2be1b2 | ||
|
|
73f07c1392 | ||
|
|
4541e9ddc3 | ||
|
|
972271a1a9 | ||
|
|
e97d92a8ac | ||
|
|
9a73e352d1 | ||
|
|
08f09f81fa | ||
|
|
c72609013c |
@@ -1,4 +1,15 @@
|
||||
FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:16
|
||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
&& apt-get install ffmpeg gnupg2 -y
|
||||
# [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
|
||||
FROM mcr.microsoft.com/devcontainers/javascript-node:0-${VARIANT} as base
|
||||
|
||||
# Setup the node environment
|
||||
ENV NODE_ENV=development
|
||||
|
||||
# Install additional OS packages.
|
||||
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/
|
||||
|
||||
9
.devcontainer/dev.js
Normal file
9
.devcontainer/dev.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// Using port 3333 is important when running the client web app separately
|
||||
const Path = require('path')
|
||||
module.exports.config = {
|
||||
Port: 3333,
|
||||
ConfigPath: Path.resolve('config'),
|
||||
MetadataPath: Path.resolve('metadata'),
|
||||
FFmpegPath: '/usr/bin/ffmpeg',
|
||||
FFProbePath: '/usr/bin/ffprobe'
|
||||
}
|
||||
@@ -1,12 +1,40 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node
|
||||
{
|
||||
"build": { "dockerfile": "Dockerfile" },
|
||||
"mounts": [
|
||||
"source=abs-node-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume"
|
||||
],
|
||||
"features": {
|
||||
"fish": "latest"
|
||||
"name": "Audiobookshelf",
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
// Update 'VARIANT' to pick a Node version: 18, 16, 14.
|
||||
// Append -bullseye or -buster to pin to an OS version.
|
||||
// Use -bullseye variants on local arm64/Apple Silicon.
|
||||
"args": {
|
||||
"VARIANT": "16"
|
||||
}
|
||||
},
|
||||
"extensions": [
|
||||
"eamodio.gitlens"
|
||||
]
|
||||
"mounts": [
|
||||
"source=abs-server-node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume",
|
||||
"source=abs-client-node_modules,target=${containerWorkspaceFolder}/client/node_modules,type=volume"
|
||||
],
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
"forwardPorts": [
|
||||
3000,
|
||||
3333
|
||||
],
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "sh .devcontainer/post-create.sh",
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
// Configure properties specific to VS Code.
|
||||
"vscode": {
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"octref.vetur"
|
||||
]
|
||||
}
|
||||
}
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
29
.devcontainer/post-create.sh
Normal file
29
.devcontainer/post-create.sh
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Mark the working directory as safe for use with git
|
||||
git config --global --add safe.directory $PWD
|
||||
|
||||
# If there is no dev.js file, create it
|
||||
if [ ! -f dev.js ]; then
|
||||
cp .devcontainer/dev.js .
|
||||
fi
|
||||
|
||||
# Update permissions for node_modules folders
|
||||
# https://code.visualstudio.com/remote/advancedcontainers/improve-performance#_use-a-targeted-named-volume
|
||||
if [ -d node_modules ]; then
|
||||
sudo chown $(id -u):$(id -g) node_modules
|
||||
fi
|
||||
|
||||
if [ -d client/node_modules ]; then
|
||||
sudo chown $(id -u):$(id -g) client/node_modules
|
||||
fi
|
||||
|
||||
# Install packages for the server
|
||||
if [ -f package.json ]; then
|
||||
npm ci
|
||||
fi
|
||||
|
||||
# Install packages and build the client
|
||||
if [ -f client/package.json ]; then
|
||||
(cd client; npm ci; npm run generate)
|
||||
fi
|
||||
5
.gitattributes
vendored
Normal file
5
.gitattributes
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Set the default behavior, in case people don't have core.autocrlf set.
|
||||
* text=auto
|
||||
|
||||
# Declare files that will always have CRLF line endings on checkout.
|
||||
.devcontainer/post-create.sh text eol=lf
|
||||
44
.github/workflows/integration-test.yml
vendored
Normal file
44
.github/workflows/integration-test.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Integration Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'dependabot/**' # Don't run dependabot branches, as they are already covered by pull requests
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build and test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: setup nade
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- name: install pkg
|
||||
run: npm install -g pkg
|
||||
|
||||
- name: get client dependencies
|
||||
working-directory: client
|
||||
run: npm ci
|
||||
|
||||
- name: build client
|
||||
working-directory: client
|
||||
run: npm run generate
|
||||
|
||||
- name: get server dependencies
|
||||
run: npm ci --only=production
|
||||
|
||||
- name: build binary
|
||||
run: pkg -t node18-linux-x64 -o audiobookshelf .
|
||||
|
||||
- name: run audiobookshelf
|
||||
run: |
|
||||
./audiobookshelf &
|
||||
sleep 5
|
||||
|
||||
- name: test if server is available
|
||||
run: curl -sf http://127.0.0.1:3333 | grep Audiobookshelf
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,6 +1,6 @@
|
||||
.env
|
||||
dev.js
|
||||
node_modules/
|
||||
/dev.js
|
||||
**/node_modules/
|
||||
/config/
|
||||
/audiobooks/
|
||||
/audiobooks2/
|
||||
|
||||
44
.vscode/launch.json
vendored
Normal file
44
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Debug server",
|
||||
"runtimeExecutable": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"dev"
|
||||
],
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Debug client (nuxt)",
|
||||
"runtimeExecutable": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"dev"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/client",
|
||||
"skipFiles": [
|
||||
"${workspaceFolder}/<node_internals>/**"
|
||||
]
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Debug server and client (nuxt)",
|
||||
"configurations": [
|
||||
"Debug server",
|
||||
"Debug client (nuxt)"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
40
.vscode/tasks.json
vendored
Normal file
40
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"path": "client",
|
||||
"type": "npm",
|
||||
"script": "generate",
|
||||
"detail": "nuxt generate",
|
||||
"label": "Build client",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"dependsOn": [
|
||||
"Build client"
|
||||
],
|
||||
"type": "npm",
|
||||
"script": "dev",
|
||||
"detail": "nodemon --watch server index.js",
|
||||
"label": "Run server",
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "client",
|
||||
"type": "npm",
|
||||
"script": "dev",
|
||||
"detail": "nuxt",
|
||||
"label": "Run Live-reload client",
|
||||
"group": {
|
||||
"kind": "test",
|
||||
"isDefault": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,7 +6,7 @@ RUN npm ci && npm cache clean --force
|
||||
RUN npm run generate
|
||||
|
||||
### STAGE 1: Build server ###
|
||||
FROM sandreas/tone:v0.1.2 AS tone
|
||||
FROM sandreas/tone:v0.1.5 AS tone
|
||||
FROM node:16-alpine
|
||||
|
||||
ENV NODE_ENV=production
|
||||
@@ -29,4 +29,4 @@ HEALTHCHECK \
|
||||
--timeout=3s \
|
||||
--start-period=10s \
|
||||
CMD curl -f http://127.0.0.1/healthcheck || exit 1
|
||||
CMD ["npm", "start"]
|
||||
CMD ["node", "index.js"]
|
||||
|
||||
@@ -50,7 +50,7 @@ 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.2/tone-0.1.2-linux-x64.tar.gz --output-document=tone-0.1.2-linux-x64.tar.gz"
|
||||
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"
|
||||
@@ -66,8 +66,8 @@ install_ffmpeg() {
|
||||
# Temp downloading tone library to the ffmpeg dir
|
||||
echo "Getting tone.."
|
||||
$WGET_TONE
|
||||
tar xvf tone-0.1.2-linux-x64.tar.gz --strip-components=1
|
||||
rm tone-0.1.2-linux-x64.tar.gz
|
||||
tar xvf tone-0.1.5-linux-x64.tar.gz --strip-components=1
|
||||
rm tone-0.1.5-linux-x64.tar.gz
|
||||
|
||||
echo "Good to go on Ffmpeg (& tone)... hopefully"
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=always
|
||||
User=audiobookshelf
|
||||
Group=audiobookshelf
|
||||
PermissionsStartOnly=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -112,7 +112,7 @@ input[type=number] {
|
||||
background-color: #373838;
|
||||
}
|
||||
|
||||
.tracksTable tr:hover {
|
||||
.tracksTable tr:hover:not(:has(th)) {
|
||||
background-color: #474747;
|
||||
}
|
||||
|
||||
@@ -232,6 +232,20 @@ Bookshelf Label
|
||||
-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 {
|
||||
|
||||
@@ -48,25 +48,6 @@
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Gentium Book Basic';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/GentiumBookBasic.woff2) format('woff2');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Gentium Book Basic';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(~static/fonts/GentiumBookBasic.woff2) format('woff2');
|
||||
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;
|
||||
}
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div class="w-full h-16 bg-primary relative">
|
||||
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-50">
|
||||
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-60">
|
||||
<div class="flex h-full items-center">
|
||||
<nuxt-link to="/">
|
||||
<img src="~static/icon.svg" class="w-8 min-w-8 h-8 mr-2 sm:w-12 sm:min-w-12 sm:h-12 sm:mr-4" />
|
||||
<img src="~static/icon.svg" :alt="$strings.ButtonHome" class="w-8 min-w-8 h-8 mr-2 sm:w-10 sm:min-w-10 sm:h-10 sm:mr-4" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link to="/">
|
||||
<h1 class="text-2xl font-book mr-6 hidden lg:block hover:underline">audiobookshelf <span v-if="showExperimentalFeatures" class="material-icons text-lg text-warning pr-1">logo_dev</span></h1>
|
||||
<h1 class="text-xl mr-6 hidden lg:block hover:underline">audiobookshelf <span v-if="showExperimentalFeatures" class="material-icons text-lg text-warning pr-1">logo_dev</span></h1>
|
||||
</nuxt-link>
|
||||
|
||||
<ui-libraries-dropdown class="mr-2" />
|
||||
@@ -15,8 +15,6 @@
|
||||
<controls-global-search v-if="currentLibrary" class="mr-1 sm:mr-0" />
|
||||
<div class="flex-grow" />
|
||||
|
||||
<widgets-notification-widget class="hidden md:block" />
|
||||
|
||||
<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>
|
||||
</ui-tooltip>
|
||||
@@ -24,19 +22,27 @@
|
||||
<google-cast-launcher></google-cast-launcher>
|
||||
</div>
|
||||
|
||||
<nuxt-link v-if="currentLibrary" to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
|
||||
<span class="material-icons text-2xl" aria-label="User Stats" role="button">equalizer</span>
|
||||
<widgets-notification-widget class="hidden md:block" />
|
||||
|
||||
<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>
|
||||
</ui-tooltip>
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="userCanUpload && currentLibrary" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<span class="material-icons text-2xl" aria-label="Upload Media" role="button">upload</span>
|
||||
<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>
|
||||
</ui-tooltip>
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="userIsAdminOrUp" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||
<span class="material-icons text-2xl" aria-label="System Settings" role="button">settings</span>
|
||||
<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>
|
||||
</ui-tooltip>
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded shadow-sm ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
|
||||
<nuxt-link to="/account" class="relative w-9 h-9 md:w-32 bg-fg border border-gray-500 rounded shadow-sm ml-1.5 sm:ml-3 md:ml-5 md:pl-3 md:pr-10 py-2 text-left sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
|
||||
<span class="items-center hidden md:flex">
|
||||
<span class="block truncate">{{ username }}</span>
|
||||
</span>
|
||||
@@ -45,32 +51,32 @@
|
||||
</span>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div v-show="numLibraryItemsSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
|
||||
<h1 class="text-lg md:text-2xl px-4">{{ $getString('MessageItemsSelected', [numLibraryItemsSelected]) }}</h1>
|
||||
<div v-show="numMediaItemsSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
|
||||
<h1 class="text-lg md:text-2xl px-4">{{ $getString('MessageItemsSelected', [numMediaItemsSelected]) }}</h1>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="!isPodcastLibrary" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="playSelectedItems">
|
||||
<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>
|
||||
{{ $strings.ButtonPlay }}
|
||||
</ui-btn>
|
||||
<ui-tooltip v-if="userIsAdminOrUp && !isPodcastLibrary" :text="$strings.ButtonQuickMatch" direction="bottom">
|
||||
<ui-icon-btn :disabled="processingBatch" icon="auto_awesome" @click="batchAutoMatchClick" class="mx-1.5" />
|
||||
</ui-tooltip>
|
||||
<ui-tooltip v-if="!isPodcastLibrary" :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
|
||||
<ui-tooltip v-if="isBookLibrary" :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
|
||||
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsFinished" @click="toggleBatchRead" class="mx-1.5" />
|
||||
</ui-tooltip>
|
||||
<ui-tooltip v-if="userCanUpdate && !isPodcastLibrary" :text="$strings.LabelAddToCollection" direction="bottom">
|
||||
<ui-tooltip v-if="userCanUpdate && isBookLibrary" :text="$strings.LabelAddToCollection" direction="bottom">
|
||||
<ui-icon-btn :disabled="processingBatch" icon="collections_bookmark" @click="batchAddToCollectionClick" class="mx-1.5" />
|
||||
</ui-tooltip>
|
||||
<template v-if="userCanUpdate">
|
||||
<ui-tooltip text="Edit" direction="bottom">
|
||||
<ui-tooltip :text="$strings.LabelEdit" direction="bottom">
|
||||
<ui-icon-btn :disabled="processingBatch" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
|
||||
</ui-tooltip>
|
||||
</template>
|
||||
<ui-tooltip v-if="userCanDelete" :text="$strings.ButtonRemove" direction="bottom">
|
||||
<ui-icon-btn :disabled="processingBatch" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
|
||||
</ui-tooltip>
|
||||
<ui-tooltip :text="$strings.LabelDeselectAll" direction="bottom">
|
||||
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatch ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
||||
|
||||
<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>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,6 +103,9 @@ export default {
|
||||
isPodcastLibrary() {
|
||||
return this.libraryMediaType === 'podcast'
|
||||
},
|
||||
isBookLibrary() {
|
||||
return this.libraryMediaType === 'book'
|
||||
},
|
||||
isHome() {
|
||||
return this.$route.name === 'library-library'
|
||||
},
|
||||
@@ -109,11 +118,14 @@ export default {
|
||||
username() {
|
||||
return this.user ? this.user.username : 'err'
|
||||
},
|
||||
numLibraryItemsSelected() {
|
||||
return this.selectedLibraryItems.length
|
||||
numMediaItemsSelected() {
|
||||
return this.selectedMediaItems.length
|
||||
},
|
||||
selectedLibraryItems() {
|
||||
return this.$store.state.selectedLibraryItems
|
||||
selectedMediaItems() {
|
||||
return this.$store.state.globals.selectedMediaItems
|
||||
},
|
||||
selectedMediaItemsArePlayable() {
|
||||
return !this.selectedMediaItems.some((i) => !i.hasTracks)
|
||||
},
|
||||
userMediaProgress() {
|
||||
return this.$store.state.user.user.mediaProgress || []
|
||||
@@ -129,8 +141,8 @@ export default {
|
||||
},
|
||||
selectedIsFinished() {
|
||||
// Find an item that is not finished, if none then all items finished
|
||||
return !this.selectedLibraryItems.find((libraryItemId) => {
|
||||
var itemProgress = this.userMediaProgress.find((lip) => lip.libraryItemId === libraryItemId)
|
||||
return !this.selectedMediaItems.find((item) => {
|
||||
const itemProgress = this.userMediaProgress.find((lip) => lip.libraryItemId === item.id)
|
||||
return !itemProgress || !itemProgress.isFinished
|
||||
})
|
||||
},
|
||||
@@ -148,18 +160,103 @@ export default {
|
||||
},
|
||||
isHttps() {
|
||||
return location.protocol === 'https:' || process.env.NODE_ENV === 'development'
|
||||
},
|
||||
contextMenuItems() {
|
||||
if (!this.userIsAdminOrUp) return []
|
||||
|
||||
const options = [
|
||||
{
|
||||
text: this.$strings.ButtonQuickMatch,
|
||||
action: 'quick-match'
|
||||
}
|
||||
]
|
||||
|
||||
if (!this.isPodcastLibrary && this.selectedMediaItemsArePlayable) {
|
||||
options.push({
|
||||
text: 'Quick Embed Metadata',
|
||||
action: 'quick-embed'
|
||||
})
|
||||
}
|
||||
|
||||
options.push({
|
||||
text: 'Re-Scan',
|
||||
action: 'rescan'
|
||||
})
|
||||
|
||||
return options
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
requestBatchQuickEmbed() {
|
||||
const payload = {
|
||||
message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?',
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$axios
|
||||
.$post(`/api/tools/batch/embed-metadata`, {
|
||||
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
||||
})
|
||||
.then(() => {
|
||||
console.log('Audio metadata embed started')
|
||||
this.cancelSelectionMode()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Audio metadata embed failed', error)
|
||||
const errorMsg = error.response.data || 'Failed to embed metadata'
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
contextMenuAction(action) {
|
||||
if (action === 'quick-embed') {
|
||||
this.requestBatchQuickEmbed()
|
||||
} else if (action === 'quick-match') {
|
||||
this.batchAutoMatchClick()
|
||||
} else if (action === 'rescan') {
|
||||
this.batchRescan()
|
||||
}
|
||||
},
|
||||
async batchRescan() {
|
||||
const payload = {
|
||||
message: `Are you sure you want to re-scan ${this.selectedMediaItems.length} items?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$axios
|
||||
.$post(`/api/items/batch/scan`, {
|
||||
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
||||
})
|
||||
.then(() => {
|
||||
console.log('Batch Re-Scan started')
|
||||
this.cancelSelectionMode()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Batch Re-Scan failed', error)
|
||||
const errorMsg = error.response.data || 'Failed to batch re-scan'
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
async playSelectedItems() {
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
|
||||
var libraryItems = await this.$axios.$post(`/api/items/batch/get`, { libraryItemIds: this.selectedLibraryItems }).catch((error) => {
|
||||
var errorMsg = error.response.data || 'Failed to get items'
|
||||
console.error(errorMsg, error)
|
||||
this.$toast.error(errorMsg)
|
||||
return []
|
||||
})
|
||||
const libraryItemIds = this.selectedMediaItems.map((i) => i.id)
|
||||
const libraryItems = await this.$axios
|
||||
.$post(`/api/items/batch/get`, { libraryItemIds })
|
||||
.then((res) => res.libraryItems)
|
||||
.catch((error) => {
|
||||
const errorMsg = error.response.data || 'Failed to get items'
|
||||
console.error(errorMsg, error)
|
||||
this.$toast.error(errorMsg)
|
||||
return []
|
||||
})
|
||||
|
||||
if (!libraryItems.length) {
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
@@ -168,12 +265,15 @@ export default {
|
||||
|
||||
const queueItems = []
|
||||
libraryItems.forEach((item) => {
|
||||
let subtitle = ''
|
||||
if (item.mediaType === 'book') subtitle = item.media.metadata.authors.map((au) => au.name).join(', ')
|
||||
else if (item.mediaType === 'music') subtitle = item.media.metadata.artists.join(', ')
|
||||
queueItems.push({
|
||||
libraryItemId: item.id,
|
||||
libraryId: item.libraryId,
|
||||
episodeId: null,
|
||||
title: item.media.metadata.title,
|
||||
subtitle: item.media.metadata.authors.map((au) => au.name).join(', '),
|
||||
subtitle,
|
||||
caption: '',
|
||||
duration: item.media.duration || null,
|
||||
coverPath: item.media.coverPath || null
|
||||
@@ -185,20 +285,20 @@ export default {
|
||||
queueItems
|
||||
})
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
this.$store.commit('setSelectedLibraryItems', [])
|
||||
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||
},
|
||||
cancelSelectionMode() {
|
||||
if (this.processingBatch) return
|
||||
this.$store.commit('setSelectedLibraryItems', [])
|
||||
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||
},
|
||||
toggleBatchRead() {
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
var newIsFinished = !this.selectedIsFinished
|
||||
var updateProgressPayloads = this.selectedLibraryItems.map((lid) => {
|
||||
const newIsFinished = !this.selectedIsFinished
|
||||
const updateProgressPayloads = this.selectedMediaItems.map((item) => {
|
||||
return {
|
||||
libraryItemId: lid,
|
||||
libraryItemId: item.id,
|
||||
isFinished: newIsFinished
|
||||
}
|
||||
})
|
||||
@@ -208,7 +308,7 @@ export default {
|
||||
.then(() => {
|
||||
this.$toast.success('Batch update success!')
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
this.$store.commit('setSelectedLibraryItems', [])
|
||||
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -218,26 +318,37 @@ export default {
|
||||
})
|
||||
},
|
||||
batchDeleteClick() {
|
||||
var audiobookText = this.numLibraryItemsSelected > 1 ? `these ${this.numLibraryItemsSelected} items` : 'this item'
|
||||
var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the items from Audiobookshelf`
|
||||
if (confirm(confirmMsg)) {
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
this.$axios
|
||||
.$post(`/api/items/batch/delete`, {
|
||||
libraryItemIds: this.selectedLibraryItems
|
||||
})
|
||||
.then(() => {
|
||||
this.$toast.success('Batch delete success!')
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
this.$store.commit('setSelectedLibraryItems', [])
|
||||
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error('Batch delete failed')
|
||||
console.error('Failed to batch delete', error)
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
})
|
||||
const payload = {
|
||||
message: `This will delete ${this.numMediaItemsSelected} library items from the database and your file system. Are you sure?`,
|
||||
checkboxLabel: 'Delete from file system. Uncheck to only remove from database.',
|
||||
yesButtonText: this.$strings.ButtonDelete,
|
||||
yesButtonColor: 'error',
|
||||
checkboxDefaultValue: true,
|
||||
callback: (confirmed, hardDelete) => {
|
||||
if (confirmed) {
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
|
||||
this.$axios
|
||||
.$post(`/api/items/batch/delete?hard=${hardDelete ? 1 : 0}`, {
|
||||
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
||||
})
|
||||
.then(() => {
|
||||
this.$toast.success('Batch delete success')
|
||||
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Batch delete failed', error)
|
||||
this.$toast.error('Batch delete failed')
|
||||
})
|
||||
.finally(() => {
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
batchEditClick() {
|
||||
this.$router.push('/batch')
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<template>
|
||||
<div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative">
|
||||
<!-- Cover size widget -->
|
||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" />
|
||||
|
||||
<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 font-book mb-4 py-4">{{ libraryName }} Library is empty!</p>
|
||||
<p class="text-center text-2xl mb-4 py-4">{{ libraryName }} Library is empty!</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>
|
||||
</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 font-book py-4">No results for query</p>
|
||||
<p class="text-center text-xl py-4">No results for query</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'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
|
||||
<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>
|
||||
</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)">
|
||||
@@ -28,12 +28,15 @@
|
||||
<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">
|
||||
<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'" @selectEntity="(payload) => selectEntity(payload, index)" />
|
||||
<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>
|
||||
</div>
|
||||
@@ -89,8 +92,8 @@ export default {
|
||||
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||
return this.bookCoverWidth / baseSize
|
||||
},
|
||||
selectedLibraryItems() {
|
||||
return this.$store.state.selectedLibraryItems || []
|
||||
selectedMediaItems() {
|
||||
return this.$store.state.globals.selectedMediaItems || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -100,15 +103,15 @@ export default {
|
||||
const indexOf = shelf.shelfStartIndex + entityShelfIndex
|
||||
|
||||
const lastLastItemIndexSelected = this.lastItemIndexSelected
|
||||
if (!this.selectedLibraryItems.includes(entity.id)) {
|
||||
if (!this.selectedMediaItems.some((i) => i.id === entity.id)) {
|
||||
this.lastItemIndexSelected = indexOf
|
||||
} else {
|
||||
this.lastItemIndexSelected = -1
|
||||
}
|
||||
|
||||
if (shiftKey && lastLastItemIndexSelected >= 0) {
|
||||
var loopStart = indexOf
|
||||
var loopEnd = lastLastItemIndexSelected
|
||||
let loopStart = indexOf
|
||||
let loopEnd = lastLastItemIndexSelected
|
||||
if (indexOf > lastLastItemIndexSelected) {
|
||||
loopStart = lastLastItemIndexSelected
|
||||
loopEnd = indexOf
|
||||
@@ -117,12 +120,12 @@ export default {
|
||||
const flattenedEntitiesArray = []
|
||||
this.shelves.map((s) => flattenedEntitiesArray.push(...s.entities))
|
||||
|
||||
var isSelecting = false
|
||||
let isSelecting = false
|
||||
// If any items in this range is not selected then select all otherwise unselect all
|
||||
for (let i = loopStart; i <= loopEnd; i++) {
|
||||
const thisEntity = flattenedEntitiesArray[i]
|
||||
if (thisEntity) {
|
||||
if (!this.selectedLibraryItems.includes(thisEntity.id)) {
|
||||
if (!this.selectedMediaItems.some((i) => i.id === thisEntity.id)) {
|
||||
isSelecting = true
|
||||
break
|
||||
}
|
||||
@@ -133,13 +136,23 @@ export default {
|
||||
for (let i = loopStart; i <= loopEnd; i++) {
|
||||
const thisEntity = flattenedEntitiesArray[i]
|
||||
if (thisEntity) {
|
||||
this.$store.commit('setLibraryItemSelected', { libraryItemId: thisEntity.id, selected: isSelecting })
|
||||
const mediaItem = {
|
||||
id: thisEntity.id,
|
||||
mediaType: thisEntity.mediaType,
|
||||
hasTracks: thisEntity.mediaType === 'podcast' || thisEntity.media.audioFile || thisEntity.media.numTracks || (thisEntity.media.tracks && thisEntity.media.tracks.length)
|
||||
}
|
||||
this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: isSelecting })
|
||||
} else {
|
||||
console.error('Invalid entity index', i)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.$store.commit('toggleLibraryItemSelected', entity.id)
|
||||
const mediaItem = {
|
||||
id: entity.id,
|
||||
mediaType: entity.mediaType,
|
||||
hasTracks: entity.mediaType === 'podcast' || entity.media.audioFile || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length)
|
||||
}
|
||||
this.$store.commit('globals/toggleMediaItemSelected', mediaItem)
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
@@ -157,8 +170,8 @@ export default {
|
||||
this.loaded = true
|
||||
},
|
||||
async fetchCategories() {
|
||||
var categories = await this.$axios
|
||||
.$get(`/api/libraries/${this.currentLibraryId}/personalized`)
|
||||
const categories = await this.$axios
|
||||
.$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed`)
|
||||
.then((data) => {
|
||||
return data
|
||||
})
|
||||
@@ -175,8 +188,8 @@ export default {
|
||||
this.shelves = categories
|
||||
},
|
||||
async setShelvesFromSearch() {
|
||||
var shelves = []
|
||||
if (this.results.books && this.results.books.length) {
|
||||
const shelves = []
|
||||
if (this.results.books?.length) {
|
||||
shelves.push({
|
||||
id: 'books',
|
||||
label: 'Books',
|
||||
@@ -186,7 +199,7 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
if (this.results.podcasts && this.results.podcasts.length) {
|
||||
if (this.results.podcasts?.length) {
|
||||
shelves.push({
|
||||
id: 'podcasts',
|
||||
label: 'Podcasts',
|
||||
@@ -196,7 +209,7 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
if (this.results.series && this.results.series.length) {
|
||||
if (this.results.series?.length) {
|
||||
shelves.push({
|
||||
id: 'series',
|
||||
label: 'Series',
|
||||
@@ -211,7 +224,7 @@ export default {
|
||||
})
|
||||
})
|
||||
}
|
||||
if (this.results.tags && this.results.tags.length) {
|
||||
if (this.results.tags?.length) {
|
||||
shelves.push({
|
||||
id: 'tags',
|
||||
label: 'Tags',
|
||||
@@ -226,7 +239,7 @@ export default {
|
||||
})
|
||||
})
|
||||
}
|
||||
if (this.results.authors && this.results.authors.length) {
|
||||
if (this.results.authors?.length) {
|
||||
shelves.push({
|
||||
id: 'authors',
|
||||
label: 'Authors',
|
||||
@@ -240,6 +253,20 @@ export default {
|
||||
})
|
||||
})
|
||||
}
|
||||
if (this.results.narrators?.length) {
|
||||
shelves.push({
|
||||
id: 'narrators',
|
||||
label: 'Narrators',
|
||||
labelStringKey: 'LabelNarrators',
|
||||
type: 'narrators',
|
||||
entities: this.results.narrators.map((n) => {
|
||||
return {
|
||||
...n,
|
||||
type: 'narrator'
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
this.shelves = shelves
|
||||
},
|
||||
scan() {
|
||||
@@ -259,7 +286,8 @@ export default {
|
||||
}
|
||||
if (user.mediaProgress.length) {
|
||||
const mediaProgressToHide = user.mediaProgress.filter((mp) => mp.hideFromContinueListening)
|
||||
this.removeItemsFromContinueListening(mediaProgressToHide)
|
||||
this.removeItemsFromContinueListeningReading(mediaProgressToHide, 'continue-listening')
|
||||
this.removeItemsFromContinueListeningReading(mediaProgressToHide, 'continue-reading')
|
||||
}
|
||||
},
|
||||
libraryItemAdded(libraryItem) {
|
||||
@@ -309,8 +337,9 @@ export default {
|
||||
},
|
||||
libraryItemsAdded(libraryItems) {
|
||||
console.log('libraryItems added', libraryItems)
|
||||
// TODO: Check if audiobook would be on this shelf
|
||||
if (!this.search) {
|
||||
|
||||
const isThisLibrary = !libraryItems.some((li) => li.libraryId !== this.currentLibraryId)
|
||||
if (!this.search && isThisLibrary) {
|
||||
this.fetchCategories()
|
||||
}
|
||||
},
|
||||
@@ -319,6 +348,14 @@ export default {
|
||||
this.libraryItemUpdated(li)
|
||||
})
|
||||
},
|
||||
episodeAdded(episodeWithLibraryItem) {
|
||||
console.log('Podcast episode added', episodeWithLibraryItem)
|
||||
|
||||
const isThisLibrary = episodeWithLibraryItem.libraryItem?.libraryId === this.currentLibraryId
|
||||
if (!this.search && isThisLibrary) {
|
||||
this.fetchCategories()
|
||||
}
|
||||
},
|
||||
removeAllSeriesFromContinueSeries(seriesIds) {
|
||||
this.shelves.forEach((shelf) => {
|
||||
if (shelf.type == 'book' && shelf.id == 'continue-series') {
|
||||
@@ -330,8 +367,8 @@ export default {
|
||||
}
|
||||
})
|
||||
},
|
||||
removeItemsFromContinueListening(mediaProgressItems) {
|
||||
const continueListeningShelf = this.shelves.find((s) => s.id === 'continue-listening')
|
||||
removeItemsFromContinueListeningReading(mediaProgressItems, categoryId) {
|
||||
const continueListeningShelf = this.shelves.find((s) => s.id === categoryId)
|
||||
if (continueListeningShelf) {
|
||||
if (continueListeningShelf.type === 'book') {
|
||||
continueListeningShelf.entities = continueListeningShelf.entities.filter((ent) => {
|
||||
@@ -346,17 +383,6 @@ export default {
|
||||
})
|
||||
}
|
||||
}
|
||||
// this.shelves.forEach((shelf) => {
|
||||
// if (shelf.id == 'continue-listening') {
|
||||
// if (shelf.type == 'book') {
|
||||
// // Filter out books from continue listening shelf
|
||||
// shelf.entities = shelf.entities.filter((ent) => {
|
||||
// if (mediaProgressItems.some(mp => mp.libraryItemId === ent.id)) return false
|
||||
// return true
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
},
|
||||
authorUpdated(author) {
|
||||
this.shelves.forEach((shelf) => {
|
||||
@@ -390,13 +416,12 @@ export default {
|
||||
this.$root.socket.on('item_removed', this.libraryItemRemoved)
|
||||
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
|
||||
this.$root.socket.on('items_added', this.libraryItemsAdded)
|
||||
this.$root.socket.on('episode_added', this.episodeAdded)
|
||||
} else {
|
||||
console.error('Error socket not initialized')
|
||||
}
|
||||
},
|
||||
removeListeners() {
|
||||
this.$store.commit('user/removeSettingsListener', 'bookshelf')
|
||||
|
||||
if (this.$root.socket) {
|
||||
this.$root.socket.off('user_updated', this.userUpdated)
|
||||
this.$root.socket.off('author_updated', this.authorUpdated)
|
||||
@@ -406,6 +431,7 @@ export default {
|
||||
this.$root.socket.off('item_removed', this.libraryItemRemoved)
|
||||
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
|
||||
this.$root.socket.off('items_added', this.libraryItemsAdded)
|
||||
this.$root.socket.off('episode_added', this.episodeAdded)
|
||||
} else {
|
||||
console.error('Error socket not initialized')
|
||||
}
|
||||
|
||||
@@ -41,10 +41,15 @@
|
||||
<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" />
|
||||
</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" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute text-center categoryPlacard font-book transform z-30 bottom-px left-4 md:left-8 w-44 rounded-md" style="height: 22px">
|
||||
<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>
|
||||
@@ -88,6 +93,7 @@ export default {
|
||||
return this.bookCoverWidth * this.bookCoverAspectRatio
|
||||
},
|
||||
shelfHeight() {
|
||||
if (this.shelf.type === 'narrators') return 148
|
||||
return this.bookCoverHeight + 48
|
||||
},
|
||||
paddingLeft() {
|
||||
@@ -98,7 +104,7 @@ export default {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
isSelectionMode() {
|
||||
return this.$store.getters['getNumLibraryItemsSelected'] > 0
|
||||
return this.$store.getters['globals/getIsBatchSelectingMediaItems']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -119,14 +125,14 @@ export default {
|
||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
|
||||
},
|
||||
updateSelectionMode(val) {
|
||||
var selectedLibraryItems = this.$store.state.selectedLibraryItems
|
||||
const selectedMediaItems = this.$store.state.globals.selectedMediaItems
|
||||
if (this.shelf.type === 'book' || this.shelf.type === 'podcast') {
|
||||
this.shelf.entities.forEach((ent) => {
|
||||
var component = this.$refs[`shelf-book-${ent.id}`]
|
||||
if (!component || !component.length) return
|
||||
component = component[0]
|
||||
component.setSelectionMode(val)
|
||||
component.selected = selectedLibraryItems.includes(ent.id)
|
||||
component.selected = selectedMediaItems.some((i) => i.id === ent.id)
|
||||
})
|
||||
} else if (this.shelf.type === 'episode') {
|
||||
this.shelf.entities.forEach((ent) => {
|
||||
@@ -134,7 +140,7 @@ export default {
|
||||
if (!component || !component.length) return
|
||||
component = component[0]
|
||||
component.setSelectionMode(val)
|
||||
component.selected = selectedLibraryItems.includes(ent.id)
|
||||
component.selected = selectedMediaItems.some((i) => i.id === ent.id)
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -16,17 +16,17 @@
|
||||
<nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p class="text-sm">{{ $strings.ButtonLatest }}</p>
|
||||
</nuxt-link>
|
||||
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="flex-grow h-full flex justify-center items-center" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="flex-grow h-full flex justify-center items-center" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'">
|
||||
<p v-if="isSeriesPage" class="text-sm">{{ $strings.ButtonSeries }}</p>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
||||
</svg>
|
||||
</nuxt-link>
|
||||
<nuxt-link v-if="!isPodcastLibrary" :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'">
|
||||
<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>
|
||||
</nuxt-link>
|
||||
<nuxt-link v-if="!isPodcastLibrary" :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'">
|
||||
<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>
|
||||
<svg v-else class="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path
|
||||
@@ -39,10 +39,10 @@
|
||||
<p class="text-sm">{{ $strings.ButtonSearch }}</p>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-30 flex items-center justify-end md:justify-start px-2 md:px-8">
|
||||
<div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8">
|
||||
<!-- Series books page -->
|
||||
<template v-if="selectedSeries">
|
||||
<p class="pl-2 font-book text-base md:text-lg">
|
||||
<p class="pl-2 text-base md:text-lg">
|
||||
{{ seriesName }}
|
||||
</p>
|
||||
<div class="w-6 h-6 rounded-full bg-black bg-opacity-30 flex items-center justify-center ml-3">
|
||||
@@ -50,31 +50,36 @@
|
||||
</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" />
|
||||
<ui-btn v-if="!isBatchSelecting" color="primary" small :loading="processingSeries" class="items-center ml-1 sm:ml-4 hidden md:flex" @click="markSeriesFinished">
|
||||
<div class="h-5 w-5">
|
||||
<svg v-if="isSeriesFinished" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="rgb(63, 181, 68)">
|
||||
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-7 19.6l-7-4.66V3h14v12.93l-7 4.67zm-2.01-7.42l-2.58-2.59L6 12l4 4 8-8-1.42-1.42z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="pl-2"> {{ $strings.LabelMarkSeries }} {{ isSeriesFinished ? $strings.LabelNotFinished : $strings.LabelFinished }}</span>
|
||||
</ui-btn>
|
||||
<ui-btn v-if="isSeriesRemovedFromContinueListening && !isBatchSelecting" small :loading="processingSeries" @click="reAddSeriesToContinueListening" class="hidden md:block ml-2"> Re-Add Series to Continue Listening </ui-btn>
|
||||
|
||||
<!-- RSS feed -->
|
||||
<ui-tooltip v-if="seriesRssFeed" :text="$strings.LabelOpenRSSFeed" direction="top">
|
||||
<ui-icon-btn icon="rss_feed" class="mx-0.5" :size="7" icon-font-size="1.2rem" bg-color="success" outlined @click="showOpenSeriesRSSFeed" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-context-menu-dropdown v-if="!isBatchSelecting && seriesContextMenuItems.length" :items="seriesContextMenuItems" class="mx-px" @action="seriesContextMenuAction" />
|
||||
</template>
|
||||
<!-- library & collections page -->
|
||||
<template v-else-if="page !== 'search' && page !== 'podcast-search' && page !== 'recent-episodes' && !isHome">
|
||||
<p class="font-book hidden md:block">{{ numShowing }} {{ entityName }}</p>
|
||||
<p class="hidden md:block">{{ numShowing }} {{ entityName }}</p>
|
||||
|
||||
<div class="flex-grow hidden sm:inline-block" />
|
||||
|
||||
<ui-checkbox v-if="isLibraryPage && !isPodcastLibrary && !isBatchSelecting" v-model="settings.collapseSeries" :label="$strings.LabelCollapseSeries" checkbox-bg="bg" check-color="white" small class="mr-2" @input="updateCollapseSeries" />
|
||||
<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" />
|
||||
<controls-library-sort-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateOrder" />
|
||||
<controls-library-filter-select v-if="isSeriesPage && !isBatchSelecting" v-model="seriesFilterBy" is-series class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesFilter" />
|
||||
<controls-sort-select v-if="isSeriesPage && !isBatchSelecting" v-model="seriesSortBy" :descending.sync="seriesSortDesc" :items="seriesSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesSort" />
|
||||
<!-- 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" />
|
||||
|
||||
<!-- library sort select -->
|
||||
<controls-library-sort-select v-if="isLibraryPage && !isBatchSelecting" v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateOrder" />
|
||||
|
||||
<!-- series filter select -->
|
||||
<controls-library-filter-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesFilterBy" is-series class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesFilter" />
|
||||
|
||||
<!-- series sort select -->
|
||||
<controls-sort-select v-if="isSeriesPage && !isBatchSelecting" v-model="settings.seriesSortBy" :descending.sync="settings.seriesSortDesc" :items="seriesSortItems" class="w-36 sm:w-44 md:w-48 h-7.5 ml-1 sm:ml-4" @change="updateSeriesSort" />
|
||||
|
||||
<!-- issues page remove all button -->
|
||||
<ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}</ui-btn>
|
||||
</template>
|
||||
<!-- search page -->
|
||||
@@ -118,6 +123,32 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
seriesContextMenuItems() {
|
||||
if (!this.selectedSeries) return []
|
||||
|
||||
const items = [
|
||||
{
|
||||
text: this.isSeriesFinished ? this.$strings.MessageMarkAsNotFinished : this.$strings.MessageMarkAsFinished,
|
||||
action: 'mark-series-finished'
|
||||
}
|
||||
]
|
||||
|
||||
if (this.userIsAdminOrUp || this.selectedSeries.rssFeed) {
|
||||
items.push({
|
||||
text: this.$strings.LabelOpenRSSFeed,
|
||||
action: 'open-rss-feed'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.isSeriesRemovedFromContinueListening) {
|
||||
items.push({
|
||||
text: 'Re-Add Series to Continue Listening',
|
||||
action: 're-add-to-continue-listening'
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
},
|
||||
seriesSortItems() {
|
||||
return [
|
||||
{
|
||||
@@ -132,6 +163,14 @@ export default {
|
||||
text: this.$strings.LabelAddedAt,
|
||||
value: 'addedAt'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLastBookAdded,
|
||||
value: 'lastBookAdded'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLastBookUpdated,
|
||||
value: 'lastBookUpdated'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelTotalDuration,
|
||||
value: 'totalDuration'
|
||||
@@ -150,12 +189,21 @@ export default {
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
libraryProvider() {
|
||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||
},
|
||||
currentLibraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
isBookLibrary() {
|
||||
return this.currentLibraryMediaType === 'book'
|
||||
},
|
||||
isPodcastLibrary() {
|
||||
return this.currentLibraryMediaType === 'podcast'
|
||||
},
|
||||
isMusicLibrary() {
|
||||
return this.currentLibraryMediaType === 'music'
|
||||
},
|
||||
isLibraryPage() {
|
||||
return this.page === ''
|
||||
},
|
||||
@@ -180,10 +228,16 @@ export default {
|
||||
isAuthorsPage() {
|
||||
return this.$route.name === 'library-library-authors'
|
||||
},
|
||||
isAlbumsPage() {
|
||||
return this.page === 'albums'
|
||||
},
|
||||
numShowing() {
|
||||
return this.totalEntities
|
||||
},
|
||||
entityName() {
|
||||
if (this.isAlbumsPage) return 'Albums'
|
||||
if (this.isMusicLibrary) return 'Tracks'
|
||||
|
||||
if (this.isPodcastLibrary) return this.$strings.LabelPodcasts
|
||||
if (!this.page) return this.$strings.LabelBooks
|
||||
if (this.isSeriesPage) return this.$strings.LabelSeries
|
||||
@@ -200,12 +254,15 @@ export default {
|
||||
seriesProgress() {
|
||||
return this.selectedSeries ? this.selectedSeries.progress : null
|
||||
},
|
||||
seriesRssFeed() {
|
||||
return this.selectedSeries ? this.selectedSeries.rssFeed : null
|
||||
},
|
||||
seriesLibraryItemIds() {
|
||||
if (!this.seriesProgress) return []
|
||||
return this.seriesProgress.libraryItemIds || []
|
||||
},
|
||||
isBatchSelecting() {
|
||||
return this.$store.state.selectedLibraryItems.length
|
||||
return this.$store.getters['globals/getIsBatchSelectingMediaItems']
|
||||
},
|
||||
isSeriesFinished() {
|
||||
return this.seriesProgress && !!this.seriesProgress.isFinished
|
||||
@@ -219,33 +276,34 @@ export default {
|
||||
},
|
||||
isIssuesFilter() {
|
||||
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
|
||||
},
|
||||
seriesSortBy: {
|
||||
get() {
|
||||
return this.$store.state.libraries.seriesSortBy
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('libraries/setSeriesSortBy', val)
|
||||
}
|
||||
},
|
||||
seriesSortDesc: {
|
||||
get() {
|
||||
return this.$store.state.libraries.seriesSortDesc
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('libraries/setSeriesSortDesc', val)
|
||||
}
|
||||
},
|
||||
seriesFilterBy: {
|
||||
get() {
|
||||
return this.$store.state.libraries.seriesFilterBy
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('libraries/setSeriesFilterBy', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
seriesContextMenuAction(action) {
|
||||
if (action === 'open-rss-feed') {
|
||||
this.showOpenSeriesRSSFeed()
|
||||
} else if (action === 're-add-to-continue-listening') {
|
||||
if (this.processingSeries) {
|
||||
console.warn('Already processing series')
|
||||
return
|
||||
}
|
||||
this.reAddSeriesToContinueListening()
|
||||
} else if (action === 'mark-series-finished') {
|
||||
if (this.processingSeries) {
|
||||
console.warn('Already processing series')
|
||||
return
|
||||
}
|
||||
this.markSeriesFinished()
|
||||
}
|
||||
},
|
||||
showOpenSeriesRSSFeed() {
|
||||
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
|
||||
id: this.selectedSeries.id,
|
||||
name: this.selectedSeries.name,
|
||||
type: 'series',
|
||||
feed: this.selectedSeries.rssFeed
|
||||
})
|
||||
},
|
||||
reAddSeriesToContinueListening() {
|
||||
this.processingSeries = true
|
||||
this.$axios
|
||||
@@ -268,7 +326,11 @@ export default {
|
||||
const payload = {}
|
||||
if (author.asin) payload.asin = author.asin
|
||||
else payload.q = author.name
|
||||
console.log('Payload', payload, 'author', author)
|
||||
|
||||
payload.region = 'us'
|
||||
if (this.libraryProvider.startsWith('audible.')) {
|
||||
payload.region = this.libraryProvider.split('.').pop() || 'us'
|
||||
}
|
||||
|
||||
this.$eventBus.$emit(`searching-author-${author.id}`, true)
|
||||
|
||||
@@ -310,27 +372,38 @@ export default {
|
||||
}
|
||||
},
|
||||
markSeriesFinished() {
|
||||
var newIsFinished = !this.isSeriesFinished
|
||||
this.processingSeries = true
|
||||
var updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => {
|
||||
return {
|
||||
libraryItemId: lid,
|
||||
isFinished: newIsFinished
|
||||
}
|
||||
})
|
||||
console.log('Progress payloads', updateProgressPayloads)
|
||||
this.$axios
|
||||
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
||||
.then(() => {
|
||||
this.$toast.success('Series update success')
|
||||
this.selectedSeries.progress.isFinished = newIsFinished
|
||||
this.processingSeries = false
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error('Series update failed')
|
||||
console.error('Failed to batch update read/not read', error)
|
||||
this.processingSeries = false
|
||||
})
|
||||
const newIsFinished = !this.isSeriesFinished
|
||||
|
||||
const payload = {
|
||||
message: newIsFinished ? this.$strings.MessageConfirmMarkSeriesFinished : this.$strings.MessageConfirmMarkSeriesNotFinished,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.processingSeries = true
|
||||
const updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => {
|
||||
return {
|
||||
libraryItemId: lid,
|
||||
isFinished: newIsFinished
|
||||
}
|
||||
})
|
||||
console.log('Progress payloads', updateProgressPayloads)
|
||||
this.$axios
|
||||
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
||||
.then(() => {
|
||||
this.$toast.success(this.$strings.ToastSeriesUpdateSuccess)
|
||||
this.selectedSeries.progress.isFinished = newIsFinished
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error(this.$strings.ToastSeriesUpdateFailed)
|
||||
console.error('Failed to batch update read/not read', error)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processingSeries = false
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
updateOrder() {
|
||||
this.saveSettings()
|
||||
@@ -339,10 +412,10 @@ export default {
|
||||
this.saveSettings()
|
||||
},
|
||||
updateSeriesSort() {
|
||||
this.$eventBus.$emit('series-sort-updated')
|
||||
this.saveSettings()
|
||||
},
|
||||
updateSeriesFilter() {
|
||||
this.$eventBus.$emit('series-sort-updated')
|
||||
this.saveSettings()
|
||||
},
|
||||
updateCollapseSeries() {
|
||||
this.saveSettings()
|
||||
@@ -363,16 +436,32 @@ export default {
|
||||
},
|
||||
setBookshelfTotalEntities(totalEntities) {
|
||||
this.totalEntities = totalEntities
|
||||
},
|
||||
rssFeedOpen(data) {
|
||||
if (data.entityId === this.seriesId) {
|
||||
console.log('RSS Feed Opened', data)
|
||||
this.selectedSeries.rssFeed = data
|
||||
}
|
||||
},
|
||||
rssFeedClosed(data) {
|
||||
if (data.entityId === this.seriesId) {
|
||||
console.log('RSS Feed Closed', data)
|
||||
this.selectedSeries.rssFeed = null
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
this.$store.commit('user/addSettingsListener', { id: 'bookshelftoolbar', meth: this.settingsUpdated })
|
||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||
this.$eventBus.$on('bookshelf-total-entities', this.setBookshelfTotalEntities)
|
||||
this.$root.socket.on('rss_feed_open', this.rssFeedOpen)
|
||||
this.$root.socket.on('rss_feed_closed', this.rssFeedClosed)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$store.commit('user/removeSettingsListener', 'bookshelftoolbar')
|
||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||
this.$eventBus.$off('bookshelf-total-entities', this.setBookshelfTotalEntities)
|
||||
this.$root.socket.off('rss_feed_open', this.rssFeedOpen)
|
||||
this.$root.socket.off('rss_feed_closed', this.rssFeedClosed)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
<template>
|
||||
<div class="w-44 fixed left-0 top-16 h-full bg-bg bg-opacity-100 md:bg-opacity-70 shadow-lg border-r border-white border-opacity-5 py-3 transform transition-transform" :class="wrapperClass" v-click-outside="clickOutside">
|
||||
<div class="md:hidden flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
|
||||
<span class="material-icons text-2xl">arrow_back</span>
|
||||
<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>
|
||||
</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'">
|
||||
<p class="leading-4">{{ route.title }}</p>
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-4 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'">
|
||||
<p>{{ route.title }}</p>
|
||||
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<div class="w-full h-12 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamLibraryItem && isMobileLandscape ? '300px' : '65px' }">
|
||||
<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' }">
|
||||
<div class="flex justify-between">
|
||||
<p class="underline font-mono text-sm" @click="clickChangelog">v{{ $config.version }}</p>
|
||||
|
||||
@@ -17,8 +21,6 @@
|
||||
</div>
|
||||
<a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a>
|
||||
</div>
|
||||
|
||||
<modals-changelog-view-modal v-model="showChangelogModal" :changelog="currentVersionChangelog" :currentVersion="$config.version" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -87,6 +89,11 @@ export default {
|
||||
id: 'config-notifications',
|
||||
title: this.$strings.HeaderNotifications,
|
||||
path: '/config/notifications'
|
||||
},
|
||||
{
|
||||
id: 'config-item-metadata-utils',
|
||||
title: this.$strings.HeaderItemMetadataUtils,
|
||||
path: '/config/item-metadata-utils'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -109,7 +116,7 @@ export default {
|
||||
var classes = []
|
||||
if (this.drawerOpen) classes.push('translate-x-0')
|
||||
else classes.push('-translate-x-44')
|
||||
if (this.isMobile) classes.push('z-50')
|
||||
if (this.isMobilePortrait) classes.push('z-50')
|
||||
else classes.push('z-40')
|
||||
return classes.join(' ')
|
||||
},
|
||||
@@ -119,9 +126,11 @@ export default {
|
||||
isMobileLandscape() {
|
||||
return this.$store.state.globals.isMobileLandscape
|
||||
},
|
||||
isMobilePortrait() {
|
||||
return this.$store.state.globals.isMobilePortrait
|
||||
},
|
||||
drawerOpen() {
|
||||
if (this.isMobile) return this.isOpen
|
||||
return true
|
||||
return !this.isMobilePortrait || this.isOpen
|
||||
},
|
||||
routeName() {
|
||||
return this.$route.name
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="initialized && !totalShelves && !hasFilter && entityName === 'books'" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<p class="text-center text-2xl font-book mb-4 py-4">{{ $getString('MessageXLibraryIsEmpty', [libraryName]) }}</p>
|
||||
<div v-if="initialized && !totalShelves && !hasFilter && entityName === 'items'" class="w-full flex flex-col items-center justify-center py-12">
|
||||
<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>
|
||||
@@ -16,12 +16,12 @@
|
||||
<div v-else-if="!totalShelves && initialized" class="w-full py-16">
|
||||
<p class="text-xl text-center">{{ emptyMessage }}</p>
|
||||
<!-- Clear filter only available on Library bookshelf -->
|
||||
<div v-if="entityName === 'books'" class="flex justify-center mt-2">
|
||||
<div v-if="entityName === 'items'" class="flex justify-center mt-2">
|
||||
<ui-btn v-if="hasFilter" color="primary" @click="clearFilter">{{ $strings.ButtonClearFilter }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-30" />
|
||||
<widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -81,8 +81,11 @@ export default {
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
libraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
isPodcast() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
||||
return this.libraryMediaType === 'podcast'
|
||||
},
|
||||
emptyMessage() {
|
||||
if (this.page === 'series') return this.$strings.MessageBookshelfNoSeries
|
||||
@@ -96,17 +99,17 @@ export default {
|
||||
return this.$strings.MessageNoResults
|
||||
},
|
||||
entityName() {
|
||||
if (!this.page) return 'books'
|
||||
if (!this.page) return 'items'
|
||||
return this.page
|
||||
},
|
||||
seriesSortBy() {
|
||||
return this.$store.state.libraries.seriesSortBy
|
||||
return this.$store.getters['user/getUserSetting']('seriesSortBy')
|
||||
},
|
||||
seriesSortDesc() {
|
||||
return this.$store.state.libraries.seriesSortDesc
|
||||
return this.$store.getters['user/getUserSetting']('seriesSortDesc')
|
||||
},
|
||||
seriesFilterBy() {
|
||||
return this.$store.state.libraries.seriesFilterBy
|
||||
return this.$store.getters['user/getUserSetting']('seriesFilterBy')
|
||||
},
|
||||
orderBy() {
|
||||
return this.$store.getters['user/getUserSetting']('orderBy')
|
||||
@@ -158,12 +161,9 @@ export default {
|
||||
libraryName() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||
},
|
||||
isEntityBook() {
|
||||
return this.entityName === 'series-books' || this.entityName === 'books'
|
||||
},
|
||||
bookWidth() {
|
||||
var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
|
||||
const coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||
if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return coverSize * 1.6
|
||||
return coverSize
|
||||
},
|
||||
bookHeight() {
|
||||
@@ -192,7 +192,8 @@ export default {
|
||||
},
|
||||
shelfHeight() {
|
||||
if (this.isAlternativeBookshelfView) {
|
||||
var extraTitleSpace = this.isEntityBook ? 80 : 40
|
||||
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
|
||||
@@ -201,11 +202,11 @@ export default {
|
||||
// Includes margin
|
||||
return this.entityWidth + 24
|
||||
},
|
||||
selectedLibraryItems() {
|
||||
return this.$store.state.selectedLibraryItems || []
|
||||
selectedMediaItems() {
|
||||
return this.$store.state.globals.selectedMediaItems || []
|
||||
},
|
||||
sizeMultiplier() {
|
||||
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||
const baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||
return this.entityWidth / baseSize
|
||||
}
|
||||
},
|
||||
@@ -214,8 +215,8 @@ export default {
|
||||
this.$store.dispatch('user/updateUserSettings', { filterBy: 'all' })
|
||||
},
|
||||
editEntity(entity) {
|
||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
||||
var bookIds = this.entities.map((e) => e.id)
|
||||
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||
const bookIds = this.entities.map((e) => e.id)
|
||||
this.$store.commit('setBookshelfBookIds', bookIds)
|
||||
this.$store.commit('showEditModal', entity)
|
||||
} else if (this.entityName === 'collections') {
|
||||
@@ -229,29 +230,29 @@ export default {
|
||||
this.isSelectionMode = false
|
||||
},
|
||||
selectEntity(entity, shiftKey) {
|
||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === entity.id)
|
||||
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||
const indexOf = this.entities.findIndex((ent) => ent && ent.id === entity.id)
|
||||
const lastLastItemIndexSelected = this.lastItemIndexSelected
|
||||
if (!this.selectedLibraryItems.includes(entity.id)) {
|
||||
if (!this.selectedMediaItems.some((i) => i.id === entity.id)) {
|
||||
this.lastItemIndexSelected = indexOf
|
||||
} else {
|
||||
this.lastItemIndexSelected = -1
|
||||
}
|
||||
|
||||
if (shiftKey && lastLastItemIndexSelected >= 0) {
|
||||
var loopStart = indexOf
|
||||
var loopEnd = lastLastItemIndexSelected
|
||||
let loopStart = indexOf
|
||||
let loopEnd = lastLastItemIndexSelected
|
||||
if (indexOf > lastLastItemIndexSelected) {
|
||||
loopStart = lastLastItemIndexSelected
|
||||
loopEnd = indexOf
|
||||
}
|
||||
|
||||
var isSelecting = false
|
||||
let isSelecting = false
|
||||
// If any items in this range is not selected then select all otherwise unselect all
|
||||
for (let i = loopStart; i <= loopEnd; i++) {
|
||||
const thisEntity = this.entities[i]
|
||||
if (thisEntity && !thisEntity.collapsedSeries) {
|
||||
if (!this.selectedLibraryItems.includes(thisEntity.id)) {
|
||||
if (!this.selectedMediaItems.some((i) => i.id === thisEntity.id)) {
|
||||
isSelecting = true
|
||||
break
|
||||
}
|
||||
@@ -269,16 +270,27 @@ export default {
|
||||
const entityComponentRef = this.entityComponentRefs[i]
|
||||
if (thisEntity && entityComponentRef) {
|
||||
entityComponentRef.selected = isSelecting
|
||||
this.$store.commit('setLibraryItemSelected', { libraryItemId: thisEntity.id, selected: isSelecting })
|
||||
|
||||
const mediaItem = {
|
||||
id: thisEntity.id,
|
||||
mediaType: thisEntity.mediaType,
|
||||
hasTracks: thisEntity.mediaType === 'podcast' || thisEntity.media.audioFile || thisEntity.media.numTracks || (thisEntity.media.tracks && thisEntity.media.tracks.length)
|
||||
}
|
||||
this.$store.commit('globals/setMediaItemSelected', { item: mediaItem, selected: isSelecting })
|
||||
} else {
|
||||
console.error('Invalid entity index', i)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.$store.commit('toggleLibraryItemSelected', entity.id)
|
||||
const mediaItem = {
|
||||
id: entity.id,
|
||||
mediaType: entity.mediaType,
|
||||
hasTracks: entity.mediaType === 'podcast' || entity.media.audioFile || entity.media.numTracks || (entity.media.tracks && entity.media.tracks.length)
|
||||
}
|
||||
this.$store.commit('globals/toggleMediaItemSelected', mediaItem)
|
||||
}
|
||||
|
||||
var newIsSelectionMode = !!this.selectedLibraryItems.length
|
||||
const newIsSelectionMode = !!this.selectedMediaItems.length
|
||||
if (this.isSelectionMode !== newIsSelectionMode) {
|
||||
this.isSelectionMode = newIsSelectionMode
|
||||
this.updateBookSelectionMode(newIsSelectionMode)
|
||||
@@ -296,7 +308,7 @@ export default {
|
||||
}
|
||||
},
|
||||
async fetchEntites(page = 0) {
|
||||
var startIndex = page * this.booksPerFetch
|
||||
const startIndex = page * this.booksPerFetch
|
||||
|
||||
this.isFetchingEntities = true
|
||||
|
||||
@@ -304,9 +316,9 @@ export default {
|
||||
this.currentSFQueryString = this.buildSearchParams()
|
||||
}
|
||||
|
||||
const entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? 'items' : this.entityName
|
||||
const entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
|
||||
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
||||
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
|
||||
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed`
|
||||
|
||||
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
|
||||
console.error('failed to fetch books', error)
|
||||
@@ -328,7 +340,7 @@ export default {
|
||||
}
|
||||
|
||||
for (let i = 0; i < payload.results.length; i++) {
|
||||
var index = i + startIndex
|
||||
const index = i + startIndex
|
||||
this.entities[index] = payload.results[i]
|
||||
if (this.entityComponentRefs[index]) {
|
||||
this.entityComponentRefs[index].setEntity(this.entities[index])
|
||||
@@ -486,7 +498,7 @@ export default {
|
||||
}
|
||||
},
|
||||
settingsUpdated(settings) {
|
||||
var wasUpdated = this.checkUpdateSearchParams()
|
||||
const wasUpdated = this.checkUpdateSearchParams()
|
||||
if (wasUpdated) {
|
||||
this.resetEntities()
|
||||
} else if (settings.bookshelfCoverSize !== this.currentBookWidth) {
|
||||
@@ -505,7 +517,7 @@ export default {
|
||||
},
|
||||
libraryItemUpdated(libraryItem) {
|
||||
console.log('Item updated', libraryItem)
|
||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
||||
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
|
||||
if (indexOf >= 0) {
|
||||
this.entities[indexOf] = libraryItem
|
||||
@@ -516,7 +528,7 @@ export default {
|
||||
}
|
||||
},
|
||||
libraryItemRemoved(libraryItem) {
|
||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
||||
if (this.entityName === 'items' || this.entityName === 'series-books') {
|
||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
|
||||
if (indexOf >= 0) {
|
||||
this.entities = this.entities.filter((ent) => ent.id !== libraryItem.id)
|
||||
@@ -655,11 +667,9 @@ export default {
|
||||
}
|
||||
})
|
||||
|
||||
this.$eventBus.$on('series-sort-updated', this.seriesSortUpdated)
|
||||
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$on('socket_init', this.socketInit)
|
||||
|
||||
this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated })
|
||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||
|
||||
if (this.$root.socket) {
|
||||
this.$root.socket.on('item_updated', this.libraryItemUpdated)
|
||||
@@ -684,11 +694,9 @@ export default {
|
||||
bookshelf.removeEventListener('scroll', this.scroll)
|
||||
}
|
||||
|
||||
this.$eventBus.$off('series-sort-updated', this.seriesSortUpdated)
|
||||
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
|
||||
this.$eventBus.$off('socket_init', this.socketInit)
|
||||
|
||||
this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf')
|
||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||
|
||||
if (this.$root.socket) {
|
||||
this.$root.socket.off('item_updated', this.libraryItemUpdated)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<h1 class="text-xl">{{ headerText }}</h1>
|
||||
|
||||
<div v-if="showAddButton" class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clicked">
|
||||
<span class="material-icons" style="font-size: 1.4rem">add</span>
|
||||
<button type="button" class="material-icons" :aria-label="$strings.ButtonAdd + ': ' + headerText" style="font-size: 1.4rem">add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<!-- <div class="w-20 bg-bg h-full relative box-shadow-side z-40" style="min-width: 80px"> -->
|
||||
<div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-40" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
|
||||
<div class="w-20 bg-bg h-full fixed left-0 box-shadow-side z-50" style="min-width: 80px" :style="{ top: offsetTop + 'px' }">
|
||||
<!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar -->
|
||||
<div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" />
|
||||
|
||||
@@ -9,7 +8,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
|
||||
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonHome }}</p>
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonHome }}</p>
|
||||
|
||||
<div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
@@ -17,7 +16,7 @@
|
||||
<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>
|
||||
|
||||
<p class="font-book pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLatest }}</p>
|
||||
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLatest }}</p>
|
||||
|
||||
<div v-show="isPodcastLatestPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
@@ -27,30 +26,38 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
|
||||
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLibrary }}</p>
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLibrary }}</p>
|
||||
|
||||
<div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" 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="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" 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="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
||||
</svg>
|
||||
|
||||
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSeries }}</p>
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSeries }}</p>
|
||||
|
||||
<div v-show="isSeriesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="!isPodcastLibrary" :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'">
|
||||
<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>
|
||||
|
||||
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonCollections }}</p>
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonCollections }}</p>
|
||||
|
||||
<div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="!isPodcastLibrary" :to="`/library/${currentLibraryId}/authors`" 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="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<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>
|
||||
|
||||
<p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
|
||||
|
||||
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" 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="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
|
||||
<svg class="w-6 h-6" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
@@ -58,31 +65,47 @@
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<p class="font-book pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAuthors }}</p>
|
||||
<p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAuthors }}</p>
|
||||
|
||||
<div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</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>
|
||||
|
||||
<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="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>
|
||||
|
||||
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSearch }}</p>
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSearch }}</p>
|
||||
|
||||
<div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<p class="font-book pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p>
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">Albums</p>
|
||||
|
||||
<div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
<div v-show="isMusicAlbumsPage" 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/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>
|
||||
|
||||
<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>
|
||||
|
||||
<p class="font-book pt-1.5 text-center leading-4" style="font-size: 1rem">{{ $strings.ButtonIssues }}</p>
|
||||
<p class="pt-1.5 text-center leading-4" style="font-size: 1rem">{{ $strings.ButtonIssues }}</p>
|
||||
|
||||
<div v-show="showingIssues" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
<div class="absolute top-1 right-1 w-4 h-4 rounded-full bg-white bg-opacity-30 flex items-center justify-center">
|
||||
@@ -133,15 +156,27 @@ export default {
|
||||
currentLibraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
isBookLibrary() {
|
||||
return this.currentLibraryMediaType === 'book'
|
||||
},
|
||||
isPodcastLibrary() {
|
||||
return this.currentLibraryMediaType === 'podcast'
|
||||
},
|
||||
isMusicLibrary() {
|
||||
return this.currentLibraryMediaType === 'music'
|
||||
},
|
||||
isPodcastDownloadQueuePage() {
|
||||
return this.$route.name === 'library-library-podcast-download-queue'
|
||||
},
|
||||
isPodcastSearchPage() {
|
||||
return this.$route.name === 'library-library-podcast-search'
|
||||
},
|
||||
isPodcastLatestPage() {
|
||||
return this.$route.name === 'library-library-podcast-latest'
|
||||
},
|
||||
isMusicAlbumsPage() {
|
||||
return this.paramId === 'albums'
|
||||
},
|
||||
homePage() {
|
||||
return this.$route.name === 'library-library'
|
||||
},
|
||||
@@ -151,6 +186,9 @@ export default {
|
||||
isAuthorsPage() {
|
||||
return this.$route.name === 'library-library-authors'
|
||||
},
|
||||
isNarratorsPage() {
|
||||
return this.$route.name === 'library-library-narrators'
|
||||
},
|
||||
isPlaylistsPage() {
|
||||
return this.paramId === 'playlists'
|
||||
},
|
||||
@@ -196,4 +234,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
<template>
|
||||
<div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 sm:h-44 md:h-40 z-40 bg-primary px-4 pb-1 md:pb-4 pt-2">
|
||||
<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 id="videoDock" />
|
||||
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-1 sm:left-4 cursor-pointer" :style="{ top: bookCoverPosTop + 'px' }">
|
||||
<nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-2 top-2 md:left-4 cursor-pointer">
|
||||
<covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" />
|
||||
</nuxt-link>
|
||||
<div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : 'pl-20 sm:pl-24'">
|
||||
<div>
|
||||
<nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg">
|
||||
<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>
|
||||
<p v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</p>
|
||||
<p 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>
|
||||
</p>
|
||||
<p v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">{{ $strings.LabelUnknown }}</p>
|
||||
<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}?library=${libraryId}`" 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>
|
||||
</div>
|
||||
|
||||
<div class="text-gray-400 flex items-center">
|
||||
@@ -77,7 +81,7 @@ export default {
|
||||
sleepTimerRemaining: 0,
|
||||
sleepTimer: null,
|
||||
displayTitle: null,
|
||||
initialPlaybackRate: 1,
|
||||
currentPlaybackRate: 1,
|
||||
syncFailedToast: null
|
||||
}
|
||||
},
|
||||
@@ -85,12 +89,15 @@ export default {
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
bookCoverWidth() {
|
||||
return 88
|
||||
isSquareCover() {
|
||||
return this.coverAspectRatio === 1
|
||||
},
|
||||
bookCoverPosTop() {
|
||||
if (this.coverAspectRatio == 1) return -10
|
||||
return -64
|
||||
isMobile() {
|
||||
return this.$store.state.globals.isMobile
|
||||
},
|
||||
bookCoverWidth() {
|
||||
if (this.isMobile) return 64 / this.coverAspectRatio
|
||||
return 77 / this.coverAspectRatio
|
||||
},
|
||||
cover() {
|
||||
if (this.media.coverPath) return this.media.coverPath
|
||||
@@ -113,19 +120,31 @@ export default {
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
streamEpisode() {
|
||||
if (!this.$store.state.streamEpisodeId) return null
|
||||
const episodes = this.streamLibraryItem.media.episodes || []
|
||||
return episodes.find((ep) => ep.id === this.$store.state.streamEpisodeId)
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.streamLibraryItem ? this.streamLibraryItem.id : null
|
||||
return this.streamLibraryItem?.id || null
|
||||
},
|
||||
media() {
|
||||
return this.streamLibraryItem ? this.streamLibraryItem.media || {} : {}
|
||||
return this.streamLibraryItem?.media || {}
|
||||
},
|
||||
isPodcast() {
|
||||
return this.streamLibraryItem ? this.streamLibraryItem.mediaType === 'podcast' : false
|
||||
return this.streamLibraryItem?.mediaType === 'podcast'
|
||||
},
|
||||
isMusic() {
|
||||
return this.streamLibraryItem?.mediaType === 'music'
|
||||
},
|
||||
isExplicit() {
|
||||
return this.mediaMetadata.explicit || false
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
chapters() {
|
||||
if (this.streamEpisode) return this.streamEpisode.chapters || []
|
||||
return this.media.chapters || []
|
||||
},
|
||||
title() {
|
||||
@@ -139,12 +158,17 @@ export default {
|
||||
return this.streamLibraryItem ? this.streamLibraryItem.libraryId : null
|
||||
},
|
||||
totalDurationPretty() {
|
||||
return this.$secondsToTimestamp(this.totalDuration)
|
||||
// Adjusted by playback rate
|
||||
return this.$secondsToTimestamp(this.totalDuration / this.currentPlaybackRate)
|
||||
},
|
||||
podcastAuthor() {
|
||||
if (!this.isPodcast) return null
|
||||
return this.mediaMetadata.author || 'Unknown'
|
||||
},
|
||||
musicArtists() {
|
||||
if (!this.isMusic) return null
|
||||
return this.mediaMetadata.artists.join(', ')
|
||||
},
|
||||
playerQueueItems() {
|
||||
return this.$store.state.playerQueueItems || []
|
||||
}
|
||||
@@ -238,12 +262,16 @@ export default {
|
||||
this.playerHandler.setVolume(volume)
|
||||
},
|
||||
setPlaybackRate(playbackRate) {
|
||||
this.initialPlaybackRate = playbackRate
|
||||
this.currentPlaybackRate = playbackRate
|
||||
this.playerHandler.setPlaybackRate(playbackRate)
|
||||
},
|
||||
seek(time) {
|
||||
this.playerHandler.seek(time)
|
||||
},
|
||||
playbackTimeUpdate(time) {
|
||||
// When updating progress from another session
|
||||
this.playerHandler.seek(time, false)
|
||||
},
|
||||
setCurrentTime(time) {
|
||||
this.currentTime = time
|
||||
if (this.$refs.audioPlayer) {
|
||||
@@ -342,21 +370,22 @@ export default {
|
||||
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
|
||||
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
|
||||
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
|
||||
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionPreviousTrack)
|
||||
const hasNextChapter = this.$refs.audioPlayer && this.$refs.audioPlayer.hasNextChapter
|
||||
navigator.mediaSession.setActionHandler('nexttrack', hasNextChapter ? this.mediaSessionNextTrack : null)
|
||||
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionSeekBackward)
|
||||
navigator.mediaSession.setActionHandler('nexttrack', this.mediaSessionSeekForward)
|
||||
} else {
|
||||
console.warn('Media session not available')
|
||||
}
|
||||
},
|
||||
streamProgress(data) {
|
||||
if (!data.numSegments) return
|
||||
var chunks = data.chunks
|
||||
console.log(`[StreamContainer] Stream Progress ${data.percent}`)
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
|
||||
} else {
|
||||
console.error('No Audio Ref')
|
||||
if (this.playerHandler.isPlayingLocalItem && this.playerHandler.currentStreamId === data.stream) {
|
||||
if (!data.numSegments) return
|
||||
var chunks = data.chunks
|
||||
console.log(`[StreamContainer] Stream Progress ${data.percent}`)
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
|
||||
} else {
|
||||
console.error('No Audio Ref')
|
||||
}
|
||||
}
|
||||
},
|
||||
sessionOpen(session) {
|
||||
@@ -365,7 +394,7 @@ export default {
|
||||
libraryItem: session.libraryItem,
|
||||
episodeId: session.episodeId
|
||||
})
|
||||
this.playerHandler.prepareOpenSession(session, this.initialPlaybackRate)
|
||||
this.playerHandler.prepareOpenSession(session, this.currentPlaybackRate)
|
||||
},
|
||||
streamOpen(session) {
|
||||
console.log(`[StreamContainer] Stream session open`, session)
|
||||
@@ -405,8 +434,8 @@ export default {
|
||||
}
|
||||
},
|
||||
async playLibraryItem(payload) {
|
||||
var libraryItemId = payload.libraryItemId
|
||||
var episodeId = payload.episodeId || null
|
||||
const libraryItemId = payload.libraryItemId
|
||||
const episodeId = payload.episodeId || null
|
||||
|
||||
if (this.playerHandler.libraryItemId == libraryItemId && this.playerHandler.episodeId == episodeId) {
|
||||
if (payload.startTime !== null && !isNaN(payload.startTime)) {
|
||||
@@ -417,11 +446,12 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
var libraryItem = await this.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => {
|
||||
const libraryItem = await this.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => {
|
||||
console.error('Failed to fetch full item', error)
|
||||
return null
|
||||
})
|
||||
if (!libraryItem) return
|
||||
|
||||
this.$store.commit('setMediaPlaying', {
|
||||
libraryItem,
|
||||
episodeId,
|
||||
@@ -431,7 +461,7 @@ export default {
|
||||
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()
|
||||
})
|
||||
|
||||
this.playerHandler.load(libraryItem, episodeId, true, this.initialPlaybackRate, payload.startTime)
|
||||
this.playerHandler.load(libraryItem, episodeId, true, this.currentPlaybackRate, payload.startTime)
|
||||
},
|
||||
pauseItem() {
|
||||
this.playerHandler.pause()
|
||||
@@ -439,17 +469,26 @@ export default {
|
||||
showFailedProgressSyncs() {
|
||||
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
|
||||
this.syncFailedToast = this.$toast('Progress is not being synced. Restart playback', { timeout: false, type: 'error' })
|
||||
},
|
||||
sessionClosedEvent(sessionId) {
|
||||
if (this.playerHandler.currentSessionId === sessionId) {
|
||||
console.log('sessionClosedEvent closing current session', sessionId)
|
||||
this.playerHandler.resetPlayer() // Closes player without reporting to server
|
||||
this.$store.commit('setMediaPlaying', null)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$eventBus.$on('cast-session-active', this.castSessionActive)
|
||||
this.$eventBus.$on('playback-seek', this.seek)
|
||||
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
|
||||
this.$eventBus.$on('play-item', this.playLibraryItem)
|
||||
this.$eventBus.$on('pause-item', this.pauseItem)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('cast-session-active', this.castSessionActive)
|
||||
this.$eventBus.$off('playback-seek', this.seek)
|
||||
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
|
||||
this.$eventBus.$off('play-item', this.playLibraryItem)
|
||||
this.$eventBus.$off('pause-item', this.pauseItem)
|
||||
}
|
||||
@@ -460,4 +499,4 @@ export default {
|
||||
#streamContainer {
|
||||
box-shadow: 0px -6px 8px #1111113f;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<nuxt-link :to="`/author/${author.id}`">
|
||||
<nuxt-link :to="`/author/${author.id}?library=${currentLibraryId}`">
|
||||
<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 -->
|
||||
@@ -13,10 +13,14 @@
|
||||
|
||||
<!-- 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">
|
||||
<span class="material-icons text-lg">search</span>
|
||||
<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)">
|
||||
<span class="material-icons text-lg">edit</span>
|
||||
<ui-tooltip :text="$strings.LabelEdit" direction="bottom">
|
||||
<span class="material-icons text-lg">edit</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- Loading spinner -->
|
||||
@@ -73,6 +77,12 @@ export default {
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
libraryProvider() {
|
||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -88,6 +98,11 @@ export default {
|
||||
if (this.asin) payload.asin = this.asin
|
||||
else payload.q = this.name
|
||||
|
||||
payload.region = 'us'
|
||||
if (this.libraryProvider.startsWith('audible.')) {
|
||||
payload.region = this.libraryProvider.split('.').pop() || 'us'
|
||||
}
|
||||
|
||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return null
|
||||
|
||||
@@ -28,7 +28,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="px-4 flex-grow">
|
||||
<h1>{{ book.title }}</h1>
|
||||
<h1>
|
||||
<div class="flex items-center">
|
||||
{{ book.title }}<widgets-explicit-indicator :explicit="book.explicit" />
|
||||
</div>
|
||||
</h1>
|
||||
<p class="text-base text-gray-300 whitespace-nowrap truncate">by {{ 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>
|
||||
@@ -78,4 +82,4 @@ export default {
|
||||
this.selectedCover = this.bookCovers.length ? this.bookCovers[0] : this.book.cover || null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
0
client/components/cards/EpisodeSearchCard.vue
Normal file
0
client/components/cards/EpisodeSearchCard.vue
Normal file
@@ -6,7 +6,7 @@
|
||||
<covers-group-cover ref="groupcover" :id="groupEncode" :name="groupName" :type="groupType" :book-items="bookItems" :width="width" :height="height" :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 class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ groupName }}</p>
|
||||
<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>
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
<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">{{ matchHtml }}</p>
|
||||
<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'" class="m-0 p-0 truncate text-xs" 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" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -61,17 +61,19 @@ export default {
|
||||
},
|
||||
matchHtml() {
|
||||
if (!this.matchText || !this.search) return ''
|
||||
if (this.matchKey === 'subtitle') 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 === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
||||
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">Series: ${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}`
|
||||
}
|
||||
},
|
||||
|
||||
94
client/components/cards/ItemTaskRunningCard.vue
Normal file
94
client/components/cards/ItemTaskRunningCard.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="flex items-center px-1 overflow-hidden">
|
||||
<div class="w-8 flex items-center justify-center">
|
||||
<!-- <div class="text-lg"> -->
|
||||
<span v-if="isFinished" :class="taskIconStatus" class="material-icons text-base">{{ actionIcon }}</span>
|
||||
<widgets-loading-spinner v-else />
|
||||
<!-- </div> -->
|
||||
</div>
|
||||
<div class="flex-grow px-2 taskRunningCardContent">
|
||||
<p class="truncate text-sm">{{ title }}</p>
|
||||
|
||||
<p class="truncate text-xs text-gray-300">{{ description }}</p>
|
||||
|
||||
<p v-if="isFailed && failedMessage" class="text-xs truncate text-red-500">{{ failedMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
task: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
title() {
|
||||
return this.task.title || 'No Title'
|
||||
},
|
||||
description() {
|
||||
return this.task.description || ''
|
||||
},
|
||||
details() {
|
||||
return this.task.details || 'Unknown'
|
||||
},
|
||||
isFinished() {
|
||||
return !!this.task.isFinished
|
||||
},
|
||||
isFailed() {
|
||||
return !!this.task.isFailed
|
||||
},
|
||||
isSuccess() {
|
||||
return this.isFinished && !this.isFailed
|
||||
},
|
||||
failedMessage() {
|
||||
return this.task.error || ''
|
||||
},
|
||||
action() {
|
||||
return this.task.action || ''
|
||||
},
|
||||
actionIcon() {
|
||||
if (this.isFailed) {
|
||||
return 'error'
|
||||
} else if (this.isSuccess) {
|
||||
return 'done'
|
||||
}
|
||||
switch (this.action) {
|
||||
case 'download-podcast-episode':
|
||||
return 'cloud_download'
|
||||
case 'encode-m4b':
|
||||
return 'sync'
|
||||
default:
|
||||
return 'settings'
|
||||
}
|
||||
},
|
||||
taskIconStatus() {
|
||||
if (this.isFinished && this.isFailed) {
|
||||
return 'text-red-500'
|
||||
}
|
||||
if (this.isFinished && !this.isFailed) {
|
||||
return 'text-green-500'
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.taskRunningCardContent {
|
||||
width: calc(100% - 84px);
|
||||
height: 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -114,6 +114,7 @@ export default {
|
||||
var files = this.item.itemFiles.concat(this.item.otherFiles)
|
||||
return {
|
||||
index: this.item.index,
|
||||
directory: this.directory,
|
||||
...this.itemData,
|
||||
files
|
||||
}
|
||||
|
||||
114
client/components/cards/LazyAlbumCard.vue
Normal file
114
client/components/cards/LazyAlbumCard.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
index: Number,
|
||||
width: Number,
|
||||
height: Number,
|
||||
bookCoverAspectRatio: Number,
|
||||
bookshelfView: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
albumMount: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
album: null,
|
||||
isSelectionMode: false,
|
||||
selected: false,
|
||||
isHovering: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
coverSrc() {
|
||||
const config = this.$config || this.$nuxt.$config
|
||||
if (!this.album || !this.album.libraryItemId) return `${config.routerBasePath}/book_placeholder.jpg`
|
||||
return this.store.getters['globals/getLibraryItemCoverSrcById'](this.album.libraryItemId)
|
||||
},
|
||||
labelFontSize() {
|
||||
if (this.width < 160) return 0.75
|
||||
return 0.875
|
||||
},
|
||||
sizeMultiplier() {
|
||||
const baseSize = this.bookCoverAspectRatio === 1 ? 192 : 120
|
||||
return this.width / baseSize
|
||||
},
|
||||
title() {
|
||||
return this.album ? this.album.title : ''
|
||||
},
|
||||
artist() {
|
||||
return this.album ? this.album.artist : ''
|
||||
},
|
||||
store() {
|
||||
return this.$store || this.$nuxt.$store
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.store.state.libraries.currentLibraryId
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
const constants = this.$constants || this.$nuxt.$constants
|
||||
return this.bookshelfView == constants.BookshelfView.DETAIL
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setEntity(album) {
|
||||
this.album = album
|
||||
},
|
||||
setSelectionMode(val) {
|
||||
this.isSelectionMode = val
|
||||
},
|
||||
mouseover() {
|
||||
this.isHovering = true
|
||||
},
|
||||
mouseleave() {
|
||||
this.isHovering = false
|
||||
},
|
||||
clickCard() {
|
||||
if (!this.album) return
|
||||
// const router = this.$router || this.$nuxt.$router
|
||||
// router.push(`/album/${this.$encode(this.title)}`)
|
||||
},
|
||||
clickEdit() {
|
||||
this.$emit('edit', this.album)
|
||||
},
|
||||
destroy() {
|
||||
// destroy the vue listeners, etc
|
||||
this.$destroy()
|
||||
|
||||
// remove the element from the DOM
|
||||
if (this.$el && this.$el.parentNode) {
|
||||
this.$el.parentNode.removeChild(this.$el)
|
||||
} else if (this.$el && this.$el.remove) {
|
||||
this.$el.remove()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.albumMount) {
|
||||
this.setEntity(this.albumMount)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -7,9 +7,12 @@
|
||||
|
||||
<!-- Alternative bookshelf title/author/sort -->
|
||||
<div v-if="isAlternativeBookshelfView || isAuthorBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||
{{ displayTitle }}
|
||||
</p>
|
||||
<div :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||
<div class="flex items-center">
|
||||
<span class="truncate">{{ displayTitle }}</span>
|
||||
<widgets-explicit-indicator :explicit="isExplicit" />
|
||||
</div>
|
||||
</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>
|
||||
@@ -23,7 +26,7 @@
|
||||
|
||||
<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="font-book text-gray-300 text-center">{{ title }}</p>
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="text-gray-300 text-center">{{ title }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Cover Image -->
|
||||
@@ -32,13 +35,13 @@
|
||||
<!-- 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 font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">
|
||||
<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 font-book" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
|
||||
<p class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -70,9 +73,13 @@
|
||||
</div>
|
||||
|
||||
<!-- More Menu Icon -->
|
||||
<div ref="moreIcon" v-show="!isSelectionMode" 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">
|
||||
<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 -->
|
||||
@@ -102,8 +109,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Podcast Episode # -->
|
||||
<div v-if="recentEpisodeNumber && !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 #{{ recentEpisodeNumber }}</p>
|
||||
<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 -->
|
||||
@@ -190,6 +199,12 @@ export default {
|
||||
isPodcast() {
|
||||
return this.mediaType === 'podcast'
|
||||
},
|
||||
isMusic() {
|
||||
return this.mediaType === 'music'
|
||||
},
|
||||
isExplicit() {
|
||||
return this.mediaMetadata.explicit || false
|
||||
},
|
||||
placeholderUrl() {
|
||||
const config = this.$config || this.$nuxt.$config
|
||||
return `${config.routerBasePath}/book_placeholder.jpg`
|
||||
@@ -210,7 +225,7 @@ export default {
|
||||
libraryId() {
|
||||
return this._libraryItem.libraryId
|
||||
},
|
||||
hasEbook() {
|
||||
ebookFormat() {
|
||||
return this.media.ebookFormat
|
||||
},
|
||||
numTracks() {
|
||||
@@ -233,7 +248,7 @@ export default {
|
||||
if (this.recentEpisode.episode) {
|
||||
return this.recentEpisode.episode.replace(/^#/, '')
|
||||
}
|
||||
return this.recentEpisode.index
|
||||
return ''
|
||||
},
|
||||
collapsedSeries() {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
@@ -241,14 +256,14 @@ export default {
|
||||
},
|
||||
booksInSeries() {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
|
||||
return this.collapsedSeries?.numBooks || 0
|
||||
},
|
||||
seriesSequenceList() {
|
||||
return this.collapsedSeries ? this.collapsedSeries.seriesSequenceList : null
|
||||
return this.collapsedSeries?.seriesSequenceList || null
|
||||
},
|
||||
libraryItemIdsInSeries() {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
return this.collapsedSeries ? this.collapsedSeries.libraryItemIds || [] : []
|
||||
return this.collapsedSeries?.libraryItemIds || []
|
||||
},
|
||||
hasCover() {
|
||||
return !!this.media.coverPath
|
||||
@@ -257,7 +272,7 @@ export default {
|
||||
return this.bookCoverAspectRatio === 1
|
||||
},
|
||||
sizeMultiplier() {
|
||||
var baseSize = this.squareAspectRatio ? 192 : 120
|
||||
const baseSize = this.squareAspectRatio ? 192 : 120
|
||||
return this.width / baseSize
|
||||
},
|
||||
title() {
|
||||
@@ -273,6 +288,10 @@ export default {
|
||||
authorLF() {
|
||||
return this.mediaMetadata.authorNameLF
|
||||
},
|
||||
artist() {
|
||||
const artists = this.mediaMetadata.artists || []
|
||||
return artists.join(', ')
|
||||
},
|
||||
displayTitle() {
|
||||
if (this.recentEpisode) return this.recentEpisode.title
|
||||
const ignorePrefix = this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix
|
||||
@@ -282,6 +301,7 @@ export default {
|
||||
displayLineTwo() {
|
||||
if (this.recentEpisode) return this.title
|
||||
if (this.isPodcast) return this.author
|
||||
if (this.isMusic) return this.artist
|
||||
if (this.collapsedSeries) return ''
|
||||
if (this.isAuthorBookshelfView) {
|
||||
return this.mediaMetadata.publishedYear || ''
|
||||
@@ -305,11 +325,20 @@ export default {
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
|
||||
},
|
||||
userProgress() {
|
||||
if (this.isMusic) return null
|
||||
if (this.episodeProgress) return this.episodeProgress
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||
},
|
||||
isEBookOnly() {
|
||||
return !this.numTracks && this.ebookFormat
|
||||
},
|
||||
useEBookProgress() {
|
||||
if (!this.userProgress || this.userProgress.progress) return false
|
||||
return this.userProgress.ebookProgress > 0
|
||||
},
|
||||
userProgressPercent() {
|
||||
return this.userProgress ? this.userProgress.progress || 0 : 0
|
||||
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
|
||||
},
|
||||
itemIsFinished() {
|
||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||
@@ -338,13 +367,13 @@ export default {
|
||||
return this.store.getters['getIsStreamingFromDifferentLibrary']
|
||||
},
|
||||
showReadButton() {
|
||||
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
|
||||
return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat && (this.showExperimentalFeatures || this.enableEReader)
|
||||
},
|
||||
showPlayButton() {
|
||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
|
||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode || this.isMusic)
|
||||
},
|
||||
showSmallEBookIcon() {
|
||||
return !this.isSelectionMode && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
|
||||
return !this.isSelectionMode && this.ebookFormat && (this.showExperimentalFeatures || this.enableEReader)
|
||||
},
|
||||
isMissing() {
|
||||
return this._libraryItem.isMissing
|
||||
@@ -366,7 +395,7 @@ export default {
|
||||
if (this.isPodcast) return 'Podcast has no episodes'
|
||||
return 'Item has no audio tracks & ebook'
|
||||
}
|
||||
var txt = ''
|
||||
let txt = ''
|
||||
if (this.numMissingParts) {
|
||||
txt += `${this.numMissingParts} missing parts.`
|
||||
}
|
||||
@@ -377,7 +406,7 @@ export default {
|
||||
return txt || 'Unknown Error'
|
||||
},
|
||||
overlayWrapperClasslist() {
|
||||
var classes = []
|
||||
const classes = []
|
||||
if (this.isSelectionMode) classes.push('bg-opacity-60')
|
||||
else classes.push('bg-opacity-40')
|
||||
if (this.selected) {
|
||||
@@ -401,6 +430,8 @@ export default {
|
||||
return this.store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
moreMenuItems() {
|
||||
if (this.isMusic) return []
|
||||
|
||||
if (this.recentEpisode) {
|
||||
const items = [
|
||||
{
|
||||
@@ -417,6 +448,7 @@ export default {
|
||||
}
|
||||
]
|
||||
if (this.continueListeningShelf) {
|
||||
|
||||
items.push({
|
||||
func: 'removeFromContinueListening',
|
||||
text: this.$strings.ButtonRemoveFromContinueListening
|
||||
@@ -438,7 +470,7 @@ export default {
|
||||
return items
|
||||
}
|
||||
|
||||
var items = []
|
||||
let items = []
|
||||
if (!this.isPodcast) {
|
||||
items = [
|
||||
{
|
||||
@@ -484,7 +516,7 @@ export default {
|
||||
if (this.continueListeningShelf) {
|
||||
items.push({
|
||||
func: 'removeFromContinueListening',
|
||||
text: this.$strings.ButtonRemoveFromContinueListening
|
||||
text: this.isEBookOnly ? this.$strings.ButtonRemoveFromContinueReading : this.$strings.ButtonRemoveFromContinueListening
|
||||
})
|
||||
}
|
||||
if (!this.isPodcast) {
|
||||
@@ -502,6 +534,14 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.userCanDelete) {
|
||||
items.push({
|
||||
func: 'deleteLibraryItem',
|
||||
text: this.$strings.ButtonDelete
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
},
|
||||
_socket() {
|
||||
@@ -534,11 +574,11 @@ export default {
|
||||
return this.author
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
var constants = this.$constants || this.$nuxt.$constants
|
||||
const constants = this.$constants || this.$nuxt.$constants
|
||||
return this.bookshelfView === constants.BookshelfView.DETAIL
|
||||
},
|
||||
isAuthorBookshelfView() {
|
||||
var constants = this.$constants || this.$nuxt.$constants
|
||||
const constants = this.$constants || this.$nuxt.$constants
|
||||
return this.bookshelfView === constants.BookshelfView.AUTHOR
|
||||
},
|
||||
titleDisplayBottomOffset() {
|
||||
@@ -548,7 +588,7 @@ export default {
|
||||
},
|
||||
rssFeed() {
|
||||
if (this.booksInSeries) return null
|
||||
return this.store.getters['feeds/getFeedForItem'](this.libraryItemId)
|
||||
return this._libraryItem.rssFeed || null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -651,7 +691,7 @@ export default {
|
||||
const axios = this.$axios || this.$nuxt.$axios
|
||||
this.processing = true
|
||||
axios
|
||||
.$get(`/api/items/${this.libraryItemId}/scan`)
|
||||
.$post(`/api/items/${this.libraryItemId}/scan`)
|
||||
.then((data) => {
|
||||
var result = data.result
|
||||
if (!result) {
|
||||
@@ -723,7 +763,7 @@ export default {
|
||||
episodeId: this.recentEpisode.id,
|
||||
title: this.recentEpisode.title,
|
||||
subtitle: this.mediaMetadata.title,
|
||||
caption: this.recentEpisode.publishedAt ? `Published ${this.$formatDate(this.recentEpisode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
caption: this.recentEpisode.publishedAt ? `Published ${this.$formatDate(this.recentEpisode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||
duration: this.recentEpisode.audioFile.duration || null,
|
||||
coverPath: this.media.coverPath || null
|
||||
}
|
||||
@@ -753,6 +793,35 @@ export default {
|
||||
this.store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.recentEpisode }])
|
||||
this.store.commit('globals/setShowPlaylistsModal', true)
|
||||
},
|
||||
deleteLibraryItem() {
|
||||
const payload = {
|
||||
message: 'This will delete the library item from the database and your file system. Are you sure?',
|
||||
checkboxLabel: 'Delete from file system. Uncheck to only remove from database.',
|
||||
yesButtonText: this.$strings.ButtonDelete,
|
||||
yesButtonColor: 'error',
|
||||
checkboxDefaultValue: true,
|
||||
callback: (confirmed, hardDelete) => {
|
||||
if (confirmed) {
|
||||
this.processing = true
|
||||
const axios = this.$axios || this.$nuxt.$axios
|
||||
axios
|
||||
.$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`)
|
||||
.then(() => {
|
||||
this.$toast.success('Item deleted')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete item', error)
|
||||
this.$toast.error('Failed to delete item')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
createMoreMenu() {
|
||||
if (!this.$refs.moreIcon) return
|
||||
|
||||
@@ -804,12 +873,12 @@ export default {
|
||||
this.createMoreMenu()
|
||||
},
|
||||
async clickReadEBook() {
|
||||
var libraryItem = await this.$axios.$get(`/api/items/${this.libraryItemId}?expanded=1`).catch((error) => {
|
||||
const axios = this.$axios || this.$nuxt.$axios
|
||||
var libraryItem = await axios.$get(`/api/items/${this.libraryItemId}?expanded=1`).catch((error) => {
|
||||
console.error('Failed to get lirbary item', this.libraryItemId)
|
||||
return null
|
||||
})
|
||||
if (!libraryItem) return
|
||||
console.log('Got library itemn', libraryItem)
|
||||
this.store.commit('showEReader', libraryItem)
|
||||
},
|
||||
selectBtnClick(evt) {
|
||||
@@ -848,7 +917,7 @@ export default {
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
subtitle: this.mediaMetadata.title,
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
|
||||
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, this.dateFormat)}` : 'Unknown publish date',
|
||||
duration: episode.audioFile.duration || null,
|
||||
coverPath: this.media.coverPath || null
|
||||
})
|
||||
|
||||
@@ -9,13 +9,16 @@
|
||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||
|
||||
<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>
|
||||
</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" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -72,6 +75,9 @@ export default {
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
rssFeed() {
|
||||
return this.collection ? this.collection.rssFeed : null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||
<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>
|
||||
</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" :style="{ fontSize: labelFontSize * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -50,8 +50,8 @@ export default {
|
||||
return 0.875
|
||||
},
|
||||
sizeMultiplier() {
|
||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||
return this.width / 240
|
||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6)
|
||||
return this.width / 120
|
||||
},
|
||||
title() {
|
||||
return this.playlist ? this.playlist.name : ''
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div ref="card" :id="`series-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="rounded-sm z-10 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
|
||||
<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" />
|
||||
@@ -7,19 +7,21 @@
|
||||
|
||||
<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="isSeriesFinished" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b bg-success w-full" />
|
||||
<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 class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
|
||||
<p :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ displayTitle }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||
<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>
|
||||
</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' }">{{ displayTitle }}</p>
|
||||
<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>
|
||||
</div>
|
||||
@@ -79,13 +81,20 @@ export default {
|
||||
return this.title
|
||||
},
|
||||
displaySortLine() {
|
||||
if (this.orderBy === 'addedAt') {
|
||||
// return this.addedAt
|
||||
return 'Added ' + this.$formatDate(this.addedAt, this.dateFormat)
|
||||
} else if (this.orderBy === 'totalDuration') {
|
||||
return 'Duration: ' + this.$elapsedPrettyExtended(this.totalDuration, false)
|
||||
switch (this.orderBy) {
|
||||
case 'addedAt':
|
||||
return `${this.$strings.LabelAdded} ${this.$formatDate(this.addedAt, this.dateFormat)}`
|
||||
case 'totalDuration':
|
||||
return `${this.$strings.LabelDuration} ${this.$elapsedPrettyExtended(this.totalDuration, false)}`
|
||||
case 'lastBookUpdated':
|
||||
const lastUpdated = Math.max(...this.books.map((x) => x.updatedAt), 0)
|
||||
return `${this.$strings.LabelLastBookUpdated} ${this.$formatDate(lastUpdated, this.dateFormat)}`
|
||||
case 'lastBookAdded':
|
||||
const lastBookAdded = Math.max(...this.books.map((x) => x.addedAt), 0)
|
||||
return `${this.$strings.LabelLastBookAdded} ${this.$formatDate(lastBookAdded, this.dateFormat)}`
|
||||
default:
|
||||
return null
|
||||
}
|
||||
return null
|
||||
},
|
||||
books() {
|
||||
return this.series ? this.series.books || [] : []
|
||||
@@ -106,6 +115,14 @@ export default {
|
||||
seriesBooksFinished() {
|
||||
return this.seriesBookProgress.filter((p) => p.isFinished)
|
||||
},
|
||||
hasSeriesBookInProgress() {
|
||||
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))
|
||||
},
|
||||
isSeriesFinished() {
|
||||
return this.books.length === this.seriesBooksFinished.length
|
||||
},
|
||||
@@ -125,6 +142,9 @@ export default {
|
||||
isAlternativeBookshelfView() {
|
||||
const constants = this.$constants || this.$nuxt.$constants
|
||||
return this.bookshelfView == constants.BookshelfView.DETAIL
|
||||
},
|
||||
rssFeed() {
|
||||
return this.series ? this.series.rssFeed : null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
50
client/components/cards/NarratorCard.vue
Normal file
50
client/components/cards/NarratorCard.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
narrator: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
width: Number,
|
||||
height: Number,
|
||||
sizeMultiplier: {
|
||||
type: Number,
|
||||
default: 1
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
name() {
|
||||
return this.narrator?.name || ''
|
||||
},
|
||||
numBooks() {
|
||||
return this.narrator?.books?.length || 0
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
}
|
||||
},
|
||||
methods: {}
|
||||
}
|
||||
</script>
|
||||
34
client/components/cards/NarratorSearchCard.vue
Normal file
34
client/components/cards/NarratorSearchCard.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex-grow px-2 narratorSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ narrator }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
narrator: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.narratorSearchCardContent {
|
||||
width: calc(100% - 40px);
|
||||
height: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -54,7 +54,7 @@ export default {
|
||||
},
|
||||
folderPath() {
|
||||
if (!this.libraryFolderPath) return ''
|
||||
return `${this.libraryFolderPath}\\${this.$sanitizeFilename(this.title)}`
|
||||
return `${this.libraryFolderPath}/${this.$sanitizeFilename(this.title)}`
|
||||
},
|
||||
detailsWidth() {
|
||||
return this.width - 85
|
||||
|
||||
212
client/components/content/LibraryItemDetails.vue
Normal file
212
client/components/content/LibraryItemDetails.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="narrators?.length" class="flex py-0.5 mt-4">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNarrators }}</span>
|
||||
</div>
|
||||
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
|
||||
<template v-for="(narrator, index) in narrators">
|
||||
<nuxt-link :key="narrator" :to="`/library/${libraryId}/bookshelf?filter=narrators.${$encode(narrator)}`" class="hover:underline">{{ narrator }}</nuxt-link
|
||||
><span :key="index" v-if="index < narrators.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="publishedYear" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublishYear }}</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ publishedYear }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="publisher" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublisher }}</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ publisher }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="musicAlbum" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">Album</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ musicAlbum }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="musicAlbumArtist" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">Album Artist</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ musicAlbumArtist }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="musicTrackPretty" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">Track</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ musicTrackPretty }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="musicDiscPretty" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">Disc</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ musicDiscPretty }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="podcastType" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPodcastType }}</span>
|
||||
</div>
|
||||
<div class="capitalize">
|
||||
{{ podcastType }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex py-0.5" v-if="genres.length">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelGenres }}</span>
|
||||
</div>
|
||||
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
|
||||
<template v-for="(genre, index) in genres">
|
||||
<nuxt-link :key="genre" :to="`/library/${libraryId}/bookshelf?filter=genres.${$encode(genre)}`" class="hover:underline">{{ genre }}</nuxt-link
|
||||
><span :key="index" v-if="index < genres.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex py-0.5" v-if="tags.length">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelTags }}</span>
|
||||
</div>
|
||||
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
|
||||
<template v-for="(tag, index) in tags">
|
||||
<nuxt-link :key="tag" :to="`/library/${libraryId}/bookshelf?filter=tags.${$encode(tag)}`" class="hover:underline">{{ tag }}</nuxt-link
|
||||
><span :key="index" v-if="index < tags.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tracks.length || audioFile || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ durationPretty }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelSize }}</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ sizePretty }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
libraryId() {
|
||||
return this.libraryItem.libraryId
|
||||
},
|
||||
isPodcast() {
|
||||
return this.libraryItem.mediaType === 'podcast'
|
||||
},
|
||||
audioFile() {
|
||||
// Music track
|
||||
return this.media.audioFile
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
tracks() {
|
||||
return this.media.tracks || []
|
||||
},
|
||||
podcastEpisodes() {
|
||||
return this.media.episodes || []
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
publishedYear() {
|
||||
return this.mediaMetadata.publishedYear
|
||||
},
|
||||
genres() {
|
||||
return this.mediaMetadata.genres || []
|
||||
},
|
||||
tags() {
|
||||
return this.media.tags || []
|
||||
},
|
||||
podcastAuthor() {
|
||||
return this.mediaMetadata.author || ''
|
||||
},
|
||||
authors() {
|
||||
return this.mediaMetadata.authors || []
|
||||
},
|
||||
publisher() {
|
||||
return this.mediaMetadata.publisher || ''
|
||||
},
|
||||
musicArtists() {
|
||||
return this.mediaMetadata.artists || []
|
||||
},
|
||||
musicAlbum() {
|
||||
return this.mediaMetadata.album || ''
|
||||
},
|
||||
musicAlbumArtist() {
|
||||
return this.mediaMetadata.albumArtist || ''
|
||||
},
|
||||
musicTrackPretty() {
|
||||
if (!this.mediaMetadata.trackNumber) return null
|
||||
if (!this.mediaMetadata.trackTotal) return this.mediaMetadata.trackNumber
|
||||
return `${this.mediaMetadata.trackNumber} / ${this.mediaMetadata.trackTotal}`
|
||||
},
|
||||
musicDiscPretty() {
|
||||
if (!this.mediaMetadata.discNumber) return null
|
||||
if (!this.mediaMetadata.discTotal) return this.mediaMetadata.discNumber
|
||||
return `${this.mediaMetadata.discNumber} / ${this.mediaMetadata.discTotal}`
|
||||
},
|
||||
narrators() {
|
||||
return this.mediaMetadata.narrators || []
|
||||
},
|
||||
durationPretty() {
|
||||
if (this.isPodcast) return this.$elapsedPrettyExtended(this.totalPodcastDuration)
|
||||
|
||||
if (!this.tracks.length && !this.audioFile) return 'N/A'
|
||||
if (this.audioFile) return this.$elapsedPrettyExtended(this.duration)
|
||||
return this.$elapsedPretty(this.duration)
|
||||
},
|
||||
duration() {
|
||||
if (!this.tracks.length && !this.audioFile) return 0
|
||||
return this.media.duration
|
||||
},
|
||||
totalPodcastDuration() {
|
||||
if (!this.podcastEpisodes.length) return 0
|
||||
let totalDuration = 0
|
||||
this.podcastEpisodes.forEach((ep) => (totalDuration += ep.duration || 0))
|
||||
return totalDuration
|
||||
},
|
||||
sizePretty() {
|
||||
return this.$bytesPretty(this.media.size)
|
||||
},
|
||||
podcastType() {
|
||||
return this.mediaMetadata.type
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -63,6 +63,15 @@
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<p v-if="narratorResults.length" class="uppercase text-xs text-gray-400 mb-1 mt-3 px-1 font-semibold">{{ $strings.LabelNarrators }}</p>
|
||||
<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" />
|
||||
</nuxt-link>
|
||||
</li>
|
||||
</template>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -84,6 +93,7 @@ export default {
|
||||
authorResults: [],
|
||||
seriesResults: [],
|
||||
tagResults: [],
|
||||
narratorResults: [],
|
||||
searchTimeout: null,
|
||||
lastSearch: null
|
||||
}
|
||||
@@ -114,6 +124,7 @@ export default {
|
||||
this.authorResults = []
|
||||
this.seriesResults = []
|
||||
this.tagResults = []
|
||||
this.narratorResults = []
|
||||
this.showMenu = false
|
||||
this.isFetching = false
|
||||
this.isTyping = false
|
||||
@@ -142,7 +153,7 @@ export default {
|
||||
}
|
||||
this.isFetching = true
|
||||
|
||||
var 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=${value}&limit=3`).catch((error) => {
|
||||
console.error('Search error', error)
|
||||
return []
|
||||
})
|
||||
@@ -155,6 +166,7 @@ export default {
|
||||
this.authorResults = searchResults.authors || []
|
||||
this.seriesResults = searchResults.series || []
|
||||
this.tagResults = searchResults.tags || []
|
||||
this.narratorResults = searchResults.narrators || []
|
||||
|
||||
this.isFetching = false
|
||||
if (!this.showMenu) {
|
||||
|
||||
@@ -41,9 +41,9 @@
|
||||
<span class="font-normal block truncate py-2">No {{ sublist }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li v-else-if="sublist === 'series'" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedSublistOption($encode('No Series'))">
|
||||
<li v-else-if="sublist === 'series'" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedSublistOption($encode('no-series'))">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal block truncate py-2 text-xs text-white text-opacity-80">No Series</span>
|
||||
<span class="font-normal block truncate py-2 text-xs text-white text-opacity-80">{{ $strings.MessageNoSeries }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<template v-for="item in sublistItems">
|
||||
@@ -87,8 +87,14 @@ export default {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
libraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
isPodcast() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
||||
return this.libraryMediaType === 'podcast'
|
||||
},
|
||||
isMusic() {
|
||||
return this.libraryMediaType === 'music'
|
||||
},
|
||||
seriesItems() {
|
||||
return [
|
||||
@@ -174,6 +180,16 @@ export default {
|
||||
value: 'missing',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelTracks,
|
||||
value: 'tracks',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAbridged,
|
||||
value: 'abridged',
|
||||
sublist: false
|
||||
},
|
||||
{
|
||||
text: this.$strings.ButtonIssues,
|
||||
value: 'issues',
|
||||
@@ -209,9 +225,33 @@ export default {
|
||||
}
|
||||
]
|
||||
},
|
||||
musicItems() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelAll,
|
||||
value: 'all'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelGenre,
|
||||
value: 'genres',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelTag,
|
||||
value: 'tags',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.ButtonIssues,
|
||||
value: 'issues',
|
||||
sublist: false
|
||||
}
|
||||
]
|
||||
},
|
||||
selectItems() {
|
||||
if (this.isSeries) return this.seriesItems
|
||||
if (this.isPodcast) return this.podcastItems
|
||||
if (this.isMusic) return this.musicItems
|
||||
return this.bookItems
|
||||
},
|
||||
selectedItemSublist() {
|
||||
@@ -263,10 +303,92 @@ export default {
|
||||
return this.filterData.languages || []
|
||||
},
|
||||
progress() {
|
||||
return [this.$strings.LabelFinished, this.$strings.LabelInProgress, this.$strings.LabelNotStarted, this.$strings.LabelNotFinished]
|
||||
return [
|
||||
{
|
||||
id: 'finished',
|
||||
name: this.$strings.LabelFinished
|
||||
},
|
||||
{
|
||||
id: 'in-progress',
|
||||
name: this.$strings.LabelInProgress
|
||||
},
|
||||
{
|
||||
id: 'not-started',
|
||||
name: this.$strings.LabelNotStarted
|
||||
},
|
||||
{
|
||||
id: 'not-finished',
|
||||
name: this.$strings.LabelNotFinished
|
||||
}
|
||||
]
|
||||
},
|
||||
tracks() {
|
||||
return [
|
||||
{
|
||||
id: 'single',
|
||||
name: this.$strings.LabelTracksSingleTrack
|
||||
},
|
||||
{
|
||||
id: 'multi',
|
||||
name: this.$strings.LabelTracksMultiTrack
|
||||
}
|
||||
]
|
||||
},
|
||||
missing() {
|
||||
return ['ASIN', 'ISBN', this.$strings.LabelSubtitle, this.$strings.LabelAuthor, this.$strings.LabelPublishYear, this.$strings.LabelSeries, this.$strings.LabelDescription, this.$strings.LabelGenres, this.$strings.LabelTags, this.$strings.LabelNarrator, this.$strings.LabelPublisher, this.$strings.LabelLanguage]
|
||||
return [
|
||||
{
|
||||
id: 'asin',
|
||||
name: 'ASIN'
|
||||
},
|
||||
{
|
||||
id: 'isbn',
|
||||
name: 'ISBN'
|
||||
},
|
||||
{
|
||||
id: 'subtitle',
|
||||
name: this.$strings.LabelSubtitle
|
||||
},
|
||||
{
|
||||
id: 'authors',
|
||||
name: this.$strings.LabelAuthor
|
||||
},
|
||||
{
|
||||
id: 'publishedYear',
|
||||
name: this.$strings.LabelPublishYear
|
||||
},
|
||||
{
|
||||
id: 'series',
|
||||
name: this.$strings.LabelSeries
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
name: this.$strings.LabelDescription
|
||||
},
|
||||
{
|
||||
id: 'genres',
|
||||
name: this.$strings.LabelGenres
|
||||
},
|
||||
{
|
||||
id: 'tags',
|
||||
name: this.$strings.LabelTags
|
||||
},
|
||||
{
|
||||
id: 'narrators',
|
||||
name: this.$strings.LabelNarrator
|
||||
},
|
||||
{
|
||||
id: 'publisher',
|
||||
name: this.$strings.LabelPublisher
|
||||
},
|
||||
{
|
||||
id: 'language',
|
||||
name: this.$strings.LabelLanguage
|
||||
},
|
||||
{
|
||||
id: 'cover',
|
||||
name: this.$strings.LabelCover
|
||||
}
|
||||
]
|
||||
},
|
||||
sublistItems() {
|
||||
return (this[this.sublist] || []).map((item) => {
|
||||
|
||||
@@ -50,8 +50,14 @@ export default {
|
||||
this.$emit('update:descending', val)
|
||||
}
|
||||
},
|
||||
libraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
isPodcast() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType'] == 'podcast'
|
||||
return this.libraryMediaType === 'podcast'
|
||||
},
|
||||
isMusic() {
|
||||
return this.libraryMediaType === 'music'
|
||||
},
|
||||
podcastItems() {
|
||||
return [
|
||||
@@ -134,10 +140,40 @@ export default {
|
||||
}
|
||||
]
|
||||
},
|
||||
musicItems() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelTitle,
|
||||
value: 'media.metadata.title'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAddedAt,
|
||||
value: 'addedAt'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelSize,
|
||||
value: 'size'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelDuration,
|
||||
value: 'media.duration'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelFileBirthtime,
|
||||
value: 'birthtimeMs'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelFileModified,
|
||||
value: 'mtimeMs'
|
||||
}
|
||||
]
|
||||
},
|
||||
selectItems() {
|
||||
let items = null
|
||||
if (this.isPodcast) {
|
||||
items = this.podcastItems
|
||||
} else if (this.isMusic) {
|
||||
items = this.musicItems
|
||||
} else if (this.$store.getters['user/getUserSetting']('filterBy').startsWith('series.')) {
|
||||
items = this.seriesItems
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative ml-4 sm:ml-8" v-click-outside="clickOutside">
|
||||
<div class="flex items-center justify-center text-gray-300 cursor-pointer h-full" @mousedown.prevent @mouseup.prevent @click="setShowMenu(true)">
|
||||
<span class="font-mono uppercase text-gray-200 text-sm sm:text-base">{{ playbackRate.toFixed(1) }}<span class="text-base sm:text-lg">⨯</span></span>
|
||||
<span class="font-mono uppercase text-gray-200 text-sm sm:text-base">{{ playbackRate.toFixed(1) }}<span class="text-base">x</span></span>
|
||||
</div>
|
||||
<div v-show="showMenu" class="absolute -top-20 z-20 bg-bg border-black-200 border shadow-xl rounded-lg" :style="{ left: menuLeft + 'px' }">
|
||||
<div class="absolute -bottom-1.5 right-0 w-full flex justify-center" :style="{ left: arrowLeft + 'px' }">
|
||||
@@ -11,7 +11,7 @@
|
||||
<template v-for="rate in rates">
|
||||
<div :key="rate" class="h-full border-black-300 w-11 cursor-pointer border rounded-sm" :class="value === rate ? 'bg-black-100' : 'hover:bg-black hover:bg-opacity-10'" style="min-width: 44px; max-width: 44px" @click="set(rate)">
|
||||
<div class="w-full h-full flex justify-center items-center">
|
||||
<p class="text-xs text-center font-mono">{{ rate }}<span class="text-sm">⨯</span></p>
|
||||
<p class="text-xs text-center font-mono">{{ rate }}<span class="text-sm">x</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -19,7 +19,7 @@
|
||||
<div class="w-full py-1 px-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<ui-icon-btn :disabled="!canDecrement" icon="remove" @click="decrement" />
|
||||
<p class="px-2 text-2xl sm:text-3xl">{{ playbackRate }}<span class="text-2xl">⨯</span></p>
|
||||
<p class="px-2 text-2xl sm:text-3xl">{{ playbackRate }}<span class="text-2xl">x</span></p>
|
||||
<ui-icon-btn :disabled="!canIncrement" icon="add" @click="increment" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,7 +40,7 @@ export default {
|
||||
showMenu: false,
|
||||
currentPlaybackRate: 0,
|
||||
MIN_SPEED: 0.5,
|
||||
MAX_SPEED: 3,
|
||||
MAX_SPEED: 10,
|
||||
menuLeft: -92,
|
||||
arrowLeft: 0
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<img v-if="libraryItem" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
|
||||
<div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
|
||||
<p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||
<p class="text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||
<div class="absolute top-2 right-2">
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
@@ -17,17 +17,17 @@
|
||||
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
|
||||
<img src="/Logo.png" loading="lazy" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
|
||||
<p class="text-center font-book text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
|
||||
<p class="text-center text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center z-10" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||
<div>
|
||||
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
|
||||
<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 z-10" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
|
||||
<p class="text-center font-book" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
|
||||
<p class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
|
||||
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
||||
|
||||
<p class="font-book text-white text-opacity-60 text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
|
||||
<p class="text-white text-opacity-60 text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -138,7 +138,7 @@ export default {
|
||||
|
||||
var innerP = document.createElement('p')
|
||||
innerP.textContent = this.name
|
||||
innerP.className = 'text-sm font-book text-white'
|
||||
innerP.className = 'text-sm text-white'
|
||||
imgdiv.appendChild(innerP)
|
||||
|
||||
return imgdiv
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
|
||||
<img src="/Logo.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
|
||||
<p class="text-center font-book text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
|
||||
<p class="text-center text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,8 @@ export default {
|
||||
return `${this.naturalWidth}x${this.naturalHeight}px`
|
||||
},
|
||||
placeholderUrl() {
|
||||
return `${this.$config.routerBasePath}/book_placeholder.jpg`
|
||||
const config = this.$config || this.$nuxt.$config
|
||||
return `${config.routerBasePath}/book_placeholder.jpg`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<modals-modal ref="modal" v-model="show" name="account" :width="800" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||
<div class="px-4 w-full 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 py-2">
|
||||
<div class="w-1/2 px-2">
|
||||
@@ -22,8 +22,8 @@
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div class="flex items-center pt-4 px-2">
|
||||
<p class="px-3 font-semibold" :class="isEditingRoot ? 'text-gray-300' : ''">{{ $strings.LabelEnable }}</p>
|
||||
<ui-toggle-switch v-model="newUser.isActive" :disabled="isEditingRoot" />
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,55 +31,55 @@
|
||||
<p class="text-lg mb-2 font-semibold">{{ $strings.HeaderPermissions }}</p>
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p>{{ $strings.LabelPermissionsDownload }}</p>
|
||||
<p id="download-permissions-toggle">{{ $strings.LabelPermissionsDownload }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch v-model="newUser.permissions.download" />
|
||||
<ui-toggle-switch labeledBy="download-permissions-toggle" v-model="newUser.permissions.download" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p>{{ $strings.LabelPermissionsUpdate }}</p>
|
||||
<p id="update-permissions-toggle">{{ $strings.LabelPermissionsUpdate }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch v-model="newUser.permissions.update" />
|
||||
<ui-toggle-switch labeledBy="update-permissions-toggle" v-model="newUser.permissions.update" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p>{{ $strings.LabelPermissionsDelete }}</p>
|
||||
<p id="delete-permissions-toggle">{{ $strings.LabelPermissionsDelete }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch v-model="newUser.permissions.delete" />
|
||||
<ui-toggle-switch labeledBy="delete-permissions-toggle" v-model="newUser.permissions.delete" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p>{{ $strings.LabelPermissionsUpload }}</p>
|
||||
<p id="upload-permissions-toggle">{{ $strings.LabelPermissionsUpload }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch v-model="newUser.permissions.upload" />
|
||||
<ui-toggle-switch labeledBy="upload-permissions-toggle" v-model="newUser.permissions.upload" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p>{{ $strings.LabelPermissionsAccessExplicitContent }}</p>
|
||||
<p id="explicit-content-permissions-toggle">{{ $strings.LabelPermissionsAccessExplicitContent }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch v-model="newUser.permissions.accessExplicitContent" />
|
||||
<ui-toggle-switch labeledBy="explicit-content-permissions-toggle" v-model="newUser.permissions.accessExplicitContent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center my-2 max-w-md">
|
||||
<div class="w-1/2">
|
||||
<p>{{ $strings.LabelPermissionsAccessAllLibraries }}</p>
|
||||
<p id="access-all-libs--permissions-toggle">{{ $strings.LabelPermissionsAccessAllLibraries }}</p>
|
||||
</div>
|
||||
<div class="w-1/2">
|
||||
<ui-toggle-switch v-model="newUser.permissions.accessAllLibraries" @input="accessAllLibrariesToggled" />
|
||||
<ui-toggle-switch labeledBy="access-all-libs--permissions-toggle" v-model="newUser.permissions.accessAllLibraries" @input="accessAllLibrariesToggled" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -96,7 +96,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!newUser.permissions.accessAllTags" class="my-4">
|
||||
<ui-multi-select-dropdown v-model="newUser.itemTagsAccessible" :items="itemTags" :label="$strings.LabelTagsAccessibleToUser" />
|
||||
<div class="flex items-center">
|
||||
<ui-multi-select-dropdown v-model="newUser.itemTagsSelected" :items="itemTags" :label="tagsSelectionText" />
|
||||
<div class="flex items-center pt-4 px-2">
|
||||
<p class="px-3 font-semibold" id="selected-tags-not-accessible--permissions-toggle">{{ $strings.LabelInvert }}</p>
|
||||
<ui-toggle-switch labeledBy="selected-tags-not-accessible--permissions-toggle" v-model="newUser.permissions.selectedTagsNotAccessible" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -185,6 +192,9 @@ export default {
|
||||
value: t
|
||||
}
|
||||
})
|
||||
},
|
||||
tagsSelectionText() {
|
||||
return this.newUser.permissions.selectedTagsNotAccessible ? this.$strings.LabelTagsNotAccessibleToUser : this.$strings.LabelTagsAccessibleToUser
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -193,16 +203,19 @@ export default {
|
||||
if (this.$refs.modal) this.$refs.modal.setHide()
|
||||
},
|
||||
accessAllTagsToggled(val) {
|
||||
if (val && this.newUser.itemTagsAccessible.length) {
|
||||
this.newUser.itemTagsAccessible = []
|
||||
if (val) {
|
||||
if (this.newUser.itemTagsSelected?.length) {
|
||||
this.newUser.itemTagsSelected = []
|
||||
}
|
||||
this.newUser.permissions.selectedTagsNotAccessible = false
|
||||
}
|
||||
},
|
||||
fetchAllTags() {
|
||||
this.loadingTags = true
|
||||
this.$axios
|
||||
.$get(`/api/tags`)
|
||||
.then((tags) => {
|
||||
this.tags = tags
|
||||
.then((res) => {
|
||||
this.tags = res.tags
|
||||
this.loadingTags = false
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -226,7 +239,7 @@ export default {
|
||||
this.$toast.error('Must select at least one library')
|
||||
return
|
||||
}
|
||||
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsAccessible.length) {
|
||||
if (!this.newUser.permissions.accessAllTags && !this.newUser.itemTagsSelected.length) {
|
||||
this.$toast.error('Must select at least one tag')
|
||||
return
|
||||
}
|
||||
@@ -307,12 +320,12 @@ export default {
|
||||
delete: type === 'admin',
|
||||
upload: type === 'admin',
|
||||
accessAllLibraries: true,
|
||||
accessAllTags: true
|
||||
accessAllTags: true,
|
||||
selectedTagsNotAccessible: false
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.fetchAllTags()
|
||||
|
||||
this.isNew = !this.account
|
||||
if (this.account) {
|
||||
this.newUser = {
|
||||
@@ -322,9 +335,10 @@ export default {
|
||||
isActive: this.account.isActive,
|
||||
permissions: { ...this.account.permissions },
|
||||
librariesAccessible: [...(this.account.librariesAccessible || [])],
|
||||
itemTagsAccessible: [...(this.account.itemTagsAccessible || [])]
|
||||
itemTagsSelected: [...(this.account.itemTagsSelected || [])]
|
||||
}
|
||||
} else {
|
||||
this.fetchAllTags()
|
||||
this.newUser = {
|
||||
username: null,
|
||||
password: null,
|
||||
@@ -336,7 +350,8 @@ export default {
|
||||
delete: false,
|
||||
upload: false,
|
||||
accessAllLibraries: true,
|
||||
accessAllTags: true
|
||||
accessAllTags: true,
|
||||
selectedTagsNotAccessible: false
|
||||
},
|
||||
librariesAccessible: []
|
||||
}
|
||||
|
||||
118
client/components/modals/AudioFileDataModal.vue
Normal file
118
client/components/modals/AudioFileDataModal.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="audiofile-data-modal" :width="700" :height="'unset'">
|
||||
<div v-if="audioFile" ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
|
||||
<p class="text-base text-gray-200">{{ metadata.filename }}</p>
|
||||
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
|
||||
<ui-text-input-with-label :value="metadata.path" readonly :label="$strings.LabelPath" class="mb-4 text-sm" />
|
||||
|
||||
<div class="flex flex-col sm:flex-row text-sm">
|
||||
<div class="w-full sm:w-1/2">
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelSize }}
|
||||
</p>
|
||||
<p>{{ $bytesPretty(metadata.size) }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelDuration }}
|
||||
</p>
|
||||
<p>{{ $secondsToTimestamp(audioFile.duration) }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">{{ $strings.LabelFormat }}</p>
|
||||
<p>{{ audioFile.format }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelChapters }}
|
||||
</p>
|
||||
<p>{{ audioFile.chapters?.length || 0 }}</p>
|
||||
</div>
|
||||
<div v-if="audioFile.embeddedCoverArt" class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelEmbeddedCover }}
|
||||
</p>
|
||||
<p>{{ audioFile.embeddedCoverArt || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-1/2">
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelCodec }}
|
||||
</p>
|
||||
<p>{{ audioFile.codec }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelChannels }}
|
||||
</p>
|
||||
<p>{{ audioFile.channels }} ({{ audioFile.channelLayout }})</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelBitrate }}
|
||||
</p>
|
||||
<p>{{ $bytesPretty(audioFile.bitRate || 0, 0) }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">{{ $strings.LabelTimeBase }}</p>
|
||||
<p>{{ audioFile.timeBase }}</p>
|
||||
</div>
|
||||
<div v-if="audioFile.language" class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelLanguage }}
|
||||
</p>
|
||||
<p>{{ audioFile.language || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
|
||||
<p class="font-bold mb-2">{{ $strings.LabelMetaTags }}</p>
|
||||
|
||||
<div v-for="(value, key) in metaTags" :key="key" class="flex mb-1 text-sm">
|
||||
<p class="w-32 min-w-32 text-black-50 mb-1">
|
||||
{{ key.replace('tag', '') }}
|
||||
</p>
|
||||
<p>{{ value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
audioFile: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
metadata() {
|
||||
return this.audioFile?.metadata || {}
|
||||
},
|
||||
metaTags() {
|
||||
return this.audioFile?.metaTags || {}
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -2,7 +2,7 @@
|
||||
<modals-modal v-model="show" name="backup-scheduler" :width="700" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ $strings.HeaderSetBackupSchedule }}</p>
|
||||
<p class="text-3xl text-white truncate">{{ $strings.HeaderSetBackupSchedule }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="show && newCronExpression" class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<modals-modal v-model="show" name="batchQuickMatch" :processing="processing" :width="500" :height="'unset'">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -82,7 +82,7 @@ export default {
|
||||
return this.$store.state.globals.showBatchQuickMatchModal
|
||||
},
|
||||
selectedBookIds() {
|
||||
return this.$store.state.selectedLibraryItems || []
|
||||
return (this.$store.state.globals.selectedMediaItems || []).map((i) => i.id)
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<modals-modal v-model="show" name="bookmarks" :width="500" :height="'unset'">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ $strings.LabelYourBookmarks }}</p>
|
||||
<p class="text-3xl text-white truncate">{{ $strings.LabelYourBookmarks }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 80vh">
|
||||
@@ -73,6 +73,12 @@ export default {
|
||||
},
|
||||
canCreateBookmark() {
|
||||
return !this.bookmarks.find((bm) => bm.time === this.currentTime)
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -111,7 +117,7 @@ export default {
|
||||
},
|
||||
submitCreateBookmark() {
|
||||
if (!this.newBookmarkTitle) {
|
||||
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
||||
this.newBookmarkTitle = this.$formatDatetime(Date.now(), this.dateFormat, this.timeFormat)
|
||||
}
|
||||
var bookmark = {
|
||||
title: this.newBookmarkTitle,
|
||||
@@ -134,4 +140,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
<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">
|
||||
<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 <= 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 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)">
|
||||
<p class="chapter-title truncate text-sm md:text-base">
|
||||
{{ chap.title }}
|
||||
</p>
|
||||
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended(chap.end - chap.start) }}</span>
|
||||
<span class="font-mono text-xxs sm:text-xs text-gray-400 pl-2 whitespace-nowrap">{{ $elapsedPrettyExtended((chap.end - chap.start) / _playbackRate) }}</span>
|
||||
<span class="flex-grow" />
|
||||
<span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
|
||||
<span class="font-mono text-xs sm:text-sm text-gray-300">{{ $secondsToTimestamp(chap.start / _playbackRate) }}</span>
|
||||
|
||||
<div v-show="chap.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
||||
</div>
|
||||
@@ -28,7 +28,8 @@ export default {
|
||||
currentChapter: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
playbackRate: Number
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
@@ -47,11 +48,15 @@ export default {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
_playbackRate() {
|
||||
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
|
||||
return this.playbackRate
|
||||
},
|
||||
currentChapterId() {
|
||||
return this.currentChapter ? this.currentChapter.id : null
|
||||
},
|
||||
currentChapterStart() {
|
||||
return this.currentChapter ? this.currentChapter.start : 0
|
||||
return (this.currentChapter?.start || 0) / this._playbackRate
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -61,13 +66,11 @@ export default {
|
||||
scrollToChapter() {
|
||||
if (!this.currentChapterId) return
|
||||
|
||||
var container = this.$refs.container
|
||||
if (container) {
|
||||
var currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
|
||||
if (this.$refs.container) {
|
||||
const currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
|
||||
if (currChapterEl) {
|
||||
var offsetTop = currChapterEl.offsetTop
|
||||
var containerHeight = container.clientHeight
|
||||
container.scrollTo({ top: offsetTop - containerHeight / 2 })
|
||||
const containerHeight = this.$refs.container.clientHeight
|
||||
this.$refs.container.scrollTo({ top: currChapterEl.offsetTop - containerHeight / 2 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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: 51" @click="clickClose">
|
||||
<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>
|
||||
</div>
|
||||
@@ -15,7 +15,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-2 p-1">
|
||||
<ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn>
|
||||
<ui-btn type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<modals-modal v-model="show" name="listening-session-modal" :processing="processing" :width="700" :height="'unset'">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ $strings.HeaderSession }} {{ _session.id }}</p>
|
||||
<p class="text-3xl text-white truncate">{{ $strings.HeaderSession }} {{ _session.id }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<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">
|
||||
@@ -19,13 +19,13 @@
|
||||
<div class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelStartedAt }}</div>
|
||||
<div class="px-1">
|
||||
{{ $formatDate(_session.startedAt, 'MMMM do, yyyy HH:mm') }}
|
||||
{{ $formatDatetime(_session.startedAt, dateFormat, timeFormat) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelUpdatedAt }}</div>
|
||||
<div class="px-1">
|
||||
{{ $formatDate(_session.updatedAt, 'MMMM do, yyyy HH:mm') }}
|
||||
{{ $formatDatetime(_session.updatedAt, dateFormat, timeFormat) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center -mx-1 mb-1">
|
||||
@@ -98,7 +98,8 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<ui-btn small color="error" @click.stop="deleteSessionClick">{{ $strings.ButtonDelete }}</ui-btn>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
@@ -151,6 +152,15 @@ export default {
|
||||
else if (playMethod === this.$constants.PlayMethod.DIRECTSTREAM) return 'Direct Stream'
|
||||
else if (playMethod === this.$constants.PlayMethod.LOCAL) return 'Local'
|
||||
return 'Unknown'
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
},
|
||||
isOpenSession() {
|
||||
return !!this._session.open
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -182,8 +192,26 @@ export default {
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
this.$toast.error(errMsg || this.$strings.ToastSessionDeleteFailed)
|
||||
})
|
||||
},
|
||||
closeSessionClick() {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post(`/api/session/${this._session.id}/close`)
|
||||
.then(() => {
|
||||
this.$toast.success('Session closed')
|
||||
this.show = false
|
||||
this.$emit('closedSession')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to close session', error)
|
||||
const errMsg = error.response?.data || ''
|
||||
this.$toast.error(errMsg || 'Failed to close open session')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -39,7 +39,7 @@ export default {
|
||||
},
|
||||
zIndex: {
|
||||
type: Number,
|
||||
default: 50
|
||||
default: 60
|
||||
},
|
||||
bgOpacity: {
|
||||
type: Number,
|
||||
|
||||
@@ -2,17 +2,21 @@
|
||||
<modals-modal v-model="show" name="sleep-timer" :width="350" :height="'unset'">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
|
||||
<p class="font-book text-3xl text-white truncate pointer-events-none">{{ $strings.HeaderSleepTimer }}</p>
|
||||
<p class="text-3xl text-white truncate pointer-events-none">{{ $strings.HeaderSleepTimer }}</p>
|
||||
</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">
|
||||
<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)">
|
||||
<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>
|
||||
</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-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">
|
||||
@@ -48,19 +52,28 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
customTime: null,
|
||||
sleepTimes: [
|
||||
{
|
||||
seconds: 10,
|
||||
text: '10 seconds'
|
||||
},
|
||||
{
|
||||
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'
|
||||
@@ -72,10 +85,6 @@ export default {
|
||||
{
|
||||
seconds: 60 * 120,
|
||||
text: '2 hours'
|
||||
},
|
||||
{
|
||||
seconds: 60 * 180,
|
||||
text: '3 hours'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -97,8 +106,17 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setTime(time) {
|
||||
this.$emit('set', time.seconds)
|
||||
submitCustomTime() {
|
||||
if (!this.customTime || isNaN(this.customTime) || Number(this.customTime) <= 0) {
|
||||
this.customTime = null
|
||||
return
|
||||
}
|
||||
|
||||
const timeInSeconds = Math.round(Number(this.customTime) * 60)
|
||||
this.setTime(timeInSeconds)
|
||||
},
|
||||
setTime(seconds) {
|
||||
this.$emit('set', seconds)
|
||||
},
|
||||
increment(amount) {
|
||||
this.$emit('increment', amount)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<modals-modal v-model="show" name="edit-author" :width="800" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
@@ -35,7 +35,7 @@
|
||||
<div class="flex pt-2 px-2">
|
||||
<ui-btn type="button" @click="searchAuthor">{{ $strings.ButtonQuickMatch }}</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
<ui-btn type="submit">{{ $strings.ButtonSave }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,6 +85,12 @@ export default {
|
||||
},
|
||||
title() {
|
||||
return this.$strings.HeaderUpdateAuthor
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
libraryProvider() {
|
||||
return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -109,7 +115,8 @@ export default {
|
||||
this.processing = true
|
||||
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error(this.$strings.ToastAuthorUpdateFailed)
|
||||
const errorMsg = error.response ? error.response.data : null
|
||||
this.$toast.error(errorMsg || this.$strings.ToastAuthorUpdateFailed)
|
||||
return null
|
||||
})
|
||||
if (result) {
|
||||
@@ -125,8 +132,7 @@ export default {
|
||||
},
|
||||
async removeCover() {
|
||||
var updatePayload = {
|
||||
imagePath: null,
|
||||
relImagePath: null
|
||||
imagePath: null
|
||||
}
|
||||
this.processing = true
|
||||
var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => {
|
||||
@@ -151,6 +157,11 @@ export default {
|
||||
if (this.authorCopy.asin) payload.asin = this.authorCopy.asin
|
||||
else payload.q = this.authorCopy.name
|
||||
|
||||
payload.region = 'us'
|
||||
if (this.libraryProvider.startsWith('audible.')) {
|
||||
payload.region = this.libraryProvider.split('.').pop() || 'us'
|
||||
}
|
||||
|
||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, payload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return null
|
||||
@@ -161,8 +172,7 @@ export default {
|
||||
if (response.author.imagePath) {
|
||||
this.$toast.success(this.$strings.ToastAuthorUpdateSuccess)
|
||||
this.$store.commit('globals/showEditAuthorModal', response.author)
|
||||
}
|
||||
else this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)
|
||||
} else this.$toast.success(this.$strings.ToastAuthorUpdateSuccessNoImageFound)
|
||||
} else {
|
||||
this.$toast.info('No updates were made for Author')
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<modals-modal v-model="show" name="changelog" :width="800" :height="'unset'">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">Changelog</p>
|
||||
<p class="text-3xl text-white truncate">Changelog</p>
|
||||
</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">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<modals-modal v-model="show" name="collections" :processing="processing" :width="500" :height="'unset'">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -104,7 +104,7 @@ export default {
|
||||
return this.$store.state.globals.showBatchCollectionModal
|
||||
},
|
||||
selectedBookIds() {
|
||||
return this.$store.state.selectedLibraryItems || []
|
||||
return (this.$store.state.globals.selectedMediaItems || []).map((i) => i.id)
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<modals-modal v-model="show" name="edit-collection" :width="700" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ $strings.HeaderCollection }}</p>
|
||||
<p class="text-3xl text-white truncate">{{ $strings.HeaderCollection }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
<modals-modal v-model="show" name="edit-book" :width="800" :height="height" :processing="processing" :content-margin-top="marginTop">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-4 landscape:px-4 landscape:py-2 md:portrait:p-5 lg:p-5 w-2/3 overflow-hidden pointer-events-none">
|
||||
<p class="font-book text-xl md:portrait:text-3xl md:landscape:text-lg lg:text-3xl text-white truncate pointer-events-none">{{ title }}</p>
|
||||
<p class="text-xl md:portrait:text-3xl md:landscape:text-lg lg:text-3xl text-white truncate pointer-events-none">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="absolute -top-10 left-0 z-10 w-full flex">
|
||||
<template v-for="tab in availableTabs">
|
||||
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
|
||||
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -74,6 +74,9 @@ export default {
|
||||
this.$store.commit('setEditModalTab', val)
|
||||
}
|
||||
},
|
||||
height() {
|
||||
return Math.min(this.availableHeight, 650)
|
||||
},
|
||||
tabs() {
|
||||
return [
|
||||
{
|
||||
@@ -136,6 +139,18 @@ export default {
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
selectedLibraryItem() {
|
||||
return this.$store.state.selectedLibraryItem || {}
|
||||
},
|
||||
selectedLibraryItemId() {
|
||||
return this.selectedLibraryItem.id
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem?.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
availableTabs() {
|
||||
if (!this.userCanUpdate && !this.userCanDownload) return []
|
||||
return this.tabs.filter((tab) => {
|
||||
@@ -144,6 +159,7 @@ export default {
|
||||
if (tab.admin && !this.userIsAdminOrUp) return false
|
||||
|
||||
if (tab.id === 'tools' && this.isMissing) return false
|
||||
if (tab.id === 'chapters' && this.isEBookOnly) return false
|
||||
|
||||
if ((tab.id === 'tools' || tab.id === 'files') && this.userCanDownload) return true
|
||||
if (tab.id !== 'tools' && tab.id !== 'files' && this.userCanUpdate) return true
|
||||
@@ -151,9 +167,6 @@ export default {
|
||||
return false
|
||||
})
|
||||
},
|
||||
height() {
|
||||
return Math.min(this.availableHeight, 650)
|
||||
},
|
||||
tabName() {
|
||||
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||
return _tab ? _tab.component : ''
|
||||
@@ -161,20 +174,11 @@ export default {
|
||||
isMissing() {
|
||||
return this.selectedLibraryItem.isMissing
|
||||
},
|
||||
selectedLibraryItem() {
|
||||
return this.$store.state.selectedLibraryItem || {}
|
||||
},
|
||||
selectedLibraryItemId() {
|
||||
return this.selectedLibraryItem.id
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
isEBookOnly() {
|
||||
return this.media.ebookFile && !this.media.tracks?.length
|
||||
},
|
||||
mediaType() {
|
||||
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||
return this.libraryItem?.mediaType || null
|
||||
},
|
||||
title() {
|
||||
return this.mediaMetadata.title || 'No Title'
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
|
||||
<div class="flex flex-wrap">
|
||||
<div class="relative">
|
||||
<covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
|
||||
<!-- book cover overlay -->
|
||||
<div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
|
||||
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
|
||||
@@ -16,10 +17,10 @@
|
||||
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-2 md:mt-0">
|
||||
<div class="flex items-center">
|
||||
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 pt-4 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></ui-file-input
|
||||
>
|
||||
<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>
|
||||
</ui-file-input>
|
||||
</div>
|
||||
<form @submit.prevent="submitForm" class="flex flex-grow">
|
||||
<ui-text-input-with-label v-model="imageUrl" :label="$strings.LabelCoverImageURL" />
|
||||
@@ -27,14 +28,14 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-primary">
|
||||
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-white border-opacity-10">
|
||||
<div class="flex items-center justify-center py-2">
|
||||
<p>{{ localCovers.length }} local image{{ localCovers.length !== 1 ? 's' : '' }}</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? $strings.ButtonHide : $strings.ButtonShow }}</ui-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="showLocalCovers" class="flex items-center justify-center">
|
||||
<div v-if="showLocalCovers" class="flex items-center justify-center pb-2">
|
||||
<template v-for="cover in localCovers">
|
||||
<div :key="cover.path" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(cover)">
|
||||
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
|
||||
@@ -48,13 +49,13 @@
|
||||
</div>
|
||||
<form @submit.prevent="submitSearchForm">
|
||||
<div class="flex items-center justify-start -mx-1 h-20">
|
||||
<div class="w-40 px-1">
|
||||
<div class="w-48 px-1">
|
||||
<ui-dropdown v-model="provider" :items="providers" :label="$strings.LabelProvider" small />
|
||||
</div>
|
||||
<div class="w-72 px-1">
|
||||
<ui-text-input-with-label v-model="searchTitle" :label="searchTitleLabel" :placeholder="$strings.PlaceholderSearch" />
|
||||
</div>
|
||||
<div v-show="provider != 'itunes'" class="w-72 px-1">
|
||||
<div v-show="provider != 'itunes' && provider != 'audiobookcovers'" class="w-72 px-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>
|
||||
@@ -127,7 +128,7 @@ export default {
|
||||
},
|
||||
providers() {
|
||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||
return this.$store.state.scanners.providers
|
||||
return [{ text: 'All', value: 'all' }, ...this.$store.state.scanners.providers, ...this.$store.state.scanners.coverOnlyProviders]
|
||||
},
|
||||
searchTitleLabel() {
|
||||
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
|
||||
@@ -222,7 +223,7 @@ export default {
|
||||
this.searchTitle = this.mediaMetadata.title || ''
|
||||
this.searchAuthor = this.mediaMetadata.authorName || ''
|
||||
if (this.isPodcast) this.provider = 'itunes'
|
||||
else this.provider = localStorage.getItem('book-provider') || 'google'
|
||||
else this.provider = localStorage.getItem('book-cover-provider') || localStorage.getItem('book-provider') || 'google'
|
||||
},
|
||||
removeCover() {
|
||||
if (!this.media.coverPath) {
|
||||
@@ -287,13 +288,13 @@ export default {
|
||||
},
|
||||
getSearchQuery() {
|
||||
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
|
||||
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
|
||||
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor || ''}`
|
||||
if (this.isPodcast) searchQuery += '&podcast=1'
|
||||
return searchQuery
|
||||
},
|
||||
persistProvider() {
|
||||
try {
|
||||
localStorage.setItem('book-provider', this.provider)
|
||||
localStorage.setItem('book-cover-provider', this.provider)
|
||||
} catch (error) {
|
||||
console.error('PersistProvider', error)
|
||||
}
|
||||
@@ -303,11 +304,14 @@ export default {
|
||||
this.persistProvider()
|
||||
|
||||
this.isProcessing = true
|
||||
var searchQuery = this.getSearchQuery()
|
||||
var results = await this.$axios.$get(`/api/search/covers?${searchQuery}`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return []
|
||||
})
|
||||
const searchQuery = this.getSearchQuery()
|
||||
const results = await this.$axios
|
||||
.$get(`/api/search/covers?${searchQuery}`)
|
||||
.then((res) => res.results)
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return []
|
||||
})
|
||||
this.coversFound = results
|
||||
this.isProcessing = false
|
||||
this.hasSearched = true
|
||||
|
||||
@@ -7,11 +7,6 @@
|
||||
|
||||
<div class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'border-t border-white border-opacity-5'">
|
||||
<div class="flex items-center px-4">
|
||||
<ui-btn v-if="userCanDelete" color="error" type="button" class="h-8 hidden md:block" :padding-x="3" small @click.stop.prevent="removeItem">{{ $strings.ButtonRemove }}</ui-btn>
|
||||
<ui-icon-btn bg-color="error" icon="delete" class="md:hidden" :size="7" icon-font-size="1rem" @click.stop.prevent="removeItem" />
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<ui-tooltip :disabled="!!quickMatching" :text="$getString('MessageQuickMatchDescription', [libraryProvider])" direction="bottom" class="mr-2 md:mr-4">
|
||||
<ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">{{ $strings.ButtonQuickMatch }}</ui-btn>
|
||||
</ui-tooltip>
|
||||
@@ -20,6 +15,8 @@
|
||||
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">{{ $strings.ButtonReScan }}</ui-btn>
|
||||
</ui-tooltip>
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<!-- desktop -->
|
||||
<ui-btn @click="save" class="mx-2 hidden md:block">{{ $strings.ButtonSave }}</ui-btn>
|
||||
<ui-btn @click="saveAndClose" class="mx-2 hidden md:block">{{ $strings.ButtonSaveAndClose }}</ui-btn>
|
||||
@@ -77,9 +74,6 @@ export default {
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
libraryId() {
|
||||
return this.libraryItem ? this.libraryItem.libraryId : null
|
||||
},
|
||||
@@ -129,7 +123,7 @@ export default {
|
||||
rescan() {
|
||||
this.rescanning = true
|
||||
this.$axios
|
||||
.$get(`/api/items/${this.libraryItemId}/scan`)
|
||||
.$post(`/api/items/${this.libraryItemId}/scan`)
|
||||
.then((data) => {
|
||||
this.rescanning = false
|
||||
var result = data.result
|
||||
@@ -184,23 +178,6 @@ export default {
|
||||
}
|
||||
return false
|
||||
},
|
||||
removeItem() {
|
||||
if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) {
|
||||
this.isProcessing = true
|
||||
this.$axios
|
||||
.$delete(`/api/items/${this.libraryItemId}`)
|
||||
.then(() => {
|
||||
console.log('Item removed')
|
||||
this.$toast.success('Item Removed')
|
||||
this.$emit('close')
|
||||
this.isProcessing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Remove item failed', error)
|
||||
this.isProcessing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
checkIsScrollable() {
|
||||
this.$nextTick(() => {
|
||||
var formWrapper = document.getElementById('formWrapper')
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</div>
|
||||
<div v-if="!episodes.length" class="flex my-4 text-center justify-center text-xl">{{ $strings.MessageNoEpisodes }}</div>
|
||||
<table v-else class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<tr>
|
||||
<th class="text-left">Sort #</th>
|
||||
<th class="text-left whitespace-nowrap">{{ $strings.LabelEpisode }}</th>
|
||||
<th class="text-left">{{ $strings.EpisodeTitle }}</th>
|
||||
@@ -33,7 +33,7 @@
|
||||
<td class="text-left">
|
||||
<p class="px-4">{{ episode.episode }}</p>
|
||||
</td>
|
||||
<td class="font-book">
|
||||
<td>
|
||||
{{ episode.title }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
|
||||
<tables-library-files-table expanded :files="libraryFiles" :library-item-id="libraryItem.id" :is-missing="isMissing" />
|
||||
<tables-library-files-table expanded :library-item="libraryItem" :is-missing="isMissing" in-modal />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -30,9 +30,6 @@ export default {
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
libraryFiles() {
|
||||
return this.libraryItem.libraryFiles || []
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
|
||||
@@ -34,13 +34,25 @@
|
||||
</div>
|
||||
<ui-checkbox v-model="selectAll" checkbox-bg="bg" @input="selectAllToggled" />
|
||||
<form @submit.prevent="submitMatchUpdate">
|
||||
<div v-if="selectedMatchOrig.cover" class="flex items-center py-2">
|
||||
<ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" readonly :label="$strings.LabelCover" class="flex-grow mx-4" />
|
||||
<div class="min-w-12 max-w-12 md:min-w-16 md:max-w-16">
|
||||
<a :href="selectedMatch.cover" target="_blank" class="w-full bg-primary">
|
||||
<img :src="selectedMatch.cover" class="h-full w-full object-contain" />
|
||||
</a>
|
||||
<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">
|
||||
<ui-checkbox v-model="selectedMatchUsage.cover" checkbox-bg="bg" @input="checkboxToggled" />
|
||||
<ui-text-input-with-label v-model="selectedMatch.cover" :disabled="!selectedMatchUsage.cover" readonly :label="$strings.LabelCover" class="flex-grow mx-4" />
|
||||
</div>
|
||||
|
||||
<div class="flex py-2">
|
||||
<div>
|
||||
<p class="text-center text-gray-200">New</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" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedMatchOrig.title" class="flex items-center py-2">
|
||||
@@ -103,7 +115,7 @@
|
||||
<div v-if="selectedMatchOrig.genres && 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="selectedMatch.genres" :disabled="!selectedMatchUsage.genres" :label="$strings.LabelGenres" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,6 +176,20 @@
|
||||
<p v-if="mediaMetadata.releaseDate" class="text-xs ml-1 text-white text-opacity-60">{{ $strings.LabelCurrently }} {{ mediaMetadata.releaseDate || '' }}</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end py-2">
|
||||
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
@@ -209,6 +235,7 @@ export default {
|
||||
explicit: true,
|
||||
asin: true,
|
||||
isbn: true,
|
||||
abridged: true,
|
||||
// Podcast specific
|
||||
itunesPageUrl: true,
|
||||
itunesId: true,
|
||||
@@ -273,6 +300,12 @@ export default {
|
||||
},
|
||||
isPodcast() {
|
||||
return this.mediaType == 'podcast'
|
||||
},
|
||||
genres() {
|
||||
const filterData = this.$store.state.libraries.filterData || {}
|
||||
const currentGenres = filterData.genres || []
|
||||
const selectedMatchGenres = this.selectedMatch.genres || []
|
||||
return [...new Set([...currentGenres ,...selectedMatchGenres])]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -306,13 +339,13 @@ export default {
|
||||
this.runSearch()
|
||||
},
|
||||
async runSearch() {
|
||||
var searchQuery = this.getSearchQuery()
|
||||
const searchQuery = this.getSearchQuery()
|
||||
if (this.lastSearch === searchQuery) return
|
||||
this.searchResults = []
|
||||
this.isProcessing = true
|
||||
this.lastSearch = searchQuery
|
||||
var searchEntity = this.isPodcast ? 'podcast' : 'books'
|
||||
var results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`, { timeout: 20000 }).catch((error) => {
|
||||
const searchEntity = this.isPodcast ? 'podcast' : 'books'
|
||||
let results = await this.$axios.$get(`/api/search/${searchEntity}?${searchQuery}`, { timeout: 20000 }).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return []
|
||||
})
|
||||
@@ -327,6 +360,7 @@ export default {
|
||||
res.itunesPageUrl = res.pageUrl || null
|
||||
res.itunesId = res.id || null
|
||||
res.author = res.artistName || null
|
||||
res.explicit = res.explicit || false
|
||||
return res
|
||||
})
|
||||
}
|
||||
@@ -335,8 +369,7 @@ export default {
|
||||
this.isProcessing = false
|
||||
this.hasSearched = true
|
||||
},
|
||||
init() {
|
||||
this.clearSelectedMatch()
|
||||
initSelectedMatchUsage() {
|
||||
this.selectedMatchUsage = {
|
||||
title: true,
|
||||
subtitle: true,
|
||||
@@ -353,6 +386,7 @@ export default {
|
||||
explicit: true,
|
||||
asin: true,
|
||||
isbn: true,
|
||||
abridged: true,
|
||||
// Podcast specific
|
||||
itunesPageUrl: true,
|
||||
itunesId: true,
|
||||
@@ -360,6 +394,27 @@ export default {
|
||||
releaseDate: true
|
||||
}
|
||||
|
||||
// Load saved selected match from local storage
|
||||
try {
|
||||
let savedSelectedMatchUsage = localStorage.getItem('selectedMatchUsage')
|
||||
if (!savedSelectedMatchUsage) return
|
||||
savedSelectedMatchUsage = JSON.parse(savedSelectedMatchUsage)
|
||||
|
||||
for (const key in savedSelectedMatchUsage) {
|
||||
if (this.selectedMatchUsage[key] !== undefined) {
|
||||
this.selectedMatchUsage[key] = !!savedSelectedMatchUsage[key]
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load saved selectedMatchUsage', error)
|
||||
}
|
||||
|
||||
this.checkboxToggled()
|
||||
},
|
||||
init() {
|
||||
this.clearSelectedMatch()
|
||||
this.initSelectedMatchUsage()
|
||||
|
||||
if (this.libraryItem.id !== this.libraryItemId) {
|
||||
this.searchResults = []
|
||||
this.hasSearched = false
|
||||
@@ -376,6 +431,12 @@ export default {
|
||||
if (this.isPodcast) this.provider = 'itunes'
|
||||
else this.provider = localStorage.getItem('book-provider') || 'google'
|
||||
|
||||
// Prefer using ASIN if set and using audible provider
|
||||
if (this.provider.startsWith('audible') && this.libraryItem.media.metadata.asin) {
|
||||
this.searchTitle = this.libraryItem.media.metadata.asin
|
||||
this.searchAuthor = ''
|
||||
}
|
||||
|
||||
if (this.searchTitle) {
|
||||
this.submitSearch()
|
||||
}
|
||||
@@ -442,7 +503,6 @@ export default {
|
||||
} else if (key === 'narrator') {
|
||||
updatePayload.metadata.narrators = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||
} else if (key === 'genres') {
|
||||
// updatePayload.metadata.genres = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||
updatePayload.metadata.genres = [...this.selectedMatch[key]]
|
||||
} else if (key === 'tags') {
|
||||
updatePayload.tags = this.selectedMatch[key].split(',').map((v) => v.trim())
|
||||
@@ -465,11 +525,14 @@ export default {
|
||||
console.log('Match payload', updatePayload)
|
||||
this.isProcessing = true
|
||||
|
||||
// Persist in local storage
|
||||
localStorage.setItem('selectedMatchUsage', JSON.stringify(this.selectedMatchUsage))
|
||||
|
||||
if (updatePayload.metadata.cover) {
|
||||
var coverPayload = {
|
||||
const coverPayload = {
|
||||
url: updatePayload.metadata.cover
|
||||
}
|
||||
var success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, coverPayload).catch((error) => {
|
||||
const success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, coverPayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
@@ -483,8 +546,8 @@ export default {
|
||||
}
|
||||
|
||||
if (Object.keys(updatePayload).length) {
|
||||
var mediaUpdatePayload = updatePayload
|
||||
var updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
|
||||
const mediaUpdatePayload = updatePayload
|
||||
const updateResult = await this.$axios.$patch(`/api/items/${this.libraryItemId}/media`, mediaUpdatePayload).catch((error) => {
|
||||
console.error('Failed to update', error)
|
||||
return false
|
||||
})
|
||||
@@ -502,6 +565,7 @@ export default {
|
||||
} else {
|
||||
this.clearSelectedMatch()
|
||||
}
|
||||
|
||||
this.isProcessing = false
|
||||
},
|
||||
clearSelectedMatch() {
|
||||
|
||||
@@ -59,6 +59,14 @@ export default {
|
||||
newMaxNewEpisodesToDownload: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
libraryItem: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isProcessing: {
|
||||
get() {
|
||||
@@ -176,4 +184,4 @@ export default {
|
||||
height: calc(100% - 80px);
|
||||
max-height: calc(100% - 80px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -46,8 +46,20 @@
|
||||
>{{ $strings.ButtonOpenManager }}
|
||||
<span class="material-icons 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- queued alert -->
|
||||
<widgets-alert v-if="isMetadataEmbedQueued" type="warning" class="mt-4">
|
||||
<p class="text-lg">Queued for metadata embed ({{ queuedEmbedLIds.length }} in queue)</p>
|
||||
</widgets-alert>
|
||||
|
||||
<!-- processing alert -->
|
||||
<widgets-alert v-if="isEmbedTaskRunning" type="warning" class="mt-4">
|
||||
<p class="text-lg">Currently embedding metadata</p>
|
||||
</widgets-alert>
|
||||
</div>
|
||||
|
||||
<p v-if="!mediaTracks.length" class="text-lg text-center my-8">{{ $strings.MessageNoAudioTracks }}</p>
|
||||
@@ -71,10 +83,10 @@ export default {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem ? this.libraryItem.id : null
|
||||
return this.libraryItem?.id || null
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
return this.libraryItem?.media || {}
|
||||
},
|
||||
mediaTracks() {
|
||||
return this.media.tracks || []
|
||||
@@ -92,9 +104,49 @@ export default {
|
||||
showMp3Split() {
|
||||
if (!this.mediaTracks.length) return false
|
||||
return this.isSingleM4b && this.chapters.length
|
||||
},
|
||||
queuedEmbedLIds() {
|
||||
return this.$store.state.tasks.queuedEmbedLIds || []
|
||||
},
|
||||
isMetadataEmbedQueued() {
|
||||
return this.queuedEmbedLIds.some((lid) => lid === this.libraryItemId)
|
||||
},
|
||||
tasks() {
|
||||
return this.$store.getters['tasks/getTasksByLibraryItemId'](this.libraryItemId)
|
||||
},
|
||||
embedTask() {
|
||||
return this.tasks.find((t) => t.action === 'embed-metadata')
|
||||
},
|
||||
encodeTask() {
|
||||
return this.tasks.find((t) => t.action === 'encode-m4b')
|
||||
},
|
||||
isEmbedTaskRunning() {
|
||||
return this.embedTask && !this.embedTask?.isFinished
|
||||
},
|
||||
isEncodeTaskRunning() {
|
||||
return this.encodeTask && !this.encodeTask?.isFinished
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
methods: {
|
||||
quickEmbed() {
|
||||
const payload = {
|
||||
message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?',
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$axios
|
||||
.$post(`/api/tools/item/${this.libraryItemId}/embed-metadata`)
|
||||
.then(() => {
|
||||
console.log('Audio metadata encode started')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Audio metadata encode failed', error)
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div class="w-full h-full px-1 md:px-4 py-2 mb-4">
|
||||
<div v-if="!showDirectoryPicker" class="w-full h-full py-4">
|
||||
<div class="flex flex-wrap md:flex-nowrap -mx-1">
|
||||
<div class="w-full h-full md:px-4 py-2 mb-4">
|
||||
<div v-if="!showDirectoryPicker" class="w-full h-full md:py-4">
|
||||
<div class="flex flex-wrap md:flex-nowrap -mx-1 mb-2">
|
||||
<div class="w-2/5 md:w-72 px-1 py-1 md:py-0">
|
||||
<ui-dropdown v-model="mediaType" :items="mediaTypes" :label="$strings.LabelMediaType" :disabled="!isNew" small @input="changedMediaType" />
|
||||
</div>
|
||||
<div class="w-full md:flex-grow px-1 py-1 md:py-0">
|
||||
<ui-text-input-with-label v-model="name" :label="$strings.LabelLibraryName" @blur="nameBlurred" />
|
||||
<ui-text-input-with-label ref="nameInput" v-model="name" :label="$strings.LabelLibraryName" @blur="nameBlurred" />
|
||||
</div>
|
||||
<div class="w-1/5 md:w-18 px-1 py-1 md:py-0">
|
||||
<ui-media-icon-picker v-model="icon" :label="$strings.LabelIcon" @input="iconChanged" />
|
||||
@@ -16,16 +16,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full py-4">
|
||||
<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>
|
||||
<ui-editable-text v-model="folder.fullPath" readonly type="text" class="w-full" />
|
||||
<ui-editable-text ref="folderInput" v-model="folder.fullPath" readonly type="text" class="w-full" />
|
||||
<span v-show="folders.length > 1" class="material-icons 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>
|
||||
<ui-editable-text v-model="newFolderPath" :placeholder="$strings.PlaceholderNewFolderPath" type="text" class="w-full" @blur="newFolderInputBlurred" />
|
||||
<ui-editable-text ref="newFolderInput" v-model="newFolderPath" :placeholder="$strings.PlaceholderNewFolderPath" type="text" class="w-full" @blur="newFolderInputBlurred" />
|
||||
</div>
|
||||
|
||||
<ui-btn class="w-full mt-2" color="primary" @click="browseForFolder">{{ $strings.ButtonBrowseForFolder }}</ui-btn>
|
||||
@@ -67,6 +67,10 @@ export default {
|
||||
value: 'podcast',
|
||||
text: this.$strings.LabelPodcasts
|
||||
}
|
||||
// {
|
||||
// value: 'music',
|
||||
// text: 'Music'
|
||||
// }
|
||||
]
|
||||
},
|
||||
folderPaths() {
|
||||
@@ -78,6 +82,19 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
checkBlurExpressionInput() {
|
||||
if (this.$refs.nameInput) {
|
||||
this.$refs.nameInput.blur()
|
||||
}
|
||||
if (this.$refs.folderInput && this.$refs.folderInput.length) {
|
||||
this.$refs.folderInput.forEach((input) => {
|
||||
if (input.blur) input.blur()
|
||||
})
|
||||
}
|
||||
if (this.$refs.newFolderInput) {
|
||||
this.$refs.newFolderInput.blur()
|
||||
}
|
||||
},
|
||||
browseForFolder() {
|
||||
this.showDirectoryPicker = true
|
||||
},
|
||||
@@ -140,3 +157,14 @@ export default {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.folders-container {
|
||||
max-height: calc(80vh - 192px);
|
||||
}
|
||||
@media (max-device-width: 768px) {
|
||||
.folders-container {
|
||||
max-height: calc(80vh - 292px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,16 +2,16 @@
|
||||
<modals-modal v-model="show" name="edit-library" :width="700" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-xl md:text-3xl text-white truncate">{{ title }}</p>
|
||||
<p class="text-xl md:text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="absolute -top-10 left-0 z-10 w-full flex">
|
||||
<template v-for="tab in tabs">
|
||||
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
|
||||
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="px-2 md:px-4 w-full text-sm pt-6 pb-20 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
<div class="px-2 md:px-4 w-full text-sm pt-2 md:pt-6 pb-20 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
<component v-if="libraryCopy && show" ref="tabComponent" :is="tabName" :is-new="!library" :library="libraryCopy" :processing.sync="processing" @update="updateLibrary" @close="show = false" />
|
||||
|
||||
<div class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-white border-opacity-10">
|
||||
@@ -144,8 +144,6 @@ export default {
|
||||
return true
|
||||
},
|
||||
submit() {
|
||||
if (!this.validate()) return
|
||||
|
||||
// If custom expression input is focused then unfocus it instead of submitting
|
||||
if (this.$refs.tabComponent && this.$refs.tabComponent.checkBlurExpressionInput) {
|
||||
if (this.$refs.tabComponent.checkBlurExpressionInput()) {
|
||||
@@ -153,6 +151,8 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.validate()) return
|
||||
|
||||
if (this.library) {
|
||||
this.submitUpdateLibrary()
|
||||
} else {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<modals-modal ref="modal" v-model="show" name="notification-edit" :width="800" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<form @submit.prevent="submitForm">
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="w-full px-3 py-5 md:p-12">
|
||||
<ui-dropdown v-model="newNotification.eventName" :label="$strings.LabelNotificationEvent" :items="eventOptions" class="mb-4" @input="eventOptionUpdated" />
|
||||
|
||||
<ui-multi-select v-model="newNotification.urls" :label="$strings.LabelNotificationAppriseURL" class="mb-2" />
|
||||
<ui-multi-select ref="urlsInput" v-model="newNotification.urls" :label="$strings.LabelNotificationAppriseURL" class="mb-2" />
|
||||
|
||||
<ui-text-input-with-label v-model="newNotification.titleTemplate" :label="$strings.LabelNotificationTitleTemplate" class="mb-2" />
|
||||
|
||||
@@ -103,6 +103,8 @@ export default {
|
||||
if (this.$refs.modal) this.$refs.modal.setHide()
|
||||
},
|
||||
submitForm() {
|
||||
this.$refs.urlsInput?.forceBlur()
|
||||
|
||||
if (!this.newNotification.urls.length) {
|
||||
this.$toast.error('Must enter an Apprise URL')
|
||||
return
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<modals-modal v-model="show" name="queue-items" :width="800" :height="'unset'">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ $strings.HeaderPlayerQueue }}</p>
|
||||
<p class="text-3xl text-white truncate">{{ $strings.HeaderPlayerQueue }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden py-4" style="max-height: 80vh">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<modals-modal v-model="show" name="playlists" :processing="processing" :width="500" :height="'unset'">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden pointer-events-none">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<modals-modal v-model="show" name="edit-playlist" :width="700" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ $strings.HeaderPlaylist }}</p>
|
||||
<p class="text-3xl text-white truncate">{{ $strings.HeaderPlaylist }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
|
||||
@@ -2,17 +2,24 @@
|
||||
<modals-modal v-model="show" name="podcast-episode-edit-modal" :width="800" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="absolute -top-10 left-0 z-10 w-full flex">
|
||||
<template v-for="tab in tabs">
|
||||
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
|
||||
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-0.5 sm:mr-1 cursor-pointer hover:bg-bg border-t border-l border-r border-black-300 tab text-xs sm:text-base" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
|
||||
</template>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<component v-if="libraryItem && show" :is="tabComponentName" :library-item="libraryItem" :episode="episode" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
|
||||
<component v-if="libraryItem && show" :is="tabComponentName" :library-item="libraryItem" :episode="episodeItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" />
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
@@ -21,8 +28,8 @@
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
episodeItem: null,
|
||||
processing: false,
|
||||
selectedTab: 'details',
|
||||
tabs: [
|
||||
{
|
||||
id: 'details',
|
||||
@@ -37,6 +44,29 @@ export default {
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
const availableTabIds = this.tabs.map((tab) => tab.id)
|
||||
if (!availableTabIds.length) {
|
||||
this.show = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!availableTabIds.includes(this.selectedTab)) {
|
||||
this.selectedTab = availableTabIds[0]
|
||||
}
|
||||
|
||||
this.episodeItem = null
|
||||
this.init()
|
||||
this.registerListeners()
|
||||
} else {
|
||||
this.unregisterListeners()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
@@ -46,27 +76,118 @@ export default {
|
||||
this.$store.commit('globals/setShowEditPodcastEpisodeModal', val)
|
||||
}
|
||||
},
|
||||
selectedTab: {
|
||||
get() {
|
||||
return this.$store.state.editPodcastModalTab
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('setEditPodcastModalTab', val)
|
||||
}
|
||||
},
|
||||
libraryItem() {
|
||||
return this.$store.state.selectedLibraryItem
|
||||
},
|
||||
episode() {
|
||||
return this.$store.state.globals.selectedEpisode
|
||||
},
|
||||
selectedEpisodeId() {
|
||||
return this.episode.id
|
||||
},
|
||||
title() {
|
||||
if (!this.libraryItem) return ''
|
||||
return this.libraryItem.media.metadata.title || 'Unknown'
|
||||
return this.libraryItem?.media.metadata.title || 'Unknown'
|
||||
},
|
||||
tabComponentName() {
|
||||
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||
const _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||
return _tab ? _tab.component : ''
|
||||
},
|
||||
episodeTableEpisodeIds() {
|
||||
return this.$store.state.episodeTableEpisodeIds || []
|
||||
},
|
||||
currentEpisodeIndex() {
|
||||
if (!this.episodeTableEpisodeIds.length) return 0
|
||||
return this.episodeTableEpisodeIds.findIndex((bid) => bid === this.selectedEpisodeId)
|
||||
},
|
||||
canGoPrev() {
|
||||
return this.episodeTableEpisodeIds.length && this.currentEpisodeIndex > 0
|
||||
},
|
||||
canGoNext() {
|
||||
return this.episodeTableEpisodeIds.length && this.currentEpisodeIndex < this.episodeTableEpisodeIds.length - 1
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async goPrevEpisode() {
|
||||
if (this.currentEpisodeIndex - 1 < 0) return
|
||||
const prevEpisodeId = this.episodeTableEpisodeIds[this.currentEpisodeIndex - 1]
|
||||
this.processing = true
|
||||
const prevEpisode = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${prevEpisodeId}`).catch((error) => {
|
||||
const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch episode'
|
||||
this.$toast.error(errorMsg)
|
||||
return null
|
||||
})
|
||||
this.processing = false
|
||||
if (prevEpisode) {
|
||||
this.episodeItem = prevEpisode
|
||||
this.$store.commit('globals/setSelectedEpisode', prevEpisode)
|
||||
} else {
|
||||
console.error('Episode not found', prevEpisodeId)
|
||||
}
|
||||
},
|
||||
async goNextEpisode() {
|
||||
if (this.currentEpisodeIndex >= this.episodeTableEpisodeIds.length - 1) return
|
||||
this.processing = true
|
||||
const nextEpisodeId = this.episodeTableEpisodeIds[this.currentEpisodeIndex + 1]
|
||||
const nextEpisode = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${nextEpisodeId}`).catch((error) => {
|
||||
const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to fetch book'
|
||||
this.$toast.error(errorMsg)
|
||||
return null
|
||||
})
|
||||
this.processing = false
|
||||
if (nextEpisode) {
|
||||
this.episodeItem = nextEpisode
|
||||
this.$store.commit('globals/setSelectedEpisode', nextEpisode)
|
||||
} else {
|
||||
console.error('Episode not found', nextEpisodeId)
|
||||
}
|
||||
},
|
||||
selectTab(tab) {
|
||||
this.selectedTab = tab
|
||||
if (this.selectedTab === tab) return
|
||||
if (this.tabs.find((t) => t.id === tab)) {
|
||||
this.selectedTab = tab
|
||||
this.processing = false
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.fetchFull()
|
||||
},
|
||||
async fetchFull() {
|
||||
try {
|
||||
this.processing = true
|
||||
this.episodeItem = await this.$axios.$get(`/api/podcasts/${this.libraryItem.id}/episode/${this.selectedEpisodeId}`)
|
||||
this.processing = false
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch episode', this.selectedEpisodeId, error)
|
||||
this.processing = false
|
||||
this.show = false
|
||||
}
|
||||
},
|
||||
hotkey(action) {
|
||||
if (action === this.$hotkeys.Modal.NEXT_PAGE) {
|
||||
this.goNextEpisode()
|
||||
} else if (action === this.$hotkeys.Modal.PREV_PAGE) {
|
||||
this.goPrevEpisode()
|
||||
}
|
||||
},
|
||||
registerListeners() {
|
||||
this.$eventBus.$on('modal-hotkey', this.hotkey)
|
||||
},
|
||||
unregisterListeners() {
|
||||
this.$eventBus.$off('modal-hotkey', this.hotkey)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {},
|
||||
beforeDestroy() {
|
||||
this.unregisterListeners()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -77,4 +198,4 @@ export default {
|
||||
.tab.tab-selected {
|
||||
height: 41px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -2,25 +2,37 @@
|
||||
<modals-modal v-model="show" name="podcast-episodes-modal" :width="1200" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="wrapper" id="podcast-wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||
<div v-if="episodesCleaned.length" class="w-full py-3 mx-auto flex">
|
||||
<form @submit.prevent="submit" class="flex flex-grow">
|
||||
<ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" />
|
||||
</form>
|
||||
</div>
|
||||
<div ref="episodeContainer" id="episodes-scroll" class="w-full overflow-x-hidden overflow-y-auto">
|
||||
<div
|
||||
v-for="(episode, index) in episodes"
|
||||
v-for="(episode, index) in episodesList"
|
||||
:key="index"
|
||||
class="relative"
|
||||
:class="itemEpisodeMap[episode.enclosure.url] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
|
||||
@click="toggleSelectEpisode(index, episode)"
|
||||
:class="itemEpisodeMap[episode.cleanUrl] ? 'bg-primary bg-opacity-40' : selectedEpisodes[episode.cleanUrl] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
|
||||
@click="toggleSelectEpisode(episode)"
|
||||
>
|
||||
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
||||
<span v-if="itemEpisodeMap[episode.enclosure.url]" class="material-icons text-success text-xl">download_done</span>
|
||||
<ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
|
||||
<span v-if="itemEpisodeMap[episode.cleanUrl]" class="material-icons 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">
|
||||
<p v-if="episode.episode" class="font-semibold text-gray-200">#{{ episode.episode }}</p>
|
||||
<p class="break-words mb-1">{{ episode.title }}</p>
|
||||
<div class="flex items-center font-semibold text-gray-200">
|
||||
<div v-if="episode.season || episode.episode">#</div>
|
||||
<div v-if="episode.season">{{ episode.season }}x</div>
|
||||
<div v-if="episode.episode">{{ episode.episode }}</div>
|
||||
</div>
|
||||
<div class="flex items-center mb-1">
|
||||
<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 class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
||||
</div>
|
||||
@@ -51,8 +63,12 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
episodesCleaned: [],
|
||||
selectedEpisodes: {},
|
||||
selectAll: false
|
||||
selectAll: false,
|
||||
search: null,
|
||||
searchTimeout: null,
|
||||
searchText: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -77,7 +93,7 @@ export default {
|
||||
return this.libraryItem.media.metadata.title || 'Unknown'
|
||||
},
|
||||
allDownloaded() {
|
||||
return !this.episodes.some((episode) => !this.itemEpisodeMap[episode.enclosure.url])
|
||||
return !this.episodesCleaned.some((episode) => !this.itemEpisodeMap[episode.cleanUrl])
|
||||
},
|
||||
episodesSelected() {
|
||||
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
|
||||
@@ -93,38 +109,52 @@ export default {
|
||||
itemEpisodeMap() {
|
||||
var map = {}
|
||||
this.itemEpisodes.forEach((item) => {
|
||||
if (item.enclosure) map[item.enclosure.url] = true
|
||||
if (item.enclosure) map[item.enclosure.url.split('?')[0]] = true
|
||||
})
|
||||
return map
|
||||
},
|
||||
episodesList() {
|
||||
return this.episodesCleaned.filter((episode) => {
|
||||
if (!this.searchText) return true
|
||||
return (episode.title && episode.title.toLowerCase().includes(this.searchText)) || (episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
inputUpdate() {
|
||||
clearTimeout(this.searchTimeout)
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
if (!this.search || !this.search.trim()) {
|
||||
this.searchText = ''
|
||||
return
|
||||
}
|
||||
this.searchText = this.search.toLowerCase().trim()
|
||||
}, 500)
|
||||
},
|
||||
toggleSelectAll(val) {
|
||||
for (let i = 0; i < this.episodes.length; i++) {
|
||||
const episode = this.episodes[i]
|
||||
if (this.itemEpisodeMap[episode.enclosure.url]) this.selectedEpisodes[String(i)] = false
|
||||
else this.$set(this.selectedEpisodes, String(i), val)
|
||||
for (const episode of this.episodesCleaned) {
|
||||
if (this.itemEpisodeMap[episode.cleanUrl]) this.selectedEpisodes[episode.cleanUrl] = false
|
||||
else this.$set(this.selectedEpisodes, episode.cleanUrl, val)
|
||||
}
|
||||
},
|
||||
checkSetIsSelectedAll() {
|
||||
for (let i = 0; i < this.episodes.length; i++) {
|
||||
const episode = this.episodes[i]
|
||||
if (!this.itemEpisodeMap[episode.enclosure.url] && !this.selectedEpisodes[String(i)]) {
|
||||
for (const episode of this.episodesCleaned) {
|
||||
if (!this.itemEpisodeMap[episode.cleanUrl] && !this.selectedEpisodes[episode.cleanUrl]) {
|
||||
this.selectAll = false
|
||||
return
|
||||
}
|
||||
}
|
||||
this.selectAll = true
|
||||
},
|
||||
toggleSelectEpisode(index, episode) {
|
||||
if (this.itemEpisodeMap[episode.enclosure.url]) return
|
||||
this.$set(this.selectedEpisodes, String(index), !this.selectedEpisodes[String(index)])
|
||||
toggleSelectEpisode(episode) {
|
||||
if (this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]]) return
|
||||
this.$set(this.selectedEpisodes, episode.cleanUrl, !this.selectedEpisodes[episode.cleanUrl])
|
||||
this.checkSetIsSelectedAll()
|
||||
},
|
||||
submit() {
|
||||
var episodesToDownload = []
|
||||
if (this.episodesSelected.length) {
|
||||
episodesToDownload = this.episodesSelected.map((episodeIndex) => this.episodes[Number(episodeIndex)])
|
||||
episodesToDownload = this.episodesSelected.map((cleanUrl) => this.episodesCleaned.find((ep) => ep.cleanUrl == cleanUrl))
|
||||
}
|
||||
|
||||
var payloadSize = JSON.stringify(episodesToDownload).length
|
||||
@@ -154,7 +184,15 @@ export default {
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
|
||||
this.episodesCleaned = this.episodes
|
||||
.filter((ep) => ep.enclosure?.url)
|
||||
.map((_ep) => {
|
||||
return {
|
||||
..._ep,
|
||||
cleanUrl: _ep.enclosure.url.split('?')[0]
|
||||
}
|
||||
})
|
||||
this.episodesCleaned.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
|
||||
this.selectAll = false
|
||||
this.selectedEpisodes = {}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<modals-modal v-model="show" name="new-podcast-modal" :width="1000" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-3/4 overflow-hidden">
|
||||
<p class="font-book text-xl md:text-3xl text-white truncate">{{ title }}</p>
|
||||
<p class="text-xl md:text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="wrapper" id="podcast-wrapper" class="p-2 md:p-8 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-x-hidden overflow-y-auto" style="max-height: 80vh">
|
||||
@@ -28,6 +28,17 @@
|
||||
<ui-multi-select v-model="podcast.genres" :items="podcast.genres" :label="$strings.LabelGenres" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap">
|
||||
<div class="md:w-1/4 p-2">
|
||||
<ui-dropdown :label="$strings.LabelPodcastType" v-model="podcast.type" :items="podcastTypes" small />
|
||||
</div>
|
||||
<div class="md:w-1/4 p-2">
|
||||
<ui-text-input-with-label v-model="podcast.language" :label="$strings.LabelLanguage" />
|
||||
</div>
|
||||
<div class="md:w-1/4 px-2 pt-7">
|
||||
<ui-checkbox v-model="podcast.explicit" :label="$strings.LabelExplicit" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 w-full">
|
||||
<ui-textarea-with-label v-model="podcast.description" :label="$strings.LabelDescription" :rows="3" />
|
||||
</div>
|
||||
@@ -82,7 +93,10 @@ export default {
|
||||
itunesPageUrl: '',
|
||||
itunesId: '',
|
||||
itunesArtistId: '',
|
||||
autoDownloadEpisodes: false
|
||||
autoDownloadEpisodes: false,
|
||||
language: '',
|
||||
explicit: false,
|
||||
type: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -140,6 +154,9 @@ export default {
|
||||
selectedFolderPath() {
|
||||
if (!this.selectedFolder) return ''
|
||||
return this.selectedFolder.fullPath
|
||||
},
|
||||
podcastTypes() {
|
||||
return this.$store.state.globals.podcastTypes || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -170,7 +187,9 @@ export default {
|
||||
itunesPageUrl: this.podcast.itunesPageUrl,
|
||||
itunesId: this.podcast.itunesId,
|
||||
itunesArtistId: this.podcast.itunesArtistId,
|
||||
language: this.podcast.language
|
||||
language: this.podcast.language,
|
||||
explicit: this.podcast.explicit,
|
||||
type: this.podcast.type
|
||||
},
|
||||
autoDownloadEpisodes: this.podcast.autoDownloadEpisodes
|
||||
}
|
||||
@@ -205,9 +224,11 @@ export default {
|
||||
this.podcast.itunesPageUrl = this._podcastData.pageUrl || ''
|
||||
this.podcast.itunesId = this._podcastData.id || ''
|
||||
this.podcast.itunesArtistId = this._podcastData.artistId || ''
|
||||
this.podcast.language = this._podcastData.language || ''
|
||||
this.podcast.language = this._podcastData.language || this.feedMetadata.language || ''
|
||||
this.podcast.autoDownloadEpisodes = false
|
||||
this.podcast.type = this._podcastData.type || this.feedMetadata.type || 'episodic'
|
||||
|
||||
this.podcast.explicit = this._podcastData.explicit || this.feedMetadata.explicit === 'yes' || this.feedMetadata.explicit == 'true'
|
||||
if (this.folderItems[0]) {
|
||||
this.selectedFolderId = this.folderItems[0].value
|
||||
this.folderUpdated()
|
||||
@@ -226,4 +247,4 @@ export default {
|
||||
#episodes-scroll {
|
||||
max-height: calc(80vh - 200px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<modals-modal v-model="show" name="opml-feeds-modal" :width="1000" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="wrapper" class="p-4 w-full text-sm py-2 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||
@@ -97,7 +97,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
toFeedMetadata(feed) {
|
||||
var metadata = feed.metadata
|
||||
const metadata = feed.metadata
|
||||
return {
|
||||
title: metadata.title,
|
||||
author: metadata.author,
|
||||
@@ -122,9 +122,9 @@ export default {
|
||||
},
|
||||
async submit() {
|
||||
this.processing = true
|
||||
var newFeedPayloads = this.feedMetadata.map((metadata) => {
|
||||
const newFeedPayloads = this.feedMetadata.map((metadata) => {
|
||||
return {
|
||||
path: `${this.selectedFolderPath}\\${this.$sanitizeFilename(metadata.title)}`,
|
||||
path: `${this.selectedFolderPath}/${this.$sanitizeFilename(metadata.title)}`,
|
||||
folderId: this.selectedFolderId,
|
||||
libraryId: this.currentLibrary.id,
|
||||
media: {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<modals-modal v-model="show" name="podcast-episode-remove-modal" :width="500" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<modals-modal v-model="show" name="podcast-episode-view-modal" :width="800" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ $strings.LabelEpisode }}</p>
|
||||
<p class="text-3xl text-white truncate">{{ $strings.LabelEpisode }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="wrapper" class="p-4 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-y-auto" style="max-height: 80vh">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<ui-text-input-with-label v-model="newEpisode.episode" :label="$strings.LabelEpisode" />
|
||||
</div>
|
||||
<div class="w-1/5 p-1">
|
||||
<ui-text-input-with-label v-model="newEpisode.episodeType" :label="$strings.LabelEpisodeType" />
|
||||
<ui-dropdown v-model="newEpisode.episodeType" :label="$strings.LabelEpisodeType" :items="episodeTypes" small />
|
||||
</div>
|
||||
<div class="w-2/5 p-1">
|
||||
<ui-text-input-with-label v-model="pubDateInput" @input="updatePubDate" type="datetime-local" :label="$strings.LabelPubDate" />
|
||||
@@ -24,11 +24,17 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end pt-4">
|
||||
<ui-btn @click="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
<!-- desktop -->
|
||||
<ui-btn @click="submit" class="mx-2 hidden md:block">{{ $strings.ButtonSave }}</ui-btn>
|
||||
<ui-btn @click="saveAndClose" class="mx-2 hidden md:block">{{ $strings.ButtonSaveAndClose }}</ui-btn>
|
||||
|
||||
<!-- mobile -->
|
||||
<ui-btn @click="saveAndClose" class="mx-2 md:hidden">{{ $strings.ButtonSave }}</ui-btn>
|
||||
</div>
|
||||
<div v-if="enclosureUrl" class="py-4">
|
||||
<p class="text-xs text-gray-300 font-semibold">Episode URL from RSS feed</p>
|
||||
<a :href="enclosureUrl" target="_blank" class="text-xs text-blue-400 hover:text-blue-500 hover:underline">{{ enclosureUrl }}</a>
|
||||
<div v-if="enclosureUrl" class="pb-4 pt-6">
|
||||
<ui-text-input-with-label :value="enclosureUrl" readonly class="text-xs">
|
||||
<label class="px-1 text-xs text-gray-200 font-semibold">Episode URL from RSS feed</label>
|
||||
</ui-text-input-with-label>
|
||||
</div>
|
||||
<div v-else class="py-4">
|
||||
<p class="text-xs text-gray-300 font-semibold">Episode not linked to RSS feed episode</p>
|
||||
@@ -89,6 +95,9 @@ export default {
|
||||
},
|
||||
enclosureUrl() {
|
||||
return this.enclosure.url
|
||||
},
|
||||
episodeTypes() {
|
||||
return this.$store.state.globals.episodeTypes || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -122,28 +131,43 @@ export default {
|
||||
}
|
||||
return updatePayload
|
||||
},
|
||||
submit() {
|
||||
const payload = this.getUpdatePayload()
|
||||
if (!Object.keys(payload).length) {
|
||||
return this.$toast.info('No updates were made')
|
||||
async saveAndClose() {
|
||||
const wasUpdated = await this.submit()
|
||||
if (wasUpdated !== null) this.$emit('close')
|
||||
},
|
||||
async submit() {
|
||||
if (this.isProcessing) {
|
||||
return null
|
||||
}
|
||||
|
||||
const updatedDetails = this.getUpdatePayload()
|
||||
if (!Object.keys(updatedDetails).length) {
|
||||
this.$toast.info('No changes were made')
|
||||
return false
|
||||
}
|
||||
return this.updateDetails(updatedDetails)
|
||||
},
|
||||
async updateDetails(updatedDetails) {
|
||||
this.isProcessing = true
|
||||
this.$axios
|
||||
.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, payload)
|
||||
.then(() => {
|
||||
this.isProcessing = false
|
||||
const updateResult = await this.$axios.$patch(`/api/podcasts/${this.libraryItem.id}/episode/${this.episodeId}`, updatedDetails).catch((error) => {
|
||||
console.error('Failed update episode', error)
|
||||
this.isProcessing = false
|
||||
this.$toast.error(error?.response?.data || 'Failed to update episode')
|
||||
return false
|
||||
})
|
||||
|
||||
this.isProcessing = false
|
||||
if (updateResult) {
|
||||
if (updateResult) {
|
||||
this.$toast.success('Podcast episode updated')
|
||||
this.$emit('close')
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to update episode'
|
||||
console.error('Failed update episode', error)
|
||||
this.isProcessing = false
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
return true
|
||||
} else {
|
||||
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -2,17 +2,38 @@
|
||||
<modals-modal v-model="show" name="rss-feed-modal" :width="600" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
|
||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="wrapper" class="px-8 py-6 w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden">
|
||||
<div v-if="currentFeedUrl" class="w-full">
|
||||
<div v-if="currentFeed" class="w-full">
|
||||
<p class="text-lg font-semibold mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</p>
|
||||
|
||||
<div class="w-full relative">
|
||||
<ui-text-input v-model="currentFeedUrl" readonly />
|
||||
<ui-text-input v-model="currentFeed.feedUrl" readonly />
|
||||
|
||||
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(currentFeedUrl)">content_copy</span>
|
||||
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base transition-transform duration-100 text-gray-300 hover:text-white transform hover:scale-125 cursor-pointer" @click="copyToClipboard(currentFeed.feedUrl)">content_copy</span>
|
||||
</div>
|
||||
|
||||
<div v-if="currentFeed.meta" class="mt-5">
|
||||
<div class="flex py-0.5">
|
||||
<div class="w-48">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedPreventIndexing }}</span>
|
||||
</div>
|
||||
<div>{{ currentFeed.meta.preventIndexing ? 'Yes' : 'No' }}</div>
|
||||
</div>
|
||||
<div v-if="currentFeed.meta.ownerName" class="flex py-0.5">
|
||||
<div class="w-48">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerName }}</span>
|
||||
</div>
|
||||
<div>{{ currentFeed.meta.ownerName }}</div>
|
||||
</div>
|
||||
<div v-if="currentFeed.meta.ownerEmail" class="flex py-0.5">
|
||||
<div class="w-48">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerEmail }}</span>
|
||||
</div>
|
||||
<div>{{ currentFeed.meta.ownerEmail }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full">
|
||||
@@ -22,13 +43,14 @@
|
||||
<ui-text-input-with-label v-model="newFeedSlug" :label="$strings.LabelRSSFeedSlug" />
|
||||
<p class="text-xs text-gray-400 py-0.5 px-1">{{ $getString('MessageFeedURLWillBe', [demoFeedUrl]) }}</p>
|
||||
</div>
|
||||
<widgets-rss-feed-metadata-builder v-model="metadataDetails" />
|
||||
|
||||
<p v-if="isHttp" class="w-full pt-2 text-warning text-xs">{{ $strings.NoteRSSFeedPodcastAppsHttps }}</p>
|
||||
<p v-if="hasEpisodesWithoutPubDate" class="w-full pt-2 text-warning text-xs">{{ $strings.NoteRSSFeedPodcastAppsPubDate }}</p>
|
||||
</div>
|
||||
<div v-show="userIsAdminOrUp" class="flex items-center pt-6">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="currentFeedUrl" color="error" small @click="closeFeed">{{ $strings.ButtonCloseFeed }}</ui-btn>
|
||||
<ui-btn v-if="currentFeed" color="error" small @click="closeFeed">{{ $strings.ButtonCloseFeed }}</ui-btn>
|
||||
<ui-btn v-else color="success" small @click="openFeed">{{ $strings.ButtonOpenFeed }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
@@ -37,19 +59,16 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
feedUrl: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
newFeedSlug: null,
|
||||
currentFeedUrl: null
|
||||
currentFeed: null,
|
||||
metadataDetails: {
|
||||
preventIndexing: true,
|
||||
ownerName: '',
|
||||
ownerEmail: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -65,23 +84,29 @@ export default {
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
return this.$store.state.globals.showRSSFeedOpenCloseModal
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
this.$store.commit('globals/setShowRSSFeedOpenCloseModal', val)
|
||||
}
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
rssFeedEntity() {
|
||||
return this.$store.state.globals.rssFeedEntity || {}
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
entityId() {
|
||||
return this.rssFeedEntity.id
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
entityType() {
|
||||
return this.rssFeedEntity.type
|
||||
},
|
||||
entityFeed() {
|
||||
return this.rssFeedEntity.feed
|
||||
},
|
||||
hasEpisodesWithoutPubDate() {
|
||||
return !!this.rssFeedEntity.hasEpisodesWithoutPubDate
|
||||
},
|
||||
title() {
|
||||
return this.mediaMetadata.title
|
||||
return this.rssFeedEntity.name
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
@@ -91,12 +116,6 @@ export default {
|
||||
},
|
||||
isHttp() {
|
||||
return window.origin.startsWith('http://')
|
||||
},
|
||||
episodes() {
|
||||
return this.media.episodes || []
|
||||
},
|
||||
hasEpisodesWithoutPubDate() {
|
||||
return this.episodes.some((ep) => !ep.pubDate)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -106,7 +125,7 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
var sanitized = this.$sanitizeSlug(this.newFeedSlug)
|
||||
const sanitized = this.$sanitizeSlug(this.newFeedSlug)
|
||||
if (this.newFeedSlug !== sanitized) {
|
||||
this.newFeedSlug = sanitized
|
||||
this.$toast.warning('Slug had to be modified - Run again')
|
||||
@@ -115,25 +134,22 @@ export default {
|
||||
|
||||
const payload = {
|
||||
serverAddress: window.origin,
|
||||
slug: this.newFeedSlug
|
||||
slug: this.newFeedSlug,
|
||||
metadataDetails: this.metadataDetails
|
||||
}
|
||||
if (this.$isDev) payload.serverAddress = `http://localhost:3333${this.$config.routerBasePath}`
|
||||
|
||||
console.log('Payload', payload)
|
||||
this.$axios
|
||||
.$post(`/api/items/${this.libraryItemId}/open-feed`, payload)
|
||||
.$post(`/api/feeds/${this.entityType}/${this.entityId}/open`, payload)
|
||||
.then((data) => {
|
||||
if (data.success) {
|
||||
console.log('Opened RSS Feed', data)
|
||||
this.currentFeedUrl = data.feedUrl
|
||||
} else {
|
||||
const errorMsg = data.error || 'Unknown error'
|
||||
this.$toast.error(errorMsg)
|
||||
}
|
||||
console.log('Opened RSS Feed', data)
|
||||
this.currentFeed = data.feed
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to open RSS Feed', error)
|
||||
this.$toast.error()
|
||||
const errorMsg = error.response ? error.response.data : null
|
||||
this.$toast.error(errorMsg || 'Failed to open RSS Feed')
|
||||
})
|
||||
},
|
||||
copyToClipboard(str) {
|
||||
@@ -142,22 +158,23 @@ export default {
|
||||
closeFeed() {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post(`/api/items/${this.libraryItem.id}/close-feed`)
|
||||
.$post(`/api/feeds/${this.currentFeed.id}/close`)
|
||||
.then(() => {
|
||||
this.$toast.success(this.$strings.ToastRSSFeedCloseSuccess)
|
||||
this.show = false
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to close RSS feed', error)
|
||||
this.processing = false
|
||||
this.$toast.error(this.$strings.ToastRSSFeedCloseFailed)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
init() {
|
||||
if (!this.libraryItem) return
|
||||
this.newFeedSlug = this.libraryItem.id
|
||||
this.currentFeedUrl = this.feedUrl
|
||||
if (!this.entityId) return
|
||||
this.newFeedSlug = this.entityId
|
||||
this.currentFeed = this.entityFeed
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
@@ -15,7 +15,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Hover timestamp -->
|
||||
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
||||
<div ref="hoverTimestamp" class="absolute -top-8 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none z-10">
|
||||
<p ref="hoverTimestampText" class="text-xs font-mono text-center px-2 py-0.5 truncate whitespace-nowrap">00:00</p>
|
||||
</div>
|
||||
<div ref="hoverTimestampArrow" class="absolute -top-3 left-0 bg-white text-black rounded-full opacity-0 pointer-events-none">
|
||||
@@ -38,7 +38,8 @@ export default {
|
||||
currentChapter: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
playbackRate: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -63,6 +64,10 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
_playbackRate() {
|
||||
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
|
||||
return this.playbackRate
|
||||
},
|
||||
currentChapterDuration() {
|
||||
if (!this.currentChapter) return 0
|
||||
return this.currentChapter.end - this.currentChapter.start
|
||||
@@ -81,11 +86,11 @@ export default {
|
||||
clickTrack(e) {
|
||||
if (this.loading) return
|
||||
|
||||
var offsetX = e.offsetX
|
||||
var perc = offsetX / this.trackWidth
|
||||
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0;
|
||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration;
|
||||
const time = baseTime + (perc * duration);
|
||||
const offsetX = e.offsetX
|
||||
const perc = offsetX / this.trackWidth
|
||||
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
|
||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
||||
const time = baseTime + perc * duration
|
||||
if (isNaN(time) || time === null) {
|
||||
console.error('Invalid time', perc, time)
|
||||
return
|
||||
@@ -111,7 +116,7 @@ export default {
|
||||
this.updateReadyTrack()
|
||||
},
|
||||
updateReadyTrack() {
|
||||
var widthReady = Math.round(this.trackWidth * this.percentReady)
|
||||
const widthReady = Math.round(this.trackWidth * this.percentReady)
|
||||
if (this.readyTrackWidth === widthReady) return
|
||||
this.readyTrackWidth = widthReady
|
||||
if (this.$refs.readyTrack) this.$refs.readyTrack.style.width = widthReady + 'px'
|
||||
@@ -124,7 +129,7 @@ export default {
|
||||
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
|
||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
||||
|
||||
var ptWidth = Math.round((time / duration) * this.trackWidth)
|
||||
const ptWidth = Math.round((time / duration) * this.trackWidth)
|
||||
if (this.playedTrackWidth === ptWidth) {
|
||||
return
|
||||
}
|
||||
@@ -133,7 +138,7 @@ export default {
|
||||
},
|
||||
setChapterTicks() {
|
||||
this.chapterTicks = this.chapters.map((chap) => {
|
||||
var perc = chap.start / this.duration
|
||||
const perc = chap.start / this.duration
|
||||
return {
|
||||
title: chap.title,
|
||||
left: perc * this.trackWidth
|
||||
@@ -141,12 +146,12 @@ export default {
|
||||
})
|
||||
},
|
||||
mousemoveTrack(e) {
|
||||
var offsetX = e.offsetX
|
||||
const offsetX = e.offsetX
|
||||
|
||||
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0;
|
||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration;
|
||||
const progressTime = (offsetX / this.trackWidth) * duration;
|
||||
const totalTime = baseTime + progressTime;
|
||||
const baseTime = this.useChapterTrack ? this.currentChapterStart : 0
|
||||
const duration = this.useChapterTrack ? this.currentChapterDuration : this.duration
|
||||
const progressTime = (offsetX / this.trackWidth) * duration
|
||||
const totalTime = baseTime + progressTime
|
||||
|
||||
if (this.$refs.hoverTimestamp) {
|
||||
var width = this.$refs.hoverTimestamp.clientWidth
|
||||
@@ -167,7 +172,7 @@ export default {
|
||||
this.$refs.hoverTimestampArrow.style.left = posLeft + 'px'
|
||||
}
|
||||
if (this.$refs.hoverTimestampText) {
|
||||
var hoverText = this.$secondsToTimestamp(progressTime)
|
||||
var hoverText = this.$secondsToTimestamp(progressTime / this._playbackRate)
|
||||
|
||||
var chapter = this.chapters.find((chapter) => chapter.start <= totalTime && totalTime < chapter.end)
|
||||
if (chapter && chapter.title) {
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate.sync="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
|
||||
</div>
|
||||
|
||||
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" :current-chapter="currentChapter" @seek="seek" />
|
||||
<player-track-bar ref="trackbar" :loading="loading" :chapters="chapters" :duration="duration" :current-chapter="currentChapter" :playback-rate="playbackRate" @seek="seek" />
|
||||
|
||||
<div class="flex">
|
||||
<p ref="currentTimestamp" class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">00:00:00</p>
|
||||
@@ -59,7 +59,7 @@
|
||||
<p class="font-mono text-xxs sm:text-sm text-gray-100 pointer-events-auto">{{ timeRemainingPretty }}</p>
|
||||
</div>
|
||||
|
||||
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
||||
<modals-chapters-modal v-model="showChaptersModal" :current-chapter="currentChapter" :playback-rate="playbackRate" :chapters="chapters" @select="selectChapter" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -92,6 +92,11 @@ export default {
|
||||
useChapterTrack: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
playbackRate() {
|
||||
this.updateTimestamp()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sleepTimerRemainingString() {
|
||||
var rounded = Math.round(this.sleepTimerRemaining)
|
||||
@@ -213,18 +218,14 @@ export default {
|
||||
}
|
||||
},
|
||||
increasePlaybackRate() {
|
||||
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
||||
if (currentRateIndex >= rates.length - 1) return
|
||||
this.playbackRate = rates[currentRateIndex + 1] || 1
|
||||
this.playbackRateChanged(this.playbackRate)
|
||||
if (this.playbackRate >= 10) return
|
||||
this.playbackRate = Number((this.playbackRate + 0.1).toFixed(1))
|
||||
this.setPlaybackRate(this.playbackRate)
|
||||
},
|
||||
decreasePlaybackRate() {
|
||||
var rates = [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||
var currentRateIndex = rates.findIndex((r) => r === this.playbackRate)
|
||||
if (currentRateIndex <= 0) return
|
||||
this.playbackRate = rates[currentRateIndex - 1] || 1
|
||||
this.playbackRateChanged(this.playbackRate)
|
||||
if (this.playbackRate <= 0.5) return
|
||||
this.playbackRate = Number((this.playbackRate - 0.1).toFixed(1))
|
||||
this.setPlaybackRate(this.playbackRate)
|
||||
},
|
||||
setPlaybackRate(playbackRate) {
|
||||
this.$emit('setPlaybackRate', playbackRate)
|
||||
@@ -234,13 +235,10 @@ export default {
|
||||
this.showChaptersModal = false
|
||||
},
|
||||
setUseChapterTrack() {
|
||||
var useChapterTrack = !this.useChapterTrack
|
||||
this.useChapterTrack = useChapterTrack
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(useChapterTrack)
|
||||
this.useChapterTrack = !this.useChapterTrack
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
|
||||
|
||||
this.$store.dispatch('user/updateUserSettings', { useChapterTrack }).catch((err) => {
|
||||
console.error('Failed to update settings', err)
|
||||
})
|
||||
this.$store.dispatch('user/updateUserSettings', { useChapterTrack: this.useChapterTrack })
|
||||
this.updateTimestamp()
|
||||
},
|
||||
checkUpdateChapterTrack() {
|
||||
@@ -292,14 +290,13 @@ export default {
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setPercentageReady(percentageReady)
|
||||
},
|
||||
updateTimestamp() {
|
||||
var ts = this.$refs.currentTimestamp
|
||||
const ts = this.$refs.currentTimestamp
|
||||
if (!ts) {
|
||||
console.error('No timestamp el')
|
||||
return
|
||||
}
|
||||
const time = this.useChapterTrack ? Math.max(0, this.currentTime - this.currentChapterStart) : this.currentTime
|
||||
var currTimeClean = this.$secondsToTimestamp(time)
|
||||
ts.innerText = currTimeClean
|
||||
ts.innerText = this.$secondsToTimestamp(time / this.playbackRate)
|
||||
},
|
||||
setBufferTime(bufferTime) {
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setBufferTime(bufferTime)
|
||||
@@ -311,11 +308,11 @@ export default {
|
||||
init() {
|
||||
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
||||
|
||||
var _useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false
|
||||
const _useChapterTrack = this.$store.getters['user/getUserSetting']('useChapterTrack') || false
|
||||
this.useChapterTrack = this.chapters.length ? _useChapterTrack : false
|
||||
|
||||
if (this.$refs.trackbar) this.$refs.trackbar.setUseChapterTrack(this.useChapterTrack)
|
||||
this.$emit('setPlaybackRate', this.playbackRate)
|
||||
this.setPlaybackRate(this.playbackRate)
|
||||
},
|
||||
settingsUpdated(settings) {
|
||||
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
|
||||
@@ -345,13 +342,14 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
|
||||
this.init()
|
||||
this.$eventBus.$on('player-hotkey', this.hotkey)
|
||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$store.commit('user/removeSettingsListener', 'audioplayer')
|
||||
this.$eventBus.$off('player-hotkey', this.hotkey)
|
||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
<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" />
|
||||
<div ref="content" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickedOutside">
|
||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||
<p class="text-lg mb-8 mt-2 px-1" v-html="message" />
|
||||
<p class="text-lg mb-6 mt-2 px-1" v-html="message" />
|
||||
|
||||
<ui-checkbox v-if="checkboxLabel" v-model="checkboxValue" checkbox-bg="bg" :label="checkboxLabel" label-class="pl-2 text-base" class="mb-6 px-1" />
|
||||
|
||||
<div class="flex px-1 items-center">
|
||||
<ui-btn v-if="isYesNo" color="primary" @click="nevermind">{{ $strings.ButtonCancel }}</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="isYesNo" color="success" @click="confirm">{{ $strings.ButtonYes }}</ui-btn>
|
||||
<ui-btn v-if="isYesNo" :color="yesButtonColor" @click="confirm">{{ yesButtonText }}</ui-btn>
|
||||
<ui-btn v-else color="primary" @click="confirm">{{ $strings.ButtonOk }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
@@ -21,7 +24,8 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
el: null,
|
||||
content: null
|
||||
content: null,
|
||||
checkboxValue: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -57,6 +61,18 @@ export default {
|
||||
persistent() {
|
||||
return !!this.confirmPromptOptions.persistent
|
||||
},
|
||||
checkboxLabel() {
|
||||
return this.confirmPromptOptions.checkboxLabel
|
||||
},
|
||||
yesButtonText() {
|
||||
return this.confirmPromptOptions.yesButtonText || this.$strings.ButtonYes
|
||||
},
|
||||
yesButtonColor() {
|
||||
return this.confirmPromptOptions.yesButtonColor || 'success'
|
||||
},
|
||||
checkboxDefaultValue() {
|
||||
return !!this.confirmPromptOptions.checkboxDefaultValue
|
||||
},
|
||||
isYesNo() {
|
||||
return this.type === 'yesNo'
|
||||
},
|
||||
@@ -84,10 +100,11 @@ export default {
|
||||
this.show = false
|
||||
},
|
||||
confirm() {
|
||||
if (this.callback) this.callback(true)
|
||||
if (this.callback) this.callback(true, this.checkboxValue)
|
||||
this.show = false
|
||||
},
|
||||
setShow() {
|
||||
this.checkboxValue = this.checkboxDefaultValue
|
||||
this.$eventBus.$emit('showing-prompt', true)
|
||||
document.body.appendChild(this.el)
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 right-20 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-52">
|
||||
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 left-8 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-52">
|
||||
<div v-for="(file, index) in pages" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index)">
|
||||
<p class="text-sm truncate">{{ file }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="showInfoMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 right-10 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-96">
|
||||
<div v-show="showInfoMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 left-20 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-96">
|
||||
<div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
|
||||
<p class="text-xs">
|
||||
<strong>{{ key }}</strong
|
||||
@@ -14,17 +14,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="comicMetadata" class="absolute top-0 right-52 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="showInfoMenu = !showInfoMenu">
|
||||
<div v-if="comicMetadata" class="absolute top-0 left-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="showInfoMenu = !showInfoMenu">
|
||||
<span class="material-icons text-xl">more</span>
|
||||
</div>
|
||||
<div class="absolute top-0 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" style="right: 156px" @mousedown.prevent @click.stop.prevent="showPageMenu = !showPageMenu">
|
||||
<div class="absolute top-0 left-8 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="showPageMenu = !showPageMenu">
|
||||
<span class="material-icons text-xl">menu</span>
|
||||
</div>
|
||||
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
|
||||
<div class="absolute top-0 right-16 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20">
|
||||
<p class="font-mono">{{ page + 1 }} / {{ numPages }}</p>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden m-auto comicwrapper relative">
|
||||
<div class="overflow-hidden w-full h-full relative">
|
||||
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
|
||||
<div class="flex items-center justify-center h-full w-1/2">
|
||||
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
|
||||
@@ -36,17 +36,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-full flex justify-center">
|
||||
<img v-if="mainImg" :src="mainImg" class="object-contain comicimg" />
|
||||
<img v-if="mainImg" :src="mainImg" class="object-contain h-full m-auto" />
|
||||
</div>
|
||||
|
||||
<div v-show="loading" class="w-full h-full absolute top-0 left-0 flex items-center justify-center z-10">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <div v-show="loading" class="w-screen h-screen absolute top-0 left-0 bg-black bg-opacity-20 flex items-center justify-center">
|
||||
<ui-loading-indicator />
|
||||
</div> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -61,7 +57,12 @@ Archive.init({
|
||||
|
||||
export default {
|
||||
props: {
|
||||
url: String
|
||||
url: String,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
playerOpen: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -249,15 +250,6 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
.pagemenu {
|
||||
max-height: calc(100vh - 60px);
|
||||
}
|
||||
.comicimg {
|
||||
height: calc(100vh - 40px);
|
||||
margin: auto;
|
||||
}
|
||||
.comicwrapper {
|
||||
width: 100vw;
|
||||
height: calc(100vh - 40px);
|
||||
margin-top: 20px;
|
||||
max-height: calc(100% - 48px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,18 +1,14 @@
|
||||
<template>
|
||||
<div class="h-full w-full">
|
||||
<div class="h-full flex items-center">
|
||||
<div style="width: 100px; max-width: 100px" class="h-full flex items-center overflow-x-hidden justify-center">
|
||||
<span v-show="hasPrev" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
|
||||
<div id="epub-reader" class="h-full w-full">
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div style="width: 100px; max-width: 100px" class="h-full hidden sm:flex items-center overflow-x-hidden justify-center">
|
||||
<span v-if="hasPrev" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
|
||||
</div>
|
||||
<div id="frame" class="w-full" style="height: 650px">
|
||||
<div id="viewer" class="border border-gray-100 bg-white shadow-md"></div>
|
||||
|
||||
<div class="py-4 flex justify-center" style="height: 50px">
|
||||
<p>{{ progress }}%</p>
|
||||
</div>
|
||||
<div id="frame" class="w-full" style="height: 80%">
|
||||
<div id="viewer"></div>
|
||||
</div>
|
||||
<div style="width: 100px; max-width: 100px" class="h-full flex items-center justify-center overflow-x-hidden">
|
||||
<span v-show="hasNext" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="next">chevron_right</span>
|
||||
<div style="width: 100px; max-width: 100px" class="h-full hidden sm:flex items-center justify-center overflow-x-hidden">
|
||||
<span v-if="hasNext" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="next">chevron_right</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -21,109 +17,266 @@
|
||||
<script>
|
||||
import ePub from 'epubjs'
|
||||
|
||||
/**
|
||||
* @typedef {object} EpubReader
|
||||
* @property {ePub.Book} book
|
||||
* @property {ePub.Rendition} rendition
|
||||
*/
|
||||
export default {
|
||||
props: {
|
||||
url: String
|
||||
url: String,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
playerOpen: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
windowWidth: 0,
|
||||
windowHeight: 0,
|
||||
/** @type {ePub.Book} */
|
||||
book: null,
|
||||
rendition: null,
|
||||
chapters: [],
|
||||
title: '',
|
||||
author: '',
|
||||
progress: 0,
|
||||
hasNext: true,
|
||||
hasPrev: false
|
||||
/** @type {ePub.Rendition} */
|
||||
rendition: null
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
changedChapter() {
|
||||
if (this.rendition) {
|
||||
this.rendition.display(this.selectedChapter)
|
||||
}
|
||||
watch: {
|
||||
playerOpen() {
|
||||
this.resize()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
/** @returns {string} */
|
||||
libraryItemId() {
|
||||
return this.libraryItem?.id
|
||||
},
|
||||
hasPrev() {
|
||||
return !this.rendition?.location?.atStart
|
||||
},
|
||||
hasNext() {
|
||||
return !this.rendition?.location?.atEnd
|
||||
},
|
||||
/** @returns {Array<ePub.NavItem>} */
|
||||
chapters() {
|
||||
return this.book ? this.book.navigation.toc : []
|
||||
},
|
||||
userMediaProgress() {
|
||||
if (!this.libraryItemId) return
|
||||
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||
},
|
||||
localStorageLocationsKey() {
|
||||
return `ebookLocations-${this.libraryItemId}`
|
||||
},
|
||||
readerWidth() {
|
||||
if (this.windowWidth < 640) return this.windowWidth
|
||||
return this.windowWidth - 200
|
||||
},
|
||||
readerHeight() {
|
||||
if (this.windowHeight < 400 || !this.playerOpen) return this.windowHeight
|
||||
return this.windowHeight - 164
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
prev() {
|
||||
if (this.rendition) {
|
||||
this.rendition.prev()
|
||||
}
|
||||
return this.rendition?.prev()
|
||||
},
|
||||
next() {
|
||||
if (this.rendition) {
|
||||
this.rendition.next()
|
||||
return this.rendition?.next()
|
||||
},
|
||||
goToChapter(href) {
|
||||
return this.rendition?.display(href)
|
||||
},
|
||||
keyUp(e) {
|
||||
const rtl = this.book.package.metadata.direction === 'rtl'
|
||||
if ((e.keyCode || e.which) == 37) {
|
||||
return rtl ? this.next() : this.prev()
|
||||
} else if ((e.keyCode || e.which) == 39) {
|
||||
return rtl ? this.prev() : this.next()
|
||||
}
|
||||
},
|
||||
keyUp() {
|
||||
if ((e.keyCode || e.which) == 37) {
|
||||
this.prev()
|
||||
} else if ((e.keyCode || e.which) == 39) {
|
||||
this.next()
|
||||
/**
|
||||
* @param {object} payload
|
||||
* @param {string} payload.ebookLocation - CFI of the current location
|
||||
* @param {string} payload.ebookProgress - eBook Progress Percentage
|
||||
*/
|
||||
updateProgress(payload) {
|
||||
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
|
||||
console.error('EpubReader.updateProgress failed:', error)
|
||||
})
|
||||
},
|
||||
getAllEbookLocationData() {
|
||||
const locations = []
|
||||
let totalSize = 0 // Total in bytes
|
||||
|
||||
for (const key in localStorage) {
|
||||
if (!localStorage.hasOwnProperty(key) || !key.startsWith('ebookLocations-')) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const ebookLocations = JSON.parse(localStorage[key])
|
||||
if (!ebookLocations.locations) throw new Error('Invalid locations object')
|
||||
|
||||
ebookLocations.key = key
|
||||
ebookLocations.size = (localStorage[key].length + key.length) * 2
|
||||
locations.push(ebookLocations)
|
||||
totalSize += ebookLocations.size
|
||||
} catch (error) {
|
||||
console.error('Failed to parse ebook locations', key, error)
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by oldest lastAccessed first
|
||||
locations.sort((a, b) => a.lastAccessed - b.lastAccessed)
|
||||
|
||||
return {
|
||||
locations,
|
||||
totalSize
|
||||
}
|
||||
},
|
||||
/** @param {string} locationString */
|
||||
checkSaveLocations(locationString) {
|
||||
const maxSizeInBytes = 3000000 // Allow epub locations to take up to 3MB of space
|
||||
const newLocationsSize = JSON.stringify({ lastAccessed: Date.now(), locations: locationString }).length * 2
|
||||
|
||||
// Too large overall
|
||||
if (newLocationsSize > maxSizeInBytes) {
|
||||
console.error('Epub locations are too large to store. Size =', newLocationsSize)
|
||||
return
|
||||
}
|
||||
|
||||
const ebookLocationsData = this.getAllEbookLocationData()
|
||||
|
||||
let availableSpace = maxSizeInBytes - ebookLocationsData.totalSize
|
||||
|
||||
// Remove epub locations until there is room for locations
|
||||
while (availableSpace < newLocationsSize && ebookLocationsData.locations.length) {
|
||||
const oldestLocation = ebookLocationsData.locations.shift()
|
||||
console.log(`Removing cached locations for epub "${oldestLocation.key}" taking up ${oldestLocation.size} bytes`)
|
||||
availableSpace += oldestLocation.size
|
||||
localStorage.removeItem(oldestLocation.key)
|
||||
}
|
||||
|
||||
console.log(`Cacheing epub locations with key "${this.localStorageLocationsKey}" taking up ${newLocationsSize} bytes`)
|
||||
this.saveLocations(locationString)
|
||||
},
|
||||
/** @param {string} locationString */
|
||||
saveLocations(locationString) {
|
||||
localStorage.setItem(
|
||||
this.localStorageLocationsKey,
|
||||
JSON.stringify({
|
||||
lastAccessed: Date.now(),
|
||||
locations: locationString
|
||||
})
|
||||
)
|
||||
},
|
||||
loadLocations() {
|
||||
const locationsObjString = localStorage.getItem(this.localStorageLocationsKey)
|
||||
if (!locationsObjString) return null
|
||||
|
||||
const locationsObject = JSON.parse(locationsObjString)
|
||||
|
||||
// Remove invalid location objects
|
||||
if (!locationsObject.locations) {
|
||||
console.error('Invalid epub locations stored', this.localStorageLocationsKey)
|
||||
localStorage.removeItem(this.localStorageLocationsKey)
|
||||
return null
|
||||
}
|
||||
|
||||
// Update lastAccessed
|
||||
this.saveLocations(locationsObject.locations)
|
||||
|
||||
return locationsObject.locations
|
||||
},
|
||||
/** @param {string} location - CFI of the new location */
|
||||
relocated(location) {
|
||||
if (this.userMediaProgress?.ebookLocation === location.start.cfi) {
|
||||
return
|
||||
}
|
||||
|
||||
if (location.end.percentage) {
|
||||
this.updateProgress({
|
||||
ebookLocation: location.start.cfi,
|
||||
ebookProgress: location.end.percentage
|
||||
})
|
||||
} else {
|
||||
this.updateProgress({
|
||||
ebookLocation: location.start.cfi
|
||||
})
|
||||
}
|
||||
},
|
||||
initEpub() {
|
||||
// var book = ePub(this.url, {
|
||||
// requestHeaders: {
|
||||
// Authorization: `Bearer ${this.userToken}`
|
||||
// }
|
||||
// })
|
||||
var book = ePub(this.url)
|
||||
this.book = book
|
||||
/** @type {EpubReader} */
|
||||
const reader = this
|
||||
|
||||
this.rendition = book.renderTo('viewer', {
|
||||
width: window.innerWidth - 200,
|
||||
height: 600,
|
||||
ignoreClass: 'annotator-hl',
|
||||
manager: 'continuous',
|
||||
spread: 'always'
|
||||
/** @type {ePub.Book} */
|
||||
reader.book = new ePub(reader.url, {
|
||||
width: this.readerWidth,
|
||||
height: this.readerHeight - 50
|
||||
})
|
||||
var displayed = this.rendition.display()
|
||||
|
||||
book.ready
|
||||
.then(() => {
|
||||
console.log('Book ready')
|
||||
return book.locations.generate(1600)
|
||||
/** @type {ePub.Rendition} */
|
||||
reader.rendition = reader.book.renderTo('viewer', {
|
||||
width: this.readerWidth,
|
||||
height: this.readerHeight * 0.8
|
||||
})
|
||||
|
||||
// load saved progress
|
||||
reader.rendition.display(this.userMediaProgress?.ebookLocation || reader.book.locations.start)
|
||||
|
||||
// load style
|
||||
reader.rendition.themes.default({ '*': { color: '#fff!important' } })
|
||||
|
||||
reader.book.ready.then(() => {
|
||||
// set up event listeners
|
||||
reader.rendition.on('relocated', reader.relocated)
|
||||
reader.rendition.on('keydown', reader.keyUp)
|
||||
|
||||
let touchStart = 0
|
||||
let touchEnd = 0
|
||||
reader.rendition.on('touchstart', (event) => {
|
||||
touchStart = event.changedTouches[0].screenX
|
||||
})
|
||||
.then((locations) => {
|
||||
// console.log('Loaded locations', locations)
|
||||
// Wait for book to be rendered to get current page
|
||||
displayed.then(() => {
|
||||
// Get the current CFI
|
||||
var currentLocation = this.rendition.currentLocation()
|
||||
if (!currentLocation.start) {
|
||||
console.error('No Start', currentLocation)
|
||||
} else {
|
||||
var currentPage = book.locations.percentageFromCfi(currentLocation.start.cfi)
|
||||
// console.log('current page', currentPage)
|
||||
}
|
||||
|
||||
reader.rendition.on('touchend', (event) => {
|
||||
touchEnd = event.changedTouches[0].screenX
|
||||
const touchDistanceX = Math.abs(touchEnd - touchStart)
|
||||
if (touchStart < touchEnd && touchDistanceX > 120) {
|
||||
this.next()
|
||||
}
|
||||
if (touchStart > touchEnd && touchDistanceX > 120) {
|
||||
this.prev()
|
||||
}
|
||||
})
|
||||
|
||||
// load ebook cfi locations
|
||||
const savedLocations = this.loadLocations()
|
||||
if (savedLocations) {
|
||||
reader.book.locations.load(savedLocations)
|
||||
} else {
|
||||
reader.book.locations.generate().then(() => {
|
||||
this.checkSaveLocations(reader.book.locations.save())
|
||||
})
|
||||
})
|
||||
|
||||
book.loaded.navigation.then((toc) => {
|
||||
var _chapters = []
|
||||
toc.forEach((chapter) => {
|
||||
_chapters.push(chapter)
|
||||
})
|
||||
this.chapters = _chapters
|
||||
})
|
||||
book.loaded.metadata.then((metadata) => {
|
||||
this.author = metadata.creator
|
||||
this.title = metadata.title
|
||||
})
|
||||
|
||||
this.rendition.on('keyup', this.keyUp)
|
||||
|
||||
this.rendition.on('relocated', (location) => {
|
||||
var percent = book.locations.percentageFromCfi(location.start.cfi)
|
||||
this.progress = Math.floor(percent * 100)
|
||||
|
||||
this.hasNext = !location.atEnd
|
||||
this.hasPrev = !location.atStart
|
||||
}
|
||||
})
|
||||
},
|
||||
resize() {
|
||||
this.windowWidth = window.innerWidth
|
||||
this.windowHeight = window.innerHeight
|
||||
this.rendition?.resize(this.readerWidth, this.readerHeight * 0.8)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.windowWidth = window.innerWidth
|
||||
this.windowHeight = window.innerHeight
|
||||
window.addEventListener('resize', this.resize)
|
||||
this.initEpub()
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.resize)
|
||||
this.book?.destroy()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<div class="h-full max-h-full w-full">
|
||||
<div class="ebook-viewer absolute overflow-y-scroll left-0 right-0 top-12 w-full max-w-4xl m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
|
||||
<div class="ebook-viewer absolute overflow-y-scroll left-0 right-0 top-16 w-full max-w-4xl m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
|
||||
<iframe title="html-viewer" width="100%"> Loading </iframe>
|
||||
</div>
|
||||
</div>
|
||||
@@ -15,7 +15,12 @@ import defaultCss from '@/assets/ebooks/basic.js'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
url: String
|
||||
url: String,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
playerOpen: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
|
||||
@@ -11,15 +11,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center">
|
||||
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 z-20 rounded-b-md px-2 h-9 flex items-center text-center">
|
||||
<p class="font-mono">{{ page }} / {{ numPages }}</p>
|
||||
</div>
|
||||
<div class="absolute top-0 right-40 bg-bg text-gray-100 border-b border-l border-r border-gray-400 z-20 rounded-b-md px-2 h-9 flex items-center text-center">
|
||||
<ui-icon-btn icon="zoom_out" :size="8" :disabled="!canScaleDown" borderless class="mr-px" @click="zoomOut" />
|
||||
<ui-icon-btn icon="zoom_in" :size="8" :disabled="!canScaleUp" borderless class="ml-px" @click="zoomIn" />
|
||||
</div>
|
||||
|
||||
<div :style="{ height: pdfHeight + 'px' }" class="overflow-hidden m-auto">
|
||||
<div class="flex items-center justify-center">
|
||||
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto">
|
||||
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="overflow-auto">
|
||||
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
|
||||
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md" :src="url" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event"></pdf>
|
||||
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md" :src="url" :page="page" :rotate="rotate" @progress="progressEvt" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event" @loaded="loadedEvt"></pdf>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,17 +34,25 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import pdf from 'vue-pdf'
|
||||
import pdf from '@teckel/vue-pdf'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
pdf
|
||||
},
|
||||
props: {
|
||||
url: String
|
||||
url: String,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
playerOpen: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
windowWidth: 0,
|
||||
windowHeight: 0,
|
||||
scale: 1,
|
||||
rotate: 0,
|
||||
loadedRatio: 0,
|
||||
page: 1,
|
||||
@@ -48,35 +60,99 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
libraryItemId() {
|
||||
return this.libraryItem?.id
|
||||
},
|
||||
fitToPageWidth() {
|
||||
return this.pdfHeight * 0.6
|
||||
},
|
||||
pdfWidth() {
|
||||
return this.pdfHeight * 0.6667
|
||||
return this.fitToPageWidth * this.scale
|
||||
},
|
||||
pdfHeight() {
|
||||
return window.innerHeight - 120
|
||||
if (this.windowHeight < 400 || !this.playerOpen) return this.windowHeight - 120
|
||||
return this.windowHeight - 284
|
||||
},
|
||||
maxScale() {
|
||||
return Math.floor((this.windowWidth * 10) / this.fitToPageWidth) / 10
|
||||
},
|
||||
canGoNext() {
|
||||
return this.page < this.numPages
|
||||
},
|
||||
canGoPrev() {
|
||||
return this.page > 1
|
||||
},
|
||||
canScaleUp() {
|
||||
return this.scale < this.maxScale
|
||||
},
|
||||
canScaleDown() {
|
||||
return this.scale > 1
|
||||
},
|
||||
userMediaProgress() {
|
||||
if (!this.libraryItemId) return
|
||||
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||
},
|
||||
savedPage() {
|
||||
return Number(this.userMediaProgress?.ebookLocation || 0)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
zoomIn() {
|
||||
this.scale += 0.1
|
||||
},
|
||||
zoomOut() {
|
||||
this.scale -= 0.1
|
||||
},
|
||||
updateProgress() {
|
||||
if (!this.numPages) {
|
||||
console.error('Num pages not loaded')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
ebookLocation: this.page,
|
||||
ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))
|
||||
}
|
||||
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
|
||||
console.error('EpubReader.updateProgress failed:', error)
|
||||
})
|
||||
},
|
||||
loadedEvt() {
|
||||
if (this.savedPage && this.savedPage > 0 && this.savedPage <= this.numPages) {
|
||||
this.page = this.savedPage
|
||||
}
|
||||
},
|
||||
progressEvt(progress) {
|
||||
this.loadedRatio = progress
|
||||
},
|
||||
numPagesLoaded(e) {
|
||||
this.numPages = e
|
||||
},
|
||||
prev() {
|
||||
if (this.page <= 1) return
|
||||
this.page--
|
||||
this.updateProgress()
|
||||
},
|
||||
next() {
|
||||
if (this.page >= this.numPages) return
|
||||
this.page++
|
||||
this.updateProgress()
|
||||
},
|
||||
error(err) {
|
||||
console.error(err)
|
||||
},
|
||||
resize() {
|
||||
this.windowWidth = window.innerWidth
|
||||
this.windowHeight = window.innerHeight
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
this.windowWidth = window.innerWidth
|
||||
this.windowHeight = window.innerHeight
|
||||
window.addEventListener('resize', this.resize)
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.resize)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,24 +1,53 @@
|
||||
<template>
|
||||
<div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-50 bg-primary text-white">
|
||||
<div v-if="show" id="reader" class="absolute top-0 left-0 w-full z-60 bg-primary text-white" :class="{ 'reader-player-open': !!streamLibraryItem }">
|
||||
<div class="absolute top-4 left-4 z-20">
|
||||
<span v-if="hasToC && !tocOpen" ref="tocButton" class="material-icons cursor-pointer text-2xl" @click="toggleToC">menu</span>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-4 left-1/2 transform -translate-x-1/2">
|
||||
<h1 class="text-lg sm:text-xl md:text-2xl mb-1" style="line-height: 1.15; font-weight: 100">
|
||||
<span style="font-weight: 600">{{ abTitle }}</span>
|
||||
<span v-if="abAuthor" style="display: inline"> – </span>
|
||||
<span v-if="abAuthor">{{ abAuthor }}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-4 right-4 z-20">
|
||||
<span class="material-icons cursor-pointer text-4xl" @click="close">close</span>
|
||||
<span v-if="hasSettings" class="material-icons cursor-pointer text-2xl" @click="openSettings">settings</span>
|
||||
<span class="material-icons cursor-pointer text-2xl" @click="close">close</span>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-4 left-4 font-book">
|
||||
<h1 class="text-2xl mb-1">{{ abTitle }}</h1>
|
||||
<p v-if="abAuthor">by {{ abAuthor }}</p>
|
||||
<component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" :library-item="selectedLibraryItem" :player-open="!!streamLibraryItem" />
|
||||
|
||||
<!-- TOC side nav -->
|
||||
<div v-if="tocOpen" class="w-full h-full fixed inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div>
|
||||
<div v-if="hasToC" class="w-96 h-full max-h-full absolute top-0 left-0 bg-bg shadow-xl transition-transform z-30" :class="tocOpen ? 'translate-x-0' : '-translate-x-96'" @click.stop.prevent="toggleToC">
|
||||
<div class="p-4 h-full">
|
||||
<p class="text-lg font-semibold mb-2">Table of Contents</p>
|
||||
<div class="tocContent">
|
||||
<ul>
|
||||
<li v-for="chapter in chapters" :key="chapter.id" class="py-1">
|
||||
<a :href="chapter.href" class="text-white/70 hover:text-white" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.label }}</a>
|
||||
<ul v-if="chapter.subitems.length">
|
||||
<li v-for="subchapter in chapter.subitems" :key="subchapter.id" class="py-1 pl-4">
|
||||
<a :href="subchapter.href" class="text-white/70 hover:text-white" @click.prevent="$refs.readerComponent.goToChapter(subchapter.href)">{{ subchapter.label }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" />
|
||||
|
||||
<div class="absolute bottom-2 left-2">{{ ebookType }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
chapters: [],
|
||||
tocOpen: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
@@ -43,6 +72,15 @@ export default {
|
||||
else if (this.ebookType === 'comic') return 'readers-comic-reader'
|
||||
return null
|
||||
},
|
||||
streamLibraryItem() {
|
||||
return this.$store.state.streamLibraryItem
|
||||
},
|
||||
hasToC() {
|
||||
return this.isEpub
|
||||
},
|
||||
hasSettings() {
|
||||
return false
|
||||
},
|
||||
abTitle() {
|
||||
return this.mediaMetadata.title
|
||||
},
|
||||
@@ -92,31 +130,46 @@ export default {
|
||||
},
|
||||
ebookUrl() {
|
||||
if (!this.ebookFile) return null
|
||||
var itemRelPath = this.selectedLibraryItem.relPath
|
||||
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
|
||||
var relPath = this.ebookFile.metadata.relPath
|
||||
if (relPath.startsWith('/')) relPath = relPath.slice(1)
|
||||
let filepath = ''
|
||||
if (this.selectedLibraryItem.isFile) {
|
||||
filepath = this.$encodeUriPath(this.ebookFile.metadata.filename)
|
||||
} else {
|
||||
const itemRelPath = this.selectedLibraryItem.relPath
|
||||
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
|
||||
const relPath = this.ebookFile.metadata.relPath
|
||||
if (relPath.startsWith('/')) relPath = relPath.slice(1)
|
||||
|
||||
const relRelPath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
|
||||
return `/ebook/${this.libraryId}/${this.folderId}/${relRelPath}`
|
||||
filepath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
|
||||
}
|
||||
return `/ebook/${this.libraryId}/${this.folderId}/${filepath}`
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleToC() {
|
||||
this.tocOpen = !this.tocOpen
|
||||
this.chapters = this.$refs.readerComponent.chapters
|
||||
},
|
||||
openSettings() {},
|
||||
hotkey(action) {
|
||||
console.log('Reader hotkey', action)
|
||||
if (!this.$refs.readerComponent) return
|
||||
|
||||
if (action === this.$hotkeys.EReader.NEXT_PAGE) {
|
||||
if (this.$refs.readerComponent.next) this.$refs.readerComponent.next()
|
||||
this.next()
|
||||
} else if (action === this.$hotkeys.EReader.PREV_PAGE) {
|
||||
if (this.$refs.readerComponent.prev) this.$refs.readerComponent.prev()
|
||||
this.prev()
|
||||
} else if (action === this.$hotkeys.EReader.CLOSE) {
|
||||
this.close()
|
||||
}
|
||||
},
|
||||
next() {
|
||||
if (this.$refs.readerComponent?.next) this.$refs.readerComponent.next()
|
||||
},
|
||||
prev() {
|
||||
if (this.$refs.readerComponent?.prev) this.$refs.readerComponent.prev()
|
||||
},
|
||||
registerListeners() {
|
||||
this.$eventBus.$on('reader-hotkey', this.hotkey)
|
||||
},
|
||||
@@ -141,8 +194,19 @@ export default {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* @import url(@/assets/calibre/basic.css); */
|
||||
.ebook-viewer {
|
||||
height: calc(100% - 96px);
|
||||
.tocContent {
|
||||
height: calc(100% - 36px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
#reader {
|
||||
height: 100%;
|
||||
}
|
||||
#reader.reader-player-open {
|
||||
height: calc(100% - 164px);
|
||||
}
|
||||
@media (max-height: 400px) {
|
||||
#reader.reader-player-open {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="w-96 my-6 mx-auto">
|
||||
<h1 class="text-2xl mb-4 font-book">{{ $strings.HeaderStatsMinutesListeningChart }}</h1>
|
||||
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsMinutesListeningChart }}</h1>
|
||||
<div class="relative w-96 h-72">
|
||||
<div class="absolute top-0 left-0">
|
||||
<template v-for="lbl in yAxisLabels">
|
||||
@@ -27,7 +27,7 @@
|
||||
<div class="absolute -bottom-2 left-0 flex ml-6">
|
||||
<template v-for="dayObj in last7Days">
|
||||
<div :key="dayObj.date" :style="{ width: daySpacing + daySpacing / 14 + 'px' }">
|
||||
<p class="text-sm font-book">{{ dayObj.dayOfWeek.slice(0, 3) }}</p>
|
||||
<p class="text-sm">{{ dayObj.dayOfWeekAbbr }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -108,6 +108,7 @@ export default {
|
||||
var _date = this.$addDaysToToday(i * -1)
|
||||
days.push({
|
||||
dayOfWeek: this.$formatJsDate(_date, 'EEEE'),
|
||||
dayOfWeekAbbr: this.$formatJsDate(_date, 'EEE'),
|
||||
date: this.$formatJsDate(_date, 'yyyy-MM-dd')
|
||||
})
|
||||
}
|
||||
@@ -218,4 +219,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -68,7 +68,7 @@ export default {
|
||||
dayLabels() {
|
||||
return [
|
||||
{
|
||||
label: 'Mon',
|
||||
label: this.$formatJsDate(new Date(2023, 0, 2), 'EEE'),
|
||||
style: {
|
||||
transform: `translate(${-25}px, ${13}px)`,
|
||||
lineHeight: '10px',
|
||||
@@ -76,7 +76,7 @@ export default {
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Wed',
|
||||
label: this.$formatJsDate(new Date(2023, 0, 4), 'EEE'),
|
||||
style: {
|
||||
transform: `translate(${-25}px, ${13 * 3}px)`,
|
||||
lineHeight: '10px',
|
||||
@@ -84,7 +84,7 @@ export default {
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Fri',
|
||||
label: this.$formatJsDate(new Date(2023, 0, 6), 'EEE'),
|
||||
style: {
|
||||
transform: `translate(${-25}px, ${13 * 5}px)`,
|
||||
lineHeight: '10px',
|
||||
@@ -270,4 +270,4 @@ export default {
|
||||
},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</svg>
|
||||
<div class="px-2">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ totalItems }}</p>
|
||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsItemsInLibrary }}</p>
|
||||
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsItemsInLibrary }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<span class="material-icons text-7xl">show_chart</span>
|
||||
<div class="px-1">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ totalTime }}</p>
|
||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ useOverallHours ? $strings.LabelStatsOverallHours : $strings.LabelStatsOverallDays }}</p>
|
||||
<p class="text-xs md:text-sm text-white text-opacity-80">{{ useOverallHours ? $strings.LabelStatsOverallHours : $strings.LabelStatsOverallDays }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</svg>
|
||||
<div class="px-1">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ totalAuthors }}</p>
|
||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsAuthors }}</p>
|
||||
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsAuthors }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<span class="material-icons-outlined text-6xl pt-1">insert_drive_file</span>
|
||||
<div class="px-1">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ totalSizeNum }}</p>
|
||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelSize }} ({{ totalSizeMod }})</p>
|
||||
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelSize }} ({{ totalSizeMod }})</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<span class="material-icons-outlined text-6xl pt-1">audio_file</span>
|
||||
<div class="px-1">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ numAudioTracks }}</p>
|
||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsAudioTracks }}</p>
|
||||
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelStatsAudioTracks }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
123
client/components/tables/AudioTracksTableRow.vue
Normal file
123
client/components/tables/AudioTracksTableRow.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<tr>
|
||||
<td class="text-center">
|
||||
<p>{{ track.index }}</p>
|
||||
</td>
|
||||
<td class="font-sans">{{ showFullPath ? track.metadata.path : track.metadata.filename }}</td>
|
||||
<td v-if="!showFullPath" class="hidden lg:table-cell">
|
||||
{{ track.audioFile.codec || '' }}
|
||||
</td>
|
||||
<td v-if="!showFullPath" class="hidden xl:table-cell">
|
||||
{{ $bytesPretty(track.audioFile.bitRate || 0, 0) }}
|
||||
</td>
|
||||
<td class="hidden md:table-cell">
|
||||
{{ $bytesPretty(track.metadata.size) }}
|
||||
</td>
|
||||
<td class="hidden sm:table-cell">
|
||||
{{ $secondsToTimestamp(track.duration) }}
|
||||
</td>
|
||||
<td v-if="contextMenuItems.length" class="text-center">
|
||||
<ui-context-menu-dropdown :items="contextMenuItems" menu-width="110px" @action="contextMenuAction" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItemId: String,
|
||||
showFullPath: Boolean,
|
||||
track: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userIsAdmin() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
contextMenuItems() {
|
||||
const items = []
|
||||
if (this.userCanDownload) {
|
||||
items.push({
|
||||
text: this.$strings.LabelDownload,
|
||||
action: 'download'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.userCanDelete) {
|
||||
items.push({
|
||||
text: this.$strings.ButtonDelete,
|
||||
action: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.userIsAdmin) {
|
||||
items.push({
|
||||
text: this.$strings.LabelMoreInfo,
|
||||
action: 'more'
|
||||
})
|
||||
}
|
||||
return items
|
||||
},
|
||||
downloadUrl() {
|
||||
return `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(this.track.metadata.relPath).replace(/^\//, '')}?token=${this.userToken}`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
contextMenuAction(action) {
|
||||
if (action === 'delete') {
|
||||
this.deleteLibraryFile()
|
||||
} else if (action === 'download') {
|
||||
this.downloadLibraryFile()
|
||||
} else if (action === 'more') {
|
||||
this.$emit('showMore', this.track.audioFile)
|
||||
}
|
||||
},
|
||||
deleteLibraryFile() {
|
||||
const payload = {
|
||||
message: 'This will delete the file from your file system. Are you sure?',
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$axios
|
||||
.$delete(`/api/items/${this.libraryItemId}/file/${this.track.audioFile.ino}`)
|
||||
.then(() => {
|
||||
this.$toast.success('File deleted')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete file', error)
|
||||
this.$toast.error('Failed to delete file')
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
downloadLibraryFile() {
|
||||
const a = document.createElement('a')
|
||||
a.style.display = 'none'
|
||||
a.href = this.downloadUrl
|
||||
a.download = this.track.metadata.filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(() => {
|
||||
a.remove()
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -17,7 +17,7 @@
|
||||
<td>
|
||||
<p class="truncate text-xs sm:text-sm md:text-base">/{{ backup.path.replace(/\\/g, '/') }}</p>
|
||||
</td>
|
||||
<td class="hidden sm:table-cell font-sans text-sm">{{ backup.datePretty }}</td>
|
||||
<td class="hidden sm:table-cell font-sans text-sm">{{ $formatDatetime(backup.createdAt, dateFormat, timeFormat) }}</td>
|
||||
<td class="hidden sm:table-cell font-mono md:text-sm text-xs">{{ $bytesPretty(backup.fileSize) }}</td>
|
||||
<td>
|
||||
<div class="w-full flex flex-row items-center justify-center">
|
||||
@@ -46,7 +46,7 @@
|
||||
<p class="text-error text-lg font-semibold">{{ $strings.MessageImportantNotice }}</p>
|
||||
<p class="text-base py-1" v-html="$strings.MessageRestoreBackupWarning" />
|
||||
|
||||
<p class="text-lg text-center my-8">{{ $strings.MessageRestoreBackupConfirm }} {{ selectedBackup.datePretty }}?</p>
|
||||
<p class="text-lg text-center my-8">{{ $strings.MessageRestoreBackupConfirm }} {{ $formatDatetime(selectedBackup.createdAt, dateFormat, timeFormat) }}?</p>
|
||||
<div class="flex px-1 items-center">
|
||||
<ui-btn color="primary" @click="showConfirmApply = false">{{ $strings.ButtonNevermind }}</ui-btn>
|
||||
<div class="flex-grow" />
|
||||
@@ -71,6 +71,12 @@ export default {
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
timeFormat() {
|
||||
return this.$store.state.serverSettings.timeFormat
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -90,7 +96,7 @@ export default {
|
||||
})
|
||||
},
|
||||
deleteBackupClick(backup) {
|
||||
if (confirm(this.$getString('MessageConfirmDeleteBackup', [backup.datePretty]))) {
|
||||
if (confirm(this.$getString('MessageConfirmDeleteBackup', [this.$formatDatetime(backup.createdAt, this.dateFormat, this.timeFormat)]))) {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/backups/${backup.id}`)
|
||||
@@ -208,4 +214,4 @@ export default {
|
||||
padding-bottom: 5px;
|
||||
background-color: #333;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
<transition name="slide">
|
||||
<table class="text-sm tracksTable" v-show="expanded || keepOpen">
|
||||
<tr class="font-book">
|
||||
<tr>
|
||||
<th class="text-left w-16"><span class="px-4">Id</span></th>
|
||||
<th class="text-left">{{ $strings.LabelTitle }}</th>
|
||||
<th class="text-center">{{ $strings.LabelStart }}</th>
|
||||
@@ -21,7 +21,7 @@
|
||||
<td class="text-left">
|
||||
<p class="px-4">{{ chapter.id }}</p>
|
||||
</td>
|
||||
<td class="font-book">
|
||||
<td>
|
||||
{{ chapter.title }}
|
||||
</td>
|
||||
<td class="font-mono text-center hover:underline cursor-pointer" @click.stop="goToTimestamp(chapter.start)">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<span class="text-sm font-mono">{{ files.length }}</span>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
|
||||
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
|
||||
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
|
||||
<span class="material-icons text-4xl">expand_more</span>
|
||||
</div>
|
||||
@@ -14,64 +14,83 @@
|
||||
<transition name="slide">
|
||||
<div class="w-full" v-show="showFiles">
|
||||
<table class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<tr>
|
||||
<th class="text-left px-4">{{ $strings.LabelPath }}</th>
|
||||
<th class="text-left w-24 min-w-24">{{ $strings.LabelSize }}</th>
|
||||
<th class="text-left px-4 w-24">{{ $strings.LabelType }}</th>
|
||||
<th v-if="userCanDownload && !isMissing" class="text-center w-20">{{ $strings.LabelDownload }}</th>
|
||||
<th v-if="userCanDelete || userCanDownload || (userIsAdmin && audioFiles.length && !inModal)" class="text-center w-16"></th>
|
||||
</tr>
|
||||
<template v-for="file in files">
|
||||
<tr :key="file.path">
|
||||
<td class="font-book px-4">
|
||||
{{ showFullPath ? file.metadata.path : file.metadata.relPath }}
|
||||
</td>
|
||||
<td class="font-mono">
|
||||
{{ $bytesPretty(file.metadata.size) }}
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
<div class="flex items-center">
|
||||
<p>{{ file.fileType }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="userCanDownload && !isMissing" class="text-center">
|
||||
<a :href="`${$config.routerBasePath}/s/item/${libraryItemId}/${$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-for="file in filesWithAudioFile">
|
||||
<tables-library-files-table-row :key="file.path" :libraryItemId="libraryItemId" :showFullPath="showFullPath" :file="file" :inModal="inModal" @showMore="showMore" />
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :audio-file="selectedAudioFile" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
files: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
libraryItemId: String,
|
||||
isMissing: Boolean,
|
||||
expanded: Boolean // start expanded
|
||||
expanded: Boolean, // start expanded
|
||||
inModal: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showFiles: false,
|
||||
showFullPath: false
|
||||
showFullPath: false,
|
||||
showAudioFileDataModal: false,
|
||||
selectedAudioFile: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userIsAdmin() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
files() {
|
||||
return this.libraryItem.libraryFiles || []
|
||||
},
|
||||
audioFiles() {
|
||||
if (this.libraryItem.mediaType === 'podcast') {
|
||||
return this.libraryItem.media?.episodes.map((ep) => ep.audioFile) || []
|
||||
}
|
||||
return this.libraryItem.media?.audioFiles || []
|
||||
},
|
||||
filesWithAudioFile() {
|
||||
return this.files.map((file) => {
|
||||
if (file.fileType === 'audio') {
|
||||
file.audioFile = this.audioFiles.find((af) => af.ino === file.ino)
|
||||
}
|
||||
return file
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickBar() {
|
||||
this.showFiles = !this.showFiles
|
||||
},
|
||||
showMore(audioFile) {
|
||||
this.selectedAudioFile = audioFile
|
||||
this.showAudioFileDataModal = true
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
118
client/components/tables/LibraryFilesTableRow.vue
Normal file
118
client/components/tables/LibraryFilesTableRow.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<tr>
|
||||
<td class="px-4">
|
||||
{{ showFullPath ? file.metadata.path : file.metadata.relPath }}
|
||||
</td>
|
||||
<td>
|
||||
{{ $bytesPretty(file.metadata.size) }}
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
<div class="flex items-center">
|
||||
<p>{{ file.fileType }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="contextMenuItems.length" class="text-center">
|
||||
<ui-context-menu-dropdown :items="contextMenuItems" menu-width="110px" @action="contextMenuAction" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItemId: String,
|
||||
showFullPath: Boolean,
|
||||
file: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
inModal: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userIsAdmin() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
downloadUrl() {
|
||||
return `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(this.file.metadata.relPath).replace(/^\//, '')}?token=${this.userToken}`
|
||||
},
|
||||
contextMenuItems() {
|
||||
const items = []
|
||||
if (this.userCanDownload) {
|
||||
items.push({
|
||||
text: this.$strings.LabelDownload,
|
||||
action: 'download'
|
||||
})
|
||||
}
|
||||
if (this.userCanDelete) {
|
||||
items.push({
|
||||
text: this.$strings.ButtonDelete,
|
||||
action: 'delete'
|
||||
})
|
||||
}
|
||||
// Currently not showing this option in the Files tab modal
|
||||
if (this.userIsAdmin && this.file.audioFile && !this.inModal) {
|
||||
items.push({
|
||||
text: this.$strings.LabelMoreInfo,
|
||||
action: 'more'
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
contextMenuAction(action) {
|
||||
if (action === 'delete') {
|
||||
this.deleteLibraryFile()
|
||||
} else if (action === 'download') {
|
||||
this.downloadLibraryFile()
|
||||
} else if (action === 'more') {
|
||||
this.$emit('showMore', this.file.audioFile)
|
||||
}
|
||||
},
|
||||
deleteLibraryFile() {
|
||||
const payload = {
|
||||
message: 'This will delete the file from your file system. Are you sure?',
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$axios
|
||||
.$delete(`/api/items/${this.libraryItemId}/file/${this.file.ino}`)
|
||||
.then(() => {
|
||||
this.$toast.success('File deleted')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete file', error)
|
||||
this.$toast.error('Failed to delete file')
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
downloadLibraryFile() {
|
||||
const a = document.createElement('a')
|
||||
a.style.display = 'none'
|
||||
a.href = this.downloadUrl
|
||||
a.download = this.file.metadata.filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(() => {
|
||||
a.remove()
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -5,9 +5,8 @@
|
||||
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||
<span class="text-sm font-mono">{{ tracks.length }}</span>
|
||||
</div>
|
||||
<!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> -->
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
|
||||
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
|
||||
<nuxt-link v-if="userCanUpdate && !isFile" :to="`/audiobook/${libraryItemId}/edit`" class="mr-2 md:mr-4" @mousedown.prevent>
|
||||
<ui-btn small color="primary">{{ $strings.ButtonManageTracks }}</ui-btn>
|
||||
</nuxt-link>
|
||||
@@ -18,44 +17,23 @@
|
||||
<transition name="slide">
|
||||
<div class="w-full" v-show="showTracks">
|
||||
<table class="text-sm tracksTable">
|
||||
<tr class="font-book">
|
||||
<tr>
|
||||
<th class="w-10">#</th>
|
||||
<th class="text-left">{{ $strings.LabelFilename }}</th>
|
||||
<th class="text-left w-20">{{ $strings.LabelSize }}</th>
|
||||
<th class="text-left w-20">{{ $strings.LabelDuration }}</th>
|
||||
<th v-if="userCanDownload" class="text-center w-20">{{ $strings.LabelDownload }}</th>
|
||||
<th v-if="showExperimentalFeatures" class="text-center w-20">
|
||||
<div class="flex items-center">
|
||||
<p>Tone</p>
|
||||
<ui-tooltip text="Experimental feature for testing Tone library metadata scan results. Results logged in browser console." class="ml-2 w-2" direction="left">
|
||||
<span class="material-icons-outlined text-sm">information</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</th>
|
||||
<th v-if="!showFullPath" class="text-left w-20 hidden lg:table-cell">{{ $strings.LabelCodec }}</th>
|
||||
<th v-if="!showFullPath" class="text-left w-20 hidden xl:table-cell">{{ $strings.LabelBitrate }}</th>
|
||||
<th class="text-left w-20 hidden md:table-cell">{{ $strings.LabelSize }}</th>
|
||||
<th class="text-left w-20 hidden sm:table-cell">{{ $strings.LabelDuration }}</th>
|
||||
<th class="text-center w-16"></th>
|
||||
</tr>
|
||||
<template v-for="track in tracks">
|
||||
<tr :key="track.index">
|
||||
<td class="text-center">
|
||||
<p>{{ track.index }}</p>
|
||||
</td>
|
||||
<td class="font-sans">{{ showFullPath ? track.metadata.path : track.metadata.filename }}</td>
|
||||
<td class="font-mono">
|
||||
{{ $bytesPretty(track.metadata.size) }}
|
||||
</td>
|
||||
<td class="font-mono">
|
||||
{{ $secondsToTimestamp(track.duration) }}
|
||||
</td>
|
||||
<td v-if="userCanDownload" class="text-center">
|
||||
<a :href="`${$config.routerBasePath}/s/item/${libraryItemId}/${$encodeUriPath(track.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text pt-1">download</span></a>
|
||||
</td>
|
||||
<td v-if="showExperimentalFeatures" class="text-center">
|
||||
<ui-icon-btn borderless :loading="toneProbing" icon="search" @click="toneProbe(track.index)" />
|
||||
</td>
|
||||
</tr>
|
||||
<tables-audio-tracks-table-row :key="track.index" :track="track" :library-item-id="libraryItemId" :showFullPath="showFullPath" @showMore="showMore" />
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :audio-file="selectedAudioFile" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -77,47 +55,31 @@ export default {
|
||||
return {
|
||||
showTracks: false,
|
||||
showFullPath: false,
|
||||
toneProbing: false
|
||||
selectedAudioFile: null,
|
||||
showAudioFileDataModal: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userIsAdmin() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickBar() {
|
||||
this.showTracks = !this.showTracks
|
||||
},
|
||||
toneProbe(index) {
|
||||
this.toneProbing = true
|
||||
|
||||
this.$axios
|
||||
.$post(`/api/items/${this.libraryItemId}/tone-scan/${index}`)
|
||||
.then((data) => {
|
||||
console.log('Tone probe data', data)
|
||||
if (data.error) {
|
||||
this.$toast.error('Tone probe error: ' + data.error)
|
||||
} else {
|
||||
this.$toast.success('Tone probe successful! Check browser console')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to tone probe', error)
|
||||
this.$toast.error('Tone probe failed')
|
||||
})
|
||||
.finally(() => {
|
||||
this.toneProbing = false
|
||||
})
|
||||
showMore(audioFile) {
|
||||
this.selectedAudioFile = audioFile
|
||||
this.showAudioFileDataModal = true
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user