mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-07-25 14:08:35 +02:00
Compare commits
535
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af8b5b63d5 | ||
|
|
4485d0833e | ||
|
|
a163a6af88 | ||
|
|
38bb5af04b | ||
|
|
11804d1cb8 | ||
|
|
22529fc1d2 | ||
|
|
62b0c5fd62 | ||
|
|
1ed28a4fd4 | ||
|
|
5044aea1d7 | ||
|
|
47fda0ccca | ||
|
|
742490775a | ||
|
|
95bbed2b21 | ||
|
|
2b446e227d | ||
|
|
ea6417dbb1 | ||
|
|
d902417959 | ||
|
|
358197db03 | ||
|
|
102fd1f1a1 | ||
|
|
fe9168c6cf | ||
|
|
f7663fc17f | ||
|
|
e370ec36ab | ||
|
|
8f181c74d4 | ||
|
|
e2fc5bcbb1 | ||
|
|
67af09c4bd | ||
|
|
b81abf4da5 | ||
|
|
2c288bb34d | ||
|
|
df762e8910 | ||
|
|
253aa19701 | ||
|
|
ea608d875b | ||
|
|
d4340a8b95 | ||
|
|
33b3aafb14 | ||
|
|
6cedc2fe35 | ||
|
|
1bb03015d9 | ||
|
|
c5bef72c63 | ||
|
|
8b7c00b337 | ||
|
|
6199d4fab4 | ||
|
|
2063d8647a | ||
|
|
c0a9f85f77 | ||
|
|
b1651c5b01 | ||
|
|
1a8af2caee | ||
|
|
a593070b9f | ||
|
|
1d700b0663 | ||
|
|
2233885142 | ||
|
|
faf87d2942 | ||
|
|
706673aed0 | ||
|
|
eaea688e33 | ||
|
|
256be3521d | ||
|
|
cec1e8593d | ||
|
|
e18f9be810 | ||
|
|
69dca2db43 | ||
|
|
c03321b55e | ||
|
|
f8b4287229 | ||
|
|
ce2fa0c116 | ||
|
|
ebe9a230fd | ||
|
|
a05ceea4bc | ||
|
|
67c2ded1f0 | ||
|
|
66d09c886e | ||
|
|
bbff7513f1 | ||
|
|
4195279c5d | ||
|
|
464d967a0a | ||
|
|
4dd7bf8916 | ||
|
|
4b134eaef9 | ||
|
|
ece62c8b18 | ||
|
|
b430c185b1 | ||
|
|
5c13ccf9e7 | ||
|
|
cb0e2ba305 | ||
|
|
011fbb38cb | ||
|
|
0a69450bc0 | ||
|
|
b4a97f4347 | ||
|
|
96f7aa95ee | ||
|
|
e92f2177e8 | ||
|
|
ab7bda402e | ||
|
|
d78c168e16 | ||
|
|
2b04b4f008 | ||
|
|
a6cc0d742c | ||
|
|
e7e9a2755c | ||
|
|
56ea4ce2f5 | ||
|
|
05528f6dfd | ||
|
|
49cd36c33e | ||
|
|
898d96ce3a | ||
|
|
b257836b76 | ||
|
|
8fab4e56ef | ||
|
|
8e34c230d7 | ||
|
|
b32f2e2973 | ||
|
|
1047d1a598 | ||
|
|
1e5115b743 | ||
|
|
e1e12de91b | ||
|
|
58950afb99 | ||
|
|
89b64b165f | ||
|
|
90ab927bdc | ||
|
|
50ebf4eacb | ||
|
|
064ba080ab | ||
|
|
4ee0345217 | ||
|
|
d45cbbba98 | ||
|
|
612a7a7063 | ||
|
|
62e3ca4068 | ||
|
|
34dcdc89c3 | ||
|
|
bcc202ff3b | ||
|
|
d4a56b3823 | ||
|
|
626239038b | ||
|
|
f7da431e61 | ||
|
|
6ff59c423b | ||
|
|
076d4977e3 | ||
|
|
2a737a9d72 | ||
|
|
8c77a19eb6 | ||
|
|
3918696477 | ||
|
|
8a6a2b8577 | ||
|
|
584023380c | ||
|
|
1f4dd2bdb7 | ||
|
|
f7d5a0732b | ||
|
|
2a6cc882a5 | ||
|
|
f385339da3 | ||
|
|
c37566a999 | ||
|
|
f90a4b1374 | ||
|
|
b649680d3e | ||
|
|
b84c2abe14 | ||
|
|
58aff762c0 | ||
|
|
e5eef69c91 | ||
|
|
54f91b7a88 | ||
|
|
17676173ef | ||
|
|
31f4aa944a | ||
|
|
b30bd495b7 | ||
|
|
06f893436e | ||
|
|
20a2b3ed90 | ||
|
|
b1e3bc46c8 | ||
|
|
8bc5932889 | ||
|
|
3cd597d71e | ||
|
|
48d5ad462e | ||
|
|
40cab87640 | ||
|
|
f405947ad8 | ||
|
|
fd550c15a5 | ||
|
|
d18537cf04 | ||
|
|
f01f0229a8 | ||
|
|
b369eb2d40 | ||
|
|
f08c65c5bf | ||
|
|
1043f4ecd7 | ||
|
|
4f17a32373 | ||
|
|
2203aae6e6 | ||
|
|
7b60c7b6c5 | ||
|
|
be98e06efc | ||
|
|
45239bca34 | ||
|
|
84509ec1a8 | ||
|
|
2493ce7ed7 | ||
|
|
2b1e53b371 | ||
|
|
d643a1769f | ||
|
|
a63b7a188c | ||
|
|
f9c0162b8a | ||
|
|
cda3cb3512 | ||
|
|
766f6938a7 | ||
|
|
8e1ccb517b | ||
|
|
540aab6f83 | ||
|
|
d1969d7897 | ||
|
|
b69e8fd649 | ||
|
|
2e95cf908d | ||
|
|
b589bae2b0 | ||
|
|
e7e4891d82 | ||
|
|
79df676a13 | ||
|
|
03a46decba | ||
|
|
d80482f443 | ||
|
|
735cce7614 | ||
|
|
8ea35a0b36 | ||
|
|
ad90bd3d05 | ||
|
|
fa3856e845 | ||
|
|
752d4ddc8c | ||
|
|
fd2d7a05d1 | ||
|
|
ccf4675867 | ||
|
|
bc043c73ca | ||
|
|
291ab247bc | ||
|
|
d901f470a1 | ||
|
|
0ebb01e39d | ||
|
|
20ac99bf8d | ||
|
|
90e86e1575 | ||
|
|
28652031e7 | ||
|
|
cd8be40cc8 | ||
|
|
52fc937f96 | ||
|
|
68e4722387 | ||
|
|
5f5b38b2f9 | ||
|
|
65f8d3ce81 | ||
|
|
ac571fd63d | ||
|
|
ea2d6e9f31 | ||
|
|
80b999d648 | ||
|
|
54720039e4 | ||
|
|
8c6717c201 | ||
|
|
6610754e25 | ||
|
|
d38622d8a2 | ||
|
|
cd2750a489 | ||
|
|
30140629ae | ||
|
|
0ce29337ef | ||
|
|
6715f29ebb | ||
|
|
b4a8dc9e26 | ||
|
|
c349a0bf00 | ||
|
|
0dc6c142be | ||
|
|
e46c1d9120 | ||
|
|
27ad554ea1 | ||
|
|
5660772b46 | ||
|
|
dab80642ab | ||
|
|
da46191208 | ||
|
|
6484c4de79 | ||
|
|
6716b6042a | ||
|
|
0686859fa7 | ||
|
|
4180862c3a | ||
|
|
7aa20d3211 | ||
|
|
b694b55710 | ||
|
|
024b613063 | ||
|
|
8768d11a20 | ||
|
|
5898303ba4 | ||
|
|
e79d09941c | ||
|
|
c52a60fe77 | ||
|
|
e64f9aa427 | ||
|
|
ee343f78ef | ||
|
|
1f20d5821f | ||
|
|
b0c512e946 | ||
|
|
0d13b7abd1 | ||
|
|
43fd8285c9 | ||
|
|
2f7e8d618a | ||
|
|
a0b910deeb | ||
|
|
e9dc45613c | ||
|
|
b5e90ec3d2 | ||
|
|
3d9f42c1e1 | ||
|
|
e62f430f37 | ||
|
|
9c53564c46 | ||
|
|
f7add6f6cd | ||
|
|
7786e6a827 | ||
|
|
b213e2aabf | ||
|
|
ebca0fafca | ||
|
|
ac8a2ca985 | ||
|
|
6380d7cc02 | ||
|
|
9056ba6b8d | ||
|
|
23801363c4 | ||
|
|
1cdcd7327f | ||
|
|
50ec50003e | ||
|
|
5ff4db1a3f | ||
|
|
394b44c343 | ||
|
|
eb2aa6f773 | ||
|
|
81130e04e5 | ||
|
|
c0ec0758f4 | ||
|
|
e21f7c545e | ||
|
|
c16dd2fcf6 | ||
|
|
90974e6d73 | ||
|
|
8e4f75d85b | ||
|
|
792ee2adb8 | ||
|
|
2c57489c07 | ||
|
|
e3321770fd | ||
|
|
848673f028 | ||
|
|
e6d3003ecf | ||
|
|
90551b7ce7 | ||
|
|
aabac5da13 | ||
|
|
4fad77f9c1 | ||
|
|
0fb6d746eb | ||
|
|
7fc712d19b | ||
|
|
d6018cfe10 | ||
|
|
5b8f231a98 | ||
|
|
cb503f84e1 | ||
|
|
a34f9b80ac | ||
|
|
3e6696b516 | ||
|
|
9cd8bd439a | ||
|
|
ca5b99e23d | ||
|
|
c44fd74f5b | ||
|
|
f82c4834c3 | ||
|
|
dda95bd69c | ||
|
|
a623cb57a7 | ||
|
|
e798d930fb | ||
|
|
f91b0c9e2f | ||
|
|
b6824cf242 | ||
|
|
41477fdac1 | ||
|
|
d4e8afc2f1 | ||
|
|
d42cbfe3e4 | ||
|
|
c7c3ef52e6 | ||
|
|
92344325b6 | ||
|
|
a8172fb3f9 | ||
|
|
a73ec783d5 | ||
|
|
5c9fc4c9e4 | ||
|
|
494ff38ef3 | ||
|
|
b7e6cbf1ab | ||
|
|
b33623b74f | ||
|
|
4f939ce6fc | ||
|
|
4f45f360df | ||
|
|
4ad54f34fe | ||
|
|
574dda5286 | ||
|
|
d788623509 | ||
|
|
f047f6a23b | ||
|
|
48b30f6b03 | ||
|
|
67d32164d9 | ||
|
|
6672a21049 | ||
|
|
b833019a14 | ||
|
|
00eeeb2a5a | ||
|
|
06a0a68143 | ||
|
|
1a7ae468bd | ||
|
|
e8f74d7358 | ||
|
|
984f01da23 | ||
|
|
d6e75410a5 | ||
|
|
7627e1b58c | ||
|
|
5fb31b18ae | ||
|
|
20903e8217 | ||
|
|
8fe348d4eb | ||
|
|
24cda8a179 | ||
|
|
fc954a968b | ||
|
|
aae53a54cd | ||
|
|
cfedcffc98 | ||
|
|
0e74c74a6c | ||
|
|
fb86c45e78 | ||
|
|
124300b215 | ||
|
|
6746c0627b | ||
|
|
0f2cd3b132 | ||
|
|
e94d6fdc1e | ||
|
|
e301b3071c | ||
|
|
3d3dad5e4a | ||
|
|
f8a9033738 | ||
|
|
edb25f5bcd | ||
|
|
bcb239efd1 | ||
|
|
d208211c6f | ||
|
|
0b20b72241 | ||
|
|
5c29459af2 | ||
|
|
e54fd38784 | ||
|
|
94774f13df | ||
|
|
43ec35fe67 | ||
|
|
b2645dc444 | ||
|
|
c79624a82c | ||
|
|
8201f3c9a6 | ||
|
|
e0ac6a1c38 | ||
|
|
400da5566d | ||
|
|
9b67037774 | ||
|
|
48596542c7 | ||
|
|
9715d65d88 | ||
|
|
2e14b31a69 | ||
|
|
1c8ce6c3db | ||
|
|
dc74a6213d | ||
|
|
e9a66bb7a7 | ||
|
|
3c6d4b5f19 | ||
|
|
f04eebe0d8 | ||
|
|
63b5227ccc | ||
|
|
934d9b1df4 | ||
|
|
8994cb3a6e | ||
|
|
4a32de482b | ||
|
|
95f47793d2 | ||
|
|
f27048618d | ||
|
|
c90ad419e8 | ||
|
|
2cdc3ecb9d | ||
|
|
9f89fcd968 | ||
|
|
7c2d51f811 | ||
|
|
41fe7a29cd | ||
|
|
54f3eec303 | ||
|
|
cd91013f69 | ||
|
|
0af16ef05f | ||
|
|
5ba830d56e | ||
|
|
81919e963a | ||
|
|
8a1d4bb958 | ||
|
|
eabac0e709 | ||
|
|
d90783d7c8 | ||
|
|
faf8211e5c | ||
|
|
65d90947c5 | ||
|
|
752e918d26 | ||
|
|
ad08cc69f4 | ||
|
|
da3882fd5e | ||
|
|
c04c7a55d9 | ||
|
|
3318846b43 | ||
|
|
e0860feeb9 | ||
|
|
c0253a69b9 | ||
|
|
9242c7e6e0 | ||
|
|
0bfc579ae4 | ||
|
|
5fad06756b | ||
|
|
5be6afa6c1 | ||
|
|
5ba3b8be7a | ||
|
|
0eb62f541d | ||
|
|
6baff881c7 | ||
|
|
8c23c1e795 | ||
|
|
9706c66a27 | ||
|
|
f4f7252537 | ||
|
|
430d200151 | ||
|
|
7be82d03f3 | ||
|
|
15aaef742d | ||
|
|
e443ba09da | ||
|
|
8235d1f1f7 | ||
|
|
9a39dcceee | ||
|
|
a707b7fc86 | ||
|
|
b6fea85ef4 | ||
|
|
7e3b3f55ae | ||
|
|
d2d3bbe8a1 | ||
|
|
41a65c0f9d | ||
|
|
165ddd58a6 | ||
|
|
09dc04c0de | ||
|
|
57d5dbc0d0 | ||
|
|
11af75e127 | ||
|
|
f8d82a3020 | ||
|
|
81d7b1cfd7 | ||
|
|
e15c1bcb32 | ||
|
|
826d414d9f | ||
|
|
0da6fca727 | ||
|
|
2327bc1705 | ||
|
|
3bb76cecac | ||
|
|
e3f82bb237 | ||
|
|
fcd6270f2d | ||
|
|
d5411703ca | ||
|
|
8fc2058db8 | ||
|
|
d03949f5d1 | ||
|
|
ff01786063 | ||
|
|
bb117ab4da | ||
|
|
f5ec247b47 | ||
|
|
6e6dd00523 | ||
|
|
c9fb98f033 | ||
|
|
75ecff5e6d | ||
|
|
91a9f918fb | ||
|
|
03305f90ca | ||
|
|
9428bc07c5 | ||
|
|
f38f9c3502 | ||
|
|
64a796aece | ||
|
|
63039fe8c9 | ||
|
|
3f2b723d0f | ||
|
|
ffd7fb1cf9 | ||
|
|
b049b206cf | ||
|
|
35f56dc8a9 | ||
|
|
a71cd326cc | ||
|
|
cc841d1c7c | ||
|
|
16ecdcf96e | ||
|
|
96ec56d1ad | ||
|
|
9b15bafc9f | ||
|
|
aa02f2e465 | ||
|
|
15464617ef | ||
|
|
60ecceb59d | ||
|
|
3fe943e989 | ||
|
|
5e7d039454 | ||
|
|
f928df146a | ||
|
|
6e57cb49c4 | ||
|
|
2f8494b013 | ||
|
|
6f1b93bd9f | ||
|
|
19b928d7f6 | ||
|
|
b849be6f5e | ||
|
|
86096df1a4 | ||
|
|
98a465c7b4 | ||
|
|
3315f61ae4 | ||
|
|
f977445d7a | ||
|
|
095d5e1cec | ||
|
|
69fbfa4b33 | ||
|
|
1c89d33726 | ||
|
|
acee907932 | ||
|
|
08ad216fe2 | ||
|
|
3cc7a0937c | ||
|
|
322cad5548 | ||
|
|
73d70dd480 | ||
|
|
56bc569d4a | ||
|
|
981ae58f6a | ||
|
|
b2bfa4c002 | ||
|
|
8b6003c5e3 | ||
|
|
017b6237ca | ||
|
|
d5496c2e9a | ||
|
|
5ef8961c33 | ||
|
|
e8bea48577 | ||
|
|
6cae78f5c2 | ||
|
|
d9d31a0620 | ||
|
|
3682c5fca2 | ||
|
|
14b04a14b6 | ||
|
|
f218d90bfb | ||
|
|
020f936ad4 | ||
|
|
224e5efe48 | ||
|
|
439ab2c8c0 | ||
|
|
26d0209763 | ||
|
|
29086fc783 | ||
|
|
bb656a265f | ||
|
|
b11ad9dc5b | ||
|
|
2b1667e532 | ||
|
|
228a198598 | ||
|
|
2843308fb8 | ||
|
|
793f0c05f7 | ||
|
|
cf7271dc57 | ||
|
|
54664b0c32 | ||
|
|
ecf910dc55 | ||
|
|
460daec72e | ||
|
|
4dd67adcab | ||
|
|
023c83ee78 | ||
|
|
2b8ed16616 | ||
|
|
6ae4d72b68 | ||
|
|
c6bd93ef5c | ||
|
|
e1c02ce74c | ||
|
|
efc6d68403 | ||
|
|
7412d0899f | ||
|
|
42eb0a8384 | ||
|
|
27e83e8ed9 | ||
|
|
dd66042766 | ||
|
|
dc790d5450 | ||
|
|
01ac844024 | ||
|
|
c8064ac7bc | ||
|
|
f5da44dc6f | ||
|
|
cbdff29684 | ||
|
|
19d6c84113 | ||
|
|
d9f4ed3d54 | ||
|
|
fdb26b7700 | ||
|
|
94cb7a65a2 | ||
|
|
9298065934 | ||
|
|
1cf01a5d12 | ||
|
|
e3aa96cbd5 | ||
|
|
c7678da664 | ||
|
|
52701048ad | ||
|
|
776c02828f | ||
|
|
69308413f6 | ||
|
|
84e376a7cf | ||
|
|
1822b21af8 | ||
|
|
8b222435e6 | ||
|
|
fdd251ec2c | ||
|
|
78170d4e10 | ||
|
|
837981b8b6 | ||
|
|
be23c6703f | ||
|
|
0c72889917 | ||
|
|
02746bc35f | ||
|
|
85a5c33bd8 | ||
|
|
5067c0f6d7 | ||
|
|
89938ab120 | ||
|
|
9a32396daa | ||
|
|
ddb1796891 | ||
|
|
76253b7b9d | ||
|
|
74fef1d2f4 | ||
|
|
7306ffa709 | ||
|
|
6f289293cc | ||
|
|
783f924bb9 | ||
|
|
044a35aacf | ||
|
|
78448894d2 | ||
|
|
aca348e783 | ||
|
|
e6c1874cbb | ||
|
|
1b06dbd78c | ||
|
|
a26f37aa49 | ||
|
|
8af3c09458 | ||
|
|
fed6579e08 | ||
|
|
6621f8b2ee | ||
|
|
737b6d7c26 | ||
|
|
59c71b71fb | ||
|
|
f89148b92e | ||
|
|
88fbebe5c4 | ||
|
|
e8abb18350 | ||
|
|
cc9dead1eb | ||
|
|
f9dd8188ee | ||
|
|
579785d2c6 | ||
|
|
257a1ceb51 | ||
|
|
5c60cd136c | ||
|
|
2bfe548c52 | ||
|
|
164fd92a18 | ||
|
|
0036a19659 | ||
|
|
ef4d7ba1bf |
+1
-6
@@ -1,13 +1,8 @@
|
||||
# editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
labels: bug
|
||||
|
||||
---
|
||||
|
||||
### Steps to reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Expected behaviour
|
||||
- Tell us what should happen
|
||||
|
||||
### Actual behaviour
|
||||
- Tell us what happens
|
||||
|
||||
### Environment data
|
||||
|
||||
Audiobookshelf Version:
|
||||
|
||||
- [ ] Android App?
|
||||
- [ ] iOS App?
|
||||
|
||||
#### Android Issue
|
||||
Android version:
|
||||
|
||||
Device model:
|
||||
|
||||
Stock or customized system:
|
||||
|
||||
#### iOS Issue
|
||||
iOS Version:
|
||||
|
||||
iPhone model:
|
||||
@@ -0,0 +1,88 @@
|
||||
name: 🐞 ABS App Bug Report
|
||||
description: File a bug/issue and help us improve the Audiobookshelf mobile apps.
|
||||
title: '[Bug]: '
|
||||
labels: ['bug', 'triage']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: '## App Bug Description'
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: 'Thank you for filing a bug report! 🐛'
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: 'Join the [discord server](https://discord.gg/HQgCbd6E75) for questions or if you are not sure about a bug.'
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What was the Problem?
|
||||
description: Please help us understand the problem.
|
||||
placeholder: Describe the problem you saw or experienced in detail! Include a screenshot or video, if applicable.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: Steps to Reproduce the Issue
|
||||
description: Please help us understand how we can reliably reproduce the issue.
|
||||
placeholder: '1. Go to the library page of a Podcast library and...'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-was-expected
|
||||
attributes:
|
||||
label: What was Expected?
|
||||
description: Please help us understand what should have happened.
|
||||
placeholder: Please help us visualize by including a doodle or screenshot.
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: '## Mobile Environment'
|
||||
- type: input
|
||||
id: phone-model
|
||||
attributes:
|
||||
label: Phone Model
|
||||
description: What kind of phone are you using?
|
||||
placeholder: e.g. Pixel 6, iPhone 14, Samusung Galaxy s23, etc
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: phone-os
|
||||
attributes:
|
||||
label: Phone OS
|
||||
description: What operating system? If Android, is it stock or a custom system?
|
||||
placeholder: e.g. Android 14, iOS 17.4, etc
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: app-version
|
||||
attributes:
|
||||
label: Audiobookshelf App Version
|
||||
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
||||
multiple: true
|
||||
options:
|
||||
- Android App - 0.9.76
|
||||
- iOS App - 0.9.76
|
||||
- Android App - 0.9.75
|
||||
- iOS App - 0.9.75
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: install-source
|
||||
attributes:
|
||||
label: Installation Source
|
||||
multiple: true
|
||||
options:
|
||||
- Google Play Store
|
||||
- Testflight
|
||||
- SideStore
|
||||
- Other (List in "Additional Notes")
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional-notes
|
||||
attributes:
|
||||
label: Additional Notes
|
||||
description: Anything else you want to add?
|
||||
placeholder: 'e.g. I have tried X, Y, and Z.'
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
labels: enhancement
|
||||
|
||||
---
|
||||
|
||||
### Is your feature request related to a problem? Please describe.
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
### Describe the solution you'd like
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
### Describe alternatives you've considered
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
### Additional context
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,57 @@
|
||||
name: 🚀 App Feature Request
|
||||
description: Request a feature/enhancement
|
||||
title: '[Enhancement]: '
|
||||
labels: ['enhancement']
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: '## App Feature Request Description'
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: 'Please first search in both issues & discussions for your enhancement and make sure your app is up to date.'
|
||||
- type: textarea
|
||||
id: describe
|
||||
attributes:
|
||||
label: Describe the Feature/Enhancement
|
||||
description: Please help us understand what you want.
|
||||
placeholder: What is your vision?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: the-why
|
||||
attributes:
|
||||
label: Why would this be helpful?
|
||||
description: Please help us understand why this would enhance your experience.
|
||||
placeholder: Explain the "why" or "use case".
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: image
|
||||
attributes:
|
||||
label: Future Implementation (Screenshot)
|
||||
description: Please help us visualize by including a doodle or screenshot.
|
||||
placeholder: How could this look or where could it go?
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: '## App Current Implementation'
|
||||
- type: dropdown
|
||||
id: version
|
||||
attributes:
|
||||
label: Audiobookshelf App Version
|
||||
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
||||
multiple: true
|
||||
options:
|
||||
- Android App - 0.9.76
|
||||
- iOS App - 0.9.76
|
||||
- Android App - 0.9.75
|
||||
- iOS App - 0.9.75
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: current-image
|
||||
attributes:
|
||||
label: Current Implementation (Screenshot)
|
||||
description: What screen were you looking at when you thought of this enhancement?
|
||||
placeholder: If an image is not applicable, please explain why.
|
||||
@@ -16,10 +16,10 @@ jobs:
|
||||
- name: checkout sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: use Node.js 16.x
|
||||
- name: use Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 20
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v2
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Close fixed issues on release.
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close issues marked as fixed upon a release.
|
||||
uses: gcampbell-msft/fixed-pending-release@7fa1b75a0c04bcd4b375110522878e5f6100cff5
|
||||
with:
|
||||
label: 'awaiting release'
|
||||
removeLabel: true
|
||||
applyToAll: true
|
||||
message: Fixed in [${releaseTag}](${releaseUrl}).
|
||||
@@ -15,10 +15,10 @@ jobs:
|
||||
- name: checkout sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: use Node.js 16.x
|
||||
- name: use Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 20
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v2
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Verify all i18n files are alphabetized
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
paths:
|
||||
- strings/** # Should only check if any strings changed
|
||||
|
||||
jobs:
|
||||
update_translations:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Check out the repository
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Set up node to run the javascript
|
||||
- name: Set up node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
# The only argument is the `directory`, which is where the i18n files are
|
||||
# stored.
|
||||
- name: Run Update JSON Files action
|
||||
uses: audiobookshelf/audiobookshelf-i18n-updater@v1.3.0
|
||||
with:
|
||||
directory: 'strings/' # Adjust the directory path as needed
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 400,
|
||||
"proseWrap": "never",
|
||||
"trailingComma": "none",
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.html"],
|
||||
"options": {
|
||||
"singleQuote": false,
|
||||
"wrapAttributes": false,
|
||||
"sortAttributes": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"EditorConfig.EditorConfig",
|
||||
"esbenp.prettier-vscode",
|
||||
"octref.vetur"
|
||||
]
|
||||
}
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"vetur.format.defaultFormatterOptions": {
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 400,
|
||||
"proseWrap": "never",
|
||||
"trailingComma": "none"
|
||||
},
|
||||
"prettyhtml": {
|
||||
"printWidth": 400,
|
||||
"singleQuote": false,
|
||||
"wrapAttributes": false,
|
||||
"sortAttributes": false
|
||||
}
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"editor.detectIndentation": true,
|
||||
"editor.tabSize": 2,
|
||||
"javascript.format.semicolons": "remove",
|
||||
"[javascript][json][jsonc]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[vue]": {
|
||||
"editor.defaultFormatter": "octref.vetur"
|
||||
}
|
||||
}
|
||||
@@ -28,13 +28,13 @@ android {
|
||||
kotlinOptions {
|
||||
freeCompilerArgs = ['-Xjvm-default=all']
|
||||
}
|
||||
compileSdkVersion rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
compileSdkVersion rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "com.audiobookshelf.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 98
|
||||
versionName "0.9.67-beta"
|
||||
versionCode 108
|
||||
versionName "0.9.77-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
manifestPlaceholders = [
|
||||
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
||||
@@ -118,7 +118,7 @@ dependencies {
|
||||
implementation 'io.github.pilgr:paperdb:2.7.2'
|
||||
|
||||
// Simple Storage
|
||||
implementation "com.anggrayudi:storage:1.5.4"
|
||||
implementation "com.anggrayudi:storage:1.5.6"
|
||||
|
||||
// OK HTTP
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
|
||||
|
||||
@@ -9,6 +9,8 @@ android {
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':byteowls-capacitor-filesharer')
|
||||
implementation project(':capacitor-community-volume-buttons')
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-browser')
|
||||
implementation project(':capacitor-clipboard')
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">audiobookshelf</string>
|
||||
<string name="title_activity_main">audiobookshelf</string>
|
||||
<string name="app_name">Audiobookshelf</string>
|
||||
<string name="title_activity_main">Audiobookshelf</string>
|
||||
<string name="package_name">com.audiobookshelf.app</string>
|
||||
<string name="custom_url_scheme">audiobookshelf</string>
|
||||
<string name="add_widget">Add widget</string>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
<!-- Permissions -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
@@ -15,10 +16,10 @@
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true"
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
[
|
||||
{
|
||||
"pkg": "@byteowls/capacitor-filesharer",
|
||||
"classpath": "com.byteowls.capacitor.filesharer.FileSharerPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor-community/volume-buttons",
|
||||
"classpath": "com.ryltsov.alex.plugins.volume.buttons.VolumeButtonsPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/app",
|
||||
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
||||
|
||||
@@ -103,6 +103,9 @@ class MainActivity : BridgeActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
fun isPlayerNotificationServiceInitialized():Boolean {
|
||||
return ::foregroundService.isInitialized
|
||||
}
|
||||
|
||||
fun stopMyService() {
|
||||
if (mBounded) {
|
||||
@@ -116,6 +119,7 @@ class MainActivity : BridgeActivity() {
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
storageHelper.onSaveInstanceState(outState)
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.clear()
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
|
||||
@@ -96,7 +96,7 @@ class Podcast(
|
||||
@JsonIgnore
|
||||
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
|
||||
val localEpisodeId = "local_ep_" + episode.id
|
||||
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,null,null,audioTrack,episode.chapters,audioTrack.duration,0, episode.id, localEpisodeId)
|
||||
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,null,null,audioTrack,episode.chapters,audioTrack.duration,episode.size, episode.id, localEpisodeId)
|
||||
episodes?.add(newEpisode)
|
||||
|
||||
var index = 1
|
||||
|
||||
@@ -20,6 +20,14 @@ enum class ShakeSensitivitySetting {
|
||||
VERY_LOW, LOW, MEDIUM, HIGH, VERY_HIGH
|
||||
}
|
||||
|
||||
enum class DownloadUsingCellularSetting {
|
||||
ASK, ALWAYS, NEVER
|
||||
}
|
||||
|
||||
enum class StreamingUsingCellularSetting {
|
||||
ASK, ALWAYS, NEVER
|
||||
}
|
||||
|
||||
data class ServerConnectionConfig(
|
||||
var id:String,
|
||||
var index:Int,
|
||||
@@ -107,6 +115,7 @@ data class PlayItemRequestPayload(
|
||||
data class DeviceSettings(
|
||||
var disableAutoRewind:Boolean,
|
||||
var enableAltView:Boolean,
|
||||
var allowSeekingOnMediaControls:Boolean,
|
||||
var jumpBackwardsTime:Int,
|
||||
var jumpForwardTime:Int,
|
||||
var enableMp3IndexSeeking:Boolean,
|
||||
@@ -121,7 +130,10 @@ data class DeviceSettings(
|
||||
var autoSleepTimerAutoRewindTime: Long, //Time in milliseconds
|
||||
var sleepTimerLength: Long, // Time in milliseconds
|
||||
var disableSleepTimerFadeOut: Boolean,
|
||||
var disableSleepTimerResetFeedback: Boolean
|
||||
var disableSleepTimerResetFeedback: Boolean,
|
||||
var languageCode: String,
|
||||
var downloadUsingCellular: DownloadUsingCellularSetting,
|
||||
var streamingUsingCellular: StreamingUsingCellularSetting
|
||||
) {
|
||||
companion object {
|
||||
// Static method to get default device settings
|
||||
@@ -129,6 +141,7 @@ data class DeviceSettings(
|
||||
return DeviceSettings(
|
||||
disableAutoRewind = false,
|
||||
enableAltView = true,
|
||||
allowSeekingOnMediaControls = false,
|
||||
jumpBackwardsTime = 10,
|
||||
jumpForwardTime = 10,
|
||||
enableMp3IndexSeeking = false,
|
||||
@@ -143,7 +156,10 @@ data class DeviceSettings(
|
||||
autoSleepTimerAutoRewind = false,
|
||||
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
|
||||
disableSleepTimerFadeOut = false,
|
||||
disableSleepTimerResetFeedback = false
|
||||
disableSleepTimerResetFeedback = false,
|
||||
languageCode = "en-us",
|
||||
downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS,
|
||||
streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.audiobookshelf.app.player.PLAYMETHOD_LOCAL
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@@ -78,10 +79,31 @@ class LocalLibraryItem(
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun hasTracks(episode:PodcastEpisode?): Boolean {
|
||||
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
|
||||
if (episode != null) { // Get podcast episode audio track
|
||||
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
|
||||
}
|
||||
if (audioTracks.size == 0) return false
|
||||
audioTracks.forEach {
|
||||
// Check that metadata is not null
|
||||
if (it.metadata === null) {
|
||||
return false
|
||||
}
|
||||
// Check that file exists
|
||||
val file = File(it.metadata!!.path)
|
||||
if (!file.exists()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getPlaybackSession(episode:PodcastEpisode?, deviceInfo:DeviceInfo):PlaybackSession {
|
||||
val localEpisodeId = episode?.id
|
||||
val sessionId = "play_local_${UUID.randomUUID()}"
|
||||
val sessionId = "${UUID.randomUUID()}"
|
||||
|
||||
// Get current progress for local media
|
||||
val mediaProgressId = if (localEpisodeId.isNullOrEmpty()) id else "$id-$localEpisodeId"
|
||||
|
||||
@@ -48,6 +48,19 @@ object DeviceManager {
|
||||
if (deviceData.deviceSettings?.autoSleepTimerAutoRewindTime == null) {
|
||||
deviceData.deviceSettings?.autoSleepTimerAutoRewindTime = 300000L // 5 minutes
|
||||
}
|
||||
|
||||
// Language added in v0.9.69
|
||||
if (deviceData.deviceSettings?.languageCode == null) {
|
||||
deviceData.deviceSettings?.languageCode = "en-us"
|
||||
}
|
||||
|
||||
if (deviceData.deviceSettings?.downloadUsingCellular == null) {
|
||||
deviceData.deviceSettings?.downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS
|
||||
}
|
||||
|
||||
if (deviceData.deviceSettings?.streamingUsingCellular == null) {
|
||||
deviceData.deviceSettings?.streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS
|
||||
}
|
||||
}
|
||||
|
||||
fun getBase64Id(id:String):String {
|
||||
|
||||
@@ -70,6 +70,7 @@ class FolderScanner(var ctx: Context) {
|
||||
)
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
val trackFileMetadata = FileMetadata(file.name, file.extension, file.absolutePath, file.getBasePath(ctx), file.length())
|
||||
// Create new audio track
|
||||
val track = AudioTrack(
|
||||
audioTrackFromServer.index,
|
||||
@@ -78,7 +79,7 @@ class FolderScanner(var ctx: Context) {
|
||||
localFile.filename ?: "",
|
||||
localFile.contentUrl,
|
||||
localFile.mimeType ?: "",
|
||||
null,
|
||||
trackFileMetadata,
|
||||
true,
|
||||
localFileId,
|
||||
null,
|
||||
@@ -274,7 +275,8 @@ class FolderScanner(var ctx: Context) {
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
// Create new audio track
|
||||
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioTrackFromServer.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, null, audioTrackFromServer.index)
|
||||
val trackFileMetadata = FileMetadata(docFile.name ?: "", docFile.extension ?: "", docFile.getAbsolutePath(ctx), docFile.getBasePath(ctx), docFile.length())
|
||||
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioTrackFromServer.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", trackFileMetadata, true, localFileId, null, audioTrackFromServer.index)
|
||||
audioTracks.add(track)
|
||||
|
||||
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
|
||||
|
||||
@@ -247,14 +247,18 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
}
|
||||
|
||||
private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig | $serverConfigIdUsed")
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig | serverConfigIdUsed=$serverConfigIdUsed | lastServerConnectionConfigId=${DeviceManager.deviceData.lastServerConnectionConfigId}")
|
||||
|
||||
coroutineScope {
|
||||
if (!DeviceManager.checkConnectivity(ctx)) {
|
||||
serverUserMediaProgress = mutableListOf()
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig: No connectivity")
|
||||
cb(false)
|
||||
} else if (DeviceManager.deviceData.lastServerConnectionConfigId.isNullOrBlank()) { // If in offline mode last server connection config is unset
|
||||
serverUserMediaProgress = mutableListOf()
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig: No last server connection config")
|
||||
cb(false)
|
||||
} else {
|
||||
|
||||
var hasValidConn = false
|
||||
var lookupMediaProgress = true
|
||||
|
||||
@@ -332,6 +336,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
checkSetValidServerConnectionConfig { isConnected ->
|
||||
if (isConnected) {
|
||||
serverConfigIdUsed = DeviceManager.serverConnectionConfigId
|
||||
Log.d(tag, "loadAndroidAutoItems: Connected to server config id=$serverConfigIdUsed")
|
||||
|
||||
loadLibraries { libraries ->
|
||||
if (libraries.isEmpty()) {
|
||||
@@ -358,6 +363,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
}
|
||||
}
|
||||
} else { // Not connected to server
|
||||
Log.d(tag, "loadAndroidAutoItems: Not connected to server")
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +238,8 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
||||
saveLocalProgress(it)
|
||||
lastSyncTime = System.currentTimeMillis()
|
||||
|
||||
Log.d(tag, "Sync local device current serverConnectionConfigId=${DeviceManager.serverConnectionConfig?.id}")
|
||||
|
||||
// Local library item is linked to a server library item
|
||||
// Send sync to server also if connected to this server and local item belongs to this server
|
||||
if (hasNetworkConnection && shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
|
||||
|
||||
+2
-4
@@ -15,8 +15,6 @@ import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px
|
||||
|
||||
class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, private val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
|
||||
private val tag = "MediaDescriptionAdapter"
|
||||
|
||||
@@ -77,7 +75,7 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
||||
.load(uri)
|
||||
.placeholder(R.drawable.icon)
|
||||
.error(R.drawable.icon)
|
||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
||||
.submit()
|
||||
.get()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
@@ -85,7 +83,7 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
||||
Glide.with(playerNotificationService)
|
||||
.asBitmap()
|
||||
.load(Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon))
|
||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
||||
.submit()
|
||||
.get()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,15 +116,16 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
|
||||
if (mediaId.isNullOrEmpty()) {
|
||||
libraryItemWrapper = playerNotificationService.mediaManager.getFirstItem()
|
||||
} else if (mediaId.startsWith("ep_") || mediaId.startsWith("local_ep_")) { // Playing podcast episode
|
||||
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
|
||||
libraryItemWrapper = libraryItemWithEpisode?.libraryItemWrapper
|
||||
podcastEpisode = libraryItemWithEpisode?.episode
|
||||
} else {
|
||||
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
|
||||
|
||||
if (libraryItemWrapper == null) {
|
||||
Log.e(tag, "onPlayFromMediaId: Media item not found $mediaId")
|
||||
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
|
||||
if (libraryItemWithEpisode != null) {
|
||||
libraryItemWrapper = libraryItemWithEpisode.libraryItemWrapper
|
||||
podcastEpisode = libraryItemWithEpisode.episode
|
||||
} else {
|
||||
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
|
||||
if (libraryItemWrapper == null) {
|
||||
Log.e(tag, "onPlayFromMediaId: Media item not found $mediaId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -49,10 +49,10 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
|
||||
val libraryItemWrapper: LibraryItemWrapper?
|
||||
var podcastEpisode: PodcastEpisode? = null
|
||||
|
||||
if (mediaId.startsWith("ep_") || mediaId.startsWith("local_ep_")) { // Playing podcast episode
|
||||
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
|
||||
libraryItemWrapper = libraryItemWithEpisode?.libraryItemWrapper
|
||||
podcastEpisode = libraryItemWithEpisode?.episode
|
||||
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
|
||||
if (libraryItemWithEpisode != null) {
|
||||
libraryItemWrapper = libraryItemWithEpisode.libraryItemWrapper
|
||||
podcastEpisode = libraryItemWithEpisode.episode
|
||||
} else {
|
||||
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
|
||||
}
|
||||
|
||||
+23
-15
@@ -34,6 +34,7 @@ import com.audiobookshelf.app.managers.SleepTimerManager
|
||||
import com.audiobookshelf.app.media.MediaManager
|
||||
import com.audiobookshelf.app.media.MediaProgressSyncer
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.google.android.exoplayer2.*
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||
@@ -319,25 +320,16 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
|
||||
.setExtras(extra)
|
||||
.setTitle(currentPlaybackSession!!.displayTitle)
|
||||
.setIconUri(coverUri)
|
||||
|
||||
bitmap?.let {
|
||||
mediaDescriptionBuilder.setIconBitmap(it)
|
||||
}
|
||||
} ?: mediaDescriptionBuilder.setIconUri(coverUri)
|
||||
|
||||
return mediaDescriptionBuilder.build()
|
||||
}
|
||||
}
|
||||
|
||||
mediaSessionConnector.setEnabledPlaybackActions(
|
||||
PlaybackStateCompat.ACTION_PLAY_PAUSE
|
||||
or PlaybackStateCompat.ACTION_PLAY
|
||||
or PlaybackStateCompat.ACTION_PAUSE
|
||||
or PlaybackStateCompat.ACTION_SEEK_TO
|
||||
or PlaybackStateCompat.ACTION_FAST_FORWARD
|
||||
or PlaybackStateCompat.ACTION_REWIND
|
||||
or PlaybackStateCompat.ACTION_STOP
|
||||
)
|
||||
setMediaSessionConnectorPlaybackActions()
|
||||
mediaSessionConnector.setQueueNavigator(queueNavigator)
|
||||
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
|
||||
|
||||
@@ -512,6 +504,20 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray())
|
||||
}
|
||||
|
||||
fun setMediaSessionConnectorPlaybackActions() {
|
||||
var playbackActions = PlaybackStateCompat.ACTION_PLAY_PAUSE or
|
||||
PlaybackStateCompat.ACTION_PLAY or
|
||||
PlaybackStateCompat.ACTION_PAUSE or
|
||||
PlaybackStateCompat.ACTION_FAST_FORWARD or
|
||||
PlaybackStateCompat.ACTION_REWIND or
|
||||
PlaybackStateCompat.ACTION_STOP
|
||||
|
||||
if (deviceSettings.allowSeekingOnMediaControls) {
|
||||
playbackActions = playbackActions or PlaybackStateCompat.ACTION_SEEK_TO
|
||||
}
|
||||
mediaSessionConnector.setEnabledPlaybackActions(playbackActions)
|
||||
}
|
||||
|
||||
fun handlePlayerPlaybackError(errorMessage:String) {
|
||||
// On error and was attempting to direct play - fallback to transcode
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
@@ -881,16 +887,18 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
fun closePlayback(calledOnError:Boolean? = false) {
|
||||
Log.d(tag, "closePlayback")
|
||||
val config = DeviceManager.serverConnectionConfig
|
||||
|
||||
val isLocal = mediaProgressSyncer.currentIsLocal
|
||||
val currentSessionId = mediaProgressSyncer.currentSessionId
|
||||
if (mediaProgressSyncer.listeningTimerRunning) {
|
||||
Log.i(tag, "About to close playback so stopping media progress syncer first")
|
||||
|
||||
mediaProgressSyncer.stop(calledOnError == false) { // If closing on error then do not sync progress (causes exception)
|
||||
Log.d(tag, "Media Progress syncer stopped")
|
||||
|
||||
// If not local session then close on server
|
||||
if (!isLocal && currentSessionId != "") {
|
||||
apiHandler.closePlaybackSession(currentSessionId) {
|
||||
apiHandler.closePlaybackSession(currentSessionId, config) {
|
||||
Log.d(tag, "Closed playback session $currentSessionId")
|
||||
}
|
||||
}
|
||||
@@ -898,7 +906,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
} else {
|
||||
// If not local session then close on server
|
||||
if (!isLocal && currentSessionId != "") {
|
||||
apiHandler.closePlaybackSession(currentSessionId) {
|
||||
apiHandler.closePlaybackSession(currentSessionId, config) {
|
||||
Log.d(tag, "Closed playback session $currentSessionId")
|
||||
}
|
||||
}
|
||||
@@ -941,7 +949,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
appVersion: 0.9.46-beta
|
||||
*/
|
||||
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
|
||||
return DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, Build.VERSION.RELEASE)
|
||||
return DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||
}
|
||||
|
||||
private val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
|
||||
|
||||
@@ -198,6 +198,9 @@ class AbsAudioPlayer : Plugin() {
|
||||
return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}"))
|
||||
}
|
||||
}
|
||||
if (!it.hasTracks(episode)) {
|
||||
return call.resolve(JSObject("{\"error\":\"No audio files found on device. Download book again to fix.\"}"))
|
||||
}
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.audiobookshelf.app.plugins
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
import com.audiobookshelf.app.data.*
|
||||
@@ -493,9 +495,16 @@ class AbsDatabase : Plugin() {
|
||||
fun updateDeviceSettings(call:PluginCall) { // Returns device data
|
||||
Log.d(tag, "updateDeviceSettings ${call.data}")
|
||||
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
DeviceManager.deviceData.deviceSettings = newDeviceSettings
|
||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
|
||||
// Updates playback actions for media notification (handles media control seek locking setting)
|
||||
if (mainActivity.isPlayerNotificationServiceInitialized()) {
|
||||
mainActivity.foregroundService.setMediaSessionConnectorPlaybackActions()
|
||||
}
|
||||
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.deviceData)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +196,11 @@ class AbsFileSystem : Plugin() {
|
||||
if (localLibraryItem?.folderId?.startsWith("internal-") == true) {
|
||||
Log.d(tag, "Deleting internal library item at absolutePath $absolutePath")
|
||||
val file = File(absolutePath)
|
||||
success = file.deleteRecursively()
|
||||
success = if (file.exists()) {
|
||||
file.deleteRecursively()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
var subfolderPathToDelete = ""
|
||||
localLibraryItem?.folderId?.let { folderId ->
|
||||
@@ -218,7 +222,12 @@ class AbsFileSystem : Plugin() {
|
||||
}
|
||||
|
||||
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
|
||||
success = docfile?.delete() == true
|
||||
if (docfile?.exists() == true) {
|
||||
success = docfile.delete() == true
|
||||
} else {
|
||||
Log.d(tag, "Folder $contentUrl doesn't exist")
|
||||
success = true
|
||||
}
|
||||
|
||||
if (subfolderPathToDelete != "") {
|
||||
Log.d(tag, "Deleting empty subfolder at $subfolderPathToDelete")
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.audiobookshelf.app.server
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
@@ -8,6 +11,7 @@ import com.audiobookshelf.app.media.MediaEventManager
|
||||
import com.audiobookshelf.app.media.MediaProgressSyncData
|
||||
import com.audiobookshelf.app.media.SyncResult
|
||||
import com.audiobookshelf.app.models.User
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
@@ -31,7 +35,7 @@ class ApiHandler(var ctx:Context) {
|
||||
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>)
|
||||
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>, val deviceInfo:DeviceInfo)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LocalSessionSyncResult(val id:String, val success:Boolean, val progressSynced:Boolean?, val error:String?)
|
||||
data class LocalSessionsSyncResponsePayload(val results:List<LocalSessionSyncResult>)
|
||||
@@ -40,10 +44,17 @@ class ApiHandler(var ctx:Context) {
|
||||
val address = config?.address ?: DeviceManager.serverAddress
|
||||
val token = config?.token ?: DeviceManager.token
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("${address}$endpoint").addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
makeRequest(request, httpClient, cb)
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.url("${address}$endpoint").addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
makeRequest(request, httpClient, cb)
|
||||
} catch(e: Exception) {
|
||||
e.printStackTrace()
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("error", "Request failed: ${e.message}")
|
||||
cb(jsobj)
|
||||
}
|
||||
}
|
||||
|
||||
private fun postRequest(endpoint:String, payload: JSObject?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
|
||||
@@ -53,23 +64,38 @@ class ApiHandler(var ctx:Context) {
|
||||
val requestBody = payload?.toString()?.toRequestBody(mediaType) ?: EMPTY_REQUEST
|
||||
val requestUrl = "${address}$endpoint"
|
||||
Log.d(tag, "postRequest to $requestUrl")
|
||||
val request = Request.Builder().post(requestBody)
|
||||
.url(requestUrl).addHeader("Authorization", "Bearer ${token}")
|
||||
.build()
|
||||
makeRequest(request, null, cb)
|
||||
try {
|
||||
val request = Request.Builder().post(requestBody)
|
||||
.url(requestUrl).addHeader("Authorization", "Bearer ${token}")
|
||||
.build()
|
||||
makeRequest(request, null, cb)
|
||||
} catch(e: Exception) {
|
||||
e.printStackTrace()
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("error", "Request failed: ${e.message}")
|
||||
cb(jsobj)
|
||||
}
|
||||
}
|
||||
|
||||
private fun patchRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val requestBody = payload.toString().toRequestBody(mediaType)
|
||||
val request = Request.Builder().patch(requestBody)
|
||||
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
|
||||
.build()
|
||||
makeRequest(request, null, cb)
|
||||
try {
|
||||
val request = Request.Builder().patch(requestBody)
|
||||
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
|
||||
.build()
|
||||
makeRequest(request, null, cb)
|
||||
} catch(e: Exception) {
|
||||
e.printStackTrace()
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("error", "Request failed: ${e.message}")
|
||||
cb(jsobj)
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeRequest(request:Request, httpClient:OkHttpClient?, cb: (JSObject) -> Unit) {
|
||||
val client = httpClient ?: defaultClient
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.d(tag, "FAILURE TO CONNECT")
|
||||
@@ -293,9 +319,9 @@ class ApiHandler(var ctx:Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun closePlaybackSession(playbackSessionId:String, cb: (Boolean) -> Unit) {
|
||||
fun closePlaybackSession(playbackSessionId:String, config:ServerConnectionConfig?, cb: (Boolean) -> Unit) {
|
||||
Log.d(tag, "closePlaybackSession: playbackSessionId=$playbackSessionId")
|
||||
postRequest("/api/session/$playbackSessionId/close", null, null) {
|
||||
postRequest("/api/session/$playbackSessionId/close", null, config) {
|
||||
cb(true)
|
||||
}
|
||||
}
|
||||
@@ -322,7 +348,11 @@ class ApiHandler(var ctx:Context) {
|
||||
}
|
||||
|
||||
fun sendSyncLocalSessions(playbackSessions:List<PlaybackSession>, cb: (Boolean, String?) -> Unit) {
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions)))
|
||||
@SuppressLint("HardwareIds")
|
||||
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
|
||||
val deviceInfo = DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions, deviceInfo)))
|
||||
Log.d(tag, "Sending ${playbackSessions.size} saved local playback sessions to server")
|
||||
postRequest("/api/session/local-all", payload, null) {
|
||||
if (!it.getString("error").isNullOrEmpty()) {
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">audiobookshelf</string>
|
||||
<string name="title_activity_main">audiobookshelf</string>
|
||||
<string name="app_name">Audiobookshelf</string>
|
||||
<string name="title_activity_main">Audiobookshelf</string>
|
||||
<string name="package_name">com.audiobookshelf.app</string>
|
||||
<string name="custom_url_scheme">audiobookshelf</string>
|
||||
<string name="add_widget">Add widget</string>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.7.20'
|
||||
ext.kotlin_version = '2.0.0'
|
||||
|
||||
repositories {
|
||||
google()
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':byteowls-capacitor-filesharer'
|
||||
project(':byteowls-capacitor-filesharer').projectDir = new File('../node_modules/@byteowls/capacitor-filesharer/android')
|
||||
|
||||
include ':capacitor-community-volume-buttons'
|
||||
project(':capacitor-community-volume-buttons').projectDir = new File('../node_modules/@capacitor-community/volume-buttons/android')
|
||||
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 34
|
||||
targetSdkVersion = 33
|
||||
targetSdkVersion = 34
|
||||
androidxActivityVersion = '1.7.0'
|
||||
androidxAppCompatVersion = '1.6.1'
|
||||
androidxAppCompatVersion = '1.7.0'
|
||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||
androidxCoreVersion = '1.10.0'
|
||||
androidPlayCore = '1.9.0'
|
||||
@@ -13,8 +13,8 @@ ext {
|
||||
androidxEspressoCoreVersion = '3.5.1'
|
||||
cordovaAndroidVersion = '10.1.1'
|
||||
androidx_car_version = '1.0.0-alpha7'
|
||||
androidx_core_ktx_version = '1.12.0'
|
||||
androidx_media_version = '1.6.0'
|
||||
androidx_core_ktx_version = '1.13.1'
|
||||
androidx_media_version = '1.7.0'
|
||||
androidx_preference_version = '1.1.1'
|
||||
androidx_test_runner_version = '1.3.0'
|
||||
arch_lifecycle_version = '2.2.0'
|
||||
@@ -27,7 +27,7 @@ ext {
|
||||
gradle_version = '3.1.4'
|
||||
gson_version = '2.8.5'
|
||||
junit_version = '4.13'
|
||||
kotlin_version = '1.8.10'
|
||||
kotlin_version = '2.0.0'
|
||||
kotlin_coroutines_version = '1.6.4'
|
||||
multidex_version = '1.0.3'
|
||||
play_services_auth_version = '18.1.0'
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
class WrappingMarquee {
|
||||
#scrollDelay = 2000
|
||||
#scrollSpeed = 30
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} el
|
||||
*/
|
||||
constructor(el) {
|
||||
this.el = el
|
||||
/** @type {HTMLElement} */
|
||||
this.pEl = el?.firstElementChild
|
||||
|
||||
this.innerText = ''
|
||||
this.isScrolling = false
|
||||
|
||||
/** @type {NodeJS.Timeout} */
|
||||
this.timer = null
|
||||
/** @type {number} */
|
||||
this.animationId = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Transparent gradient mask shown when text is scrolling left and overflowing right
|
||||
*
|
||||
* @param {boolean} showLeft
|
||||
*/
|
||||
setMask(showLeft) {
|
||||
if (!this.el) return
|
||||
this.el.style.maskImage = showLeft ? 'linear-gradient(90deg, transparent 0%, #fff 10%, #000 90%, transparent)' : 'linear-gradient(90deg, #000 90%, transparent)'
|
||||
}
|
||||
|
||||
startScroll() {
|
||||
if (this.isScrolling) {
|
||||
console.warn('Already scrolling')
|
||||
return
|
||||
}
|
||||
|
||||
this.isScrolling = true
|
||||
this.setMask(true)
|
||||
|
||||
let textScrollAmount = this.el.scrollWidth
|
||||
this.pEl.innerHTML = this.innerText + ' '.repeat(15)
|
||||
let totalScrollAmount = this.el.scrollWidth
|
||||
let scrollDuration = totalScrollAmount * this.#scrollSpeed
|
||||
|
||||
this.pEl.innerHTML = this.pEl.innerHTML + this.innerText
|
||||
|
||||
let done = false
|
||||
let start, previousTimeStamp
|
||||
|
||||
const step = (timeStamp) => {
|
||||
if (start === undefined) {
|
||||
start = timeStamp
|
||||
}
|
||||
const elapsed = timeStamp - start
|
||||
|
||||
if (this.isScrolling && previousTimeStamp !== timeStamp) {
|
||||
const amountToMove = Math.min(elapsed / scrollDuration * totalScrollAmount, totalScrollAmount)
|
||||
this.pEl.style.transform = `translateX(-${amountToMove}px)`
|
||||
if (amountToMove === totalScrollAmount) done = true
|
||||
if (amountToMove > textScrollAmount) this.setMask(false)
|
||||
}
|
||||
|
||||
if (!this.isScrolling || done) { // canceled or done
|
||||
this.isScrolling = false
|
||||
this.pEl.style.transform = 'translateX(0px)'
|
||||
this.pEl.innerText = this.innerText
|
||||
this.setMask(false)
|
||||
if (done) {
|
||||
this.startTimer()
|
||||
}
|
||||
} else if (elapsed < scrollDuration) { // step
|
||||
previousTimeStamp = timeStamp
|
||||
this.animationId = window.requestAnimationFrame(step)
|
||||
}
|
||||
}
|
||||
this.animationId = window.requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
startTimer() {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = setTimeout(() => {
|
||||
this.startScroll()
|
||||
}, this.#scrollDelay)
|
||||
}
|
||||
|
||||
reset() {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
this.isScrolling = false
|
||||
window.cancelAnimationFrame(this.animationId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize and start marquee if text overflows container
|
||||
* resets the marquee if already active
|
||||
*
|
||||
* @param {string} innerText
|
||||
*/
|
||||
init(innerText) {
|
||||
if (!this.el || !this.pEl) return
|
||||
|
||||
this.reset()
|
||||
|
||||
this.innerText = innerText
|
||||
this.pEl.innerText = innerText
|
||||
this.pEl.style.transform = 'translateX(0px)'
|
||||
|
||||
if (this.el.scrollWidth > this.el.clientWidth) {
|
||||
this.setMask(false)
|
||||
this.startTimer()
|
||||
} else {
|
||||
this.el.style.maskImage = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
export default WrappingMarquee
|
||||
+5
-7
@@ -1,6 +1,8 @@
|
||||
@import "./tailwind.css";
|
||||
@import "./fonts.css";
|
||||
@import './defaultStyles.css';
|
||||
@import './absicons.css';
|
||||
@import './transitions.css';
|
||||
|
||||
* {
|
||||
-webkit-touch-callout: none;
|
||||
@@ -15,10 +17,6 @@ textarea {
|
||||
user-select: auto;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #262626;
|
||||
}
|
||||
|
||||
.layout-wrapper {
|
||||
height: calc(100vh - env(safe-area-inset-top));
|
||||
min-height: calc(100vh - env(safe-area-inset-top));
|
||||
@@ -148,15 +146,15 @@ Bookshelf Label
|
||||
.tracksTable {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
border: 1px solid #474747;
|
||||
border: 1px solid rgb(var(--color-secondary));
|
||||
}
|
||||
|
||||
.tracksTable tr:nth-child(even) {
|
||||
background-color: #2e2e2e;
|
||||
background-color: rgb(var(--color-secondary));
|
||||
}
|
||||
|
||||
.tracksTable tr {
|
||||
background-color: #373838;
|
||||
background-color: rgb(var(--color-bg));
|
||||
}
|
||||
|
||||
.tracksTable td {
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
(function (global, factory) {
|
||||
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
|
||||
typeof define === 'function' && define.amd ? define(['exports'], factory) :
|
||||
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global['fast-sort'] = {}));
|
||||
}(this, (function (exports) {
|
||||
'use strict';
|
||||
|
||||
// >>> INTERFACES <<<
|
||||
// >>> HELPERS <<<
|
||||
var castComparer = function (comparer) { return function (a, b, order) { return comparer(a, b, order) * order; }; };
|
||||
var throwInvalidConfigErrorIfTrue = function (condition, context) {
|
||||
if (condition)
|
||||
throw Error("Invalid sort config: " + context);
|
||||
};
|
||||
var unpackObjectSorter = function (sortByObj) {
|
||||
var _a = sortByObj || {}, asc = _a.asc, desc = _a.desc;
|
||||
var order = asc ? 1 : -1;
|
||||
var sortBy = (asc || desc);
|
||||
// Validate object config
|
||||
throwInvalidConfigErrorIfTrue(!sortBy, 'Expected `asc` or `desc` property');
|
||||
throwInvalidConfigErrorIfTrue(asc && desc, 'Ambiguous object with `asc` and `desc` config properties');
|
||||
var comparer = sortByObj.comparer && castComparer(sortByObj.comparer);
|
||||
return { order: order, sortBy: sortBy, comparer: comparer };
|
||||
};
|
||||
// >>> SORTERS <<<
|
||||
var multiPropertySorterProvider = function (defaultComparer) {
|
||||
return function multiPropertySorter(sortBy, sortByArr, depth, order, comparer, a, b) {
|
||||
var valA;
|
||||
var valB;
|
||||
if (typeof sortBy === 'string') {
|
||||
valA = a[sortBy];
|
||||
valB = b[sortBy];
|
||||
}
|
||||
else if (typeof sortBy === 'function') {
|
||||
valA = sortBy(a);
|
||||
valB = sortBy(b);
|
||||
}
|
||||
else {
|
||||
var objectSorterConfig = unpackObjectSorter(sortBy);
|
||||
return multiPropertySorter(objectSorterConfig.sortBy, sortByArr, depth, objectSorterConfig.order, objectSorterConfig.comparer || defaultComparer, a, b);
|
||||
}
|
||||
var equality = comparer(valA, valB, order);
|
||||
if ((equality === 0 || (valA == null && valB == null)) &&
|
||||
sortByArr.length > depth) {
|
||||
return multiPropertySorter(sortByArr[depth], sortByArr, depth + 1, order, comparer, a, b);
|
||||
}
|
||||
return equality;
|
||||
};
|
||||
};
|
||||
function getSortStrategy(sortBy, comparer, order) {
|
||||
// Flat array sorter
|
||||
if (sortBy === undefined || sortBy === true) {
|
||||
return function (a, b) { return comparer(a, b, order); };
|
||||
}
|
||||
// Sort list of objects by single object key
|
||||
if (typeof sortBy === 'string') {
|
||||
throwInvalidConfigErrorIfTrue(sortBy.includes('.'), 'String syntax not allowed for nested properties.');
|
||||
return function (a, b) { return comparer(a[sortBy], b[sortBy], order); };
|
||||
}
|
||||
// Sort list of objects by single function sorter
|
||||
if (typeof sortBy === 'function') {
|
||||
return function (a, b) { return comparer(sortBy(a), sortBy(b), order); };
|
||||
}
|
||||
// Sort by multiple properties
|
||||
if (Array.isArray(sortBy)) {
|
||||
var multiPropSorter_1 = multiPropertySorterProvider(comparer);
|
||||
return function (a, b) { return multiPropSorter_1(sortBy[0], sortBy, 1, order, comparer, a, b); };
|
||||
}
|
||||
// Unpack object config to get actual sorter strategy
|
||||
var objectSorterConfig = unpackObjectSorter(sortBy);
|
||||
return getSortStrategy(objectSorterConfig.sortBy, objectSorterConfig.comparer || comparer, objectSorterConfig.order);
|
||||
}
|
||||
var sortArray = function (order, ctx, sortBy, comparer) {
|
||||
var _a;
|
||||
if (!Array.isArray(ctx)) {
|
||||
return ctx;
|
||||
}
|
||||
// Unwrap sortBy if array with only 1 value to get faster sort strategy
|
||||
if (Array.isArray(sortBy) && sortBy.length < 2) {
|
||||
_a = sortBy, sortBy = _a[0];
|
||||
}
|
||||
return ctx.sort(getSortStrategy(sortBy, comparer, order));
|
||||
};
|
||||
// >>> Public <<<
|
||||
var createNewSortInstance = function (opts) {
|
||||
var comparer = castComparer(opts.comparer);
|
||||
return function (_ctx) {
|
||||
var ctx = Array.isArray(_ctx) && !opts.inPlaceSorting
|
||||
? _ctx.slice()
|
||||
: _ctx;
|
||||
return {
|
||||
/**
|
||||
* Sort array in ascending order.
|
||||
* @example
|
||||
* sort([3, 1, 4]).asc();
|
||||
* sort(users).asc(u => u.firstName);
|
||||
* sort(users).asc([
|
||||
* U => u.firstName
|
||||
* u => u.lastName,
|
||||
* ]);
|
||||
*/
|
||||
asc: function (sortBy) {
|
||||
return sortArray(1, ctx, sortBy, comparer);
|
||||
},
|
||||
/**
|
||||
* Sort array in descending order.
|
||||
* @example
|
||||
* sort([3, 1, 4]).desc();
|
||||
* sort(users).desc(u => u.firstName);
|
||||
* sort(users).desc([
|
||||
* U => u.firstName
|
||||
* u => u.lastName,
|
||||
* ]);
|
||||
*/
|
||||
desc: function (sortBy) {
|
||||
return sortArray(-1, ctx, sortBy, comparer);
|
||||
},
|
||||
/**
|
||||
* Sort array in ascending or descending order. It allows sorting on multiple props
|
||||
* in different order for each of them.
|
||||
* @example
|
||||
* sort(users).by([
|
||||
* { asc: u => u.score }
|
||||
* { desc: u => u.age }
|
||||
* ]);
|
||||
*/
|
||||
by: function (sortBy) {
|
||||
return sortArray(1, ctx, sortBy, comparer);
|
||||
},
|
||||
};
|
||||
};
|
||||
};
|
||||
var defaultComparer = function (a, b, order) {
|
||||
if (a == null)
|
||||
return order;
|
||||
if (b == null)
|
||||
return -order;
|
||||
if (a < b)
|
||||
return -1;
|
||||
if (a === b)
|
||||
return 0;
|
||||
return 1;
|
||||
};
|
||||
var sort = createNewSortInstance({
|
||||
comparer: defaultComparer,
|
||||
});
|
||||
var inPlaceSort = createNewSortInstance({
|
||||
comparer: defaultComparer,
|
||||
inPlaceSorting: true,
|
||||
});
|
||||
|
||||
exports.createNewSortInstance = createNewSortInstance;
|
||||
exports.inPlaceSort = inPlaceSort;
|
||||
exports.sort = sort;
|
||||
|
||||
Object.defineProperty(exports, '__esModule', { value: true });
|
||||
|
||||
})));
|
||||
@@ -0,0 +1,47 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
color: white;
|
||||
--color-bg: 56 56 56;
|
||||
--color-bg-hover: 102 104 107;
|
||||
--color-fg: 230 237 243;
|
||||
--color-fg-muted: 142 147 153;
|
||||
--color-primary: 35 35 35;
|
||||
--color-secondary: 47 48 48;
|
||||
--color-border: 75 85 89;
|
||||
--color-bg-toggle: 56 56 56;
|
||||
--color-bg-toggle-selected: 81 82 84;
|
||||
--color-track-cursor: 229 231 235;
|
||||
--color-track: 107 114 128;
|
||||
--color-track-buffered: 75 85 99;
|
||||
--gradient-item-page: linear-gradient(169deg, rgba(0, 0, 0, 0.4) 0%, rgba(55, 56, 56, 1) 80%);
|
||||
--gradient-audio-player: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(38, 38, 38, 1) 80%);
|
||||
--gradient-minimized-audio-player: linear-gradient(145deg, rgba(38, 38, 38, 0.5) 0%, rgba(38, 38, 38, 0.9) 20%, rgb(38, 38, 38) 60%);
|
||||
}
|
||||
|
||||
html[data-theme='light'] {
|
||||
color: black;
|
||||
--color-bg: 255 255 255;
|
||||
--color-bg-hover: 208 210 212;
|
||||
--color-fg: 37 37 37;
|
||||
--color-fg-muted: 101 109 118;
|
||||
--color-primary: 222 222 222;
|
||||
--color-secondary: 246 248 250;
|
||||
--color-border: 189 191 191;
|
||||
--color-bg-toggle: 222 222 222;
|
||||
--color-bg-toggle-selected: 255 255 255;
|
||||
--color-track-cursor: 101 109 118;
|
||||
--color-track: 189 191 191;
|
||||
--color-track-buffered: 129 131 131;
|
||||
--gradient-item-page: linear-gradient(169deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 1) 80%);
|
||||
--gradient-audio-player: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 80%);
|
||||
--gradient-minimized-audio-player: linear-gradient(145deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0.9) 20%, rgb(255, 255, 255) 60%);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-primary
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
.slide-enter-active {
|
||||
-moz-transition-duration: 0.1s;
|
||||
-webkit-transition-duration: 0.1s;
|
||||
-o-transition-duration: 0.1s;
|
||||
transition-duration: 0.1s;
|
||||
-moz-transition-timing-function: ease-in;
|
||||
-webkit-transition-timing-function: ease-in;
|
||||
-o-transition-timing-function: ease-in;
|
||||
transition-timing-function: ease-in;
|
||||
}
|
||||
|
||||
.slide-leave-active {
|
||||
-moz-transition-duration: 0.2s;
|
||||
-webkit-transition-duration: 0.2s;
|
||||
-o-transition-duration: 0.2s;
|
||||
transition-duration: 0.2s;
|
||||
-moz-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||
-webkit-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||
-o-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||
}
|
||||
|
||||
.slide-enter-to,
|
||||
.slide-leave {
|
||||
max-height: 600px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slide-enter,
|
||||
.slide-leave-to {
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.menu-enter,
|
||||
.menu-leave-active {
|
||||
transform: translateY(-15px);
|
||||
}
|
||||
|
||||
.menu-enter-active {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.menu-leave-active {
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.menu-enter,
|
||||
.menu-leave-active {
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -4,13 +4,13 @@
|
||||
<nuxt-link v-show="!showBack" to="/" class="mr-3">
|
||||
<img src="/Logo.png" class="h-10 w-10" />
|
||||
</nuxt-link>
|
||||
<a v-if="showBack" @click="back" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-2 cursor-pointer">
|
||||
<span class="material-icons text-3xl text-white">arrow_back</span>
|
||||
<a v-if="showBack" @click="back" class="rounded-full h-10 w-10 flex items-center justify-center mr-2 cursor-pointer">
|
||||
<span class="material-icons text-3xl text-fg">arrow_back</span>
|
||||
</a>
|
||||
<div v-if="user && currentLibrary">
|
||||
<div v-if="user && currentLibrary && socketConnected">
|
||||
<div class="pl-1.5 pr-2.5 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
|
||||
<ui-library-icon :icon="currentLibraryIcon" :size="4" font-size="base" />
|
||||
<p class="text-smleading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
|
||||
<p class="text-sm leading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,14 +54,17 @@ export default {
|
||||
this.$store.commit('setCastAvailable', val)
|
||||
}
|
||||
},
|
||||
socketConnected() {
|
||||
return this.$store.state.socketConnected
|
||||
},
|
||||
currentLibrary() {
|
||||
return this.$store.getters['libraries/getCurrentLibrary']
|
||||
},
|
||||
currentLibraryName() {
|
||||
return this.currentLibrary ? this.currentLibrary.name : ''
|
||||
return this.currentLibrary?.name || ''
|
||||
},
|
||||
currentLibraryIcon() {
|
||||
return this.currentLibrary ? this.currentLibrary.icon : 'database'
|
||||
return this.currentLibrary?.icon || 'database'
|
||||
},
|
||||
showBack() {
|
||||
if (!this.$route.name) return true
|
||||
@@ -71,7 +74,7 @@ export default {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
username() {
|
||||
return this.user ? this.user.username : 'err'
|
||||
return this.user?.username || 'err'
|
||||
},
|
||||
isCasting() {
|
||||
return this.$store.state.isCasting
|
||||
@@ -157,4 +160,4 @@ export default {
|
||||
transform: translate(10px, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+209
-108
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div v-if="playbackSession" id="streamContainer" class="fixed top-0 left-0 layout-wrapper right-0 z-50 pointer-events-none" :class="{ fullscreen: showFullscreen, 'ios-player': $platform === 'ios', 'web-player': $platform === 'web' }">
|
||||
<div v-if="showFullscreen" class="w-full h-full z-10 absolute top-0 left-0 pointer-events-auto" :style="{ backgroundColor: coverRgb }">
|
||||
<div class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: linear-gradient(169deg, rgba(0, 0, 0, 0) 0%, rgba(38, 38, 38, 1) 80%)" />
|
||||
<div class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-audio-player)" />
|
||||
|
||||
<div class="top-4 left-4 absolute cursor-pointer">
|
||||
<span class="material-icons text-5xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="collapseFullscreen">expand_more</span>
|
||||
@@ -12,20 +12,20 @@
|
||||
<div class="top-6 right-4 absolute cursor-pointer">
|
||||
<span class="material-icons text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="showMoreMenuDialog = true">more_vert</span>
|
||||
</div>
|
||||
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" :class="{ 'text-black text-opacity-75': coverBgIsLight }" style="font-size: 10px">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p>
|
||||
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" :class="{ 'text-black text-opacity-75': coverBgIsLight }" style="font-size: 10px">{{ isDirectPlayMethod ? $strings.LabelPlaybackDirect : isLocalPlayMethod ? $strings.LabelPlaybackLocal : $strings.LabelPlaybackTranscode }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="useChapterTrack && useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
|
||||
<div v-if="playerSettings.useChapterTrack && playerSettings.useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
|
||||
<div class="flex">
|
||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ currentTimePretty }}</p>
|
||||
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ currentTimePretty }}</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ totalTimeRemainingPretty }}</p>
|
||||
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ totalTimeRemainingPretty }}</p>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="h-1 w-full bg-gray-500 bg-opacity-50 relative rounded-full">
|
||||
<div ref="totalReadyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||
<div ref="totalBufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||
<div ref="totalPlayedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||
<div class="h-1 w-full bg-track/50 relative rounded-full">
|
||||
<div ref="totalReadyTrack" class="h-full bg-track-buffered absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||
<div ref="totalBufferedTrack" class="h-full bg-track absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||
<div ref="totalPlayedTrack" class="h-full bg-track-cursor absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,64 +41,66 @@
|
||||
</div>
|
||||
|
||||
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor">
|
||||
<p class="title-text truncate">{{ title }}</p>
|
||||
<p class="author-text text-white text-opacity-75 truncate">{{ authorName }}</p>
|
||||
<div ref="titlewrapper" class="overflow-hidden relative">
|
||||
<p class="title-text whitespace-nowrap"></p>
|
||||
</div>
|
||||
<p class="author-text text-fg text-opacity-75 truncate">{{ authorName }}</p>
|
||||
</div>
|
||||
|
||||
<div id="playerContent" class="playerContainer w-full z-20 absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" :style="{ backgroundColor: showFullscreen ? '' : coverRgb }" @click="clickContainer">
|
||||
<div v-if="showFullscreen" class="absolute bottom-4 left-0 right-0 w-full pb-4 pt-2 mx-auto px-6" style="max-width: 414px">
|
||||
<div class="flex items-center justify-between pointer-events-auto">
|
||||
<span v-if="!isPodcast && serverLibraryItemId && networkConnected" class="material-icons text-3xl text-white text-opacity-75 cursor-pointer" @click="$emit('showBookmarks')">{{ bookmarks.length ? 'bookmark' : 'bookmark_border' }}</span>
|
||||
<span v-if="!isPodcast && serverLibraryItemId && socketConnected" class="material-icons text-3xl text-fg-muted cursor-pointer" @click="$emit('showBookmarks')">{{ bookmarks.length ? 'bookmark' : 'bookmark_border' }}</span>
|
||||
<!-- hidden for podcasts but still using this as a placeholder -->
|
||||
<span v-else class="material-icons text-3xl text-white text-opacity-0">bookmark</span>
|
||||
|
||||
<span class="font-mono text-white text-opacity-75 cursor-pointer" style="font-size: 1.35rem" @click="$emit('selectPlaybackSpeed')">{{ currentPlaybackRate }}x</span>
|
||||
<svg v-if="!sleepTimerRunning" xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 text-white text-opacity-75 cursor-pointer" fill="none" viewBox="0 0 24 24" stroke="currentColor" @click.stop="$emit('showSleepTimer')">
|
||||
<span class="font-mono text-fg-muted cursor-pointer" style="font-size: 1.35rem" @click="$emit('selectPlaybackSpeed')">{{ currentPlaybackRate }}x</span>
|
||||
<svg v-if="!sleepTimerRunning" xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 text-fg-muted cursor-pointer" fill="none" viewBox="0 0 24 24" stroke="currentColor" @click.stop="$emit('showSleepTimer')">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
<div v-else class="h-7 w-7 flex items-center justify-around cursor-pointer" @click.stop="$emit('showSleepTimer')">
|
||||
<p class="text-xl font-mono text-success">{{ sleepTimeRemainingPretty }}</p>
|
||||
</div>
|
||||
|
||||
<span class="material-icons text-3xl text-white cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="clickChaptersBtn">format_list_bulleted</span>
|
||||
<span class="material-icons text-3xl text-fg cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="clickChaptersBtn">format_list_bulleted</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: linear-gradient(145deg, rgba(38, 38, 38, 0.5) 0%, rgba(38, 38, 38, 0.9) 20%, rgb(38, 38, 38) 60%)" />
|
||||
<div v-else class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-minimized-audio-player)" />
|
||||
|
||||
<div id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px">
|
||||
<div class="flex items-center max-w-full" :class="lockUi ? 'justify-center' : 'justify-between'">
|
||||
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||
<span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
||||
<div class="flex items-center max-w-full" :class="playerSettings.lockUi ? 'justify-center' : 'justify-between'">
|
||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-icons next-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||
<span v-show="!playerSettings.lockUi" class="material-icons jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
||||
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4 relative overflow-hidden" :style="{ backgroundColor: coverRgb }" :class="{ 'animate-spin': seekLoading }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
|
||||
<div v-if="!coverBgIsLight" class="absolute top-0 left-0 w-full h-full bg-white bg-opacity-20 pointer-events-none" />
|
||||
|
||||
<span v-if="!isLoading" class="material-icons" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
||||
<widgets-spinner-icon v-else class="h-8 w-8" />
|
||||
</div>
|
||||
<span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
|
||||
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||
<span v-show="!playerSettings.lockUi" class="material-icons jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
|
||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-icons next-icon text-fg cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="playerTrack" class="absolute left-0 w-full px-6">
|
||||
<div class="flex pointer-events-none">
|
||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
|
||||
<p class="font-mono text-fg" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
|
||||
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
|
||||
</div>
|
||||
<div ref="track" class="h-1.5 w-full bg-gray-500 bg-opacity-50 relative rounded-full" :class="{ 'animate-pulse': isLoading }" @click.stop>
|
||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="trackCursor" class="h-7 w-7 rounded-full absolute pointer-events-auto flex items-center justify-center" :style="{ top: '-11px' }" :class="{ 'opacity-0': lockUi || !showFullscreen }" @touchstart="touchstartCursor">
|
||||
<div class="bg-gray-200 rounded-full w-3.5 h-3.5 pointer-events-none" />
|
||||
<div ref="track" class="h-1.5 w-full bg-track/50 relative rounded-full" :class="{ 'animate-pulse': isLoading }" @click.stop>
|
||||
<div ref="readyTrack" class="h-full bg-track-buffered absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="bufferedTrack" class="h-full bg-track absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="playedTrack" class="h-full bg-track-cursor absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="trackCursor" class="h-7 w-7 rounded-full absolute pointer-events-auto flex items-center justify-center" :style="{ top: '-11px' }" :class="{ 'opacity-0': playerSettings.lockUi || !showFullscreen }" @touchstart="touchstartCursor">
|
||||
<div class="bg-track-cursor rounded-full w-3.5 h-3.5 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" :playback-rate="currentPlaybackRate" @select="selectChapter" />
|
||||
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction" />
|
||||
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" width="80vw" @action="clickMenuAction" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -106,6 +108,7 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
||||
import { FastAverageColor } from 'fast-average-color'
|
||||
import WrappingMarquee from '@/assets/WrappingMarquee.js'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@@ -145,10 +148,12 @@ export default {
|
||||
onPlaybackSpeedChangedListener: null,
|
||||
touchStartY: 0,
|
||||
touchStartTime: 0,
|
||||
touchEndY: 0,
|
||||
useChapterTrack: false,
|
||||
useTotalTrack: true,
|
||||
lockUi: false,
|
||||
playerSettings: {
|
||||
useChapterTrack: false,
|
||||
useTotalTrack: true,
|
||||
scaleElapsedTimeBySpeed: true,
|
||||
lockUi: false
|
||||
},
|
||||
isLoading: false,
|
||||
isDraggingCursor: false,
|
||||
draggingTouchStartX: 0,
|
||||
@@ -157,16 +162,22 @@ export default {
|
||||
syncStatus: 0,
|
||||
showMoreMenuDialog: false,
|
||||
coverRgb: 'rgb(55, 56, 56)',
|
||||
coverBgIsLight: false
|
||||
coverBgIsLight: false,
|
||||
titleMarquee: null,
|
||||
isRefreshingUI: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
showFullscreen(val) {
|
||||
this.updateScreenSize()
|
||||
this.$store.commit('setPlayerFullscreen', !!val)
|
||||
document.querySelector('body').style.backgroundColor = this.showFullscreen ? this.coverRgb : ''
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
this.updateScreenSize()
|
||||
},
|
||||
title(val) {
|
||||
if (this.titleMarquee) this.titleMarquee.init(val)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -175,7 +186,7 @@ export default {
|
||||
// TODO: Implement on iOS
|
||||
if (this.$platform !== 'ios' && !this.isPodcast && this.mediaId) {
|
||||
items.push({
|
||||
text: 'History',
|
||||
text: this.$strings.ButtonHistory,
|
||||
value: 'history',
|
||||
icon: 'history'
|
||||
})
|
||||
@@ -184,22 +195,27 @@ export default {
|
||||
items.push(
|
||||
...[
|
||||
{
|
||||
text: 'Total Track',
|
||||
text: this.$strings.LabelTotalTrack,
|
||||
value: 'total_track',
|
||||
icon: this.useTotalTrack ? 'check_box' : 'check_box_outline_blank'
|
||||
icon: this.playerSettings.useTotalTrack ? 'check_box' : 'check_box_outline_blank'
|
||||
},
|
||||
{
|
||||
text: 'Chapter Track',
|
||||
text: this.$strings.LabelChapterTrack,
|
||||
value: 'chapter_track',
|
||||
icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
|
||||
icon: this.playerSettings.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
|
||||
},
|
||||
{
|
||||
text: this.lockUi ? 'Unlock Player' : 'Lock Player',
|
||||
text: this.$strings.LabelScaleElapsedTimeBySpeed,
|
||||
value: 'scale_elapsed_time',
|
||||
icon: this.playerSettings.scaleElapsedTimeBySpeed ? 'check_box' : 'check_box_outline_blank'
|
||||
},
|
||||
{
|
||||
text: this.playerSettings.lockUi ? this.$strings.LabelUnlockPlayer : this.$strings.LabelLockPlayer,
|
||||
value: 'lock',
|
||||
icon: this.lockUi ? 'lock' : 'lock_open'
|
||||
icon: this.playerSettings.lockUi ? 'lock' : 'lock_open'
|
||||
},
|
||||
{
|
||||
text: 'Close Player',
|
||||
text: this.$strings.LabelClosePlayer,
|
||||
value: 'close',
|
||||
icon: 'close'
|
||||
}
|
||||
@@ -256,22 +272,22 @@ export default {
|
||||
return this.mediaPlayer === 'cast-player'
|
||||
},
|
||||
mediaPlayer() {
|
||||
return this.playbackSession ? this.playbackSession.mediaPlayer : null
|
||||
return this.playbackSession?.mediaPlayer || null
|
||||
},
|
||||
mediaType() {
|
||||
return this.playbackSession ? this.playbackSession.mediaType : null
|
||||
return this.playbackSession?.mediaType || null
|
||||
},
|
||||
isPodcast() {
|
||||
return this.mediaType === 'podcast'
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.playbackSession ? this.playbackSession.mediaMetadata : null
|
||||
return this.playbackSession?.mediaMetadata || null
|
||||
},
|
||||
libraryItem() {
|
||||
return this.playbackSession ? this.playbackSession.libraryItem || null : null
|
||||
return this.playbackSession?.libraryItem || null
|
||||
},
|
||||
localLibraryItem() {
|
||||
return this.playbackSession ? this.playbackSession.localLibraryItem || null : null
|
||||
return this.playbackSession?.localLibraryItem || null
|
||||
},
|
||||
localLibraryItemCoverSrc() {
|
||||
var localItemCover = this.localLibraryItem?.coverContentUrl || null
|
||||
@@ -279,7 +295,7 @@ export default {
|
||||
return null
|
||||
},
|
||||
playMethod() {
|
||||
return this.playbackSession ? this.playbackSession.playMethod : null
|
||||
return this.playbackSession?.playMethod || 0
|
||||
},
|
||||
isLocalPlayMethod() {
|
||||
return this.playMethod == this.$constants.PlayMethod.LOCAL
|
||||
@@ -288,19 +304,19 @@ export default {
|
||||
return this.playMethod == this.$constants.PlayMethod.DIRECTPLAY
|
||||
},
|
||||
title() {
|
||||
if (this.currentChapterTitle && this.showFullscreen) return this.currentChapterTitle
|
||||
if (this.playbackSession) return this.playbackSession.displayTitle
|
||||
return this.mediaMetadata ? this.mediaMetadata.title : 'Title'
|
||||
const mediaItemTitle = this.playbackSession?.displayTitle || this.mediaMetadata?.title || 'Title'
|
||||
if (this.currentChapterTitle) {
|
||||
if (this.showFullscreen) return this.currentChapterTitle
|
||||
return `${mediaItemTitle} | ${this.currentChapterTitle}`
|
||||
}
|
||||
return mediaItemTitle
|
||||
},
|
||||
authorName() {
|
||||
if (this.playbackSession) return this.playbackSession.displayAuthor
|
||||
return this.mediaMetadata ? this.mediaMetadata.authorName : 'Author'
|
||||
return this.mediaMetadata?.authorName || 'Author'
|
||||
},
|
||||
chapters() {
|
||||
if (this.playbackSession && this.playbackSession.chapters) {
|
||||
return this.playbackSession.chapters
|
||||
}
|
||||
return []
|
||||
return this.playbackSession?.chapters || []
|
||||
},
|
||||
currentChapter() {
|
||||
if (!this.chapters.length) return null
|
||||
@@ -311,7 +327,7 @@ export default {
|
||||
return this.chapters.find((c) => Number(Number(c.start).toFixed(2)) > this.currentTime)
|
||||
},
|
||||
currentChapterTitle() {
|
||||
return this.currentChapter ? this.currentChapter.title : ''
|
||||
return this.currentChapter?.title || ''
|
||||
},
|
||||
currentChapterDuration() {
|
||||
return this.currentChapter ? this.currentChapter.end - this.currentChapter.start : this.totalDuration
|
||||
@@ -321,11 +337,14 @@ export default {
|
||||
},
|
||||
currentTimePretty() {
|
||||
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
||||
return this.$secondsToTimestamp(currentTimeToUse / this.currentPlaybackRate)
|
||||
if (this.playerSettings.scaleElapsedTimeBySpeed) {
|
||||
currentTimeToUse = currentTimeToUse / this.currentPlaybackRate
|
||||
}
|
||||
return this.$secondsToTimestamp(currentTimeToUse)
|
||||
},
|
||||
timeRemaining() {
|
||||
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
if (this.playerSettings.useChapterTrack && this.currentChapter) {
|
||||
var currChapTime = currentTimeToUse - this.currentChapter.start
|
||||
return (this.currentChapterDuration - currChapTime) / this.currentPlaybackRate
|
||||
}
|
||||
@@ -356,8 +375,8 @@ export default {
|
||||
return secondsRemaining + 's'
|
||||
}
|
||||
},
|
||||
networkConnected() {
|
||||
return this.$store.state.networkConnected
|
||||
socketConnected() {
|
||||
return this.$store.state.socketConnected
|
||||
},
|
||||
mediaId() {
|
||||
if (this.isPodcast || !this.playbackSession) return null
|
||||
@@ -411,7 +430,11 @@ export default {
|
||||
AbsAudioPlayer.requestSession()
|
||||
},
|
||||
clickContainer() {
|
||||
this.expandToFullscreen()
|
||||
},
|
||||
expandToFullscreen() {
|
||||
this.showFullscreen = true
|
||||
if (this.titleMarquee) this.titleMarquee.reset()
|
||||
|
||||
// Update track for total time bar if useChapterTrack is set
|
||||
this.$nextTick(() => {
|
||||
@@ -420,6 +443,8 @@ export default {
|
||||
},
|
||||
collapseFullscreen() {
|
||||
this.showFullscreen = false
|
||||
if (this.titleMarquee) this.titleMarquee.reset()
|
||||
|
||||
this.forceCloseDropdownMenu()
|
||||
},
|
||||
async jumpNextChapter() {
|
||||
@@ -493,7 +518,7 @@ export default {
|
||||
this.updateReadyTrack()
|
||||
},
|
||||
updateReadyTrack() {
|
||||
if (this.useChapterTrack) {
|
||||
if (this.playerSettings.useChapterTrack) {
|
||||
if (this.$refs.totalReadyTrack) {
|
||||
this.$refs.totalReadyTrack.style.width = this.readyTrackWidth + 'px'
|
||||
}
|
||||
@@ -510,11 +535,14 @@ export default {
|
||||
}
|
||||
|
||||
let currentTime = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
if (this.playerSettings.useChapterTrack && this.currentChapter) {
|
||||
currentTime = Math.max(0, currentTime - this.currentChapter.start)
|
||||
}
|
||||
if (this.playerSettings.scaleElapsedTimeBySpeed) {
|
||||
currentTime = currentTime / this.currentPlaybackRate
|
||||
}
|
||||
|
||||
ts.innerText = this.$secondsToTimestamp(currentTime / this.currentPlaybackRate)
|
||||
ts.innerText = this.$secondsToTimestamp(currentTime)
|
||||
},
|
||||
timeupdate() {
|
||||
if (!this.$refs.playedTrack) {
|
||||
@@ -542,7 +570,7 @@ export default {
|
||||
let bufferedPercent = this.bufferedTime / this.totalDuration
|
||||
const totalBufferedPercent = bufferedPercent
|
||||
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
if (this.playerSettings.useChapterTrack && this.currentChapter) {
|
||||
const currChapTime = currentTimeToUse - this.currentChapter.start
|
||||
percentDone = currChapTime / this.currentChapterDuration
|
||||
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration))
|
||||
@@ -556,7 +584,7 @@ export default {
|
||||
this.$refs.trackCursor.style.left = ptWidth - 14 + 'px'
|
||||
}
|
||||
|
||||
if (this.useChapterTrack) {
|
||||
if (this.playerSettings.useChapterTrack) {
|
||||
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px'
|
||||
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px'
|
||||
}
|
||||
@@ -583,7 +611,7 @@ export default {
|
||||
}
|
||||
},
|
||||
async touchstartCursor(e) {
|
||||
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return
|
||||
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.playerSettings.lockUi) return
|
||||
|
||||
await this.$hapticsImpact()
|
||||
this.isDraggingCursor = true
|
||||
@@ -624,38 +652,49 @@ export default {
|
||||
resetStream(startTime) {
|
||||
this.closePlayback()
|
||||
},
|
||||
handleGesture() {
|
||||
var touchDistance = this.touchEndY - this.touchStartY
|
||||
if (touchDistance > 100) {
|
||||
this.collapseFullscreen()
|
||||
}
|
||||
},
|
||||
touchstart(e) {
|
||||
if (!this.showFullscreen || !e.changedTouches) return
|
||||
if (!e.changedTouches || this.$store.state.globals.isModalOpen) return
|
||||
const touchPosY = e.changedTouches[0].pageY
|
||||
// when minimized only listen to touchstart on the player
|
||||
if (!this.showFullscreen && touchPosY < window.innerHeight - 120) return
|
||||
|
||||
this.touchStartY = e.changedTouches[0].screenY
|
||||
if (this.touchStartY > window.innerHeight / 3) {
|
||||
// console.log('touch too low')
|
||||
return
|
||||
// for ios
|
||||
if (!this.showFullscreen && e.pageX < 20) {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
}
|
||||
|
||||
this.touchStartY = touchPosY
|
||||
this.touchStartTime = Date.now()
|
||||
},
|
||||
touchend(e) {
|
||||
if (!e.changedTouches) return
|
||||
const touchDuration = Date.now() - this.touchStartTime
|
||||
const touchEndY = e.changedTouches[0].pageY
|
||||
const touchDistanceY = touchEndY - this.touchStartY
|
||||
|
||||
// reset touch start data
|
||||
this.touchStartTime = 0
|
||||
this.touchStartY = 0
|
||||
|
||||
if (this.isDraggingCursor) {
|
||||
if (this.draggingCurrentTime !== this.currentTime) {
|
||||
this.seek(this.draggingCurrentTime)
|
||||
}
|
||||
this.isDraggingCursor = false
|
||||
} else if (this.showFullscreen) {
|
||||
this.touchEndY = e.changedTouches[0].screenY
|
||||
var touchDuration = Date.now() - this.touchStartTime
|
||||
} else {
|
||||
if (touchDuration > 1200) {
|
||||
// console.log('touch too long', touchDuration)
|
||||
return
|
||||
}
|
||||
this.handleGesture()
|
||||
if (this.showFullscreen) {
|
||||
// Touch start higher than touchend
|
||||
if (touchDistanceY > 100) {
|
||||
this.collapseFullscreen()
|
||||
}
|
||||
} else if (touchDistanceY < -100) {
|
||||
this.expandToFullscreen()
|
||||
}
|
||||
}
|
||||
},
|
||||
touchmove(e) {
|
||||
@@ -665,7 +704,7 @@ export default {
|
||||
let duration = this.totalDuration
|
||||
let minTime = 0
|
||||
let maxTime = duration
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
if (this.playerSettings.useChapterTrack && this.currentChapter) {
|
||||
duration = this.currentChapterDuration
|
||||
minTime = this.currentChapter.start
|
||||
maxTime = minTime + duration
|
||||
@@ -685,32 +724,42 @@ export default {
|
||||
if (action === 'history') {
|
||||
this.$router.push(`/media/${this.mediaId}/history?title=${this.title}`)
|
||||
this.showFullscreen = false
|
||||
} else if (action === 'scale_elapsed_time') {
|
||||
this.playerSettings.scaleElapsedTimeBySpeed = !this.playerSettings.scaleElapsedTimeBySpeed
|
||||
this.updateTimestamp()
|
||||
this.savePlayerSettings()
|
||||
} else if (action === 'lock') {
|
||||
this.lockUi = !this.lockUi
|
||||
this.$localStore.setPlayerLock(this.lockUi)
|
||||
this.playerSettings.lockUi = !this.playerSettings.lockUi
|
||||
this.savePlayerSettings()
|
||||
} else if (action === 'chapter_track') {
|
||||
this.useChapterTrack = !this.useChapterTrack
|
||||
this.useTotalTrack = !this.useChapterTrack || this.useTotalTrack
|
||||
this.playerSettings.useChapterTrack = !this.playerSettings.useChapterTrack
|
||||
this.playerSettings.useTotalTrack = !this.playerSettings.useChapterTrack || this.playerSettings.useTotalTrack
|
||||
|
||||
this.updateTimestamp()
|
||||
this.updateTrack()
|
||||
this.updateReadyTrack()
|
||||
this.$localStore.setUseTotalTrack(this.useTotalTrack)
|
||||
this.$localStore.setUseChapterTrack(this.useChapterTrack)
|
||||
this.updateUseChapterTrack()
|
||||
this.savePlayerSettings()
|
||||
} else if (action === 'total_track') {
|
||||
this.useTotalTrack = !this.useTotalTrack
|
||||
this.useChapterTrack = !this.useTotalTrack || this.useChapterTrack
|
||||
this.playerSettings.useTotalTrack = !this.playerSettings.useTotalTrack
|
||||
this.playerSettings.useChapterTrack = !this.playerSettings.useTotalTrack || this.playerSettings.useChapterTrack
|
||||
|
||||
this.updateTimestamp()
|
||||
this.updateTrack()
|
||||
this.updateReadyTrack()
|
||||
this.$localStore.setUseTotalTrack(this.useTotalTrack)
|
||||
this.$localStore.setUseChapterTrack(this.useChapterTrack)
|
||||
this.updateUseChapterTrack()
|
||||
this.savePlayerSettings()
|
||||
} else if (action === 'close') {
|
||||
this.closePlayback()
|
||||
}
|
||||
})
|
||||
},
|
||||
updateUseChapterTrack() {
|
||||
// Chapter track in NowPlaying only supported on iOS for now
|
||||
if (this.$platform === 'ios') {
|
||||
AbsAudioPlayer.setChapterTrack({ enabled: this.playerSettings.useChapterTrack })
|
||||
}
|
||||
},
|
||||
forceCloseDropdownMenu() {
|
||||
if (this.$refs.dropdownMenu && this.$refs.dropdownMenu.closeMenu) {
|
||||
this.$refs.dropdownMenu.closeMenu()
|
||||
@@ -727,6 +776,30 @@ export default {
|
||||
this.isLoading = false
|
||||
this.playbackSession = null
|
||||
},
|
||||
async loadPlayerSettings() {
|
||||
const savedPlayerSettings = await this.$localStore.getPlayerSettings()
|
||||
if (!savedPlayerSettings) {
|
||||
// In 0.9.72-beta 'useChapterTrack', 'useTotalTrack' and 'playerLock' was replaced with 'playerSettings' JSON object
|
||||
// Check if this old key was set and if so migrate them over to 'playerSettings'
|
||||
const chapterTrackPref = await this.$localStore.getPreferenceByKey('useChapterTrack')
|
||||
if (chapterTrackPref) {
|
||||
this.playerSettings.useChapterTrack = chapterTrackPref === '1'
|
||||
const totalTrackPref = await this.$localStore.getPreferenceByKey('useTotalTrack')
|
||||
this.playerSettings.useTotalTrack = totalTrackPref === '1'
|
||||
const playerLockPref = await this.$localStore.getPreferenceByKey('playerLock')
|
||||
this.playerSettings.lockUi = playerLockPref === '1'
|
||||
}
|
||||
this.savePlayerSettings()
|
||||
} else {
|
||||
this.playerSettings.useChapterTrack = !!savedPlayerSettings.useChapterTrack
|
||||
this.playerSettings.useTotalTrack = !!savedPlayerSettings.useTotalTrack
|
||||
this.playerSettings.lockUi = !!savedPlayerSettings.lockUi
|
||||
this.playerSettings.scaleElapsedTimeBySpeed = !!savedPlayerSettings.scaleElapsedTimeBySpeed
|
||||
}
|
||||
},
|
||||
savePlayerSettings() {
|
||||
return this.$localStore.setPlayerSettings({ ...this.playerSettings })
|
||||
},
|
||||
//
|
||||
// Listeners from audio AbsAudioPlayer
|
||||
//
|
||||
@@ -771,6 +844,10 @@ export default {
|
||||
|
||||
// Set track width
|
||||
this.$nextTick(() => {
|
||||
if (this.titleMarquee) this.titleMarquee.reset()
|
||||
this.titleMarquee = new WrappingMarquee(this.$refs.titlewrapper)
|
||||
this.titleMarquee.init(this.title)
|
||||
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
} else {
|
||||
@@ -793,9 +870,7 @@ export default {
|
||||
this.updateTimestamp()
|
||||
},
|
||||
async init() {
|
||||
this.useChapterTrack = await this.$localStore.getUseChapterTrack()
|
||||
this.useTotalTrack = await this.$localStore.getUseTotalTrack()
|
||||
this.lockUi = await this.$localStore.getPlayerLock()
|
||||
await this.loadPlayerSettings()
|
||||
|
||||
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
|
||||
this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
|
||||
@@ -806,26 +881,51 @@ export default {
|
||||
this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
|
||||
this.onPlaybackSpeedChangedListener = AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged)
|
||||
},
|
||||
screenOrientationChange() {
|
||||
setTimeout(() => {
|
||||
this.updateScreenSize()
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
this.updateTrack()
|
||||
this.updateReadyTrack()
|
||||
async screenOrientationChange() {
|
||||
if (this.isRefreshingUI) return
|
||||
this.isRefreshingUI = true
|
||||
const windowWidth = window.innerWidth
|
||||
this.refreshUI()
|
||||
|
||||
// Window width does not always change right away. Wait up to 250ms for a change.
|
||||
// iPhone 10 on iOS 16 took between 100 - 200ms to update when going from portrait to landscape
|
||||
// but landscape to portrait was immediate
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
if (window.innerWidth !== windowWidth) {
|
||||
this.refreshUI()
|
||||
break
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
||||
this.isRefreshingUI = false
|
||||
},
|
||||
refreshUI() {
|
||||
this.updateScreenSize()
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
this.updateTrack()
|
||||
this.updateReadyTrack()
|
||||
}
|
||||
},
|
||||
updateScreenSize() {
|
||||
setTimeout(() => {
|
||||
if (this.titleMarquee) this.titleMarquee.init(this.title)
|
||||
}, 500)
|
||||
|
||||
this.windowHeight = window.innerHeight
|
||||
this.windowWidth = window.innerWidth
|
||||
const coverHeight = this.fullscreenBookCoverWidth * this.bookCoverAspectRatio
|
||||
const coverImageWidthCollapsed = 46 / this.bookCoverAspectRatio
|
||||
const titleAuthorLeftOffsetCollapsed = 30 + coverImageWidthCollapsed
|
||||
const titleAuthorWidthCollapsed = this.windowWidth - 128 - titleAuthorLeftOffsetCollapsed - 10
|
||||
|
||||
document.documentElement.style.setProperty('--cover-image-width', this.fullscreenBookCoverWidth + 'px')
|
||||
document.documentElement.style.setProperty('--cover-image-height', coverHeight + 'px')
|
||||
document.documentElement.style.setProperty('--cover-image-width-collapsed', coverImageWidthCollapsed + 'px')
|
||||
document.documentElement.style.setProperty('--cover-image-height-collapsed', 46 + 'px')
|
||||
document.documentElement.style.setProperty('--title-author-left-offset-collapsed', 30 + coverImageWidthCollapsed + 'px')
|
||||
document.documentElement.style.setProperty('--title-author-left-offset-collapsed', titleAuthorLeftOffsetCollapsed + 'px')
|
||||
document.documentElement.style.setProperty('--title-author-width-collapsed', titleAuthorWidthCollapsed + 'px')
|
||||
},
|
||||
minimizePlayerEvt() {
|
||||
this.collapseFullscreen()
|
||||
@@ -848,7 +948,7 @@ export default {
|
||||
window.addEventListener('resize', this.screenOrientationChange)
|
||||
|
||||
this.$eventBus.$on('minimize-player', this.minimizePlayerEvt)
|
||||
document.body.addEventListener('touchstart', this.touchstart)
|
||||
document.body.addEventListener('touchstart', this.touchstart, { passive: false })
|
||||
document.body.addEventListener('touchend', this.touchend)
|
||||
document.body.addEventListener('touchmove', this.touchmove)
|
||||
this.$nextTick(this.init)
|
||||
@@ -893,6 +993,7 @@ export default {
|
||||
--cover-image-width-collapsed: 46px;
|
||||
--cover-image-height-collapsed: 46px;
|
||||
--title-author-left-offset-collapsed: 80px;
|
||||
--title-author-width-collapsed: 40%;
|
||||
}
|
||||
|
||||
.playerContainer {
|
||||
@@ -940,7 +1041,7 @@ export default {
|
||||
transition-property: left, bottom, width, height;
|
||||
transform-origin: left bottom;
|
||||
|
||||
width: 40%;
|
||||
width: var(--title-author-width-collapsed);
|
||||
bottom: 76px;
|
||||
left: var(--title-author-left-offset-collapsed);
|
||||
text-align: left;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<script>
|
||||
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
import CellularPermissionHelpers from '@/mixins/cellularPermissionHelpers'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
@@ -39,6 +40,7 @@ export default {
|
||||
serverEpisodeId: null
|
||||
}
|
||||
},
|
||||
mixins: [CellularPermissionHelpers],
|
||||
computed: {
|
||||
bookmarks() {
|
||||
if (!this.serverLibraryItemId) return []
|
||||
@@ -193,17 +195,27 @@ export default {
|
||||
const startTime = payload.startTime
|
||||
const startWhenReady = !payload.paused
|
||||
|
||||
const isLocal = libraryItemId.startsWith('local')
|
||||
if (!isLocal) {
|
||||
const hasPermission = await this.checkCellularPermission('streaming')
|
||||
if (!hasPermission) {
|
||||
this.$store.commit('setPlayerDoneStartingPlayback')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// When playing local library item and can also play this item from the server
|
||||
// then store the server library item id so it can be used if a cast is made
|
||||
const serverLibraryItemId = payload.serverLibraryItemId || null
|
||||
const serverEpisodeId = payload.serverEpisodeId || null
|
||||
|
||||
if (libraryItemId.startsWith('local') && this.$store.state.isCasting) {
|
||||
if (isLocal && this.$store.state.isCasting) {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Warning',
|
||||
message: `Cannot cast downloaded media items. Confirm to close cast and play on your device.`
|
||||
})
|
||||
if (!value) {
|
||||
this.$store.commit('setPlayerDoneStartingPlayback')
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -217,6 +229,7 @@ export default {
|
||||
} else if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.play()
|
||||
}
|
||||
this.$store.commit('setPlayerDoneStartingPlayback')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -254,6 +267,9 @@ export default {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to play')
|
||||
})
|
||||
.finally(() => {
|
||||
this.$store.commit('setPlayerDoneStartingPlayback')
|
||||
})
|
||||
},
|
||||
pauseItem() {
|
||||
if (this.$refs.audioPlayer && this.$refs.audioPlayer.isPlaying) {
|
||||
@@ -368,4 +384,4 @@ export default {
|
||||
this.$eventBus.$off('device-focus-update', this.deviceFocused)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="fixed top-0 left-0 right-0 layout-wrapper w-full z-50 overflow-hidden pointer-events-none">
|
||||
<div class="absolute top-0 left-0 w-full h-full bg-black transition-opacity duration-200" :class="show ? 'bg-opacity-60 pointer-events-auto' : 'bg-opacity-0'" @click="clickBackground" />
|
||||
<div class="absolute top-0 right-0 w-64 h-full bg-primary transform transition-transform py-6 pointer-events-auto" :class="show ? '' : 'translate-x-64'" @click.stop>
|
||||
<div class="absolute top-0 right-0 w-64 h-full bg-bg transform transition-transform py-6 pointer-events-auto" :class="show ? '' : 'translate-x-64'" @click.stop>
|
||||
<div class="px-6 mb-4">
|
||||
<p v-if="user" class="text-base">
|
||||
Welcome,
|
||||
@@ -11,25 +11,25 @@
|
||||
|
||||
<div class="w-full overflow-y-auto">
|
||||
<template v-for="item in navItems">
|
||||
<button v-if="item.action" :key="item.text" class="w-full hover:bg-bg hover:bg-opacity-60 flex items-center py-3 px-6 text-gray-300" @click="clickAction(item.action)">
|
||||
<button v-if="item.action" :key="item.text" class="w-full hover:bg-bg/60 flex items-center py-3 px-6 text-fg-muted" @click="clickAction(item.action)">
|
||||
<span class="text-lg" :class="item.iconOutlined ? 'material-icons-outlined' : 'material-icons'">{{ item.icon }}</span>
|
||||
<p class="pl-4">{{ item.text }}</p>
|
||||
</button>
|
||||
<nuxt-link v-else :to="item.to" :key="item.text" class="w-full hover:bg-bg hover:bg-opacity-60 flex items-center py-3 px-6 text-gray-300" :class="currentRoutePath.startsWith(item.to) ? 'bg-bg bg-opacity-60' : ''">
|
||||
<nuxt-link v-else :to="item.to" :key="item.text" class="w-full hover:bg-bg/60 flex items-center py-3 px-6 text-fg" :class="currentRoutePath.startsWith(item.to) ? 'bg-bg-hover/50' : 'text-fg-muted'">
|
||||
<span class="text-lg" :class="item.iconOutlined ? 'material-icons-outlined' : 'material-icons'">{{ item.icon }}</span>
|
||||
<p class="pl-4">{{ item.text }}</p>
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 w-full py-6 px-6 text-gray-300">
|
||||
<div class="absolute bottom-0 left-0 w-full py-6 px-6 text-fg">
|
||||
<div v-if="serverConnectionConfig" class="mb-4 flex justify-center">
|
||||
<p class="text-xs text-gray-400" style="word-break: break-word">{{ serverConnectionConfig.address }} (v{{ serverSettings.version }})</p>
|
||||
<p class="text-xs text-fg-muted" style="word-break: break-word">{{ serverConnectionConfig.address }} (v{{ serverSettings.version }})</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<p class="text-xs">{{ $config.version }}</p>
|
||||
<div class="flex-grow" />
|
||||
<div v-if="user" class="flex items-center" @click="disconnect">
|
||||
<p class="text-xs pr-2">Disconnect</p>
|
||||
<p class="text-xs pr-2">{{ $strings.ButtonDisconnect }}</p>
|
||||
<i class="material-icons text-sm -mb-0.5">cloud_off</i>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,13 +79,16 @@ export default {
|
||||
return this.$store.state.serverSettings || {}
|
||||
},
|
||||
username() {
|
||||
return this.user ? this.user.username : ''
|
||||
return this.user?.username || ''
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
navItems() {
|
||||
var items = [
|
||||
{
|
||||
icon: 'home',
|
||||
text: 'Home',
|
||||
text: this.$strings.ButtonHome,
|
||||
to: '/bookshelf'
|
||||
}
|
||||
]
|
||||
@@ -93,19 +96,19 @@ export default {
|
||||
items = [
|
||||
{
|
||||
icon: 'cloud_off',
|
||||
text: 'Connect to Server',
|
||||
text: this.$strings.ButtonConnectToServer,
|
||||
to: '/connect'
|
||||
}
|
||||
].concat(items)
|
||||
} else {
|
||||
items.push({
|
||||
icon: 'person',
|
||||
text: 'Account',
|
||||
text: this.$strings.HeaderAccount,
|
||||
to: '/account'
|
||||
})
|
||||
items.push({
|
||||
icon: 'equalizer',
|
||||
text: 'User Stats',
|
||||
text: this.$strings.ButtonUserStats,
|
||||
to: '/stats'
|
||||
})
|
||||
}
|
||||
@@ -114,27 +117,33 @@ export default {
|
||||
items.push({
|
||||
icon: 'folder',
|
||||
iconOutlined: true,
|
||||
text: 'Local Media',
|
||||
text: this.$strings.ButtonLocalMedia,
|
||||
to: '/localMedia/folders'
|
||||
})
|
||||
} else {
|
||||
items.push({
|
||||
icon: 'download',
|
||||
iconOutlined: false,
|
||||
text: 'Downloads',
|
||||
text: this.$strings.HeaderDownloads,
|
||||
to: '/downloads'
|
||||
})
|
||||
}
|
||||
items.push({
|
||||
icon: 'settings',
|
||||
text: 'Settings',
|
||||
text: this.$strings.HeaderSettings,
|
||||
to: '/settings'
|
||||
})
|
||||
|
||||
if (this.serverConnectionConfig) {
|
||||
items.push({
|
||||
icon: 'language',
|
||||
text: this.$strings.ButtonGoToWebClient,
|
||||
action: 'openWebClient'
|
||||
})
|
||||
|
||||
items.push({
|
||||
icon: 'login',
|
||||
text: 'Switch Server/User',
|
||||
text: this.$strings.ButtonSwitchServerUser,
|
||||
action: 'logout'
|
||||
})
|
||||
}
|
||||
@@ -151,6 +160,10 @@ export default {
|
||||
if (action === 'logout') {
|
||||
await this.logout()
|
||||
this.$router.push('/connect')
|
||||
} else if (action === 'openWebClient') {
|
||||
this.show = false
|
||||
let path = `/library/${this.$store.state.libraries.currentLibraryId}`
|
||||
await this.$store.dispatch('user/openWebClient', path)
|
||||
}
|
||||
},
|
||||
clickBackground() {
|
||||
@@ -158,6 +171,10 @@ export default {
|
||||
},
|
||||
async logout() {
|
||||
if (this.user) {
|
||||
if (this.$store.getters['getIsPlayerOpen']) {
|
||||
this.$eventBus.$emit('close-stream')
|
||||
}
|
||||
|
||||
await this.$nativeHttp.post('/logout').catch((error) => {
|
||||
console.error('Failed to logout', error)
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<div v-show="!entities.length && initialized" class="w-full py-16 text-center text-xl">
|
||||
<div class="py-4 capitalize">No {{ entityName }}</div>
|
||||
<ui-btn v-if="hasFilter" @click="clearFilter">Clear Filter</ui-btn>
|
||||
<ui-btn v-if="hasFilter" @click="clearFilter">{{ $strings.ButtonClearFilter }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -79,7 +79,7 @@ export default {
|
||||
return this.page
|
||||
},
|
||||
hasFilter() {
|
||||
if (this.page === 'series' || this.page === 'collections') return false
|
||||
if (this.page === 'series' || this.page === 'collections' || this.page === 'playlists') return false
|
||||
return this.filterBy !== 'all'
|
||||
},
|
||||
orderBy() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full relative">
|
||||
<div v-if="altViewEnabled" class="px-5 pb-3 pt-4">
|
||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ label }}</p>
|
||||
<p class="font-semibold" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ label }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end px-3 max-w-full overflow-x-auto" :class="altViewEnabled ? '' : 'bookshelfRow'" :style="{ height: shelfHeight + 'px', paddingBottom: entityPaddingBottom + 'px' }">
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
<!-- Author name & num books overlay -->
|
||||
<div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
|
||||
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
||||
<p class="text-center font-semibold truncate text-white" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} {{ $strings.LabelBooks }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading spinner -->
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
<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-fg-muted">{{ matchHtml }}</p>
|
||||
|
||||
<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" />
|
||||
<p v-if="matchKey !== 'authors'" class="text-xs text-fg truncate">by {{ authorName }}</p>
|
||||
<p v-else class="truncate text-xs text-fg" 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>
|
||||
|
||||
@@ -7,23 +7,26 @@
|
||||
|
||||
<!-- Alternative bookshelf title/author/sort -->
|
||||
<div v-if="isAltViewEnabled" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||
{{ displayTitle }}
|
||||
</p>
|
||||
<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 :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }" class="flex items-center">
|
||||
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||
{{ displayTitle }}
|
||||
</p>
|
||||
<widgets-explicit-indicator v-if="isExplicit" />
|
||||
</div>
|
||||
<p class="truncate text-fg-muted" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || ' ' }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-fg-muted" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="seriesSequenceList" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20 text-right" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }" style="background-color: #78350f">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequenceList }}</p>
|
||||
<p class="text-white" :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequenceList }}</p>
|
||||
</div>
|
||||
<div v-else-if="booksInSeries" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }" style="background-color: #cd9d49dd">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ booksInSeries }}</p>
|
||||
<p class="text-white" :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ booksInSeries }}</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
|
||||
<div v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="text-gray-300 text-center">{{ title }}</p>
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="text-fg-muted text-center">{{ title }}</p>
|
||||
</div>
|
||||
|
||||
<img v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
|
||||
@@ -46,15 +49,20 @@
|
||||
</div>
|
||||
|
||||
<!-- Play/pause button for podcast episode -->
|
||||
<div v-if="recentEpisode" class="absolute z-10 top-0 left-0 bottom-0 right-0 m-auto flex items-center justify-center w-12 h-12 rounded-full bg-white bg-opacity-70" @click.stop="playEpisode">
|
||||
<span class="material-icons text-6xl text-black text-opacity-80">{{ streamIsPlaying ? 'pause_circle' : 'play_circle_filled' }}</span>
|
||||
<div v-if="recentEpisode" class="absolute z-10 top-0 left-0 bottom-0 right-0 m-auto flex items-center justify-center w-12 h-12 rounded-full" :class="{ 'bg-white/70': !playerIsStartingForThisMedia }" @click.stop="playEpisode">
|
||||
<span v-if="!playerIsStartingForThisMedia" class="material-icons text-6xl text-black/80">{{ streamIsPlaying ? 'pause_circle' : 'play_circle_filled' }}</span>
|
||||
<div v-else class="text-fg absolute top-0 left-0 w-full h-full flex items-center justify-center bg-black/80 rounded-full overflow-hidden">
|
||||
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No progress shown for collapsed series in library -->
|
||||
<div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
|
||||
<!-- Downloaded icon -->
|
||||
<div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast ? 1.75 : 0.375) * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast || (seriesSequence && showSequence) ? 1.75 : 0.375) * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
|
||||
</div>
|
||||
|
||||
@@ -75,19 +83,19 @@
|
||||
|
||||
<!-- Podcast Episode # -->
|
||||
<div v-if="recentEpisodeNumber !== null && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">
|
||||
<p class="text-white" :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">
|
||||
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Num Episodes -->
|
||||
<div v-else-if="numEpisodes && !numEpisodesIncomplete && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
|
||||
<p class="text-white" :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Num Episodes Incomplete -->
|
||||
<div v-else-if="numEpisodesIncomplete && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodesIncomplete }}</p>
|
||||
<p class="text-white" :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodesIncomplete }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -305,7 +313,7 @@ export default {
|
||||
return !!this.userProgress?.isFinished
|
||||
},
|
||||
showError() {
|
||||
return this.numMissingParts || this.isMissing || this.isInvalid
|
||||
return this.isMissing || this.isInvalid
|
||||
},
|
||||
localLibraryItemId() {
|
||||
if (this.isLocal) return this.libraryItemId
|
||||
@@ -322,15 +330,22 @@ export default {
|
||||
streamIsPlaying() {
|
||||
return this.store.state.playerIsPlaying && this.isStreaming
|
||||
},
|
||||
playerIsStartingPlayback() {
|
||||
// Play has been pressed and waiting for native play response
|
||||
return this.store.state.playerIsStartingPlayback
|
||||
},
|
||||
playerIsStartingForThisMedia() {
|
||||
const mediaId = this.store.state.playerStartingPlaybackMediaId
|
||||
return mediaId === this.recentEpisode?.id
|
||||
},
|
||||
isMissing() {
|
||||
return this._libraryItem.isMissing
|
||||
},
|
||||
isInvalid() {
|
||||
return this._libraryItem.isInvalid
|
||||
},
|
||||
numMissingParts() {
|
||||
if (this.isPodcast) return 0
|
||||
return this.media.numMissingParts
|
||||
isExplicit() {
|
||||
return !!this.mediaMetadata.explicit
|
||||
},
|
||||
overlayWrapperClasslist() {
|
||||
var classes = []
|
||||
@@ -353,9 +368,6 @@ export default {
|
||||
userCanDownload() {
|
||||
return this.store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userIsRoot() {
|
||||
return this.store.getters['user/getIsRoot']
|
||||
},
|
||||
titleFontSize() {
|
||||
return 0.75 * this.sizeMultiplier
|
||||
},
|
||||
@@ -447,6 +459,8 @@ export default {
|
||||
},
|
||||
async play() {},
|
||||
async playEpisode() {
|
||||
if (this.playerIsStartingPlayback) return
|
||||
|
||||
await this.$hapticsImpact()
|
||||
const eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||
if (this.streamIsPlaying) {
|
||||
@@ -454,6 +468,7 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
this.store.commit('setPlayerIsStartingPlayback', this.recentEpisode.id)
|
||||
if (this.localEpisode) {
|
||||
// Play episode locally
|
||||
eventBus.$emit('play-item', {
|
||||
@@ -477,7 +492,12 @@ export default {
|
||||
if (router) {
|
||||
if (this.recentEpisode) router.push(`/item/${this.libraryItemId}/${this.recentEpisode.id}`)
|
||||
else if (this.collapsedSeries) router.push(`/bookshelf/series/${this.collapsedSeries.id}`)
|
||||
else router.push(`/item/${this.libraryItemId}`)
|
||||
else if (this.localLibraryItem) {
|
||||
// Pass local library item id to server page to allow falling back to offline page
|
||||
router.push(`/item/${this.libraryItemId}?localLibraryItemId=${this.localLibraryItemId}`)
|
||||
} else {
|
||||
router.push(`/item/${this.libraryItemId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
|
||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-mdtext-center" :style="{ width: Math.min(240, width) + 'px' }">
|
||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(240, width) + 'px' }">
|
||||
<div class="w-full h-full flex items-center justify-center rounded-sm border" :class="isAltViewEnabled ? 'altBookshelfLabel' : 'shinyBlack'" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
|
||||
@@ -15,13 +15,13 @@
|
||||
<div v-if="!isPodcast && !collapsedSeries" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * userProgressPercent + 'px' }"></div>
|
||||
</div>
|
||||
<div class="flex-grow px-2">
|
||||
<p class="whitespace-normal" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
|
||||
<p class="whitespace-normal line-clamp-2" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
|
||||
<span v-if="seriesSequence">#{{ seriesSequence }} </span>{{ displayTitle }}
|
||||
</p>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
<p v-if="duration" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ $elapsedPretty(duration) }}</p>
|
||||
<p v-if="episodes" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ episodes }}</p>
|
||||
<p class="truncate text-fg-muted" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-fg-muted" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
<p v-if="duration" class="truncate text-fg-muted" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ $elapsedPretty(duration) }}</p>
|
||||
<p v-if="episodes" class="truncate text-fg-muted" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ episodes }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="localLibraryItem || isLocal" class="absolute top-0 right-0 z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
@@ -176,7 +176,7 @@ export default {
|
||||
return this.mediaMetadata.series
|
||||
},
|
||||
seriesSequence() {
|
||||
return this.series ? this.series.sequence : null
|
||||
return this.series?.sequence || null
|
||||
},
|
||||
collapsedSeries() {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
@@ -209,13 +209,13 @@ export default {
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||
},
|
||||
userProgressPercent() {
|
||||
return this.userProgress ? this.userProgress.progress || 0 : 0
|
||||
return this.userProgress?.progress || 0
|
||||
},
|
||||
itemIsFinished() {
|
||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||
return !!this.userProgress?.isFinished
|
||||
},
|
||||
showError() {
|
||||
return this.numMissingParts || this.isMissing || this.isInvalid
|
||||
return this.isMissing || this.isInvalid
|
||||
},
|
||||
isStreaming() {
|
||||
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
|
||||
@@ -235,10 +235,6 @@ export default {
|
||||
isInvalid() {
|
||||
return this._libraryItem.isInvalid
|
||||
},
|
||||
numMissingParts() {
|
||||
if (this.isPodcast) return 0
|
||||
return this.media.numMissingParts
|
||||
},
|
||||
store() {
|
||||
return this.$store || this.$nuxt.$store
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||
<covers-playlist-cover ref="cover" :items="items" :width="width" :height="height" />
|
||||
</div>
|
||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-mdtext-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||
<div class="w-full h-full flex items-center justify-center rounded-sm border" :class="isAltViewEnabled ? 'altBookshelfLabel' : 'shinyBlack'" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<div v-if="isAltViewEnabled && isCategorized" 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>
|
||||
</div>
|
||||
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-mdtext-center" :style="{ width: Math.min(240, width) + 'px' }">
|
||||
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md text-center" :style="{ width: Math.min(240, width) + 'px' }">
|
||||
<div class="w-full h-full flex items-center justify-center rounded-sm border" :class="isAltViewEnabled ? 'altBookshelfLabel' : 'shinyBlack'" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<div class="w-10 h-10 flex items-center justify-center">
|
||||
<span class="material-icons text-2xl text-gray-200">local_offer</span>
|
||||
<span class="material-icons text-2xl text-fg">local_offer</span>
|
||||
</div>
|
||||
<div class="flex-grow px-2 tagSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ tag }}</p>
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
<div v-show="!loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg px-4 py-6 w-full">
|
||||
<!-- list of server connection configs -->
|
||||
<template v-if="!showForm">
|
||||
<div v-for="config in serverConnectionConfigs" :key="config.id" class="flex items-center py-4 my-1 border-b border-white border-opacity-10 relative" @click="connectToServer(config)">
|
||||
<span class="material-icons-outlined text-xl text-gray-300">dns</span>
|
||||
<p class="pl-3 pr-6 text-base text-gray-200">{{ config.name }}</p>
|
||||
<div v-for="config in serverConnectionConfigs" :key="config.id" class="flex items-center py-4 my-1 border-b border-fg/10 relative" @click="connectToServer(config)">
|
||||
<span class="material-icons-outlined text-xl text-fg-muted">dns</span>
|
||||
<p class="pl-3 pr-6 text-base text-fg">{{ config.name }}</p>
|
||||
|
||||
<div class="absolute top-0 right-0 h-full px-4 flex items-center" @click.stop="editServerConfig(config)">
|
||||
<span class="material-icons text-lg text-gray-300">more_vert</span>
|
||||
<span class="material-icons text-lg text-fg-muted">more_vert</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-1 py-4 w-full">
|
||||
<ui-btn class="w-full" @click="newServerConfigClick">Add New Server</ui-btn>
|
||||
<ui-btn class="w-full" @click="newServerConfigClick">{{ $strings.ButtonAddNewServer }}</ui-btn>
|
||||
</div>
|
||||
</template>
|
||||
<!-- form to add a new server connection config -->
|
||||
@@ -20,37 +20,37 @@
|
||||
<!-- server address input -->
|
||||
<form v-if="!showAuth" @submit.prevent="submit" novalidate class="w-full">
|
||||
<div v-if="serverConnectionConfigs.length" class="flex items-center mb-4" @click="showServerList">
|
||||
<span class="material-icons text-gray-300">arrow_back</span>
|
||||
<span class="material-icons text-fg-muted">arrow_back</span>
|
||||
</div>
|
||||
<h2 class="text-lg leading-7 mb-2">Server address</h2>
|
||||
<h2 class="text-lg leading-7 mb-2">{{ $strings.LabelServerAddress }}</h2>
|
||||
<ui-text-input v-model="serverConfig.address" :disabled="processing || !networkConnected || !!serverConfig.id" placeholder="http://55.55.55.55:13378" type="url" class="w-full h-10" />
|
||||
<div class="flex justify-end items-center mt-6">
|
||||
<ui-btn :disabled="processing || !networkConnected" type="submit" :padding-x="3" class="h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
|
||||
<ui-btn :disabled="processing || !networkConnected" type="submit" :padding-x="3" class="h-10">{{ networkConnected ? $strings.ButtonSubmit : $strings.MessageNoNetworkConnection }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
<!-- username/password and auth methods -->
|
||||
<template v-else>
|
||||
<div v-if="serverConfig.id" class="flex items-center mb-4" @click="showServerList">
|
||||
<span class="material-icons text-gray-300">arrow_back</span>
|
||||
<span class="material-icons text-fg-muted">arrow_back</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<p class="text-gray-300">{{ serverConfig.address }}</p>
|
||||
<p class="text-fg-muted">{{ serverConfig.address }}</p>
|
||||
<div class="flex-grow" />
|
||||
<span v-if="!serverConfig.id" class="material-icons" style="font-size: 1.1rem" @click="editServerAddress">edit</span>
|
||||
</div>
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
|
||||
<div class="w-full h-px bg-fg/10 my-2" />
|
||||
<form v-if="isLocalAuthEnabled" @submit.prevent="submitAuth" class="pt-3">
|
||||
<ui-text-input v-model="serverConfig.username" :disabled="processing" placeholder="username" class="w-full mb-2 text-lg" />
|
||||
<ui-text-input v-model="password" type="password" :disabled="processing" placeholder="password" class="w-full mb-2 text-lg" />
|
||||
<ui-text-input v-model="serverConfig.username" :disabled="processing" :placeholder="$strings.LabelUsername" class="w-full mb-2 text-lg" />
|
||||
<ui-text-input v-model="password" type="password" :disabled="processing" :placeholder="$strings.LabelPassword" class="w-full mb-2 text-lg" />
|
||||
|
||||
<div class="flex items-center pt-2">
|
||||
<ui-icon-btn v-if="serverConfig.id" small bg-color="error" icon="delete" @click="removeServerConfigClick" />
|
||||
<ui-icon-btn v-if="serverConfig.id" small bg-color="error" icon="delete" type="button" @click="removeServerConfigClick" />
|
||||
<div class="flex-grow" />
|
||||
<ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
|
||||
<ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? $strings.ButtonSubmit : $strings.MessageNoNetworkConnection }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="isLocalAuthEnabled && isOpenIDAuthEnabled" class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
<div v-if="isLocalAuthEnabled && isOpenIDAuthEnabled" class="w-full h-px bg-fg/10 my-4" />
|
||||
<ui-btn v-if="isOpenIDAuthEnabled" :disabled="processing" class="h-10 w-full" @click="clickLoginWithOpenId">{{ oauth.buttonText }}</ui-btn>
|
||||
</template>
|
||||
</div>
|
||||
@@ -62,7 +62,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="processing ? 'opacity-100' : 'opacity-0 pointer-events-none'" class="fixed w-full h-full top-0 left-0 bg-black bg-opacity-75 flex items-center justify-center z-30 transition-opacity duration-500">
|
||||
<div :class="processing ? 'opacity-100' : 'opacity-0 pointer-events-none'" class="fixed w-full h-full top-0 left-0 bg-black/75 flex items-center justify-center z-30 transition-opacity duration-500">
|
||||
<div>
|
||||
<div class="absolute top-0 left-0 w-full p-6 flex items-center flex-col justify-center z-0 short:hidden">
|
||||
<img src="/Logo.png" class="h-20 w-20 mb-2" />
|
||||
@@ -73,7 +73,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!serverConnectionConfigs.length" class="mt-2 text-center text-error"><strong>Important!</strong> This app is designed to work with an Audiobookshelf server that you or someone you know is hosting. This app does not provide any content.</p>
|
||||
<p v-if="!serverConnectionConfigs.length" class="mt-2 text-center text-error" v-html="$strings.MessageAudiobookshelfServerRequired" />
|
||||
|
||||
<modals-custom-headers-modal v-model="showAddCustomHeaders" :custom-headers.sync="serverConfig.customHeaders" />
|
||||
</div>
|
||||
@@ -95,6 +95,7 @@ export default {
|
||||
processing: false,
|
||||
serverConfig: {
|
||||
address: null,
|
||||
version: null,
|
||||
username: null,
|
||||
customHeaders: null
|
||||
},
|
||||
@@ -107,7 +108,8 @@ export default {
|
||||
state: null,
|
||||
verifier: null,
|
||||
challenge: null,
|
||||
buttonText: 'Login with OpenID'
|
||||
buttonText: 'Login with OpenID',
|
||||
enforceHTTPs: true // RFC 6749, Section 10.9 requires https
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -115,6 +117,9 @@ export default {
|
||||
deviceData() {
|
||||
return this.$store.state.deviceData || {}
|
||||
},
|
||||
deviceSettings() {
|
||||
return this.deviceData.deviceSettings || {}
|
||||
},
|
||||
networkConnected() {
|
||||
return this.$store.state.networkConnected
|
||||
},
|
||||
@@ -155,7 +160,7 @@ export default {
|
||||
*/
|
||||
async clickLoginWithOpenId() {
|
||||
// oauth standard requires https explicitly
|
||||
if (!this.serverConfig.address.startsWith('https')) {
|
||||
if (!this.serverConfig.address.startsWith('https') && this.oauth.enforceHTTPs) {
|
||||
console.warn(`[SSO] Oauth2 requires HTTPS`)
|
||||
this.$toast.error(`SSO: The URL to the server must be https:// secured`)
|
||||
return
|
||||
@@ -179,14 +184,19 @@ export default {
|
||||
const client_id = redirectUrl.searchParams.get('client_id')
|
||||
const scope = redirectUrl.searchParams.get('scope')
|
||||
const state = redirectUrl.searchParams.get('state')
|
||||
let redirect_uri_param = redirectUrl.searchParams.get('redirect_uri')
|
||||
// Backwards compatability with 2.6.0
|
||||
if (this.serverConfig.version === '2.6.0') {
|
||||
redirect_uri_param = 'audiobookshelf://oauth'
|
||||
}
|
||||
|
||||
if (!client_id || !scope || !state) {
|
||||
console.warn(`[SSO] Invalid OpenID URL - client_id scope or state missing: ${redirectUrl}`)
|
||||
if (!client_id || !scope || !state || !redirect_uri_param) {
|
||||
console.warn(`[SSO] Invalid OpenID URL - client_id scope state or redirect_uri missing: ${redirectUrl}`)
|
||||
this.$toast.error(`SSO: Invalid answer`)
|
||||
return
|
||||
}
|
||||
|
||||
if (redirectUrl.protocol !== 'https:') {
|
||||
if (redirectUrl.protocol !== 'https:' && this.oauth.enforceHTTPs) {
|
||||
console.warn(`[SSO] Insecure Redirection by SSO provider: ${redirectUrl.protocol} is not allowed. Use HTTPS`)
|
||||
this.$toast.error(`SSO: The SSO provider must return a HTTPS secured URL`)
|
||||
return
|
||||
@@ -195,8 +205,8 @@ export default {
|
||||
// We need to verify if the state is the same later
|
||||
this.oauth.state = state
|
||||
|
||||
const host = `https://${redirectUrl.host}`
|
||||
const buildUrl = `${host}${redirectUrl.pathname}?response_type=code` + `&client_id=${encodeURIComponent(client_id)}&scope=${encodeURIComponent(scope)}&state=${encodeURIComponent(state)}` + `&redirect_uri=${encodeURIComponent('audiobookshelf://oauth')}` + `&code_challenge=${encodeURIComponent(this.oauth.challenge)}&code_challenge_method=S256`
|
||||
const host = `${redirectUrl.protocol}//${redirectUrl.host}`
|
||||
const buildUrl = `${host}${redirectUrl.pathname}?response_type=code` + `&client_id=${encodeURIComponent(client_id)}&scope=${encodeURIComponent(scope)}&state=${encodeURIComponent(state)}` + `&redirect_uri=${encodeURIComponent(redirect_uri_param)}` + `&code_challenge=${encodeURIComponent(this.oauth.challenge)}&code_challenge_method=S256`
|
||||
|
||||
// example url for authentik
|
||||
// const authURL = "https://authentik/application/o/authorize/?response_type=code&client_id=41cd96f...&redirect_uri=audiobookshelf%3A%2F%2Foauth&scope=openid%20openid%20email%20profile&state=asdds..."
|
||||
@@ -243,8 +253,11 @@ export default {
|
||||
this.oauth.verifier = verifier
|
||||
this.oauth.challenge = challenge
|
||||
|
||||
// set parameter isRest to true, so the backend wont attempt a redirect after we call backend:/callback in exchangeCodeForToken
|
||||
const backendEndpoint = `${url}auth/openid?code_challenge=${challenge}&code_challenge_method=S256&isRest=true`
|
||||
let backendEndpoint = `${url}/auth/openid?code_challenge=${challenge}&code_challenge_method=S256&redirect_uri=${encodeURIComponent('audiobookshelf://oauth')}&client_id=${encodeURIComponent('Audiobookshelf-App')}&response_type=code`
|
||||
// Backwards compatability with 2.6.0
|
||||
if (this.serverConfig.version === '2.6.0') {
|
||||
backendEndpoint += '&isRest=true'
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await CapacitorHttp.get({
|
||||
@@ -568,7 +581,7 @@ export default {
|
||||
})
|
||||
},
|
||||
requestServerLogin() {
|
||||
return this.postRequest(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password }, this.serverConfig.customHeaders, 20000)
|
||||
return this.postRequest(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password || '' }, this.serverConfig.customHeaders, 20000)
|
||||
.then((data) => {
|
||||
if (!data.user) {
|
||||
console.error(data.error)
|
||||
@@ -608,6 +621,7 @@ export default {
|
||||
this.showAuth = true
|
||||
this.authMethods = statusData.data.authMethods || []
|
||||
this.oauth.buttonText = statusData.data.authFormData?.authOpenIDButtonText || 'Login with OpenID'
|
||||
this.serverConfig.version = statusData.data.serverVersion
|
||||
|
||||
if (statusData.data.authFormData?.authOpenIDAutoLaunch) {
|
||||
this.clickLoginWithOpenId()
|
||||
@@ -789,6 +803,7 @@ export default {
|
||||
|
||||
this.$store.commit('setServerSettings', serverSettings)
|
||||
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
|
||||
this.$setServerLanguageCode(serverSettings.language)
|
||||
|
||||
// Set library - Use last library if set and available fallback to default user library
|
||||
var lastLibraryId = await this.$localStore.getLastLibraryId()
|
||||
@@ -801,9 +816,23 @@ export default {
|
||||
this.serverConfig.userId = user.id
|
||||
this.serverConfig.token = user.token
|
||||
this.serverConfig.username = user.username
|
||||
delete this.serverConfig.version
|
||||
|
||||
var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig)
|
||||
|
||||
// Set the device language to match the servers if this is the first server connection
|
||||
if (!this.serverConnectionConfigs.length && serverSettings.language !== 'en-us') {
|
||||
const deviceSettings = {
|
||||
...this.deviceSettings,
|
||||
languageCode: serverSettings.language
|
||||
}
|
||||
const updatedDeviceData = await this.$db.updateDeviceSettings(deviceSettings)
|
||||
if (updatedDeviceData) {
|
||||
this.$store.commit('setDeviceData', updatedDeviceData)
|
||||
this.$setLanguageCode(updatedDeviceData.deviceSettings?.languageCode || 'en-us')
|
||||
}
|
||||
}
|
||||
|
||||
this.$store.commit('user/setUser', user)
|
||||
this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig)
|
||||
|
||||
|
||||
@@ -15,7 +15,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="text-white text-opacity-60 text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
|
||||
<p class="text-fg-muted text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,24 +2,24 @@
|
||||
<div class="w-full px-2">
|
||||
<img v-if="podcast.imageUrl" :src="podcast.imageUrl" class="h-36 w-36 object-contain mx-auto mb-2" />
|
||||
|
||||
<ui-text-input-with-label v-model="podcast.title" :label="'Title'" class="mb-2 text-sm" @input="titleUpdated" />
|
||||
<ui-text-input-with-label v-model="podcast.title" :label="$strings.LabelTitle" class="mb-2 text-sm" @input="titleUpdated" />
|
||||
|
||||
<ui-text-input-with-label v-model="podcast.author" :label="'Author'" class="mb-2 text-sm" />
|
||||
<ui-text-input-with-label v-model="podcast.author" :label="$strings.LabelAuthor" class="mb-2 text-sm" />
|
||||
|
||||
<ui-text-input-with-label v-model="podcast.feedUrl" :label="'Feed URL'" readonly class="mb-2 text-sm" />
|
||||
<ui-text-input-with-label v-model="podcast.feedUrl" :label="$strings.LabelFeedURL" readonly class="mb-2 text-sm" />
|
||||
|
||||
<ui-multi-select v-model="podcast.genres" :items="podcast.genres" :label="'Genres'" class="mb-2 text-sm" />
|
||||
<ui-multi-select v-model="podcast.genres" :items="podcast.genres" :label="$strings.LabelGenres" class="mb-2 text-sm" />
|
||||
|
||||
<ui-textarea-with-label v-model="podcast.description" :label="'Description'" :rows="3" class="mb-2 text-sm" />
|
||||
<ui-textarea-with-label v-model="podcast.description" :label="$strings.LabelDescription" :rows="3" class="mb-2 text-sm" />
|
||||
|
||||
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" :label="'Folder'" class="mb-2 text-sm" @input="folderUpdated" />
|
||||
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" :label="$strings.LabelFolder" class="mb-2 text-sm" @input="folderUpdated" />
|
||||
|
||||
<ui-text-input-with-label v-model="fullPath" :label="'Podcast Path'" input-class="h-10" readonly class="mb-2 text-sm" />
|
||||
<ui-text-input-with-label v-model="fullPath" :label="$strings.LabelPath" input-class="h-10" readonly class="mb-2 text-sm" />
|
||||
|
||||
<div class="flex items-center py-4 px-2">
|
||||
<ui-checkbox v-model="podcast.autoDownloadEpisodes" :label="'Auto Download Episodes'" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-sm font-semibold" />
|
||||
<ui-checkbox v-model="podcast.autoDownloadEpisodes" :label="$strings.LabelAutoDownloadEpisodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-sm font-semibold" />
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" @click="submit">Submit</ui-btn>
|
||||
<ui-btn color="success" @click="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -144,11 +144,11 @@ export default {
|
||||
.post('/api/podcasts', podcastPayload)
|
||||
.then((libraryItem) => {
|
||||
this._processing = false
|
||||
this.$toast.success('Podcast added')
|
||||
this.$toast.success(this.$strings.ToastPodcastCreateSuccess)
|
||||
this.$router.push(`/item/${libraryItem.id}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to add podcast'
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : this.$strings.ToastPodcastCreateFailed
|
||||
console.error('Failed to create podcast', error)
|
||||
this._processing = false
|
||||
this.$toast.error(errorMsg)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full h-9 bg-bg relative">
|
||||
<div id="bookshelf-navbar" class="absolute z-10 top-0 left-0 w-full h-full flex bg-secondary text-gray-200">
|
||||
<nuxt-link v-for="item in items" :key="item.to" :to="item.to" class="h-full flex-grow flex items-center justify-center" :class="routeName === item.routeName ? 'bg-primary' : 'text-gray-400'">
|
||||
<div id="bookshelf-navbar" class="absolute z-10 top-0 left-0 w-full h-full flex bg-secondary">
|
||||
<nuxt-link v-for="item in items" :key="item.to" :to="item.to" class="h-full flex-grow flex items-center justify-center" :class="routeName === item.routeName ? 'bg-primary' : 'text-fg-muted'">
|
||||
<p v-if="routeName === item.routeName" class="text-sm font-semibold">{{ item.text }}</p>
|
||||
<span v-else-if="item.iconPack === 'abs-icons'" class="abs-icons" :class="`icon-${item.icon} ${item.iconClass || ''}`"></span>
|
||||
<span v-else :class="`${item.iconPack} ${item.iconClass || ''}`">{{ item.icon }}</span>
|
||||
@@ -20,7 +20,7 @@ export default {
|
||||
return this.$store.getters['libraries/getCurrentLibrary']
|
||||
},
|
||||
currentLibraryIcon() {
|
||||
return this.currentLibrary ? this.currentLibrary.icon : 'database'
|
||||
return this.currentLibrary?.icon || 'database'
|
||||
},
|
||||
userHasPlaylists() {
|
||||
return this.$store.state.libraries.numUserPlaylists
|
||||
@@ -38,7 +38,7 @@ export default {
|
||||
iconPack: 'abs-icons',
|
||||
icon: 'home',
|
||||
iconClass: 'text-xl',
|
||||
text: 'Home'
|
||||
text: this.$strings.ButtonHome
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/latest',
|
||||
@@ -46,7 +46,7 @@ export default {
|
||||
iconPack: 'abs-icons',
|
||||
icon: 'list',
|
||||
iconClass: 'text-xl',
|
||||
text: 'Latest'
|
||||
text: this.$strings.ButtonLatest
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/library',
|
||||
@@ -54,18 +54,18 @@ export default {
|
||||
iconPack: 'abs-icons',
|
||||
icon: this.currentLibraryIcon,
|
||||
iconClass: 'text-lg',
|
||||
text: 'Library'
|
||||
text: this.$strings.ButtonLibrary
|
||||
}
|
||||
]
|
||||
|
||||
if (this.userIsAdminOrUp) {
|
||||
items.push({
|
||||
to: '/bookshelf/search',
|
||||
routeName: 'bookshelf-search',
|
||||
to: '/bookshelf/add-podcast',
|
||||
routeName: 'bookshelf-add-podcast',
|
||||
iconPack: 'material-icons',
|
||||
icon: 'podcasts',
|
||||
iconClass: 'text-xl',
|
||||
text: 'Add'
|
||||
text: this.$strings.ButtonAdd
|
||||
})
|
||||
}
|
||||
} else {
|
||||
@@ -76,7 +76,7 @@ export default {
|
||||
iconPack: 'abs-icons',
|
||||
icon: 'home',
|
||||
iconClass: 'text-xl',
|
||||
text: 'Home'
|
||||
text: this.$strings.ButtonHome
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/library',
|
||||
@@ -84,7 +84,7 @@ export default {
|
||||
iconPack: 'abs-icons',
|
||||
icon: this.currentLibraryIcon,
|
||||
iconClass: 'text-lg',
|
||||
text: 'Library'
|
||||
text: this.$strings.ButtonLibrary
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/series',
|
||||
@@ -92,7 +92,7 @@ export default {
|
||||
iconPack: 'abs-icons',
|
||||
icon: 'columns',
|
||||
iconClass: 'text-lg pt-px',
|
||||
text: 'Series'
|
||||
text: this.$strings.ButtonSeries
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/collections',
|
||||
@@ -100,7 +100,7 @@ export default {
|
||||
iconPack: 'material-icons-outlined',
|
||||
icon: 'collections_bookmark',
|
||||
iconClass: 'text-xl',
|
||||
text: 'Collections'
|
||||
text: this.$strings.ButtonCollections
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/authors',
|
||||
@@ -108,7 +108,7 @@ export default {
|
||||
iconPack: 'abs-icons',
|
||||
icon: 'authors',
|
||||
iconClass: 'text-2xl',
|
||||
text: 'Authors'
|
||||
text: this.$strings.ButtonAuthors
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -119,7 +119,7 @@ export default {
|
||||
routeName: 'bookshelf-playlists',
|
||||
iconPack: 'material-icons',
|
||||
icon: 'queue_music',
|
||||
text: 'Playlists'
|
||||
text: this.$strings.ButtonPlaylists
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
</div>
|
||||
<span class="material-icons px-2" @click="showSortModal = true">sort</span>
|
||||
</template>
|
||||
<span v-if="seriesBookPage" class="material-icons px-2" @click="downloadSeries">download</span>
|
||||
<span v-if="(page == 'library' && isBookLibrary) || seriesBookPage" class="material-icons px-2" @click="showMoreMenuDialog = true">more_vert</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,16 +65,16 @@ export default {
|
||||
return this.$route.query || {}
|
||||
},
|
||||
entityTitle() {
|
||||
if (this.isPodcast) return 'Podcasts'
|
||||
if (this.page === 'library') return 'Books'
|
||||
else if (this.page === 'series') {
|
||||
return 'Series'
|
||||
} else if (this.page === 'collections') {
|
||||
return 'Collections'
|
||||
if (this.page === 'library') {
|
||||
return this.isPodcast ? this.$strings.LabelPodcasts : this.$strings.LabelBooks
|
||||
} else if (this.page === 'playlists') {
|
||||
return 'Playlists'
|
||||
return this.$strings.ButtonPlaylists
|
||||
} else if (this.page === 'series') {
|
||||
return this.$strings.LabelSeries
|
||||
} else if (this.page === 'collections') {
|
||||
return this.$strings.ButtonCollections
|
||||
} else if (this.page === 'authors') {
|
||||
return 'Authors'
|
||||
return this.$strings.LabelAuthors
|
||||
}
|
||||
return ''
|
||||
},
|
||||
@@ -92,7 +93,7 @@ export default {
|
||||
if (this.seriesBookPage) {
|
||||
return [
|
||||
{
|
||||
text: 'Collapse Sub-Series',
|
||||
text: this.$strings.LabelCollapseSeries,
|
||||
value: 'collapse_subseries',
|
||||
icon: this.settings.collapseBookSeries ? 'check_box' : 'check_box_outline_blank'
|
||||
}
|
||||
@@ -100,7 +101,7 @@ export default {
|
||||
} else {
|
||||
return [
|
||||
{
|
||||
text: 'Collapse Series',
|
||||
text: this.$strings.LabelCollapseSeries,
|
||||
value: 'collapse_series',
|
||||
icon: this.settings.collapseSeries ? 'check_box' : 'check_box_outline_blank'
|
||||
}
|
||||
@@ -144,6 +145,10 @@ export default {
|
||||
async changeView() {
|
||||
this.bookshelfListView = !this.bookshelfListView
|
||||
await this.$hapticsImpact()
|
||||
},
|
||||
downloadSeries() {
|
||||
console.log('Download Series click')
|
||||
this.$eventBus.$emit('download-series-click')
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="200" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-8 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Auto Rewind Time</p>
|
||||
<div class="absolute top-11 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate capitalize">{{ $strings.LabelAutoRewindTime }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center"
|
||||
@click="
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="
|
||||
show = false
|
||||
manualTimerModal = false
|
||||
"
|
||||
>
|
||||
">
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<div v-if="manualTimerModal" class="p-4">
|
||||
<div class="flex mb-4" @click="manualTimerModal = false">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<div class="flex my-2 justify-between">
|
||||
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
|
||||
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px">
|
||||
<span class="material-icons">remove</span>
|
||||
</ui-btn>
|
||||
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
|
||||
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
|
||||
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px">
|
||||
<span class="material-icons">add</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">Set Timer</ui-btn>
|
||||
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">{{ $strings.ButtonSetTimer }}</ui-btn>
|
||||
</div>
|
||||
<ul v-else class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="timeout in timeouts">
|
||||
@@ -35,7 +36,7 @@
|
||||
</template>
|
||||
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="manualTimerModal = true">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate text-lg text-center">Custom time</span>
|
||||
<span class="font-normal block truncate text-lg text-center">{{ $strings.LabelCustomTime }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="400" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-8 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Bookmarks</p>
|
||||
<div class="absolute top-11 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">{{ $strings.LabelYourBookmarks }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div ref="container" class="w-full rounded-lg bg-primary border border-white border-opacity-20 overflow-y-auto overflow-x-hidden" style="max-height: 80vh" @click.stop.prevent>
|
||||
<div ref="container" class="w-full rounded-lg bg-primary border border-border overflow-y-auto overflow-x-hidden" style="max-height: 80vh" @click.stop.prevent>
|
||||
<div class="w-full h-full p-4" v-show="showBookmarkTitleInput">
|
||||
<div class="flex mb-4 items-center">
|
||||
<div class="w-9 h-9 flex items-center justify-center rounded-full hover:bg-white hover:bg-opacity-10 cursor-pointer" @click.stop="showBookmarkTitleInput = false">
|
||||
@@ -14,9 +14,7 @@
|
||||
</div>
|
||||
<p class="text-xl pl-2">{{ selectedBookmark ? 'Edit Bookmark' : 'New Bookmark' }}</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="text-xl font-mono">
|
||||
{{ this.$secondsToTimestamp(currentTime) }}
|
||||
</p>
|
||||
<p class="text-xl font-mono">{{ this.$secondsToTimestamp(currentTime) }}</p>
|
||||
</div>
|
||||
|
||||
<ui-text-input-with-label v-model="newBookmarkTitle" label="Note" />
|
||||
@@ -29,14 +27,12 @@
|
||||
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" @click="clickBookmark" @edit="editBookmark" @delete="deleteBookmark" />
|
||||
</template>
|
||||
<div v-if="!bookmarks.length" class="flex h-32 items-center justify-center">
|
||||
<p class="text-xl">No Bookmarks</p>
|
||||
<p class="text-xl">{{ $strings.MessageNoBookmarks }}</p>
|
||||
</div>
|
||||
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center justify-between border-b border-white border-opacity-10 bg-blue-500 bg-opacity-20 cursor-pointer text-white text-opacity-80 hover:bg-opacity-40 hover:text-opacity-100" @click.stop="createBookmark">
|
||||
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center justify-between border-b border-fg/10 bg-success cursor-pointer text-white text-opacity-80" @click.stop="createBookmark">
|
||||
<span class="material-icons">add</span>
|
||||
<p class="text-base pl-2">Create Bookmark</p>
|
||||
<p class="text-sm font-mono">
|
||||
{{ this.$secondsToTimestamp(currentTime) }}
|
||||
</p>
|
||||
<p class="text-base pl-2">{{ $strings.ButtonCreateBookmark }}</p>
|
||||
<p class="text-sm font-mono">{{ this.$secondsToTimestamp(currentTime) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,7 +94,7 @@ export default {
|
||||
await this.$hapticsImpact()
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Remove Bookmark',
|
||||
message: `Are you sure you want to remove bookmark?`
|
||||
message: this.$strings.MessageConfirmRemoveBookmark
|
||||
})
|
||||
if (!value) return
|
||||
|
||||
@@ -108,7 +104,7 @@ export default {
|
||||
this.$store.commit('user/deleteBookmark', { libraryItemId: this.libraryItemId, time: bm.time })
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error(`Failed to remove bookmark`)
|
||||
this.$toast.error(this.$strings.ToastBookmarkRemoveFailed)
|
||||
console.error(error)
|
||||
})
|
||||
},
|
||||
@@ -124,7 +120,7 @@ export default {
|
||||
this.showBookmarkTitleInput = false
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error(`Failed to update bookmark`)
|
||||
this.$toast.error(this.$strings.ToastBookmarkUpdateFailed)
|
||||
console.error(error)
|
||||
})
|
||||
},
|
||||
@@ -142,7 +138,7 @@ export default {
|
||||
this.$toast.success('Bookmark added')
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error(`Failed to create bookmark`)
|
||||
this.$toast.error(this.$strings.ToastBookmarkCreateFailed)
|
||||
console.error(error)
|
||||
})
|
||||
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
<modals-modal v-model="show" :width="400" height="100%">
|
||||
<template #outer>
|
||||
<div v-if="currentChapter" class="absolute top-10 left-4 z-40 pt-1" style="max-width: 80%">
|
||||
<p class="text-white text-lg truncate">{{ chapters.length }} Chapters</p>
|
||||
<p class="text-white text-lg truncate">{{ chapters.length }} {{ $strings.LabelChapters }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-secondary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-secondary rounded-lg border border-fg/20" style="max-height: 75%" @click.stop>
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="(chapter, index) in chapters">
|
||||
<li :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-gray-50 select-none relative py-4 cursor-pointer" :class="currentChapterId === chapter.id ? 'bg-primary bg-opacity-80' : ''" role="option" @click="clickedOption(chapter)">
|
||||
<template v-for="chapter in chapters">
|
||||
<li :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-fg select-none relative py-4 cursor-pointer" :class="currentChapterId === chapter.id ? 'bg-primary bg-opacity-80' : ''" role="option" @click="clickedOption(chapter)">
|
||||
<div class="relative flex items-center pl-3 pr-20">
|
||||
<p class="font-normal block truncate text-sm text-white text-opacity-80">{{ index + 1 }} - {{ chapter.title }}</p>
|
||||
<p class="font-normal block truncate text-sm text-fg/80">{{ chapter.title }}</p>
|
||||
<div class="absolute top-0 right-3 -mt-0.5">
|
||||
<span class="font-mono text-white text-opacity-90 leading-3 text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start / _playbackRate) }}</span>
|
||||
<span class="font-mono text-fg-muted leading-3 text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start / _playbackRate) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
</template>
|
||||
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white/20 p-2" style="max-height: 75%" @click.stop>
|
||||
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-fg/20 p-2" style="max-height: 75%" @click.stop>
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in itemsToShow">
|
||||
<slot :name="item.value" :item="item" :selected="item.value === selected">
|
||||
<li :key="item.value" :ref="`item-${item.value}`" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" :style="{ paddingTop: itemPaddingY, paddingBottom: itemPaddingY }" role="option" @click="clickedOption(item.value)">
|
||||
<li :key="item.value" :ref="`item-${item.value}`" class="text-fg select-none relative cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" :style="{ paddingTop: itemPaddingY, paddingBottom: itemPaddingY }" role="option" @click="clickedOption(item.value)">
|
||||
<div class="relative flex items-center px-3">
|
||||
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">{{ item.icon }}</span>
|
||||
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
|
||||
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-fg text-opacity-80">{{ item.icon }}</span>
|
||||
<p class="font-normal block truncate text-base text-fg text-opacity-80">{{ item.text }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</slot>
|
||||
@@ -40,7 +40,7 @@ export default {
|
||||
default: '16px'
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
type: [String, Number],
|
||||
default: 300
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" width="90%" height="100%">
|
||||
<template #outer>
|
||||
<div v-show="selected !== 'all'" class="absolute top-10 left-4 z-40">
|
||||
<ui-btn class="text-xl border-yellow-400 border-opacity-40" @click="clearSelected">Clear</ui-btn>
|
||||
<div v-show="selected !== 'all'" class="absolute top-12 left-4 z-40">
|
||||
<ui-btn class="text-lg border-yellow-400 border-opacity-40 h-10" :padding-y="0" @click="clearSelected">{{ $strings.ButtonClearFilter }}</ui-btn>
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-fg/20 mt-8" style="max-height: 75%" @click.stop>
|
||||
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative py-4 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
|
||||
<li :key="item.value" class="text-fg select-none relative py-4 pr-9 cursor-pointer" :class="item.value === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-normal ml-3 block truncate text-lg">{{ item.text }}</span>
|
||||
</div>
|
||||
@@ -20,12 +20,12 @@
|
||||
</template>
|
||||
</ul>
|
||||
<ul v-show="sublist" class="h-full w-full rounded-lg" role="listbox" aria-labelledby="listbox-label">
|
||||
<li class="text-gray-50 select-none relative py-3 pl-9 cursor-pointer hover:bg-black-400" role="option" @click="sublist = null">
|
||||
<li class="text-fg select-none relative py-3 pl-9 cursor-pointer" role="option" @click="sublist = null">
|
||||
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
|
||||
<span class="material-icons text-2xl">arrow_left</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-normal ml-3 block truncate text-lg">Back</span>
|
||||
<span class="font-normal ml-3 block truncate text-lg">{{ $strings.ButtonBack }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
|
||||
@@ -34,7 +34,7 @@
|
||||
</div>
|
||||
</li>
|
||||
<template v-for="item in sublistItems">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative px-4 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
|
||||
<li :key="item.value" class="text-fg select-none relative px-4 cursor-pointer" :class="`${sublist}.${item.value}` === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal truncate py-3 text-base">{{ item.text }}</span>
|
||||
</div>
|
||||
@@ -57,62 +57,67 @@ export default {
|
||||
sublist: null,
|
||||
bookItems: [
|
||||
{
|
||||
text: 'All',
|
||||
text: this.$strings.LabelAll,
|
||||
value: 'all'
|
||||
},
|
||||
{
|
||||
text: 'Genre',
|
||||
text: this.$strings.LabelGenre,
|
||||
value: 'genres',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Tag',
|
||||
text: this.$strings.LabelTag,
|
||||
value: 'tags',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Series',
|
||||
text: this.$strings.LabelSeries,
|
||||
value: 'series',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Authors',
|
||||
text: this.$strings.LabelAuthor,
|
||||
value: 'authors',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Narrator',
|
||||
text: this.$strings.LabelNarrator,
|
||||
value: 'narrators',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Language',
|
||||
text: this.$strings.LabelLanguage,
|
||||
value: 'languages',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Progress',
|
||||
text: this.$strings.LabelProgress,
|
||||
value: 'progress',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Issues',
|
||||
text: this.$strings.LabelEbooks,
|
||||
value: 'ebooks',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.ButtonIssues,
|
||||
value: 'issues',
|
||||
sublist: false
|
||||
}
|
||||
],
|
||||
podcastItems: [
|
||||
{
|
||||
text: 'All',
|
||||
text: this.$strings.LabelAll,
|
||||
value: 'all'
|
||||
},
|
||||
{
|
||||
text: 'Genre',
|
||||
text: this.$strings.LabelGenre,
|
||||
value: 'genres',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Tag',
|
||||
text: this.$strings.LabelTag,
|
||||
value: 'tags',
|
||||
sublist: true
|
||||
}
|
||||
@@ -176,24 +181,36 @@ export default {
|
||||
return [
|
||||
{
|
||||
id: 'finished',
|
||||
name: 'Finished'
|
||||
name: this.$strings.LabelFinished
|
||||
},
|
||||
{
|
||||
id: 'in-progress',
|
||||
name: 'In Progress'
|
||||
name: this.$strings.LabelInProgress
|
||||
},
|
||||
{
|
||||
id: 'not-started',
|
||||
name: 'Not Started'
|
||||
name: this.$strings.LabelNotStarted
|
||||
},
|
||||
{
|
||||
id: 'not-finished',
|
||||
name: 'Not Finished'
|
||||
name: this.$strings.LabelNotFinished
|
||||
}
|
||||
]
|
||||
},
|
||||
ebooks() {
|
||||
return [
|
||||
{
|
||||
id: 'ebook',
|
||||
name: this.$strings.LabelHasEbook
|
||||
},
|
||||
{
|
||||
id: 'supplementary',
|
||||
name: this.$strings.LabelHasSupplementaryEbook
|
||||
}
|
||||
]
|
||||
},
|
||||
sublistItems() {
|
||||
return (this[this.sublist] || []).map((item) => {
|
||||
const sublistItems = (this[this.sublist] || []).map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
return {
|
||||
text: item,
|
||||
@@ -206,6 +223,13 @@ export default {
|
||||
}
|
||||
}
|
||||
})
|
||||
if (this.sublist === 'series') {
|
||||
sublistItems.unshift({
|
||||
text: this.$strings.MessageNoSeries,
|
||||
value: this.$encode('no-series')
|
||||
})
|
||||
}
|
||||
return sublistItems
|
||||
},
|
||||
filterData() {
|
||||
return this.$store.state.libraries.filterData || {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="modal modal-bg w-screen fixed bottom-0 left-0 flex items-center justify-center z-50" :class="halfScreen ? 'h-[50vh]' : 'h-screen'" @click.stop @touchstart.stop @touchend.stop>
|
||||
<div ref="content" class="relative text-white h-full w-full bg-bg">
|
||||
<div ref="wrapper" class="modal modal-bg w-screen fixed bottom-0 left-0 flex items-center justify-center z-50" :class="halfScreen ? 'h-[50vh] min-h-[400px]' : 'h-screen'" @click.stop @touchstart.stop @touchend.stop>
|
||||
<div ref="content" class="relative text-fg h-full w-full bg-bg">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="400" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-8 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Details</p>
|
||||
<div class="absolute top-11 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">{{ $strings.HeaderDetails }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20 p-2" style="max-height: 75%" @click.stop>
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-border p-2" style="max-height: 75%" @click.stop>
|
||||
<p class="mb-2">{{ mediaMetadata.title }}</p>
|
||||
|
||||
<div v-if="size" class="text-sm mb-2">Size: {{ $bytesPretty(size) }}</div>
|
||||
<div v-if="size" class="text-sm mb-2">{{ $strings.LabelSize }}: {{ $bytesPretty(size) }}</div>
|
||||
|
||||
<p class="mb-1 text-xs text-gray-200">ID: {{ _libraryItem.id }}</p>
|
||||
<p class="mb-1 text-xs text-fg">ID: {{ _libraryItem.id }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<modals-dialog v-model="show" :items="moreMenuItems" @action="moreMenuAction" />
|
||||
<modals-item-details-modal v-model="showDetailsModal" :library-item="libraryItem" />
|
||||
<modals-dialog v-model="showSendEbookDevicesModal" title="Select a device" :items="ereaderDeviceItems" @action="sendEbookToDeviceAction" />
|
||||
<modals-dialog v-model="showSendEbookDevicesModal" :title="$strings.LabelSelectADevice" :items="ereaderDeviceItems" @action="sendEbookToDeviceAction" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -26,6 +26,10 @@ export default {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
playlist: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
hideRssFeedOption: Boolean
|
||||
},
|
||||
data() {
|
||||
@@ -52,7 +56,7 @@ export default {
|
||||
// TODO: Implement on iOS
|
||||
if (this.$platform !== 'ios' && !this.isPodcast) {
|
||||
items.push({
|
||||
text: 'History',
|
||||
text: this.$strings.ButtonHistory,
|
||||
value: 'history',
|
||||
icon: 'history'
|
||||
})
|
||||
@@ -61,7 +65,7 @@ export default {
|
||||
if (!this.isPodcast || this.episode) {
|
||||
if (!this.userIsFinished) {
|
||||
items.push({
|
||||
text: 'Mark as Finished',
|
||||
text: this.$strings.MessageMarkAsFinished,
|
||||
value: 'markFinished',
|
||||
icon: 'beenhere'
|
||||
})
|
||||
@@ -69,7 +73,7 @@ export default {
|
||||
|
||||
if (this.progressPercent > 0) {
|
||||
items.push({
|
||||
text: 'Discard Progress',
|
||||
text: this.$strings.MessageDiscardProgress,
|
||||
value: 'discardProgress',
|
||||
icon: 'backspace'
|
||||
})
|
||||
@@ -78,23 +82,32 @@ export default {
|
||||
|
||||
if ((!this.isPodcast && this.serverLibraryItemId) || (this.episode && this.serverEpisodeId)) {
|
||||
items.push({
|
||||
text: 'Add to Playlist',
|
||||
text: this.$strings.LabelAddToPlaylist,
|
||||
value: 'playlist',
|
||||
icon: 'playlist_add'
|
||||
})
|
||||
|
||||
if (this.ereaderDeviceItems.length) {
|
||||
items.push({
|
||||
text: 'Send ebook to device',
|
||||
text: this.$strings.ButtonSendEbookToDevice,
|
||||
value: 'sendEbook',
|
||||
icon: 'send'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// If on playlist page show remove from playlist option
|
||||
if (this.playlist) {
|
||||
items.push({
|
||||
text: this.$strings.LabelRemoveFromPlaylist,
|
||||
value: 'removeFromPlaylist',
|
||||
icon: 'playlist_remove'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.showRSSFeedOption) {
|
||||
items.push({
|
||||
text: this.rssFeed ? 'RSS Feed' : 'Open RSS Feed',
|
||||
text: this.rssFeed ? this.$strings.HeaderRSSFeed : this.$strings.HeaderOpenRSSFeed,
|
||||
value: 'rssFeed',
|
||||
icon: 'rss_feed'
|
||||
})
|
||||
@@ -102,29 +115,37 @@ export default {
|
||||
|
||||
if (this.localLibraryItemId) {
|
||||
items.push({
|
||||
text: 'Manage Local Files',
|
||||
text: this.$strings.ButtonManageLocalFiles,
|
||||
value: 'manageLocal',
|
||||
icon: 'folder'
|
||||
})
|
||||
|
||||
if (!this.isPodcast) {
|
||||
items.push({
|
||||
text: 'Delete Local Item',
|
||||
text: this.$strings.ButtonDeleteLocalItem,
|
||||
value: 'deleteLocal',
|
||||
icon: 'delete'
|
||||
})
|
||||
} else if (this.localEpisodeId) {
|
||||
items.push({
|
||||
text: 'Delete Local Episode',
|
||||
text: this.$strings.ButtonDeleteLocalEpisode,
|
||||
value: 'deleteLocalEpisode',
|
||||
icon: 'delete'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isConnectedToServer) {
|
||||
items.push({
|
||||
text: this.$strings.ButtonGoToWebClient,
|
||||
value: 'openWebClient',
|
||||
icon: 'language'
|
||||
})
|
||||
}
|
||||
|
||||
if (!this.episode) {
|
||||
items.push({
|
||||
text: 'More Info',
|
||||
text: this.$strings.LabelMoreInfo,
|
||||
value: 'details',
|
||||
icon: 'info'
|
||||
})
|
||||
@@ -165,7 +186,7 @@ export default {
|
||||
},
|
||||
localEpisode() {
|
||||
if (this.isLocal) return this.episode
|
||||
return this.episode.localEpisode
|
||||
return this.episode?.localEpisode
|
||||
},
|
||||
localEpisodeId() {
|
||||
return this.localEpisode?.id || null
|
||||
@@ -255,6 +276,8 @@ export default {
|
||||
} else if (action === 'playlist') {
|
||||
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.episode }])
|
||||
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
|
||||
} else if (action === 'removeFromPlaylist') {
|
||||
this.removeFromPlaylistClick()
|
||||
} else if (action === 'markFinished') {
|
||||
if (this.episode) this.toggleEpisodeFinished()
|
||||
else this.toggleFinished()
|
||||
@@ -270,6 +293,8 @@ export default {
|
||||
this.clickRSSFeed()
|
||||
} else if (action === 'sendEbook') {
|
||||
this.showSendEbookDevicesModal = true
|
||||
} else if (action === 'openWebClient') {
|
||||
this.$store.dispatch('user/openWebClient', `/item/${this.serverLibraryItemId}`)
|
||||
}
|
||||
},
|
||||
async toggleFinished() {
|
||||
@@ -279,7 +304,7 @@ export default {
|
||||
if (this.userItemProgress && this.userItemProgress.progress > 0 && !this.userIsFinished) {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: 'Are you sure you want to mark this item as Finished?'
|
||||
message: this.$strings.MessageConfirmMarkAsFinished
|
||||
})
|
||||
if (!value) return
|
||||
}
|
||||
@@ -304,7 +329,7 @@ export default {
|
||||
}
|
||||
await this.$nativeHttp.patch(`/api/me/progress/${this.serverLibraryItemId}`, updatePayload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
this.$toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)
|
||||
})
|
||||
}
|
||||
this.$emit('update:processing', false)
|
||||
@@ -335,7 +360,7 @@ export default {
|
||||
}
|
||||
await this.$nativeHttp.patch(`/api/me/progress/${this.serverLibraryItemId}/${this.serverEpisodeId}`, updatePayload).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
this.$toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)
|
||||
})
|
||||
}
|
||||
this.$emit('update:processing', false)
|
||||
@@ -345,7 +370,7 @@ export default {
|
||||
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: 'Are you sure you want to reset your progress?'
|
||||
message: this.$strings.MessageConfirmDiscardProgress
|
||||
})
|
||||
if (value) {
|
||||
this.$emit('update:processing', true)
|
||||
@@ -381,18 +406,13 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
let confirmMessage = `Remove local episode "${localFile.basePath}" from your device?`
|
||||
if (this.serverLibraryItemId) {
|
||||
confirmMessage += ' The file on the server will be unaffected.'
|
||||
}
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: confirmMessage
|
||||
message: this.$getString('MessageConfirmDeleteLocalEpisode', [localFile.basePath])
|
||||
})
|
||||
if (value) {
|
||||
const res = await AbsFileSystem.deleteTrackFromItem({ id: this.localLibraryItemId, trackLocalFileId: localFile.id, trackContentUrl: localEpisodeAudioTrack.contentUrl })
|
||||
if (res?.id) {
|
||||
this.$toast.success('Deleted episode successfully')
|
||||
if (this.isLocal) {
|
||||
// If this is local episode then redirect to server episode when available
|
||||
if (this.serverEpisodeId) {
|
||||
@@ -411,18 +431,13 @@ export default {
|
||||
async deleteLocalItem() {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
let confirmMessage = 'Remove local files of this item from your device?'
|
||||
if (this.serverLibraryItemId) {
|
||||
confirmMessage += ' The files on the server and your progress will be unaffected.'
|
||||
}
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: confirmMessage
|
||||
message: this.$strings.MessageConfirmDeleteLocalFiles
|
||||
})
|
||||
if (value) {
|
||||
const res = await AbsFileSystem.deleteItem(this.localLibraryItem)
|
||||
if (res?.success) {
|
||||
this.$toast.success('Deleted successfully')
|
||||
if (this.isLocal) {
|
||||
// If local then redirect to server version when available
|
||||
if (this.serverLibraryItemId) {
|
||||
@@ -466,6 +481,29 @@ export default {
|
||||
.finally(() => {
|
||||
this.$emit('update:processing', false)
|
||||
})
|
||||
},
|
||||
removeFromPlaylistClick() {
|
||||
if (!this.playlist) {
|
||||
this.$toast.error('Invalid: No Playlist')
|
||||
return
|
||||
}
|
||||
|
||||
this.$emit('update:processing', true)
|
||||
let url = `/api/playlists/${this.playlist.id}/item/${this.serverLibraryItemId}`
|
||||
if (this.serverEpisodeId) url += `/${this.serverEpisodeId}`
|
||||
this.$nativeHttp
|
||||
.delete(url)
|
||||
.then(() => {
|
||||
this.$toast.success('Item removed from playlist')
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMsg = error.response?.data || 'Unknown error'
|
||||
console.error('Failed to remove item from playlist', error)
|
||||
this.$toast.error('Failed to remove from playlist: ' + errorMsg)
|
||||
})
|
||||
.finally(() => {
|
||||
this.$emit('update:processing', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="300" :processing="processing" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-8 left-4 z-40" style="max-width: 80%">
|
||||
<p class="text-white text-2xl truncate">Libraries</p>
|
||||
<div class="absolute top-11 left-4 z-40" style="max-width: 80%">
|
||||
<p class="text-white text-2xl truncate">{{ $strings.HeaderLibraries }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-secondary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-secondary rounded-lg border border-border" style="max-height: 75%" @click.stop>
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="library in libraries">
|
||||
<li :key="library.id" class="text-gray-50 select-none relative py-3 cursor-pointer" :class="currentLibraryId === library.id ? 'bg-primary bg-opacity-80' : ''" role="option" @click="clickedOption(library)">
|
||||
<li :key="library.id" class="text-fg select-none relative py-3 cursor-pointer" :class="currentLibraryId === library.id ? 'bg-primary bg-opacity-80' : ''" role="option" @click="clickedOption(library)">
|
||||
<div v-show="currentLibraryId === library.id" class="absolute top-0 left-0 w-0.5 bg-warning h-full" />
|
||||
<div class="flex items-center px-3">
|
||||
<ui-library-icon :icon="library.icon" />
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<div ref="wrapper" class="modal modal-bg w-full h-full max-h-screen fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-50 opacity-0">
|
||||
<div class="absolute top-0 left-0 w-full h-40 bg-gradient-to-b from-black to-transparent opacity-90 pointer-events-none" />
|
||||
|
||||
<div class="absolute z-40 top-10 right-4 h-10 w-10 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
|
||||
<div class="absolute z-40 top-11 right-4 h-10 w-10 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
|
||||
<span class="material-icons text-4xl">close</span>
|
||||
</div>
|
||||
<slot name="outer" />
|
||||
<div ref="content" style="min-height: 200px" class="relative text-white max-h-screen" :style="{ height: modalHeight, width: modalWidth, maxWidth: maxWidth }" v-click-outside="clickBg">
|
||||
<div ref="content" style="min-height: 200px" class="relative text-fg max-h-screen" :style="{ height: modalHeight, width: modalWidth, maxWidth: maxWidth }" v-click-outside="clickBg">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" width="90%">
|
||||
<div class="w-full h-full bg-primary rounded-lg border border-white border-opacity-20">
|
||||
<div class="w-full h-full bg-primary rounded-lg border border-fg/20">
|
||||
<ul class="w-full rounded-lg text-base" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative py-4 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
||||
<li :key="item.value" class="text-fg select-none relative py-4 pr-9 cursor-pointer" :class="item.value === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate text-lg">{{ item.text }}</span>
|
||||
</div>
|
||||
@@ -29,83 +29,91 @@ export default {
|
||||
return {
|
||||
bookItems: [
|
||||
{
|
||||
text: 'Title',
|
||||
text: this.$strings.LabelTitle,
|
||||
value: 'media.metadata.title'
|
||||
},
|
||||
{
|
||||
text: 'Author (First Last)',
|
||||
text: this.$strings.LabelAuthorFirstLast,
|
||||
value: 'media.metadata.authorName'
|
||||
},
|
||||
{
|
||||
text: 'Author (Last, First)',
|
||||
text: this.$strings.LabelAuthorLastFirst,
|
||||
value: 'media.metadata.authorNameLF'
|
||||
},
|
||||
{
|
||||
text: 'Published Year',
|
||||
text: this.$strings.LabelPublishYear,
|
||||
value: 'media.metadata.publishedYear'
|
||||
},
|
||||
{
|
||||
text: 'Added At',
|
||||
text: this.$strings.LabelAddedAt,
|
||||
value: 'addedAt'
|
||||
},
|
||||
{
|
||||
text: 'Size',
|
||||
text: this.$strings.LabelSize,
|
||||
value: 'size'
|
||||
},
|
||||
{
|
||||
text: 'Duration',
|
||||
text: this.$strings.LabelDuration,
|
||||
value: 'media.duration'
|
||||
},
|
||||
{
|
||||
text: 'File Birthtime',
|
||||
text: this.$strings.LabelFileBirthtime,
|
||||
value: 'birthtimeMs'
|
||||
},
|
||||
{
|
||||
text: 'File Modified',
|
||||
text: this.$strings.LabelFileModified,
|
||||
value: 'mtimeMs'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelRandomly,
|
||||
value: 'random'
|
||||
}
|
||||
],
|
||||
podcastItems: [
|
||||
{
|
||||
text: 'Title',
|
||||
text: this.$strings.LabelTitle,
|
||||
value: 'media.metadata.title'
|
||||
},
|
||||
{
|
||||
text: 'Author',
|
||||
text: this.$strings.LabelAuthor,
|
||||
value: 'media.metadata.author'
|
||||
},
|
||||
{
|
||||
text: 'Added At',
|
||||
text: this.$strings.LabelAddedAt,
|
||||
value: 'addedAt'
|
||||
},
|
||||
{
|
||||
text: 'Size',
|
||||
text: this.$strings.LabelSize,
|
||||
value: 'size'
|
||||
},
|
||||
{
|
||||
text: 'File Birthtime',
|
||||
text: this.$strings.LabelFileBirthtime,
|
||||
value: 'birthtimeMs'
|
||||
},
|
||||
{
|
||||
text: 'File Modified',
|
||||
text: this.$strings.LabelFileModified,
|
||||
value: 'mtimeMs'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelRandomly,
|
||||
value: 'random'
|
||||
}
|
||||
],
|
||||
episodeItems: [
|
||||
{
|
||||
text: 'Pub Date',
|
||||
text: this.$strings.LabelPubDate,
|
||||
value: 'publishedAt'
|
||||
},
|
||||
{
|
||||
text: 'Title',
|
||||
text: this.$strings.LabelTitle,
|
||||
value: 'title'
|
||||
},
|
||||
{
|
||||
text: 'Season',
|
||||
text: this.$strings.LabelSeason,
|
||||
value: 'season'
|
||||
},
|
||||
{
|
||||
text: 'Episode',
|
||||
text: this.$strings.LabelEpisode,
|
||||
value: 'episode'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2,29 +2,29 @@
|
||||
<modals-modal v-model="show" @input="modalInput" :width="200" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-8 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Playback Speed</p>
|
||||
<p class="text-white text-2xl truncate">{{ $strings.LabelPlaybackSpeed }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center">
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-border" style="max-height: 75%" @click.stop>
|
||||
<ul class="w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="rate in rates">
|
||||
<li :key="rate" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="rate === selected ? 'bg-bg bg-opacity-80' : ''" role="option" @click="clickedOption(rate)">
|
||||
<li :key="rate" class="text-fg select-none relative py-4" :class="rate === selected ? 'bg-bg-hover/50' : ''" role="option" @click="clickedOption(rate)">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate text-lg">{{ rate }}x</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<div class="flex items-center justify-center py-3 border-t border-white border-opacity-10">
|
||||
<button :disabled="!canDecrement" @click="decrement" class="icon-num-btn w-8 h-8 text-white text-opacity-75 rounded border border-white border-opacity-20 flex items-center justify-center">
|
||||
<div class="flex items-center justify-center py-3 border-t border-fg/10">
|
||||
<button :disabled="!canDecrement" @click="decrement" class="icon-num-btn w-8 h-8 text-fg-muted rounded border border-border flex items-center justify-center">
|
||||
<span class="material-icons">remove</span>
|
||||
</button>
|
||||
<div class="w-24 text-center">
|
||||
<p class="text-xl">{{ playbackRate }}<span class="text-lg">⨯</span></p>
|
||||
</div>
|
||||
<button :disabled="!canIncrement" @click="increment" class="icon-num-btn w-8 h-8 text-white text-opacity-75 rounded border border-white border-opacity-20 flex items-center justify-center">
|
||||
<button :disabled="!canIncrement" @click="increment" class="icon-num-btn w-8 h-8 text-fg-muted rounded border border-border flex items-center justify-center">
|
||||
<span class="material-icons">add</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -71,7 +71,7 @@ export default {
|
||||
}
|
||||
},
|
||||
rates() {
|
||||
return [0.5, 1, 1.2, 1.5, 2, 3]
|
||||
return [0.5, 1, 1.2, 1.5, 1.7, 2, 3]
|
||||
},
|
||||
canIncrement() {
|
||||
return this.playbackRate + 0.1 <= this.MAX_SPEED
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<modals-modal v-model="show" :width="300" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-10 left-4 z-40" style="max-width: 80%">
|
||||
<p class="text-white text-lg truncate">Select Download Location</p>
|
||||
<p class="text-white text-lg truncate">{{ $strings.HeaderSelectDownloadLocation }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -71,7 +71,7 @@ export default {
|
||||
if (!localFolders.some((lf) => lf.id === `internal-${this.mediaType}`)) {
|
||||
localFolders.push({
|
||||
id: `internal-${this.mediaType}`,
|
||||
name: 'Internal App Storage',
|
||||
name: this.$strings.LabelInternalAppStorage,
|
||||
mediaType: this.mediaType
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<modals-modal v-model="show" :width="200" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-8 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Sleep Timer</p>
|
||||
<p class="text-white text-2xl truncate">{{ $strings.HeaderSleepTimer }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -23,24 +23,24 @@
|
||||
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
|
||||
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
|
||||
</div>
|
||||
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">Set Timer</ui-btn>
|
||||
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">{{ $strings.ButtonSetTimer }}</ui-btn>
|
||||
</div>
|
||||
<ul v-else class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="timeout in timeouts">
|
||||
<li :key="timeout" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(timeout)">
|
||||
<li :key="timeout" class="text-fg select-none relative py-4" role="option" @click="clickedOption(timeout)">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate text-lg">{{ timeout }} min</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedChapterOption">
|
||||
<li class="text-fg select-none relative py-4" role="option" @click="clickedChapterOption">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate text-lg text-center">End of Chapter</span>
|
||||
<span class="font-normal block truncate text-lg text-center">{{ $strings.LabelEndOfChapter }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="manualTimerModal = true">
|
||||
<li class="text-fg select-none relative py-4" role="option" @click="manualTimerModal = true">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate text-lg text-center">Custom time</span>
|
||||
<span class="font-normal block truncate text-lg text-center">{{ $strings.LabelCustomTime }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -2,18 +2,12 @@
|
||||
<modals-modal v-model="show" :width="200" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-8 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Sleep Timer</p>
|
||||
<p class="text-white text-2xl truncate">{{ $strings.HeaderSleepTimer }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center"
|
||||
@click="
|
||||
show = false
|
||||
manualTimerModal = false
|
||||
"
|
||||
>
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-border" style="max-height: 75%" @click.stop>
|
||||
<div v-if="manualTimerModal" class="p-4">
|
||||
<div class="flex mb-4" @click="manualTimerModal = false">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
@@ -23,24 +17,24 @@
|
||||
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
|
||||
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
|
||||
</div>
|
||||
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">Set Timer</ui-btn>
|
||||
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">{{ $strings.ButtonSetTimer }}</ui-btn>
|
||||
</div>
|
||||
<ul v-else-if="!sleepTimerRunning" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="timeout in timeouts">
|
||||
<li :key="timeout" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(timeout)">
|
||||
<li :key="timeout" class="text-fg select-none relative py-4" role="option" @click="clickedOption(timeout)">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate text-lg">{{ timeout }} min</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
<li v-if="currentEndOfChapterTime" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedChapterOption(timeout)">
|
||||
<li v-if="currentEndOfChapterTime" class="text-fg select-none relative py-4" role="option" @click="clickedChapterOption(timeout)">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate text-lg text-center">End of Chapter</span>
|
||||
<span class="font-normal block truncate text-lg text-center">{{ $strings.LabelEndOfChapter }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="manualTimerModal = true">
|
||||
<li class="text-fg select-none relative py-4" role="option" @click="manualTimerModal = true">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate text-lg text-center">Custom time</span>
|
||||
<span class="font-normal block truncate text-lg text-center">{{ $strings.LabelCustomTime }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -51,7 +45,7 @@
|
||||
<ui-btn @click="increaseSleepTime" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
|
||||
</div>
|
||||
|
||||
<ui-btn @click="cancelSleepTimer" class="w-full">{{ isAuto ? 'Disable Auto Timer' : 'Cancel Timer' }}</ui-btn>
|
||||
<ui-btn @click="cancelSleepTimer" class="w-full">{{ isAuto ? $strings.ButtonDisableAutoTimer : $strings.ButtonCancelTimer }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,7 +65,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
manualTimerModal: null,
|
||||
manualTimerModal: false,
|
||||
manualTimeoutMin: 1
|
||||
}
|
||||
},
|
||||
@@ -81,6 +75,9 @@ export default {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
if (!val) {
|
||||
this.manualTimerModal = false
|
||||
}
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<div :key="bookmark.id" :id="`bookmark-row-${bookmark.id}`" class="flex items-center px-1 py-4 justify-start cursor-pointer hover:bg-bg relative" :class="highlight ? 'bg-bg bg-opacity-60' : ' bg-opacity-20'" @click="click">
|
||||
<div :key="bookmark.id" :id="`bookmark-row-${bookmark.id}`" class="flex items-center px-1 py-4 justify-start relative" :class="highlight ? 'bg-bg bg-opacity-60' : ' bg-opacity-20'" @click="click">
|
||||
<div class="flex-grow overflow-hidden px-2">
|
||||
<div class="flex items-center mb-0.5">
|
||||
<i class="material-icons text-lg pr-1 -mb-1" :class="highlight ? 'text-success' : 'text-white/60'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</i>
|
||||
<i class="material-icons text-lg pr-1 -mb-1" :class="highlight ? 'text-success' : 'text-fg-muted'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</i>
|
||||
<p class="truncate text-sm">
|
||||
{{ bookmark.title }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm font-mono text-gray-300 flex items-center"><span class="material-icons text-base text-white/60 pl-px pr-1">schedule</span>{{ $secondsToTimestamp(bookmark.time) }}</p>
|
||||
<p class="text-sm font-mono text-fg-muted flex items-center"><span class="material-icons text-base pl-px pr-1">schedule</span>{{ $secondsToTimestamp(bookmark.time) }}</p>
|
||||
</div>
|
||||
<div class="h-full flex items-center justify-end transform w-16 pr-2" @click.stop>
|
||||
<span class="material-icons text-2xl mr-2 text-gray-200 hover:text-yellow-400" @click.stop="editClick">edit</span>
|
||||
<span class="material-icons text-2xl text-gray-200 hover:text-error cursor-pointer" @click.stop="deleteClick">delete</span>
|
||||
<span class="material-icons text-2xl mr-2 text-fg hover:text-yellow-400" @click.stop="editClick">edit</span>
|
||||
<span class="material-icons text-2xl text-fg hover:text-error" @click.stop="deleteClick">delete</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
<template>
|
||||
<modals-fullscreen-modal v-model="show" :processing="processing">
|
||||
<div class="flex items-end justify-between h-16 px-4 pb-2">
|
||||
<h1 class="text-lg">Add to Playlist</h1>
|
||||
<div class="flex items-end justify-between h-24 px-4 pb-2">
|
||||
<h1 class="text-lg">{{ $strings.LabelAddToPlaylist }}</h1>
|
||||
<button class="flex" @click="show = false">
|
||||
<span class="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- create new playlist form -->
|
||||
<div v-if="showPlaylistNameInput" class="w-full h-full max-h-[calc(100vh-128px)] flex items-center">
|
||||
<div v-if="showPlaylistNameInput" class="w-full h-full max-h-[calc(100vh-176px)] flex items-center">
|
||||
<div class="w-full px-4">
|
||||
<div class="flex mb-4 items-center">
|
||||
<div class="w-9 h-9 flex items-center justify-center rounded-full hover:bg-white hover:bg-opacity-10 cursor-pointer" @click.stop="showPlaylistNameInput = false">
|
||||
<div class="w-9 h-9 flex items-center justify-center rounded-full cursor-pointer" @click.stop="showPlaylistNameInput = false">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<p class="text-xl pl-2">New Playlist</p>
|
||||
<p class="text-xl pl-2 leading-none">{{ $strings.HeaderNewPlaylist }}</p>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
|
||||
<ui-text-input-with-label v-model="newPlaylistName" label="Name" />
|
||||
<ui-text-input-with-label v-model="newPlaylistName" :label="$strings.LabelName" />
|
||||
<div class="flex justify-end mt-6">
|
||||
<ui-btn color="success" :loading="processing" class="w-full" @click.stop="submitCreatePlaylist">Create</ui-btn>
|
||||
<ui-btn color="success" :loading="processing" class="w-full" @click.stop="submitCreatePlaylist">{{ $strings.ButtonCreate }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- playlists list -->
|
||||
<div v-if="!showPlaylistNameInput" class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(100vh-128px)]">
|
||||
<div v-if="!showPlaylistNameInput" class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(100vh-176px)]">
|
||||
<div class="w-full h-full" v-show="!showPlaylistNameInput">
|
||||
<template v-for="playlist in sortedPlaylists">
|
||||
<modals-playlists-playlist-row :key="playlist.id" :in-playlist="playlist.isItemIncluded" :playlist="playlist" @click="clickPlaylist" @close="show = false" />
|
||||
</template>
|
||||
<div v-if="!playlists.length" class="flex h-full items-center justify-center">
|
||||
<p class="text-xl">{{ loading ? 'Loading..' : 'No Playlists' }}</p>
|
||||
<p class="text-xl">{{ loading ? $strings.MessageLoading : $strings.MessageNoUserPlaylists }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- create playlist btn -->
|
||||
<div v-if="!showPlaylistNameInput" class="flex items-start justify-between h-16 pt-2 absolute bottom-0 left-0 w-full">
|
||||
<ui-btn :loading="processing" color="success" class="w-full h-full flex items-center justify-center" @click.stop="createPlaylist">
|
||||
<p class="text-base">Create New Playlist</p>
|
||||
<div v-if="!showPlaylistNameInput" class="flex items-start justify-between h-20 pt-2 absolute bottom-0 left-0 w-full">
|
||||
<ui-btn :loading="processing" color="success" class="w-full h-14 flex items-center justify-center" @click.stop="createPlaylist">
|
||||
<p class="text-base">{{ $strings.ButtonCreateNewPlaylist }}</p>
|
||||
</ui-btn>
|
||||
</div>
|
||||
</modals-fullscreen-modal>
|
||||
@@ -193,8 +193,7 @@ export default {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to create playlist', error)
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
this.$toast.error(`Failed to create playlist: ${errMsg}`)
|
||||
this.$toast.error(this.$strings.ToastPlaylistCreateFailed)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
<p class="px-2 truncate text-sm">{{ playlist.name }}</p>
|
||||
</div>
|
||||
<div class="w-24 min-w-[96px] px-1">
|
||||
<ui-btn v-if="inPlaylist" small class="w-full" @click.stop="click">Remove</ui-btn>
|
||||
<ui-btn v-else small class="w-full" @click.stop="click">Add</ui-btn>
|
||||
<ui-btn v-if="inPlaylist" small class="w-full" @click.stop="click">{{ $strings.ButtonRemove }}</ui-btn>
|
||||
<ui-btn v-else small class="w-full" @click.stop="click">{{ $strings.ButtonAdd }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<template>
|
||||
<div class="w-full py-2 text-sm">
|
||||
<div class="flex -mb-px">
|
||||
<div class="w-1/2 h-6 rounded-tl-md relative border border-white/10 flex items-center justify-center cursor-pointer" :class="!showAdvancedView ? 'text-white bg-bg border-b-bg' : 'text-gray-400 bg-primary bg-opacity-70'" @click="showAdvancedView = false">
|
||||
<div class="w-1/2 h-6 rounded-tl-md relative border border-fg/10 flex items-center justify-center cursor-pointer" :class="!showAdvancedView ? 'text-fg bg-bg border-b-bg' : 'text-fg-muted bg-primary bg-opacity-70'" @click="showAdvancedView = false">
|
||||
<p class="text-sm">{{ $strings.HeaderRSSFeedGeneral }}</p>
|
||||
</div>
|
||||
<div class="w-1/2 h-6 rounded-tr-md relative border border-white/10 flex items-center justify-center -ml-px cursor-pointer" :class="showAdvancedView ? 'text-white bg-bg border-b-bg' : 'text-gray-400 bg-primary bg-opacity-70'" @click="showAdvancedView = true">
|
||||
<div class="w-1/2 h-6 rounded-tr-md relative border border-fg/10 flex items-center justify-center -ml-px cursor-pointer" :class="showAdvancedView ? 'text-fg bg-bg border-b-bg' : 'text-fg-muted bg-primary bg-opacity-70'" @click="showAdvancedView = true">
|
||||
<p class="text-sm">{{ $strings.HeaderAdvanced }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2 py-4 md:p-4 border border-white/10 rounded-b-md mr-px" style="min-height: 220px">
|
||||
<div class="px-2 py-4 md:p-4 border border-fg/10 rounded-b-md mr-px" style="min-height: 220px">
|
||||
<template v-if="!showAdvancedView">
|
||||
<div class="flex-grow pt-2 mb-2">
|
||||
<ui-checkbox v-model="preventIndexing" :label="$strings.LabelPreventIndexing" checkbox-bg="primary" border-color="gray-600" label-class="pl-2" />
|
||||
<ui-checkbox v-model="preventIndexing" :label="$strings.LabelPreventIndexing" checkbox-bg="primary" border-color="border" label-class="pl-2" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex-grow pt-2 mb-2">
|
||||
<ui-checkbox v-model="preventIndexing" :label="$strings.LabelPreventIndexing" checkbox-bg="primary" border-color="gray-600" label-class="pl-2" />
|
||||
<ui-checkbox v-model="preventIndexing" :label="$strings.LabelPreventIndexing" checkbox-bg="primary" border-color="border" label-class="pl-2" />
|
||||
</div>
|
||||
<div class="w-full relative mb-1">
|
||||
<ui-text-input-with-label v-model="ownerName" :label="$strings.LabelRSSFeedCustomOwnerName" />
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
<template>
|
||||
<modals-fullscreen-modal v-model="show" :processing="processing">
|
||||
<div class="flex items-end justify-end h-16 pr-4 pb-2 mb-2">
|
||||
<div class="flex items-end justify-end h-24 pr-4 pb-2">
|
||||
<!-- <h1 class="text-lg">RSS Feed</h1> -->
|
||||
<button class="flex" @click="show = false">
|
||||
<span class="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="w-full px-2 h-[calc(100%-138px)] overflow-y-auto">
|
||||
<div class="w-full px-2 h-[calc(100%-176px)] overflow-y-auto">
|
||||
<div v-if="currentFeed" class="w-full">
|
||||
<div class="w-full relative">
|
||||
<h1 class="text-lg mb-4">RSS feed is open</h1>
|
||||
<h1 class="text-lg mb-4">{{ $strings.HeaderRSSFeedIsOpen }}</h1>
|
||||
|
||||
<ui-text-input v-model="currentFeed.feedUrl" class="text-sm" readonly />
|
||||
|
||||
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base" :class="linkCopied ? 'text-success' : 'text-gray-300'" @click="copyToClipboard(currentFeed.feedUrl)">{{ linkCopied ? 'done' : 'content_copy' }}</span>
|
||||
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base" :class="linkCopied ? 'text-success' : 'text-fg-muted'" @click="copyToClipboard(currentFeed.feedUrl)">{{ linkCopied ? 'done' : '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>
|
||||
<span class="text-fg-muted uppercase text-sm">{{ $strings.LabelRSSFeedPreventIndexing }}</span>
|
||||
</div>
|
||||
<div>{{ currentFeed.meta.preventIndexing ? 'Yes' : 'No' }}</div>
|
||||
<div>{{ currentFeed.meta.preventIndexing ? $strings.ButtonYes : $strings.LabelNo }}</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>
|
||||
<span class="text-fg-muted 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>
|
||||
<span class="text-fg-muted uppercase text-sm">{{ $strings.LabelRSSFeedCustomOwnerEmail }}</span>
|
||||
</div>
|
||||
<div>{{ currentFeed.meta.ownerEmail }}</div>
|
||||
</div>
|
||||
@@ -41,7 +41,7 @@
|
||||
<div v-else class="w-full">
|
||||
<div class="w-full relative mb-2">
|
||||
<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>
|
||||
<p class="text-xs text-fg-muted py-0.5 px-1">{{ $getString('MessageFeedURLWillBe', [demoFeedUrl]) }}</p>
|
||||
</div>
|
||||
<modals-rssfeeds-rss-feed-metadata-builder v-model="metadataDetails" />
|
||||
|
||||
@@ -50,10 +50,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="userIsAdminOrUp" class="flex items-center px-2 h-16">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="currentFeed" color="error" class="w-full" @click="closeFeed">{{ $strings.ButtonCloseFeed }}</ui-btn>
|
||||
<ui-btn v-else color="success" class="w-full" @click="openFeed">{{ $strings.ButtonOpenFeed }}</ui-btn>
|
||||
<div v-show="userIsAdminOrUp" class="flex items-start pt-2 h-20">
|
||||
<ui-btn v-if="currentFeed" color="error" class="w-full h-14" @click="closeFeed">{{ $strings.ButtonCloseFeed }}</ui-btn>
|
||||
<ui-btn v-else color="success" class="w-full h-14" @click="openFeed">{{ $strings.ButtonOpenFeed }}</ui-btn>
|
||||
</div>
|
||||
</modals-fullscreen-modal>
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div id="comic-reader" class="w-full h-full relative">
|
||||
<modals-modal v-model="showInfoMenu" height="90%">
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click.stop="showInfoMenu = false">
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-secondary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-bg rounded-lg border border-border text-fg" style="max-height: 75%" @click.stop>
|
||||
<div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
|
||||
<p class="text-xs">
|
||||
<strong>{{ key }}</strong>
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fixed left-0 h-8 w-full bg-primary px-4 flex items-center text-white/80" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
|
||||
<div class="fixed left-0 h-8 w-full bg-bg px-4 flex items-center text-fg-muted" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
|
||||
<div class="flex-grow" />
|
||||
<p class="text-xs">{{ page }} / {{ numPages }}</p>
|
||||
</div>
|
||||
@@ -349,7 +349,7 @@ export default {
|
||||
}
|
||||
},
|
||||
parseFilenames(filenames) {
|
||||
const acceptableImages = ['.jpeg', '.jpg', '.png']
|
||||
const acceptableImages = ['.jpeg', '.jpg', '.png', '.webp']
|
||||
var imageFiles = filenames.filter((f) => {
|
||||
return acceptableImages.includes((Path.extname(f) || '').toLowerCase())
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div id="epub-frame" class="w-full">
|
||||
<div id="viewer" class="h-full w-full"></div>
|
||||
|
||||
<div class="fixed left-0 h-8 w-full px-4 flex items-center" :class="isLightTheme ? 'bg-white text-black' : 'bg-primary text-white/80'" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
|
||||
<div class="fixed left-0 h-8 w-full px-4 flex items-center" :class="isLightTheme ? 'bg-white text-black' : 'bg-[#232323] text-white/80'" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
|
||||
<p v-if="totalLocations" class="text-xs text-slate-600">Location {{ currentLocationNum }} of {{ totalLocations }}</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="text-xs">{{ progress }}%</p>
|
||||
@@ -34,16 +34,18 @@ export default {
|
||||
currentLocationNum: 0,
|
||||
currentLocationCfi: null,
|
||||
inittingDisplay: true,
|
||||
isRefreshingUI: false,
|
||||
ereaderSettings: {
|
||||
theme: 'dark',
|
||||
fontScale: 100,
|
||||
lineSpacing: 115
|
||||
lineSpacing: 115,
|
||||
textStroke: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
isPlayerOpen() {
|
||||
this.updateHeight()
|
||||
this.refreshUI()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -112,7 +114,8 @@ export default {
|
||||
'*': {
|
||||
color: `${fontColor}!important`,
|
||||
'background-color': `${backgroundColor}!important`,
|
||||
'line-height': this.ereaderSettings.lineSpacing + '%!important'
|
||||
'line-height': this.ereaderSettings.lineSpacing + '%!important',
|
||||
'-webkit-text-stroke': this.ereaderSettings.textStroke/100 + 'px ' + fontColor + '!important'
|
||||
},
|
||||
a: {
|
||||
color: `${fontColor}!important`
|
||||
@@ -135,11 +138,6 @@ export default {
|
||||
goToChapter(href) {
|
||||
return this.rendition?.display(href)
|
||||
},
|
||||
updateHeight() {
|
||||
if (this.rendition && this.rendition.resize) {
|
||||
this.rendition.resize(window.innerWidth, window.innerHeight - this.readerHeightOffset)
|
||||
}
|
||||
},
|
||||
prev() {
|
||||
if (this.rendition) {
|
||||
this.rendition.prev()
|
||||
@@ -382,14 +380,54 @@ export default {
|
||||
this.rendition.getContents().forEach((c) => {
|
||||
c.addStylesheetRules(this.themeRules)
|
||||
})
|
||||
},
|
||||
async screenOrientationChange() {
|
||||
if (this.isRefreshingUI) return
|
||||
this.isRefreshingUI = true
|
||||
const windowWidth = window.innerWidth
|
||||
this.refreshUI()
|
||||
|
||||
// Window width does not always change right away. Wait up to 250ms for a change.
|
||||
// iPhone 10 on iOS 16 took between 100 - 200ms to update when going from portrait to landscape
|
||||
// but landscape to portrait was immediate
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
if (window.innerWidth !== windowWidth) {
|
||||
this.refreshUI()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
this.isRefreshingUI = false
|
||||
},
|
||||
refreshUI() {
|
||||
if (this.rendition?.resize) {
|
||||
this.rendition.resize(window.innerWidth, window.innerHeight - this.readerHeightOffset)
|
||||
}
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.book?.destroy()
|
||||
},
|
||||
mounted() {
|
||||
this.initEpub()
|
||||
}
|
||||
|
||||
if (screen.orientation) {
|
||||
// Not available on ios
|
||||
screen.orientation.addEventListener('change', this.screenOrientationChange)
|
||||
} else {
|
||||
document.addEventListener('orientationchange', this.screenOrientationChange)
|
||||
}
|
||||
window.addEventListener('resize', this.screenOrientationChange)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.book?.destroy()
|
||||
|
||||
if (screen.orientation) {
|
||||
// Not available on ios
|
||||
screen.orientation.removeEventListener('change', this.screenOrientationChange)
|
||||
} else {
|
||||
document.removeEventListener('orientationchange', this.screenOrientationChange)
|
||||
}
|
||||
window.removeEventListener('resize', this.screenOrientationChange)
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -404,4 +442,4 @@ export default {
|
||||
max-height: calc(100% - 132px);
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -14,11 +14,11 @@
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full 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 bg-white" :src="pdfDocInitParams" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event" @loaded="loadedEvt"></pdf>
|
||||
<pdf v-if="pdfDocInitParams" ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md bg-white" :src="pdfDocInitParams" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event" @loaded="loadedEvt"></pdf>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fixed left-0 h-8 w-full bg-primary px-4 flex items-center text-white/80" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
|
||||
<div class="fixed left-0 h-8 w-full bg-bg px-4 flex items-center text-fg-muted" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
|
||||
<div class="flex-grow" />
|
||||
<p class="text-xs">{{ page }} / {{ numPages }}</p>
|
||||
</div>
|
||||
@@ -48,7 +48,8 @@ export default {
|
||||
page: 1,
|
||||
numPages: 0,
|
||||
windowWidth: 0,
|
||||
windowHeight: 0
|
||||
windowHeight: 0,
|
||||
pdfDocInitParams: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -106,14 +107,6 @@ export default {
|
||||
if (!this.userItemProgress?.ebookLocation || isNaN(this.userItemProgress.ebookLocation)) return 0
|
||||
return Number(this.userItemProgress.ebookLocation)
|
||||
},
|
||||
pdfDocInitParams() {
|
||||
return {
|
||||
url: this.url,
|
||||
httpHeaders: {
|
||||
Authorization: `Bearer ${this.userToken}`
|
||||
}
|
||||
}
|
||||
},
|
||||
isPlayerOpen() {
|
||||
return this.$store.getters['getIsPlayerOpen']
|
||||
}
|
||||
@@ -157,6 +150,8 @@ export default {
|
||||
}
|
||||
},
|
||||
numPagesLoaded(e) {
|
||||
if (!e) return
|
||||
|
||||
this.numPages = e
|
||||
},
|
||||
prev() {
|
||||
@@ -175,6 +170,14 @@ export default {
|
||||
screenOrientationChange() {
|
||||
this.windowWidth = window.innerWidth
|
||||
this.windowHeight = window.innerHeight
|
||||
},
|
||||
init() {
|
||||
this.pdfDocInitParams = {
|
||||
url: this.url,
|
||||
httpHeaders: {
|
||||
Authorization: `Bearer ${this.userToken}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -188,6 +191,8 @@ export default {
|
||||
document.addEventListener('orientationchange', this.screenOrientationChange)
|
||||
}
|
||||
window.addEventListener('resize', this.screenOrientationChange)
|
||||
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (screen.orientation) {
|
||||
@@ -199,4 +204,4 @@ export default {
|
||||
window.removeEventListener('resize', this.screenOrientationChange)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
+142
-38
@@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<div v-if="show" :data-theme="ereaderTheme" class="group fixed top-0 left-0 right-0 layout-wrapper w-full z-40 pt-8 data-[theme=dark]:bg-primary data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': isPlayerOpen }">
|
||||
<div v-if="show" :data-theme="ereaderTheme" class="group fixed top-0 left-0 right-0 layout-wrapper w-full z-40 pt-8 data-[theme=dark]:bg-[#232323] data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': isPlayerOpen }">
|
||||
<!-- toolbar -->
|
||||
<div class="h-32 pt-10 w-full px-2 fixed top-0 left-0 z-30 transition-transform bg-bg text-white" :class="showingToolbar ? 'translate-y-0' : '-translate-y-32'" @touchstart.stop @mousedown.stop @touchend.stop @mouseup.stop>
|
||||
<div class="h-32 pt-10 w-full px-2 fixed top-0 left-0 z-30 transition-transform bg-bg text-fg" :class="showingToolbar ? 'translate-y-0' : '-translate-y-32'" :style="{ boxShadow: showingToolbar ? '0px 8px 8px #11111155' : '' }" @touchstart.stop @mousedown.stop @touchend.stop @mouseup.stop>
|
||||
<div class="flex items-center mb-2">
|
||||
<button type="button" class="inline-flex mx-2" @click.stop="show = false">
|
||||
<span class="material-icons-outlined text-3xl text-white">chevron_left</span>
|
||||
<span class="material-icons-outlined text-3xl text-fg">chevron_left</span>
|
||||
</button>
|
||||
<div class="flex-grow" />
|
||||
<button v-if="isComic || isEpub" type="button" class="inline-flex mx-2" @click.stop="clickTOCBtn">
|
||||
<span class="material-icons-outlined text-2xl text-white">format_list_bulleted</span>
|
||||
<span class="material-icons-outlined text-2xl text-fg">format_list_bulleted</span>
|
||||
</button>
|
||||
<button v-if="isEpub" type="button" class="inline-flex mx-2" @click.stop="clickSettingsBtn">
|
||||
<span class="material-icons text-2xl text-white">settings</span>
|
||||
<span class="material-icons text-2xl text-fg">settings</span>
|
||||
</button>
|
||||
<button v-if="comicHasMetadata" type="button" class="inline-flex mx-2" @click.stop="clickMetadataBtn">
|
||||
<span class="material-icons text-2xl text-white">more</span>
|
||||
<span class="material-icons text-2xl text-fg">more</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<!-- table of contents modal -->
|
||||
<modals-fullscreen-modal v-model="showTOCModal" :theme="ereaderTheme">
|
||||
<div class="flex items-end justify-between h-20 px-4 pb-2">
|
||||
<h1 class="text-lg">Table of Contents</h1>
|
||||
<h1 class="text-lg">{{ $strings.HeaderTableOfContents }}</h1>
|
||||
<button class="flex" @click.stop="showTOCModal = false">
|
||||
<span class="material-icons">close</span>
|
||||
</button>
|
||||
@@ -47,7 +47,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
<div v-if="!chapters.length" class="flex h-full items-center justify-center">
|
||||
<p class="text-xl">{{ 'No Chapters' }}</p>
|
||||
<p class="text-xl">{{ $strings.MessageNoChapters }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,31 +55,51 @@
|
||||
|
||||
<!-- ereader settings modal -->
|
||||
<modals-fullscreen-modal v-model="showSettingsModal" :theme="ereaderTheme" half-screen>
|
||||
<div class="flex items-end justify-between h-20 px-4 pb-2 mb-8">
|
||||
<h1 class="text-lg">Ereader Settings</h1>
|
||||
<button class="flex" @click="showSettingsModal = false">
|
||||
<span class="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(100vh-85px)]">
|
||||
<div class="w-full h-full px-4">
|
||||
<div class="flex items-center mb-8">
|
||||
<div class="w-32">
|
||||
<p class="text-base">Theme:</p>
|
||||
<div style="box-shadow: 0px -8px 8px #11111155">
|
||||
<div class="flex items-end justify-between h-14 px-4 pb-2 mb-6">
|
||||
<h1 class="text-lg">{{ $strings.HeaderEreaderSettings }}</h1>
|
||||
<button class="flex" @click="showSettingsModal = false">
|
||||
<span class="material-icons">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(100vh-85px)]">
|
||||
<div class="w-full h-full px-4">
|
||||
<div class="flex items-center mb-6">
|
||||
<div class="w-32">
|
||||
<p class="text-base">{{ $strings.LabelTheme }}:</p>
|
||||
</div>
|
||||
<ui-toggle-btns v-model="ereaderSettings.theme" :items="themeItems" @input="settingsUpdated" />
|
||||
</div>
|
||||
<ui-toggle-btns v-model="ereaderSettings.theme" :items="themeItems" @input="settingsUpdated" />
|
||||
</div>
|
||||
<div class="flex items-center mb-8">
|
||||
<div class="w-32">
|
||||
<p class="text-base">Font scale:</p>
|
||||
<div class="flex items-center mb-6">
|
||||
<div class="w-32">
|
||||
<p class="text-base">{{ $strings.LabelFontScale }}:</p>
|
||||
</div>
|
||||
<ui-range-input v-model="ereaderSettings.fontScale" :min="5" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
|
||||
</div>
|
||||
<ui-range-input v-model="ereaderSettings.fontScale" :min="5" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
|
||||
</div>
|
||||
<div class="flex items-center mb-8">
|
||||
<div class="w-32">
|
||||
<p class="text-base">Line spacing:</p>
|
||||
<div class="flex items-center mb-6">
|
||||
<div class="w-32">
|
||||
<p class="text-base">{{ $strings.LabelLineSpacing }}:</p>
|
||||
</div>
|
||||
<ui-range-input v-model="ereaderSettings.lineSpacing" :min="100" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
|
||||
</div>
|
||||
<div class="flex items-center mb-6">
|
||||
<div class="w-32">
|
||||
<p class="text-base">{{ $strings.LabelFontBoldness }}:</p>
|
||||
</div>
|
||||
<ui-range-input v-model="ereaderSettings.textStroke" :min="0" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
|
||||
</div>
|
||||
<div class="flex items-center mb-6">
|
||||
<div class="w-32">
|
||||
<p class="text-base">{{ $strings.LabelLayout }}:</p>
|
||||
</div>
|
||||
<ui-toggle-btns v-model="ereaderSettings.spread" :items="spreadItems" @input="settingsUpdated" />
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-32">
|
||||
<p class="text-base">{{ $strings.LabelNavigateWithVolume }}:</p>
|
||||
</div>
|
||||
<ui-toggle-btns v-model="ereaderSettings.navigateWithVolume" :items="navigateWithVolumeItems" @input="settingsUpdated" />
|
||||
</div>
|
||||
<ui-range-input v-model="ereaderSettings.lineSpacing" :min="100" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,6 +109,7 @@
|
||||
|
||||
<script>
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { VolumeButtons } from '@capacitor-community/volume-buttons'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
@@ -104,10 +125,14 @@ export default {
|
||||
showSettingsModal: false,
|
||||
comicHasMetadata: false,
|
||||
chapters: [],
|
||||
isInittingWatchVolume: false,
|
||||
ereaderSettings: {
|
||||
theme: 'dark',
|
||||
fontScale: 100,
|
||||
lineSpacing: 115
|
||||
lineSpacing: 115,
|
||||
spread: 'auto',
|
||||
textStroke: 0,
|
||||
navigateWithVolume: 'enabled'
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -118,13 +143,17 @@ export default {
|
||||
this.comicHasMetadata = false
|
||||
this.registerListeners()
|
||||
this.hideToolbar()
|
||||
|
||||
console.log('showReader for ebookFile', JSON.stringify(this.ebookFile))
|
||||
} else {
|
||||
this.unregisterListeners()
|
||||
this.$showHideStatusBar(true)
|
||||
}
|
||||
}
|
||||
},
|
||||
isPlayerOpen(newVal, oldVal) {
|
||||
// Closed player
|
||||
if (!newVal && oldVal) {
|
||||
this.initWatchVolume()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -150,16 +179,44 @@ export default {
|
||||
},
|
||||
ereaderTheme() {
|
||||
if (this.isEpub) return this.ereaderSettings.theme
|
||||
return 'dark'
|
||||
return document.documentElement.dataset.theme || 'dark'
|
||||
},
|
||||
spreadItems() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelLayoutSinglePage,
|
||||
value: 'none'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLayoutAuto,
|
||||
value: 'auto'
|
||||
}
|
||||
]
|
||||
},
|
||||
navigateWithVolumeItems() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelNavigateWithVolumeEnabled,
|
||||
value: 'enabled'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelNavigateWithVolumeMirrored,
|
||||
value: 'mirrored'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelNavigateWithVolumeDisabled,
|
||||
value: 'none'
|
||||
}
|
||||
]
|
||||
},
|
||||
themeItems() {
|
||||
return [
|
||||
{
|
||||
text: 'Dark',
|
||||
text: this.$strings.LabelThemeDark,
|
||||
value: 'dark'
|
||||
},
|
||||
{
|
||||
text: 'Light',
|
||||
text: this.$strings.LabelThemeLight,
|
||||
value: 'light'
|
||||
}
|
||||
]
|
||||
@@ -236,6 +293,8 @@ export default {
|
||||
settingsUpdated() {
|
||||
this.$refs.readerComponent?.updateSettings?.(this.ereaderSettings)
|
||||
localStorage.setItem('ereaderSettings', JSON.stringify(this.ereaderSettings))
|
||||
|
||||
this.initWatchVolume()
|
||||
},
|
||||
goToChapter(href) {
|
||||
this.showTOCModal = false
|
||||
@@ -348,26 +407,71 @@ export default {
|
||||
try {
|
||||
const settings = localStorage.getItem('ereaderSettings')
|
||||
if (settings) {
|
||||
this.ereaderSettings = JSON.parse(settings)
|
||||
const _ereaderSettings = JSON.parse(settings)
|
||||
for (const key in this.ereaderSettings) {
|
||||
if (_ereaderSettings[key] !== undefined) {
|
||||
this.ereaderSettings[key] = _ereaderSettings[key]
|
||||
}
|
||||
}
|
||||
this.settingsUpdated()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load ereader settings', error)
|
||||
}
|
||||
},
|
||||
async initWatchVolume() {
|
||||
if (this.isInittingWatchVolume || !this.isEpub) return
|
||||
this.isInittingWatchVolume = true
|
||||
const isWatching = await VolumeButtons.isWatching()
|
||||
|
||||
if (this.ereaderSettings.navigateWithVolume !== 'none' && !this.isPlayerOpen) {
|
||||
if (!isWatching.value) {
|
||||
const options = {
|
||||
disableSystemVolumeHandler: true,
|
||||
suppressVolumeIndicator: true
|
||||
}
|
||||
await VolumeButtons.watchVolume(options, this.volumePressed)
|
||||
}
|
||||
} else if (isWatching.value) {
|
||||
await VolumeButtons.clearWatch().catch((error) => {
|
||||
console.error('Failed to clear volume watch', error)
|
||||
})
|
||||
}
|
||||
|
||||
this.isInittingWatchVolume = false
|
||||
},
|
||||
registerListeners() {
|
||||
this.$eventBus.$on('close-ebook', this.closeEvt)
|
||||
document.body.addEventListener('touchstart', this.touchstart)
|
||||
document.body.addEventListener('touchend', this.touchend)
|
||||
this.initWatchVolume()
|
||||
},
|
||||
unregisterListeners() {
|
||||
this.$eventBus.$on('close-ebook', this.closeEvt)
|
||||
document.body.removeEventListener('touchstart', this.touchstart)
|
||||
document.body.removeEventListener('touchend', this.touchend)
|
||||
VolumeButtons.clearWatch().catch((error) => {
|
||||
console.error('Failed to clear volume watch', error)
|
||||
})
|
||||
},
|
||||
volumePressed(e) {
|
||||
if (this.ereaderSettings.navigateWithVolume == 'enabled') {
|
||||
if (e.direction == 'up') {
|
||||
this.prev()
|
||||
} else {
|
||||
this.next()
|
||||
}
|
||||
} else if (this.ereaderSettings.navigateWithVolume == 'mirrored') {
|
||||
if (e.direction == 'down') {
|
||||
this.prev()
|
||||
} else {
|
||||
this.next()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.unregisterListeners()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="w-96 my-6 mx-auto">
|
||||
<h1 class="text-2xl mb-4">Minutes Listening <span class="text-white text-opacity-60 text-lg">(Last 7 days)</span></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">
|
||||
@@ -32,24 +32,24 @@
|
||||
</div>
|
||||
<div class="flex justify-between pt-12">
|
||||
<div>
|
||||
<p class="text-sm text-center">Week Listening</p>
|
||||
<p class="text-sm text-center">{{ $strings.LabelStatsWeekListening }}</p>
|
||||
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ totalMinutesListeningThisWeek }}</p>
|
||||
<p class="text-sm text-center">minutes</p>
|
||||
<p class="text-sm text-center">{{ $strings.LabelStatsMinutes }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-center">Daily Average</p>
|
||||
<p class="text-sm text-center">{{ $strings.LabelStatsDailyAverage }}</p>
|
||||
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ averageMinutesPerDay }}</p>
|
||||
<p class="text-sm text-center">minutes</p>
|
||||
<p class="text-sm text-center">{{ $strings.LabelStatsMinutes }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-center">Best Day</p>
|
||||
<p class="text-sm text-center">{{ $strings.LabelStatsBestDay }}</p>
|
||||
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ mostListenedDay }}</p>
|
||||
<p class="text-sm text-center">minutes</p>
|
||||
<p class="text-sm text-center">{{ $strings.LabelStatsMinutes }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-center">Days</p>
|
||||
<p class="text-sm text-center">{{ $strings.LabelStatsDays }}</p>
|
||||
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ daysInARow }}</p>
|
||||
<p class="text-sm text-center">in a row</p>
|
||||
<p class="text-sm text-center">{{ $strings.LabelStatsInARow }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { FileSharer } from '@byteowls/capacitor-filesharer'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
variant: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
year: Number,
|
||||
processing: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
canvas: null,
|
||||
dataUrl: null,
|
||||
yearStats: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
variant() {
|
||||
this.init()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async initCanvas() {
|
||||
if (!this.yearStats) return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 800
|
||||
canvas.height = 800
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
const createRoundedRect = (x, y, w, h) => {
|
||||
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
|
||||
grd1.addColorStop(0, '#44444455')
|
||||
grd1.addColorStop(1, '#ffffff11')
|
||||
ctx.fillStyle = grd1
|
||||
ctx.strokeStyle = '#C0C0C088'
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, y, w, h, [20])
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
|
||||
ctx.letterSpacing = letterSpacing
|
||||
|
||||
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
|
||||
if (maxWidth) {
|
||||
let txtWidth = ctx.measureText(text).width
|
||||
while (txtWidth > maxWidth) {
|
||||
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
|
||||
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
|
||||
else text = text.slice(0, -3) // First check remove last 3 chars
|
||||
text += '...'
|
||||
txtWidth = ctx.measureText(text).width
|
||||
console.log(`Checking text "${text}" (width:${txtWidth})`)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillText(text, x, y)
|
||||
}
|
||||
|
||||
const addIcon = (icon, color, fontSize, x, y) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `${fontSize} Material Icons Outlined`
|
||||
ctx.fillText(icon, x, y)
|
||||
}
|
||||
|
||||
// Bg color
|
||||
ctx.fillStyle = '#232323'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Cover image tiles
|
||||
const bookCovers = this.yearStats.finishedBooksWithCovers
|
||||
bookCovers.push(...this.yearStats.booksWithCovers)
|
||||
|
||||
let finishedBookCoverImgs = {}
|
||||
|
||||
if (bookCovers.length) {
|
||||
let index = 0
|
||||
ctx.globalAlpha = 0.25
|
||||
ctx.save()
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2)
|
||||
ctx.rotate((-Math.PI / 180) * 25)
|
||||
ctx.translate(-canvas.width / 2, -canvas.height / 2)
|
||||
ctx.translate(-130, -120)
|
||||
for (let x = 0; x < 5; x++) {
|
||||
for (let y = 0; y < 5; y++) {
|
||||
const coverIndex = index % bookCovers.length
|
||||
let libraryItemId = bookCovers[coverIndex]
|
||||
index++
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.addEventListener('load', () => {
|
||||
let sw = img.width
|
||||
if (img.width > img.height) {
|
||||
sw = img.height
|
||||
}
|
||||
let sx = -(sw - img.width) / 2
|
||||
let sy = -(sw - img.height) / 2
|
||||
ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215)
|
||||
resolve()
|
||||
if (this.yearStats.finishedBooksWithCovers.includes(libraryItemId) && !finishedBookCoverImgs[libraryItemId]) {
|
||||
finishedBookCoverImgs[libraryItemId] = {
|
||||
img,
|
||||
sx,
|
||||
sy,
|
||||
sw
|
||||
}
|
||||
}
|
||||
})
|
||||
img.addEventListener('error', () => {
|
||||
resolve()
|
||||
})
|
||||
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
// Create gradient
|
||||
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
|
||||
grd1.addColorStop(0, '#000000aa')
|
||||
grd1.addColorStop(1, '#cd9d49aa')
|
||||
ctx.fillStyle = grd1
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Top Abs icon
|
||||
let tanColor = '#ffdb70'
|
||||
ctx.fillStyle = tanColor
|
||||
ctx.font = '42px absicons'
|
||||
ctx.fillText('\ue900', 15, 36)
|
||||
|
||||
// Top text
|
||||
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
|
||||
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
|
||||
|
||||
// Top left box
|
||||
createRoundedRect(50, 100, 340, 160)
|
||||
addText(this.yearStats.numBooksFinished, '64px', 'bold', 'white', '0px', 160, 165)
|
||||
addText('books finished', '28px', 'normal', tanColor, '0px', 160, 210)
|
||||
const readIconPath = new Path2D()
|
||||
readIconPath.addPath(new Path2D('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'), { a: 2, d: 2, e: 100, f: 160 })
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fill(readIconPath)
|
||||
|
||||
// Box top right
|
||||
createRoundedRect(410, 100, 340, 160)
|
||||
addText(this.$elapsedPrettyExtended(this.yearStats.totalListeningTime, true, false), '40px', 'bold', 'white', '0px', 500, 165)
|
||||
addText('spent listening', '28px', 'normal', tanColor, '0px', 500, 205)
|
||||
addIcon('watch_later', 'white', '52px', 440, 180)
|
||||
|
||||
// Box bottom left
|
||||
createRoundedRect(50, 280, 340, 160)
|
||||
addText(this.yearStats.totalListeningSessions, '64px', 'bold', 'white', '0px', 160, 345)
|
||||
addText('sessions', '28px', 'normal', tanColor, '1px', 160, 390)
|
||||
addIcon('headphones', 'white', '52px', 95, 360)
|
||||
|
||||
// Box bottom right
|
||||
createRoundedRect(410, 280, 340, 160)
|
||||
addText(this.yearStats.numBooksListened, '64px', 'bold', 'white', '0px', 500, 345)
|
||||
addText('books listened to', '28px', 'normal', tanColor, '0px', 500, 390)
|
||||
addIcon('local_library', 'white', '52px', 440, 360)
|
||||
|
||||
if (!this.variant) {
|
||||
// Text stats
|
||||
const topNarrator = this.yearStats.mostListenedNarrator
|
||||
if (topNarrator) {
|
||||
addText('TOP NARRATOR', '24px', 'normal', tanColor, '1px', 70, 520)
|
||||
addText(topNarrator.name, '36px', 'bolder', 'white', '0px', 70, 564, 330)
|
||||
addText(this.$elapsedPrettyExtended(topNarrator.time, true, false), '24px', 'lighter', 'white', '1px', 70, 599)
|
||||
}
|
||||
|
||||
const topGenre = this.yearStats.topGenres[0]
|
||||
if (topGenre) {
|
||||
addText('TOP GENRE', '24px', 'normal', tanColor, '1px', 430, 520)
|
||||
addText(topGenre.genre, '36px', 'bolder', 'white', '0px', 430, 564, 330)
|
||||
addText(this.$elapsedPrettyExtended(topGenre.time, true, false), '24px', 'lighter', 'white', '1px', 430, 599)
|
||||
}
|
||||
|
||||
const topAuthor = this.yearStats.topAuthors[0]
|
||||
if (topAuthor) {
|
||||
addText('TOP AUTHOR', '24px', 'normal', tanColor, '1px', 70, 670)
|
||||
addText(topAuthor.name, '36px', 'bolder', 'white', '0px', 70, 714, 330)
|
||||
addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '24px', 'lighter', 'white', '1px', 70, 749)
|
||||
}
|
||||
|
||||
if (this.yearStats.mostListenedMonth?.time) {
|
||||
const jsdate = new Date(this.year, this.yearStats.mostListenedMonth.month, 1)
|
||||
const monthName = this.$formatJsDate(jsdate, 'LLLL')
|
||||
addText('TOP MONTH', '24px', 'normal', tanColor, '1px', 430, 670)
|
||||
addText(monthName, '36px', 'bolder', 'white', '0px', 430, 714, 330)
|
||||
addText(this.$elapsedPrettyExtended(this.yearStats.mostListenedMonth.time, true, false), '24px', 'lighter', 'white', '1px', 430, 749)
|
||||
}
|
||||
} else if (this.variant === 1) {
|
||||
// Bottom images
|
||||
finishedBookCoverImgs = Object.values(finishedBookCoverImgs)
|
||||
if (finishedBookCoverImgs.length > 0) {
|
||||
ctx.textAlign = 'center'
|
||||
addText('Some books finished this year...', '28px', 'normal', tanColor, '0px', canvas.width / 2, 530)
|
||||
|
||||
for (let i = 0; i < Math.min(5, finishedBookCoverImgs.length); i++) {
|
||||
let imgToAdd = finishedBookCoverImgs[i]
|
||||
ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 570, 140, 140)
|
||||
}
|
||||
}
|
||||
} else if (this.variant === 2) {
|
||||
// Text stats
|
||||
if (this.yearStats.topAuthors.length) {
|
||||
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 524)
|
||||
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
|
||||
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 584 + i * 60, 330)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.yearStats.topGenres.length) {
|
||||
addText('TOP GENRES', '24px', 'normal', tanColor, '1px', 430, 524)
|
||||
for (let i = 0; i < this.yearStats.topGenres.length; i++) {
|
||||
addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 584 + i * 60, 330)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.canvas = canvas
|
||||
this.dataUrl = canvas.toDataURL('png')
|
||||
},
|
||||
refresh() {
|
||||
this.init()
|
||||
},
|
||||
share() {
|
||||
const base64Data = this.dataUrl.split(';base64,').pop()
|
||||
FileSharer.share({
|
||||
filename: `audiobookshelf_my_${this.year}.png`,
|
||||
contentType: 'image/png',
|
||||
base64Data
|
||||
}).catch((error) => {
|
||||
if (error.message !== 'USER_CANCELLED') {
|
||||
console.error('Failed to share', error.message)
|
||||
this.$toast.error('Failed to share: ' + error.message)
|
||||
}
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.$emit('update:processing', true)
|
||||
this.$nativeHttp
|
||||
.get(`/api/me/stats/year/${this.year}`)
|
||||
.then((data) => {
|
||||
this.yearStats = data || []
|
||||
return this.initCanvas()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to load year stats')
|
||||
})
|
||||
.finally(() => {
|
||||
this.$emit('update:processing', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-2 sm:p-4 mb-4">
|
||||
<!-- hack to get icon fonts loaded on init -->
|
||||
<div class="h-0 w-0 overflow-hidden opacity-0">
|
||||
<span class="material-icons-outlined">close</span>
|
||||
<span class="abs-icons icon-audiobookshelf" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<p class="hidden md:block text-xl font-semibold">{{ yearInReviewYear }} Year in Review</p>
|
||||
<div class="hidden md:block flex-grow" />
|
||||
<ui-btn class="w-full md:w-auto" @click.stop="clickShowYearInReview">{{ showYearInReview ? 'Hide Year in Review' : `See ${yearInReviewYear} Year in Review` }}</ui-btn>
|
||||
</div>
|
||||
|
||||
<!-- your year in review -->
|
||||
<div v-if="showYearInReview">
|
||||
<div class="w-full h-px bg-slate-200/10 my-4" />
|
||||
|
||||
<div class="flex items-center justify-center mb-2 max-w-[800px] mx-auto">
|
||||
<!-- previous button -->
|
||||
<ui-btn small :disabled="!yearInReviewVariant || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant--">
|
||||
<span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||
<span class="hidden sm:inline-block pr-2">Previous</span>
|
||||
</ui-btn>
|
||||
<!-- share button -->
|
||||
<ui-btn small :disabled="processingYearInReview" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReview"> Share </ui-btn>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<p class="hidden sm:block text-lg font-semibold">Your Year in Review ({{ yearInReviewVariant + 1 }})</p>
|
||||
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewVariant + 1 }}</p>
|
||||
<div class="flex-grow" />
|
||||
|
||||
<!-- refresh button -->
|
||||
<ui-btn small :disabled="processingYearInReview" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReview">
|
||||
<span class="hidden sm:inline-block">Refresh</span>
|
||||
<span class="material-icons sm:!hidden text-lg py-px">refresh</span>
|
||||
</ui-btn>
|
||||
<!-- next button -->
|
||||
<ui-btn small :disabled="yearInReviewVariant >= 2 || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant++">
|
||||
<span class="hidden sm:inline-block pl-2">Next</span>
|
||||
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
<stats-year-in-review ref="yearInReview" :variant="yearInReviewVariant" :year="yearInReviewYear" :processing.sync="processingYearInReview" />
|
||||
|
||||
<!-- your year in review short -->
|
||||
<div class="w-full max-w-[800px] mx-auto my-4">
|
||||
<!-- share button -->
|
||||
<ui-btn small :disabled="processingYearInReviewShort" class="inline-flex sm:hidden items-center font-semibold mb-1" @click="shareYearInReviewShort"> Share </ui-btn>
|
||||
<stats-year-in-review-short ref="yearInReviewShort" :year="yearInReviewYear" :processing.sync="processingYearInReviewShort" />
|
||||
</div>
|
||||
|
||||
<!-- your server in review -->
|
||||
<div v-if="isAdminOrUp" class="w-full max-w-[800px] mx-auto mb-2 mt-4 border-t pt-4 border-white/10">
|
||||
<div class="flex items-center justify-center mb-2">
|
||||
<!-- previous button -->
|
||||
<ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant--">
|
||||
<span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||
<span class="hidden sm:inline-block pr-2">Previous</span>
|
||||
</ui-btn>
|
||||
<!-- share button -->
|
||||
<ui-btn small :disabled="processingYearInReviewServer" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReviewServer"> Share </ui-btn>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<p class="hidden sm:block text-lg font-semibold">Server Year in Review ({{ yearInReviewServerVariant + 1 }})</p>
|
||||
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewServerVariant + 1 }}</p>
|
||||
<div class="flex-grow" />
|
||||
|
||||
<!-- refresh button -->
|
||||
<ui-btn small :disabled="processingYearInReviewServer" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReviewServer">
|
||||
<span class="hidden sm:inline-block">Refresh</span>
|
||||
<span class="material-icons sm:!hidden text-lg py-px">refresh</span>
|
||||
</ui-btn>
|
||||
<!-- next button -->
|
||||
<ui-btn small :disabled="yearInReviewServerVariant >= 2 || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant++">
|
||||
<span class="hidden sm:inline-block pl-2">Next</span>
|
||||
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<stats-year-in-review-server v-if="isAdminOrUp" ref="yearInReviewServer" :year="yearInReviewYear" :variant="yearInReviewServerVariant" :processing.sync="processingYearInReviewServer" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
showYearInReview: false,
|
||||
yearInReviewYear: 0,
|
||||
yearInReviewVariant: 0,
|
||||
yearInReviewServerVariant: 0,
|
||||
processingYearInReview: false,
|
||||
processingYearInReviewShort: false,
|
||||
processingYearInReviewServer: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
shareYearInReviewServer() {
|
||||
this.$refs.yearInReviewServer.share()
|
||||
},
|
||||
shareYearInReview() {
|
||||
this.$refs.yearInReview.share()
|
||||
},
|
||||
shareYearInReviewShort() {
|
||||
this.$refs.yearInReviewShort.share()
|
||||
},
|
||||
refreshYearInReviewServer() {
|
||||
this.$refs.yearInReviewServer.refresh()
|
||||
},
|
||||
refreshYearInReview() {
|
||||
this.$refs.yearInReview.refresh()
|
||||
this.$refs.yearInReviewShort.refresh()
|
||||
},
|
||||
clickShowYearInReview() {
|
||||
this.showYearInReview = !this.showYearInReview
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
this.yearInReviewYear = new Date().getFullYear()
|
||||
// When not December show previous year
|
||||
if (new Date().getMonth() < 11) {
|
||||
this.yearInReviewYear--
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { FileSharer } from '@byteowls/capacitor-filesharer'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
variant: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
processing: Boolean,
|
||||
year: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
canvas: null,
|
||||
dataUrl: null,
|
||||
yearStats: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
variant() {
|
||||
this.init()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async initCanvas() {
|
||||
if (!this.yearStats) return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 800
|
||||
canvas.height = 800
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
const createRoundedRect = (x, y, w, h) => {
|
||||
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
|
||||
grd1.addColorStop(0, '#44444455')
|
||||
grd1.addColorStop(1, '#ffffff11')
|
||||
ctx.fillStyle = grd1
|
||||
ctx.strokeStyle = '#C0C0C088'
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, y, w, h, [20])
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
|
||||
ctx.letterSpacing = letterSpacing
|
||||
|
||||
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
|
||||
if (maxWidth) {
|
||||
let txtWidth = ctx.measureText(text).width
|
||||
while (txtWidth > maxWidth) {
|
||||
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
|
||||
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
|
||||
else text = text.slice(0, -3) // First check remove last 3 chars
|
||||
text += '...'
|
||||
txtWidth = ctx.measureText(text).width
|
||||
console.log(`Checking text "${text}" (width:${txtWidth})`)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillText(text, x, y)
|
||||
}
|
||||
|
||||
// Bg color
|
||||
ctx.fillStyle = '#232323'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Cover image tiles
|
||||
let imgsToAdd = {}
|
||||
|
||||
if (this.yearStats.booksAddedWithCovers.length) {
|
||||
let index = 0
|
||||
ctx.globalAlpha = 0.25
|
||||
ctx.save()
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2)
|
||||
ctx.rotate((-Math.PI / 180) * 25)
|
||||
ctx.translate(-canvas.width / 2, -canvas.height / 2)
|
||||
ctx.translate(-130, -120)
|
||||
for (let x = 0; x < 5; x++) {
|
||||
for (let y = 0; y < 5; y++) {
|
||||
const coverIndex = index % this.yearStats.booksAddedWithCovers.length
|
||||
let libraryItemId = this.yearStats.booksAddedWithCovers[coverIndex]
|
||||
index++
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.addEventListener('load', () => {
|
||||
let sw = img.width
|
||||
if (img.width > img.height) {
|
||||
sw = img.height
|
||||
}
|
||||
let sx = -(sw - img.width) / 2
|
||||
let sy = -(sw - img.height) / 2
|
||||
ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215)
|
||||
if (!imgsToAdd[libraryItemId]) {
|
||||
imgsToAdd[libraryItemId] = {
|
||||
img,
|
||||
sx,
|
||||
sy,
|
||||
sw
|
||||
}
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
img.addEventListener('error', () => {
|
||||
resolve()
|
||||
})
|
||||
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
// Create gradient
|
||||
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
|
||||
grd1.addColorStop(0, '#000000aa')
|
||||
grd1.addColorStop(1, '#cd9d49aa')
|
||||
ctx.fillStyle = grd1
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Top Abs icon
|
||||
let tanColor = '#ffdb70'
|
||||
ctx.fillStyle = tanColor
|
||||
ctx.font = '42px absicons'
|
||||
ctx.fillText('\ue900', 15, 36)
|
||||
|
||||
// Top text
|
||||
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
|
||||
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
|
||||
|
||||
// Top left box
|
||||
createRoundedRect(40, 100, 230, 100)
|
||||
ctx.textAlign = 'center'
|
||||
addText(this.yearStats.numBooksAdded, '48px', 'bold', 'white', '0px', 155, 140)
|
||||
addText('books added', '18px', 'normal', tanColor, '0px', 155, 170)
|
||||
|
||||
// Box top right
|
||||
createRoundedRect(285, 100, 230, 100)
|
||||
addText(this.yearStats.numAuthorsAdded, '48px', 'bold', 'white', '0px', 400, 140)
|
||||
addText('authors added', '18px', 'normal', tanColor, '0px', 400, 170)
|
||||
|
||||
// Box bottom left
|
||||
createRoundedRect(530, 100, 230, 100)
|
||||
addText(this.yearStats.numListeningSessions, '48px', 'bold', 'white', '0px', 645, 140)
|
||||
addText('sessions', '18px', 'normal', tanColor, '1px', 645, 170)
|
||||
|
||||
// Text stats
|
||||
if (this.yearStats.totalBooksAddedSize) {
|
||||
addText('Your book collection grew to...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 260)
|
||||
addText(this.$bytesPretty(this.yearStats.totalBooksSize), '36px', 'bolder', 'white', '0px', canvas.width / 2, 300)
|
||||
addText('+' + this.$bytesPretty(this.yearStats.totalBooksAddedSize), '20px', 'lighter', 'white', '0px', canvas.width / 2, 330)
|
||||
}
|
||||
|
||||
if (this.yearStats.totalBooksAddedDuration) {
|
||||
addText('With a total duration of...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 400)
|
||||
addText(this.$elapsedPrettyExtended(this.yearStats.totalBooksDuration, true, false), '36px', 'bolder', 'white', '0px', canvas.width / 2, 440)
|
||||
addText('+' + this.$elapsedPrettyExtended(this.yearStats.totalBooksAddedDuration, true, false), '20px', 'lighter', 'white', '0px', canvas.width / 2, 470)
|
||||
}
|
||||
|
||||
if (!this.variant) {
|
||||
// Bottom images
|
||||
imgsToAdd = Object.values(imgsToAdd)
|
||||
if (imgsToAdd.length > 0) {
|
||||
addText('Some additions include...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 540)
|
||||
|
||||
for (let i = 0; i < Math.min(5, imgsToAdd.length); i++) {
|
||||
let imgToAdd = imgsToAdd[i]
|
||||
ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 580, 140, 140)
|
||||
}
|
||||
}
|
||||
} else if (this.variant === 1) {
|
||||
// Text stats
|
||||
ctx.textAlign = 'left'
|
||||
if (this.yearStats.topAuthors.length) {
|
||||
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 549)
|
||||
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
|
||||
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.yearStats.topNarrators.length) {
|
||||
addText('TOP NARRATORS', '24px', 'normal', tanColor, '1px', 430, 549)
|
||||
for (let i = 0; i < this.yearStats.topNarrators.length; i++) {
|
||||
addText(this.yearStats.topNarrators[i].name, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330)
|
||||
}
|
||||
}
|
||||
} else if (this.variant === 2) {
|
||||
// Text stats
|
||||
ctx.textAlign = 'left'
|
||||
if (this.yearStats.topAuthors.length) {
|
||||
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 549)
|
||||
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
|
||||
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.yearStats.topGenres.length) {
|
||||
addText('TOP GENRES', '24px', 'normal', tanColor, '1px', 430, 549)
|
||||
for (let i = 0; i < this.yearStats.topGenres.length; i++) {
|
||||
addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.canvas = canvas
|
||||
this.dataUrl = canvas.toDataURL('png')
|
||||
},
|
||||
share() {
|
||||
const base64Data = this.dataUrl.split(';base64,').pop()
|
||||
FileSharer.share({
|
||||
filename: `audiobookshelf_server_${this.year}.png`,
|
||||
contentType: 'image/png',
|
||||
base64Data
|
||||
}).catch((error) => {
|
||||
if (error.message !== 'USER_CANCELLED') {
|
||||
console.error('Failed to share', error.message)
|
||||
this.$toast.error('Failed to share: ' + error.message)
|
||||
}
|
||||
})
|
||||
},
|
||||
refresh() {
|
||||
this.init()
|
||||
},
|
||||
init() {
|
||||
this.$emit('update:processing', true)
|
||||
this.$nativeHttp
|
||||
.get(`/api/stats/year/${this.year}`)
|
||||
.then((data) => {
|
||||
this.yearStats = data || []
|
||||
return this.initCanvas()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to load year stats')
|
||||
})
|
||||
.finally(() => {
|
||||
this.$emit('update:processing', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="processing" class="max-w-[600px] h-32 sm:h-[200px] flex items-center justify-center">
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
<img v-else-if="dataUrl" :src="dataUrl" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { FileSharer } from '@byteowls/capacitor-filesharer'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
year: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
canvas: null,
|
||||
dataUrl: null,
|
||||
yearStats: null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async initCanvas() {
|
||||
if (!this.yearStats) return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 600
|
||||
canvas.height = 200
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
const createRoundedRect = (x, y, w, h) => {
|
||||
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
|
||||
grd1.addColorStop(0, '#44444455')
|
||||
grd1.addColorStop(1, '#ffffff11')
|
||||
ctx.fillStyle = grd1
|
||||
ctx.strokeStyle = '#C0C0C088'
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, y, w, h, [20])
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
|
||||
ctx.letterSpacing = letterSpacing
|
||||
|
||||
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
|
||||
if (maxWidth) {
|
||||
let txtWidth = ctx.measureText(text).width
|
||||
while (txtWidth > maxWidth) {
|
||||
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
|
||||
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
|
||||
else text = text.slice(0, -3) // First check remove last 3 chars
|
||||
text += '...'
|
||||
txtWidth = ctx.measureText(text).width
|
||||
console.log(`Checking text "${text}" (width:${txtWidth})`)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillText(text, x, y)
|
||||
}
|
||||
|
||||
const addIcon = (icon, color, fontSize, x, y) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `${fontSize} Material Icons Outlined`
|
||||
ctx.fillText(icon, x, y)
|
||||
}
|
||||
|
||||
// Bg color
|
||||
ctx.fillStyle = '#232323'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Cover image tiles
|
||||
const bookCovers = this.yearStats.finishedBooksWithCovers
|
||||
bookCovers.push(...this.yearStats.booksWithCovers)
|
||||
|
||||
if (bookCovers.length) {
|
||||
let index = 0
|
||||
ctx.globalAlpha = 0.25
|
||||
ctx.save()
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2)
|
||||
ctx.rotate((-Math.PI / 180) * 25)
|
||||
ctx.translate(-canvas.width / 2, -canvas.height / 2)
|
||||
ctx.translate(-10, -90)
|
||||
for (let x = 0; x < 4; x++) {
|
||||
for (let y = 0; y < 3; y++) {
|
||||
const coverIndex = index % bookCovers.length
|
||||
let libraryItemId = bookCovers[coverIndex]
|
||||
index++
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.addEventListener('load', () => {
|
||||
let sw = img.width
|
||||
if (img.width > img.height) {
|
||||
sw = img.height
|
||||
}
|
||||
let sx = -(sw - img.width) / 2
|
||||
let sy = -(sw - img.height) / 2
|
||||
ctx.drawImage(img, sx, sy, sw, sw, 155 * x, 155 * y, 155, 155)
|
||||
resolve()
|
||||
})
|
||||
img.addEventListener('error', () => {
|
||||
resolve()
|
||||
})
|
||||
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
// Create gradient
|
||||
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
|
||||
grd1.addColorStop(0, '#000000aa')
|
||||
grd1.addColorStop(1, '#cd9d49aa')
|
||||
ctx.fillStyle = grd1
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Top Abs icon
|
||||
let tanColor = '#ffdb70'
|
||||
ctx.fillStyle = tanColor
|
||||
ctx.font = '42px absicons'
|
||||
ctx.fillText('\ue900', 15, 36)
|
||||
|
||||
// Top text
|
||||
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
|
||||
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
|
||||
|
||||
// Top left box
|
||||
createRoundedRect(15, 75, 280, 110)
|
||||
addText(this.yearStats.numBooksFinished, '48px', 'bold', 'white', '0px', 105, 120)
|
||||
addText('books finished', '20px', 'normal', tanColor, '0px', 105, 155)
|
||||
const readIconPath = new Path2D()
|
||||
readIconPath.addPath(new Path2D('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'), { a: 1.5, d: 1.5, e: 55, f: 115 })
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fill(readIconPath)
|
||||
|
||||
createRoundedRect(305, 75, 280, 110)
|
||||
addText(this.yearStats.numBooksListened, '48px', 'bold', 'white', '0px', 400, 120)
|
||||
addText('books listened to', '20px', 'normal', tanColor, '0px', 400, 155)
|
||||
addIcon('local_library', 'white', '42px', 345, 130)
|
||||
|
||||
this.canvas = canvas
|
||||
this.dataUrl = canvas.toDataURL('png')
|
||||
},
|
||||
share() {
|
||||
const base64Data = this.dataUrl.split(';base64,').pop()
|
||||
FileSharer.share({
|
||||
filename: `audiobookshelf_my_${this.year}_short.png`,
|
||||
contentType: 'image/png',
|
||||
base64Data
|
||||
}).catch((error) => {
|
||||
if (error.message !== 'USER_CANCELLED') {
|
||||
console.error('Failed to share', error.message)
|
||||
this.$toast.error('Failed to share: ' + error.message)
|
||||
}
|
||||
})
|
||||
},
|
||||
refresh() {
|
||||
this.init()
|
||||
},
|
||||
init() {
|
||||
this.$emit('update:processing', true)
|
||||
this.$nativeHttp
|
||||
.get(`/api/me/stats/year/${this.year}`)
|
||||
.then((data) => {
|
||||
this.yearStats = data || []
|
||||
return this.initCanvas()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to load year stats')
|
||||
})
|
||||
.finally(() => {
|
||||
this.$emit('update:processing', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="w-full my-4">
|
||||
<div class="w-full bg-primary px-4 py-2 flex items-center" :class="expanded ? 'rounded-t-md' : 'rounded-md'" @click.stop="clickBar">
|
||||
<p class="pr-2">Chapters</p>
|
||||
<div class="h-6 w-6 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||
<p class="pr-2">{{ $strings.HeaderChapters }}</p>
|
||||
<div class="h-6 w-6 rounded-full bg-fg/10 flex items-center justify-center">
|
||||
<span class="text-xs font-mono">{{ chapters.length }}</span>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
@@ -13,8 +13,8 @@
|
||||
<transition name="slide">
|
||||
<table class="text-xs tracksTable" v-show="expanded">
|
||||
<tr>
|
||||
<th class="text-left">Title</th>
|
||||
<th class="text-center w-16">Start</th>
|
||||
<th class="text-left">{{ $strings.LabelTitle }}</th>
|
||||
<th class="text-center w-16">{{ $strings.LabelStart }}</th>
|
||||
</tr>
|
||||
<tr v-for="chapter in chapters" :key="chapter.id">
|
||||
<td>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="w-full my-4">
|
||||
<div class="w-full bg-primary px-4 py-2 flex items-center" :class="showTracks ? 'rounded-t-md' : 'rounded-md'" @click.stop="clickBar">
|
||||
<p class="pr-2">{{ title }}</p>
|
||||
<div class="h-6 w-6 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||
<p class="pr-2">{{ $strings.HeaderAudioTracks }}</p>
|
||||
<div class="h-6 w-6 rounded-full bg-fg/10 flex items-center justify-center">
|
||||
<span class="text-xs font-mono">{{ tracks.length }}</span>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
@@ -14,8 +14,8 @@
|
||||
<div class="w-full" v-show="showTracks">
|
||||
<table class="text-xs tracksTable">
|
||||
<tr>
|
||||
<th class="text-left">Filename</th>
|
||||
<th class="text-center w-16">Duration</th>
|
||||
<th class="text-left">{{ $strings.LabelFilename }}</th>
|
||||
<th class="text-center w-16">{{ $strings.LabelDuration }}</th>
|
||||
</tr>
|
||||
<template v-for="track in tracks">
|
||||
<tr :key="track.index">
|
||||
@@ -34,10 +34,6 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Audio Tracks'
|
||||
},
|
||||
tracks: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<div class="book-table-content h-full px-2 flex items-center">
|
||||
<div class="max-w-full">
|
||||
<p class="truncate block text-sm">{{ bookTitle }}</p>
|
||||
<p class="truncate block text-gray-400 text-xs">{{ bookAuthor }}</p>
|
||||
<p v-if="media.duration" class="text-xxs text-gray-500">{{ bookDuration }}</p>
|
||||
<p class="truncate block text-fg-muted text-xs">{{ bookAuthor }}</p>
|
||||
<p v-if="media.duration" class="text-xxs text-fg-muted">{{ bookDuration }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-8 min-w-8 flex justify-center">
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<div class="w-full bg-primary bg-opacity-40">
|
||||
<div class="w-full h-14 flex items-center px-4 bg-primary">
|
||||
<p class="pr-4">Collection List</p>
|
||||
<p class="pr-4">{{ $strings.HeaderCollectionItems }}</p>
|
||||
|
||||
<div class="w-6 h-6 md:w-7 md:h-7 bg-white bg-opacity-10 rounded-full flex items-center justify-center">
|
||||
<div class="w-6 h-6 md:w-7 md:h-7 bg-fg/10 rounded-full flex items-center justify-center">
|
||||
<span class="text-xs md:text-sm font-mono leading-none">{{ books.length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<p v-if="totalDuration" class="text-sm text-gray-200">{{ totalDurationPretty }}</p>
|
||||
<p v-if="totalDuration" class="text-sm text-fg">{{ totalDurationPretty }}</p>
|
||||
</div>
|
||||
<template v-for="book in booksCopy">
|
||||
<tables-collection-book-table-row :key="book.id" :book="book" :collection-id="collectionId" class="item collection-book-item" @edit="editBook" />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="w-full my-2">
|
||||
<div class="w-full bg-primary px-4 py-2 flex items-center" :class="showFiles ? 'rounded-t-md' : 'rounded-md'" @click.stop="clickBar">
|
||||
<p class="pr-2">Ebook Files</p>
|
||||
<div class="h-6 w-6 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||
<p class="pr-2">{{ $strings.HeaderEbookFiles }}</p>
|
||||
<div class="h-6 w-6 rounded-full bg-fg/10 flex items-center justify-center">
|
||||
<span class="text-xs font-mono">{{ ebookFiles.length }}</span>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
@@ -14,8 +14,8 @@
|
||||
<div class="w-full" v-show="showFiles">
|
||||
<table class="text-sm tracksTable">
|
||||
<tr>
|
||||
<th class="text-left px-4">Filename</th>
|
||||
<th class="text-left px-4 w-16">Read</th>
|
||||
<th class="text-left px-4">{{ $strings.LabelFilename }}</th>
|
||||
<th class="text-left px-4 w-16">{{ $strings.LabelRead }}</th>
|
||||
<th v-if="userCanUpdate && !libraryIsAudiobooksOnly" class="text-center w-16"></th>
|
||||
</tr>
|
||||
<template v-for="file in ebookFiles">
|
||||
|
||||
@@ -40,7 +40,7 @@ export default {
|
||||
const items = []
|
||||
if (this.userCanUpdate && !this.libraryIsAudiobooksOnly) {
|
||||
items.push({
|
||||
text: this.isPrimary ? 'Set as supplementary' : 'Set as primary',
|
||||
text: this.isPrimary ? this.$strings.LabelSetEbookAsSupplementary : this.$strings.LabelSetEbookAsPrimary,
|
||||
value: 'updateStatus'
|
||||
})
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user