mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-08-07 04:18:48 +02:00
Compare commits
537
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bc5932889 | ||
|
|
3cd597d71e | ||
|
|
48d5ad462e | ||
|
|
40cab87640 | ||
|
|
f405947ad8 | ||
|
|
fd550c15a5 | ||
|
|
d18537cf04 | ||
|
|
f01f0229a8 | ||
|
|
b369eb2d40 | ||
|
|
f08c65c5bf | ||
|
|
1043f4ecd7 | ||
|
|
4f17a32373 | ||
|
|
2203aae6e6 | ||
|
|
7b60c7b6c5 | ||
|
|
be98e06efc | ||
|
|
45239bca34 | ||
|
|
2493ce7ed7 | ||
|
|
2b1e53b371 | ||
|
|
d643a1769f | ||
|
|
a63b7a188c | ||
|
|
f9c0162b8a | ||
|
|
cda3cb3512 | ||
|
|
766f6938a7 | ||
|
|
8e1ccb517b | ||
|
|
540aab6f83 | ||
|
|
d1969d7897 | ||
|
|
b69e8fd649 | ||
|
|
2e95cf908d | ||
|
|
b589bae2b0 | ||
|
|
e7e4891d82 | ||
|
|
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 | ||
|
|
8dbffa7993 | ||
|
|
17ff2ed5f4 | ||
|
|
6fe470cfc1 | ||
|
|
301e9b213f | ||
|
|
2b533524fc | ||
|
|
632b1f526b | ||
|
|
81b11bac93 | ||
|
|
a88e404ccf | ||
|
|
a7cbdbfbbb | ||
|
|
ad5146e2ee | ||
|
|
36be91962c | ||
|
|
ff5a1bb09f | ||
|
|
5060554640 | ||
|
|
93cb319e97 | ||
|
|
a42dfa5649 | ||
|
|
6a938b8da1 | ||
|
|
4f994072ad | ||
|
|
7c0ef179ec | ||
|
|
5fdd0c6672 | ||
|
|
e9f523d7cb | ||
|
|
c1b13dcd24 | ||
|
|
0daa043f14 | ||
|
|
1a6b716046 | ||
|
|
945baa24f0 | ||
|
|
30d4e709f0 | ||
|
|
8d8782a5a9 | ||
|
|
087e8553c4 | ||
|
|
2984398051 | ||
|
|
e9251db647 | ||
|
|
6c88337180 | ||
|
|
74b488ad0f | ||
|
|
fc7af6d1fc | ||
|
|
737d8f19b3 | ||
|
|
d613d8954d | ||
|
|
04e468b43d | ||
|
|
dc8178769b | ||
|
|
8b631f7860 | ||
|
|
e521ddfab6 | ||
|
|
6b164bdb27 | ||
|
|
2eb399b8a7 | ||
|
|
46f558d6e0 | ||
|
|
00bed39733 | ||
|
|
2048795c92 | ||
|
|
8c9bd53934 | ||
|
|
01178d00bf | ||
|
|
7541e4b660 | ||
|
|
2366c938b5 | ||
|
|
59480ad114 | ||
|
|
ce7779607f | ||
|
|
04bc74babe | ||
|
|
ac2b674ba5 | ||
|
|
e53901e7a4 | ||
|
|
3ede41f496 | ||
|
|
0adeaaa04d | ||
|
|
57d1fbfa83 | ||
|
|
27bbe40874 | ||
|
|
60da6093f3 | ||
|
|
259c9cef20 | ||
|
|
6d61b2acaf | ||
|
|
72f687b079 | ||
|
|
0ce3c8bcbd | ||
|
|
f9621d2f60 | ||
|
|
e634132a39 | ||
|
|
59b428100b | ||
|
|
36e0c91e27 | ||
|
|
03225957aa | ||
|
|
77f70e48de | ||
|
|
cbdc3513c7 | ||
|
|
394479bea0 | ||
|
|
b9592e46a7 | ||
|
|
5810a83c66 | ||
|
|
389c7d23ca | ||
|
|
defc7d216a | ||
|
|
852702112a | ||
|
|
dd91bc6667 | ||
|
|
cdbbd56a58 | ||
|
|
d6a1eba657 | ||
|
|
da899bfddd | ||
|
|
b7b746fb2a | ||
|
|
c8053355df | ||
|
|
1ddb6eca00 | ||
|
|
f42c624cba | ||
|
|
f8fbeef9b7 | ||
|
|
46b82a81ce | ||
|
|
48342e5bd3 | ||
|
|
147f89f870 | ||
|
|
8b39ba9c92 | ||
|
|
13d32dc1f7 | ||
|
|
0d1312e497 | ||
|
|
ff4f8324e7 | ||
|
|
b4bf10d409 | ||
|
|
ae37861f7d | ||
|
|
1da03cdd65 | ||
|
|
a7443d0992 | ||
|
|
5a953464ab | ||
|
|
953bde5a42 | ||
|
|
481d0d0285 | ||
|
|
7b3a005ad6 | ||
|
|
4f9d341f69 | ||
|
|
9e7d1d1617 | ||
|
|
4f03c2a35d | ||
|
|
db3c5396e9 | ||
|
|
455af239a1 | ||
|
|
d8bc26f5f8 | ||
|
|
543ac209e4 | ||
|
|
c8b5cefeb5 | ||
|
|
f1411011e6 | ||
|
|
f3964dda17 | ||
|
|
d207e88e18 | ||
|
|
2643e98fc0 | ||
|
|
410648e316 | ||
|
|
373221703d | ||
|
|
fbcb8620f9 | ||
|
|
a8ac4970cc | ||
|
|
195e33ae32 | ||
|
|
9acf695802 | ||
|
|
e6aaccfc74 | ||
|
|
87c74fe78b | ||
|
|
7dc319c1bd | ||
|
|
c093548b71 | ||
|
|
b067a2d3d5 | ||
|
|
a9ae7bfdb8 | ||
|
|
9dcf22614c | ||
|
|
c7564d439e | ||
|
|
157dc01673 | ||
|
|
cce6e1d0ab | ||
|
|
b935cb5cf1 | ||
|
|
9ab37ac525 |
+1
-6
@@ -1,13 +1,8 @@
|
|||||||
# editorconfig.org
|
|
||||||
root = true
|
root = true
|
||||||
|
|
||||||
[*]
|
[*]
|
||||||
indent_style = space
|
indent_style = space
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
end_of_line = lf
|
|
||||||
charset = utf-8
|
charset = utf-8
|
||||||
trim_trailing_whitespace = true
|
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
[*.md]
|
|
||||||
trim_trailing_whitespace = false
|
|
||||||
|
|||||||
@@ -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.74
|
||||||
|
- iOS App - 0.9.74
|
||||||
|
- Android App - 0.9.73
|
||||||
|
- iOS App - 0.9.73
|
||||||
|
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.74
|
||||||
|
- iOS App - 0.9.74
|
||||||
|
- Android App - 0.9.73
|
||||||
|
- iOS App - 0.9.73
|
||||||
|
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,16 @@ jobs:
|
|||||||
- name: checkout sources
|
- name: checkout sources
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: use Node.js 16.x
|
- name: use Node.js 20.x
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Set up Java
|
||||||
|
uses: actions/setup-java@v2
|
||||||
|
with:
|
||||||
|
distribution: "temurin"
|
||||||
|
java-version: 17
|
||||||
|
|
||||||
- name: install dependencies
|
- name: install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|||||||
@@ -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,16 @@ jobs:
|
|||||||
- name: checkout sources
|
- name: checkout sources
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: use Node.js 16.x
|
- name: use Node.js 20.x
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Set up Java
|
||||||
|
uses: actions/setup-java@v2
|
||||||
|
with:
|
||||||
|
distribution: "temurin"
|
||||||
|
java-version: 17
|
||||||
|
|
||||||
- name: install dependencies
|
- name: install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-15
@@ -21,8 +21,6 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
||||||
|
|
||||||
namespace 'com.audiobookshelf.app'
|
namespace 'com.audiobookshelf.app'
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding true
|
viewBinding true
|
||||||
@@ -35,9 +33,12 @@ android {
|
|||||||
applicationId "com.audiobookshelf.app"
|
applicationId "com.audiobookshelf.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 95
|
versionCode 107
|
||||||
versionName "0.9.64-beta"
|
versionName "0.9.76-beta"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
manifestPlaceholders = [
|
||||||
|
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
||||||
|
]
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||||
@@ -63,14 +64,15 @@ repositories {
|
|||||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||||
}
|
}
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
// TODO: Temporarily using SNAPSHOT version of Simple Storage that resolves https://github.com/anggrayudi/SimpleStorage/issues/133
|
||||||
|
maven { url "https://oss.sonatype.org/content/repositories/snapshots" }
|
||||||
}
|
}
|
||||||
|
|
||||||
configurations.all {
|
configurations.configureEach {
|
||||||
resolutionStrategy {
|
resolutionStrategy {
|
||||||
force("com.google.android.gms:play-services-base:18.0.1")
|
force("com.google.android.gms:play-services-base:18.0.1")
|
||||||
force("androidx.appcompat:appcompat:$androidxAppCompatVersion")
|
force("androidx.appcompat:appcompat:$androidxAppCompatVersion")
|
||||||
force("androidx.core:core-ktx:$androidx_core_ktx_version")
|
force("androidx.core:core-ktx:$androidx_core_ktx_version")
|
||||||
force("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version")
|
|
||||||
force("androidx.media:media:$androidx_media_version")
|
force("androidx.media:media:$androidx_media_version")
|
||||||
force("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version")
|
force("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version")
|
||||||
force("org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version")
|
force("org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version")
|
||||||
@@ -83,12 +85,9 @@ dependencies {
|
|||||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
implementation project(':capacitor-android')
|
implementation project(':capacitor-android')
|
||||||
|
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
|
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
|
|
||||||
// testImplementation "junit:junit:$junitVersion"
|
|
||||||
// androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
|
||||||
// androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
|
||||||
implementation project(':capacitor-cordova-android-plugins')
|
implementation project(':capacitor-cordova-android-plugins')
|
||||||
|
|
||||||
implementation "androidx.core:core-ktx:$androidx_core_ktx_version"
|
implementation "androidx.core:core-ktx:$androidx_core_ktx_version"
|
||||||
@@ -116,22 +115,18 @@ dependencies {
|
|||||||
|
|
||||||
// Glide for images
|
// Glide for images
|
||||||
implementation "com.github.bumptech.glide:glide:$glide_version"
|
implementation "com.github.bumptech.glide:glide:$glide_version"
|
||||||
kapt "com.github.bumptech.glide:compiler:$glide_version"
|
|
||||||
|
|
||||||
// Paper NoSQL Db
|
// Paper NoSQL Db
|
||||||
implementation 'io.github.pilgr:paperdb:2.7.2'
|
implementation 'io.github.pilgr:paperdb:2.7.2'
|
||||||
|
|
||||||
// Simple Storage
|
// Simple Storage
|
||||||
implementation "com.anggrayudi:storage:1.5.4"
|
implementation "com.anggrayudi:storage:1.5.5-SNAPSHOT"
|
||||||
|
|
||||||
// OK HTTP
|
// OK HTTP
|
||||||
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
|
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
|
||||||
|
|
||||||
// Jackson for JSON
|
// Jackson for JSON
|
||||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.2'
|
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.2'
|
||||||
|
|
||||||
// FFMPEG-Kit
|
|
||||||
implementation 'com.arthenica:ffmpeg-kit-min:4.5.1'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
apply from: 'capacitor.build.gradle'
|
apply from: 'capacitor.build.gradle'
|
||||||
|
|||||||
@@ -2,14 +2,17 @@
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility JavaVersion.VERSION_11
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
targetCompatibility JavaVersion.VERSION_11
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation project(':byteowls-capacitor-filesharer')
|
||||||
implementation project(':capacitor-app')
|
implementation project(':capacitor-app')
|
||||||
|
implementation project(':capacitor-browser')
|
||||||
|
implementation project(':capacitor-clipboard')
|
||||||
implementation project(':capacitor-dialog')
|
implementation project(':capacitor-dialog')
|
||||||
implementation project(':capacitor-haptics')
|
implementation project(':capacitor-haptics')
|
||||||
implementation project(':capacitor-network')
|
implementation project(':capacitor-network')
|
||||||
|
|||||||
@@ -2,4 +2,5 @@
|
|||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
<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">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
</adaptive-icon>
|
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<resources>
|
||||||
|
<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>
|
||||||
|
<string name="app_widget_description">Simple widget for audiobookshelf playback</string>
|
||||||
|
<string name="action_jump_forward">Jump Forward</string>
|
||||||
|
<string name="action_jump_backward">Jump Backward</string>
|
||||||
|
<string name="action_skip_forward">Skip Forward</string>
|
||||||
|
<string name="action_skip_backward">Skip Backward</string>
|
||||||
|
<string name="action_change_speed">Change Playback Speed</string>
|
||||||
|
</resources>
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<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.INTERNET" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
@@ -15,10 +16,10 @@
|
|||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:requestLegacyExternalStorage="true"
|
android:requestLegacyExternalStorage="true"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
@@ -92,6 +93,16 @@
|
|||||||
<action android:name="android.media.browse.MediaBrowserService" />
|
<action android:name="android.media.browse.MediaBrowserService" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:grantUriPermissions="true"
|
||||||
|
android:exported="false">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -2,5 +2,10 @@
|
|||||||
"appId": "com.audiobookshelf.app",
|
"appId": "com.audiobookshelf.app",
|
||||||
"appName": "audiobookshelf-app",
|
"appName": "audiobookshelf-app",
|
||||||
"webDir": "dist",
|
"webDir": "dist",
|
||||||
"bundledWebRuntime": false
|
"bundledWebRuntime": false,
|
||||||
|
"plugins": {
|
||||||
|
"CapacitorHttp": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"pkg": "@byteowls/capacitor-filesharer",
|
||||||
|
"classpath": "com.byteowls.capacitor.filesharer.FileSharerPlugin"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"pkg": "@capacitor/app",
|
"pkg": "@capacitor/app",
|
||||||
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"pkg": "@capacitor/browser",
|
||||||
|
"classpath": "com.capacitorjs.plugins.browser.BrowserPlugin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pkg": "@capacitor/clipboard",
|
||||||
|
"classpath": "com.capacitorjs.plugins.clipboard.ClipboardPlugin"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"pkg": "@capacitor/dialog",
|
"pkg": "@capacitor/dialog",
|
||||||
"classpath": "com.capacitorjs.plugins.dialog.DialogPlugin"
|
"classpath": "com.capacitorjs.plugins.dialog.DialogPlugin"
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ import android.util.Log
|
|||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import com.anggrayudi.storage.SimpleStorage
|
import com.anggrayudi.storage.SimpleStorage
|
||||||
import com.anggrayudi.storage.SimpleStorageHelper
|
import com.anggrayudi.storage.SimpleStorageHelper
|
||||||
import com.audiobookshelf.app.plugins.AbsDatabase
|
|
||||||
import com.audiobookshelf.app.managers.DbManager
|
import com.audiobookshelf.app.managers.DbManager
|
||||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||||
import com.audiobookshelf.app.plugins.AbsAudioPlayer
|
import com.audiobookshelf.app.plugins.AbsAudioPlayer
|
||||||
|
import com.audiobookshelf.app.plugins.AbsDatabase
|
||||||
import com.audiobookshelf.app.plugins.AbsDownloader
|
import com.audiobookshelf.app.plugins.AbsDownloader
|
||||||
import com.audiobookshelf.app.plugins.AbsFileSystem
|
import com.audiobookshelf.app.plugins.AbsFileSystem
|
||||||
import com.getcapacitor.BridgeActivity
|
import com.getcapacitor.BridgeActivity
|
||||||
@@ -103,6 +103,9 @@ class MainActivity : BridgeActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun isPlayerNotificationServiceInitialized():Boolean {
|
||||||
|
return ::foregroundService.isInitialized
|
||||||
|
}
|
||||||
|
|
||||||
fun stopMyService() {
|
fun stopMyService() {
|
||||||
if (mBounded) {
|
if (mBounded) {
|
||||||
@@ -116,6 +119,7 @@ class MainActivity : BridgeActivity() {
|
|||||||
override fun onSaveInstanceState(outState: Bundle) {
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
storageHelper.onSaveInstanceState(outState)
|
storageHelper.onSaveInstanceState(outState)
|
||||||
super.onSaveInstanceState(outState)
|
super.onSaveInstanceState(outState)
|
||||||
|
outState.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||||
|
|||||||
@@ -15,9 +15,7 @@ import android.widget.RemoteViews
|
|||||||
import androidx.media.session.MediaButtonReceiver
|
import androidx.media.session.MediaButtonReceiver
|
||||||
import com.audiobookshelf.app.data.PlaybackSession
|
import com.audiobookshelf.app.data.PlaybackSession
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.audiobookshelf.app.device.WidgetEventEmitter
|
|
||||||
import com.audiobookshelf.app.managers.DbManager
|
import com.audiobookshelf.app.managers.DbManager
|
||||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.request.RequestOptions
|
import com.bumptech.glide.request.RequestOptions
|
||||||
import com.bumptech.glide.request.target.AppWidgetTarget
|
import com.bumptech.glide.request.target.AppWidgetTarget
|
||||||
@@ -28,12 +26,9 @@ import com.bumptech.glide.request.transition.Transition
|
|||||||
*/
|
*/
|
||||||
class MediaPlayerWidget : AppWidgetProvider() {
|
class MediaPlayerWidget : AppWidgetProvider() {
|
||||||
val tag = "MediaPlayerWidget"
|
val tag = "MediaPlayerWidget"
|
||||||
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||||
// There may be multiple widgets active, so update all of them
|
Log.d(tag, "onUpdate $appWidgetIds")
|
||||||
for (appWidgetId in appWidgetIds) {
|
}
|
||||||
updateAppWidget(context, appWidgetManager, appWidgetId, null, false, PlayerNotificationService.isClosed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onEnabled(context: Context) {
|
override fun onEnabled(context: Context) {
|
||||||
Log.i(tag, "onEnabled check context ${context.packageName}")
|
Log.i(tag, "onEnabled check context ${context.packageName}")
|
||||||
@@ -51,32 +46,14 @@ class MediaPlayerWidget : AppWidgetProvider() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Enter relevant functionality for when the first widget is created
|
// Enter relevant functionality for when the first widget is created
|
||||||
DeviceManager.widgetUpdater = (object : WidgetEventEmitter {
|
DeviceManager.initializeWidgetUpdater(context)
|
||||||
override fun onPlayerChanged(pns: PlayerNotificationService) {
|
|
||||||
val isPlaying = pns.currentPlayer.isPlaying
|
|
||||||
|
|
||||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
|
||||||
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
|
||||||
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
|
||||||
|
|
||||||
val playbackSession = pns.getCurrentPlaybackSessionCopy()
|
|
||||||
|
|
||||||
for (widgetId in ids) {
|
|
||||||
updateAppWidget(context, appWidgetManager, widgetId, playbackSession, isPlaying, PlayerNotificationService.isClosed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDisabled(context: Context) {
|
|
||||||
// Enter relevant functionality for when the last widget is disabled
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, playbackSession: PlaybackSession?, isPlaying:Boolean, isAppClosed:Boolean) {
|
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, playbackSession: PlaybackSession?, isPlaying:Boolean, isAppClosed:Boolean) {
|
||||||
val tag = "MediaPlayerWidget"
|
val tag = "MediaPlayerWidget"
|
||||||
val views = RemoteViews(context.packageName, R.layout.media_player_widget)
|
val views = RemoteViews(context.packageName, R.layout.media_player_widget)
|
||||||
|
Log.i(tag, "updateAppWidget ${playbackSession?.displayTitle ?: "No Title"} isPlaying=$isPlaying isAppClosed=$isAppClosed")
|
||||||
val wholeWidgetClickI = Intent(context, MainActivity::class.java)
|
val wholeWidgetClickI = Intent(context, MainActivity::class.java)
|
||||||
wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
val wholeWidgetClickPI = PendingIntent.getActivity(
|
val wholeWidgetClickPI = PendingIntent.getActivity(
|
||||||
@@ -100,7 +77,7 @@ internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManage
|
|||||||
|
|
||||||
views.setOnClickPendingIntent(R.id.widgetBackground, wholeWidgetClickPI)
|
views.setOnClickPendingIntent(R.id.widgetBackground, wholeWidgetClickPI)
|
||||||
|
|
||||||
val imageUri = playbackSession?.getCoverUri() ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
val imageUri = playbackSession?.getCoverUri(context) ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||||
val awt: AppWidgetTarget = object : AppWidgetTarget(context.applicationContext, R.id.widgetAlbumArt, views, appWidgetId) {
|
val awt: AppWidgetTarget = object : AppWidgetTarget(context.applicationContext, R.id.widgetAlbumArt, views, appWidgetId) {
|
||||||
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||||
super.onResourceReady(resource, transition)
|
super.onResourceReady(resource, transition)
|
||||||
@@ -118,7 +95,7 @@ internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManage
|
|||||||
|
|
||||||
Log.i(tag, "Update App Widget | Is Playing=$isPlaying | isAppClosed=$isAppClosed")
|
Log.i(tag, "Update App Widget | Is Playing=$isPlaying | isAppClosed=$isAppClosed")
|
||||||
|
|
||||||
val playPauseResource = if (isPlaying) R.drawable.ic_media_pause_dark else R.drawable.ic_media_play_dark
|
val playPauseResource = if (isPlaying) androidx.mediarouter.R.drawable.ic_media_pause_dark else androidx.mediarouter.R.drawable.ic_media_play_dark
|
||||||
views.setImageViewResource(R.id.widgetPlayPauseButton, playPauseResource)
|
views.setImageViewResource(R.id.widgetPlayPauseButton, playPauseResource)
|
||||||
|
|
||||||
// Instruct the widget manager to update the widget
|
// Instruct the widget manager to update the widget
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ open class MediaType(var metadata:MediaTypeMetadata, var coverPath:String?) {
|
|||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
open fun removeAudioTrack(localFileId:String) { }
|
open fun removeAudioTrack(localFileId:String) { }
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata(""),null) }
|
open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata("", false),null) }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ class Podcast(
|
|||||||
audioTracks.forEach { at ->
|
audioTracks.forEach { at ->
|
||||||
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
|
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
|
||||||
val localEpisodeId = "local_ep_" + at.localFileId
|
val localEpisodeId = "local_ep_" + at.localFileId
|
||||||
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,at.title,null,null,null, null, null, at,at.duration,0, null, localEpisodeId)
|
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,at.title,null,null,null, null, null, at,null,at.duration,0, null, localEpisodeId)
|
||||||
episodes?.add(newEpisode)
|
episodes?.add(newEpisode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,7 +67,7 @@ class Podcast(
|
|||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
override fun addAudioTrack(audioTrack:AudioTrack) {
|
override fun addAudioTrack(audioTrack:AudioTrack) {
|
||||||
val localEpisodeId = "local_ep_" + audioTrack.localFileId
|
val localEpisodeId = "local_ep_" + audioTrack.localFileId
|
||||||
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null, null, null,audioTrack,audioTrack.duration,0, null, localEpisodeId)
|
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null, null, null,audioTrack,null,audioTrack.duration,0, null, localEpisodeId)
|
||||||
episodes?.add(newEpisode)
|
episodes?.add(newEpisode)
|
||||||
|
|
||||||
var index = 1
|
var index = 1
|
||||||
@@ -96,7 +96,7 @@ class Podcast(
|
|||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
|
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
|
||||||
val localEpisodeId = "local_ep_" + episode.id
|
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,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)
|
episodes?.add(newEpisode)
|
||||||
|
|
||||||
var index = 1
|
var index = 1
|
||||||
@@ -190,7 +190,7 @@ class Book(
|
|||||||
JsonSubTypes.Type(BookMetadata::class),
|
JsonSubTypes.Type(BookMetadata::class),
|
||||||
JsonSubTypes.Type(PodcastMetadata::class)
|
JsonSubTypes.Type(PodcastMetadata::class)
|
||||||
)
|
)
|
||||||
open class MediaTypeMetadata(var title:String) {
|
open class MediaTypeMetadata(var title:String, var explicit:Boolean) {
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
open fun getAuthorDisplayName():String { return "Unknown" }
|
open fun getAuthorDisplayName():String { return "Unknown" }
|
||||||
}
|
}
|
||||||
@@ -209,13 +209,13 @@ class BookMetadata(
|
|||||||
var isbn:String?,
|
var isbn:String?,
|
||||||
var asin:String?,
|
var asin:String?,
|
||||||
var language:String?,
|
var language:String?,
|
||||||
var explicit:Boolean,
|
explicit:Boolean,
|
||||||
// In toJSONExpanded
|
// In toJSONExpanded
|
||||||
var authorName:String?,
|
var authorName:String?,
|
||||||
var authorNameLF:String?,
|
var authorNameLF:String?,
|
||||||
var narratorName:String?,
|
var narratorName:String?,
|
||||||
var seriesName:String?
|
var seriesName:String?
|
||||||
) : MediaTypeMetadata(title) {
|
) : MediaTypeMetadata(title, explicit) {
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
override fun getAuthorDisplayName():String { return authorName ?: "Unknown" }
|
override fun getAuthorDisplayName():String { return authorName ?: "Unknown" }
|
||||||
}
|
}
|
||||||
@@ -225,8 +225,9 @@ class PodcastMetadata(
|
|||||||
title:String,
|
title:String,
|
||||||
var author:String?,
|
var author:String?,
|
||||||
var feedUrl:String?,
|
var feedUrl:String?,
|
||||||
var genres:MutableList<String>
|
var genres:MutableList<String>,
|
||||||
) : MediaTypeMetadata(title) {
|
explicit:Boolean
|
||||||
|
) : MediaTypeMetadata(title, explicit) {
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
override fun getAuthorDisplayName():String { return author ?: "Unknown" }
|
override fun getAuthorDisplayName():String { return author ?: "Unknown" }
|
||||||
}
|
}
|
||||||
@@ -251,15 +252,16 @@ data class PodcastEpisode(
|
|||||||
var publishedAt:Long?,
|
var publishedAt:Long?,
|
||||||
var audioFile:AudioFile?,
|
var audioFile:AudioFile?,
|
||||||
var audioTrack:AudioTrack?,
|
var audioTrack:AudioTrack?,
|
||||||
|
var chapters:List<BookChapter>?,
|
||||||
var duration:Double?,
|
var duration:Double?,
|
||||||
var size:Long?,
|
var size:Long?,
|
||||||
var serverEpisodeId:String?, // For local podcasts to match with server podcasts
|
var serverEpisodeId:String?, // For local podcasts to match with server podcasts
|
||||||
var localEpisodeId:String? // For Android Auto server episodes with local copy
|
var localEpisodeId:String? // For Android Auto server episodes with local copy
|
||||||
) {
|
) {
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
|
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
|
||||||
val coverUri = if (libraryItem is LocalLibraryItem) {
|
val coverUri = if (libraryItem is LocalLibraryItem) {
|
||||||
libraryItem.getCoverUri()
|
libraryItem.getCoverUri(ctx)
|
||||||
} else {
|
} else {
|
||||||
(libraryItem as LibraryItem).getCoverUri()
|
(libraryItem as LibraryItem).getCoverUri()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ enum class ShakeSensitivitySetting {
|
|||||||
VERY_LOW, LOW, MEDIUM, HIGH, VERY_HIGH
|
VERY_LOW, LOW, MEDIUM, HIGH, VERY_HIGH
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class DownloadUsingCellularSetting {
|
||||||
|
ASK, ALWAYS, NEVER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class StreamingUsingCellularSetting {
|
||||||
|
ASK, ALWAYS, NEVER
|
||||||
|
}
|
||||||
|
|
||||||
data class ServerConnectionConfig(
|
data class ServerConnectionConfig(
|
||||||
var id:String,
|
var id:String,
|
||||||
var index:Int,
|
var index:Int,
|
||||||
@@ -83,7 +91,7 @@ data class LocalFolder(
|
|||||||
)
|
)
|
||||||
open class LibraryItemWrapper(var id:String) {
|
open class LibraryItemWrapper(var id:String) {
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
open fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
|
open fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
@@ -91,7 +99,6 @@ data class DeviceInfo(
|
|||||||
var deviceId:String,
|
var deviceId:String,
|
||||||
var manufacturer:String,
|
var manufacturer:String,
|
||||||
var model:String,
|
var model:String,
|
||||||
var brand:String,
|
|
||||||
var sdkVersion:Int,
|
var sdkVersion:Int,
|
||||||
var clientVersion: String
|
var clientVersion: String
|
||||||
)
|
)
|
||||||
@@ -108,8 +115,10 @@ data class PlayItemRequestPayload(
|
|||||||
data class DeviceSettings(
|
data class DeviceSettings(
|
||||||
var disableAutoRewind:Boolean,
|
var disableAutoRewind:Boolean,
|
||||||
var enableAltView:Boolean,
|
var enableAltView:Boolean,
|
||||||
|
var allowSeekingOnMediaControls:Boolean,
|
||||||
var jumpBackwardsTime:Int,
|
var jumpBackwardsTime:Int,
|
||||||
var jumpForwardTime:Int,
|
var jumpForwardTime:Int,
|
||||||
|
var enableMp3IndexSeeking:Boolean,
|
||||||
var disableShakeToResetSleepTimer:Boolean,
|
var disableShakeToResetSleepTimer:Boolean,
|
||||||
var shakeSensitivity: ShakeSensitivitySetting,
|
var shakeSensitivity: ShakeSensitivitySetting,
|
||||||
var lockOrientation: LockOrientationSetting,
|
var lockOrientation: LockOrientationSetting,
|
||||||
@@ -121,7 +130,10 @@ data class DeviceSettings(
|
|||||||
var autoSleepTimerAutoRewindTime: Long, //Time in milliseconds
|
var autoSleepTimerAutoRewindTime: Long, //Time in milliseconds
|
||||||
var sleepTimerLength: Long, // Time in milliseconds
|
var sleepTimerLength: Long, // Time in milliseconds
|
||||||
var disableSleepTimerFadeOut: Boolean,
|
var disableSleepTimerFadeOut: Boolean,
|
||||||
var disableSleepTimerResetFeedback: Boolean
|
var disableSleepTimerResetFeedback: Boolean,
|
||||||
|
var languageCode: String,
|
||||||
|
var downloadUsingCellular: DownloadUsingCellularSetting,
|
||||||
|
var streamingUsingCellular: StreamingUsingCellularSetting
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
// Static method to get default device settings
|
// Static method to get default device settings
|
||||||
@@ -129,8 +141,10 @@ data class DeviceSettings(
|
|||||||
return DeviceSettings(
|
return DeviceSettings(
|
||||||
disableAutoRewind = false,
|
disableAutoRewind = false,
|
||||||
enableAltView = true,
|
enableAltView = true,
|
||||||
|
allowSeekingOnMediaControls = false,
|
||||||
jumpBackwardsTime = 10,
|
jumpBackwardsTime = 10,
|
||||||
jumpForwardTime = 10,
|
jumpForwardTime = 10,
|
||||||
|
enableMp3IndexSeeking = false,
|
||||||
disableShakeToResetSleepTimer = false,
|
disableShakeToResetSleepTimer = false,
|
||||||
shakeSensitivity = ShakeSensitivitySetting.MEDIUM,
|
shakeSensitivity = ShakeSensitivitySetting.MEDIUM,
|
||||||
lockOrientation = LockOrientationSetting.NONE,
|
lockOrientation = LockOrientationSetting.NONE,
|
||||||
@@ -142,7 +156,10 @@ data class DeviceSettings(
|
|||||||
autoSleepTimerAutoRewind = false,
|
autoSleepTimerAutoRewind = false,
|
||||||
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
|
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
|
||||||
disableSleepTimerFadeOut = false,
|
disableSleepTimerFadeOut = false,
|
||||||
disableSleepTimerResetFeedback = false
|
disableSleepTimerResetFeedback = false,
|
||||||
|
languageCode = "en-us",
|
||||||
|
downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS,
|
||||||
|
streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class LibraryItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
|
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
|
||||||
val extras = Bundle()
|
val extras = Bundle()
|
||||||
|
|
||||||
if (localLibraryItemId != null) {
|
if (localLibraryItemId != null) {
|
||||||
@@ -90,6 +90,10 @@ class LibraryItem(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (media.metadata.explicit) {
|
||||||
|
extras.putLong(MediaConstants.METADATA_KEY_IS_EXPLICIT, MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT)
|
||||||
|
}
|
||||||
|
|
||||||
val mediaId = localLibraryItemId ?: id
|
val mediaId = localLibraryItemId ?: id
|
||||||
return MediaDescriptionCompat.Builder()
|
return MediaDescriptionCompat.Builder()
|
||||||
.setMediaId(mediaId)
|
.setMediaId(mediaId)
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import android.os.Bundle
|
|||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.support.v4.media.MediaDescriptionCompat
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.net.toFile
|
||||||
import androidx.media.utils.MediaConstants
|
import androidx.media.utils.MediaConstants
|
||||||
import com.audiobookshelf.app.BuildConfig
|
import com.audiobookshelf.app.BuildConfig
|
||||||
import com.audiobookshelf.app.R
|
import com.audiobookshelf.app.R
|
||||||
@@ -46,7 +48,10 @@ class LocalLibraryItem(
|
|||||||
val isPodcast get() = mediaType == "podcast"
|
val isPodcast get() = mediaType == "podcast"
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getCoverUri(): Uri {
|
fun getCoverUri(ctx:Context): Uri {
|
||||||
|
if (coverContentUrl?.startsWith("file:") == true) {
|
||||||
|
return FileProvider.getUriForFile(ctx, "${BuildConfig.APPLICATION_ID}.fileprovider", Uri.parse(coverContentUrl).toFile())
|
||||||
|
}
|
||||||
return if (coverContentUrl != null) Uri.parse(coverContentUrl) else Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
return if (coverContentUrl != null) Uri.parse(coverContentUrl) else Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,9 +79,9 @@ class LocalLibraryItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getPlaybackSession(episode:PodcastEpisode?):PlaybackSession {
|
fun getPlaybackSession(episode:PodcastEpisode?, deviceInfo:DeviceInfo):PlaybackSession {
|
||||||
val localEpisodeId = episode?.id
|
val localEpisodeId = episode?.id
|
||||||
val sessionId = "play_local_${UUID.randomUUID()}"
|
val sessionId = "${UUID.randomUUID()}"
|
||||||
|
|
||||||
// Get current progress for local media
|
// Get current progress for local media
|
||||||
val mediaProgressId = if (localEpisodeId.isNullOrEmpty()) id else "$id-$localEpisodeId"
|
val mediaProgressId = if (localEpisodeId.isNullOrEmpty()) id else "$id-$localEpisodeId"
|
||||||
@@ -85,19 +90,20 @@ class LocalLibraryItem(
|
|||||||
|
|
||||||
|
|
||||||
val mediaMetadata = media.metadata
|
val mediaMetadata = media.metadata
|
||||||
val chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf()
|
var chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf()
|
||||||
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
|
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
|
||||||
val authorName = mediaMetadata.getAuthorDisplayName()
|
val authorName = mediaMetadata.getAuthorDisplayName()
|
||||||
val displayTitle = episode?.title ?: mediaMetadata.title
|
val displayTitle = episode?.title ?: mediaMetadata.title
|
||||||
var duration = getDuration()
|
var duration = getDuration()
|
||||||
if (episode != null) { // Get podcast episode audio track
|
if (episode != null) { // Get podcast episode audio track
|
||||||
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
|
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
|
||||||
|
chapters = episode.chapters
|
||||||
duration = episode.audioTrack?.duration ?: 0.0
|
duration = episode.audioTrack?.duration ?: 0.0
|
||||||
Log.d("LocalLibraryItem", "getPlaybackSession: Got podcast episode audio track ${audioTracks.size}")
|
Log.d("LocalLibraryItem", "getPlaybackSession: Got podcast episode audio track ${audioTracks.size}")
|
||||||
}
|
}
|
||||||
|
|
||||||
val dateNow = System.currentTimeMillis()
|
val dateNow = System.currentTimeMillis()
|
||||||
return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, chapters ?: mutableListOf(), displayTitle, authorName,null,duration,PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player")
|
return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, deviceInfo,chapters ?: mutableListOf(), displayTitle, authorName,null,duration,PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player")
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
@@ -106,18 +112,16 @@ class LocalLibraryItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context?): MediaDescriptionCompat {
|
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context): MediaDescriptionCompat {
|
||||||
val coverUri = getCoverUri()
|
val coverUri = getCoverUri(ctx)
|
||||||
|
|
||||||
var bitmap:Bitmap? = null
|
var bitmap:Bitmap? = null
|
||||||
if (coverContentUrl != null) {
|
if (coverContentUrl != null) {
|
||||||
ctx?.let {
|
bitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||||
bitmap = if (Build.VERSION.SDK_INT < 28) {
|
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||||
MediaStore.Images.Media.getBitmap(it.contentResolver, coverUri)
|
} else {
|
||||||
} else {
|
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
||||||
val source: ImageDecoder.Source = ImageDecoder.createSource(it.contentResolver, coverUri)
|
ImageDecoder.decodeBitmap(source)
|
||||||
ImageDecoder.decodeBitmap(source)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +152,10 @@ class LocalLibraryItem(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (media.metadata.explicit) {
|
||||||
|
extras.putLong(MediaConstants.METADATA_KEY_IS_EXPLICIT, MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT)
|
||||||
|
}
|
||||||
|
|
||||||
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
|
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
|
||||||
.setMediaId(id)
|
.setMediaId(id)
|
||||||
.setTitle(title)
|
.setTitle(title)
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ data class LocalMediaItem(
|
|||||||
return if (mediaType == "book") {
|
return if (mediaType == "book") {
|
||||||
BookMetadata(name,null, mutableListOf(), mutableListOf(), mutableListOf(),null,null,null,null,null,null,null,false,null,null,null,null)
|
BookMetadata(name,null, mutableListOf(), mutableListOf(), mutableListOf(),null,null,null,null,null,null,null,false,null,null,null,null)
|
||||||
} else {
|
} else {
|
||||||
PodcastMetadata(name,null,null, mutableListOf())
|
PodcastMetadata(name,null,null, mutableListOf(), false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import android.net.Uri
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.net.toFile
|
||||||
import com.audiobookshelf.app.BuildConfig
|
import com.audiobookshelf.app.BuildConfig
|
||||||
import com.audiobookshelf.app.R
|
import com.audiobookshelf.app.R
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
@@ -27,6 +29,7 @@ class PlaybackSession(
|
|||||||
var episodeId:String?,
|
var episodeId:String?,
|
||||||
var mediaType:String,
|
var mediaType:String,
|
||||||
var mediaMetadata:MediaTypeMetadata,
|
var mediaMetadata:MediaTypeMetadata,
|
||||||
|
var deviceInfo:DeviceInfo,
|
||||||
var chapters:List<BookChapter>,
|
var chapters:List<BookChapter>,
|
||||||
var displayTitle: String?,
|
var displayTitle: String?,
|
||||||
var displayAuthor: String?,
|
var displayAuthor: String?,
|
||||||
@@ -137,8 +140,15 @@ class PlaybackSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getCoverUri(): Uri {
|
fun getCoverUri(ctx:Context): Uri {
|
||||||
if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
if (localLibraryItem?.coverContentUrl != null) {
|
||||||
|
var coverUri = Uri.parse(localLibraryItem?.coverContentUrl.toString())
|
||||||
|
if (coverUri.toString().startsWith("file:")) {
|
||||||
|
coverUri = FileProvider.getUriForFile(ctx, "${BuildConfig.APPLICATION_ID}.fileprovider", coverUri.toFile())
|
||||||
|
}
|
||||||
|
|
||||||
|
return coverUri ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||||
|
}
|
||||||
|
|
||||||
if (coverPath == null) return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
if (coverPath == null) return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||||
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
|
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
|
||||||
@@ -152,6 +162,8 @@ class PlaybackSession(
|
|||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
|
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
|
||||||
|
val coverUri = getCoverUri(ctx)
|
||||||
|
|
||||||
val metadataBuilder = MediaMetadataCompat.Builder()
|
val metadataBuilder = MediaMetadataCompat.Builder()
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
|
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
|
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
|
||||||
@@ -162,16 +174,16 @@ class PlaybackSession(
|
|||||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
|
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
|
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCoverUri().toString())
|
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString())
|
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString())
|
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
|
||||||
|
|
||||||
// Local covers get bitmap
|
// Local covers get bitmap
|
||||||
if (localLibraryItem?.coverContentUrl != null) {
|
if (localLibraryItem?.coverContentUrl != null) {
|
||||||
val bitmap = if (Build.VERSION.SDK_INT < 28) {
|
val bitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, getCoverUri())
|
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||||
} else {
|
} else {
|
||||||
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, getCoverUri())
|
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
||||||
ImageDecoder.decodeBitmap(source)
|
ImageDecoder.decodeBitmap(source)
|
||||||
}
|
}
|
||||||
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
|
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
|
||||||
@@ -182,7 +194,9 @@ class PlaybackSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getExoMediaMetadata(audioTrack:AudioTrack): MediaMetadata {
|
fun getExoMediaMetadata(ctx:Context): MediaMetadata {
|
||||||
|
val coverUri = getCoverUri(ctx)
|
||||||
|
|
||||||
val metadataBuilder = MediaMetadata.Builder()
|
val metadataBuilder = MediaMetadata.Builder()
|
||||||
.setTitle(displayTitle)
|
.setTitle(displayTitle)
|
||||||
.setDisplayTitle(displayTitle)
|
.setDisplayTitle(displayTitle)
|
||||||
@@ -191,20 +205,18 @@ class PlaybackSession(
|
|||||||
.setSubtitle(displayAuthor)
|
.setSubtitle(displayAuthor)
|
||||||
.setAlbumTitle(displayAuthor)
|
.setAlbumTitle(displayAuthor)
|
||||||
.setDescription(displayAuthor)
|
.setDescription(displayAuthor)
|
||||||
.setArtworkUri(getCoverUri())
|
.setArtworkUri(coverUri)
|
||||||
|
.setMediaType(MediaMetadata.MEDIA_TYPE_AUDIO_BOOK)
|
||||||
val contentUri = this.getContentUri(audioTrack)
|
|
||||||
metadataBuilder.setMediaUri(contentUri)
|
|
||||||
|
|
||||||
return metadataBuilder.build()
|
return metadataBuilder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getMediaItems():List<MediaItem> {
|
fun getMediaItems(ctx:Context):List<MediaItem> {
|
||||||
val mediaItems:MutableList<MediaItem> = mutableListOf()
|
val mediaItems:MutableList<MediaItem> = mutableListOf()
|
||||||
|
|
||||||
for (audioTrack in audioTracks) {
|
for (audioTrack in audioTracks) {
|
||||||
val mediaMetadata = this.getExoMediaMetadata(audioTrack)
|
val mediaMetadata = this.getExoMediaMetadata(ctx)
|
||||||
val mediaUri = this.getContentUri(audioTrack)
|
val mediaUri = this.getContentUri(audioTrack)
|
||||||
val mimeType = audioTrack.mimeType
|
val mimeType = audioTrack.mimeType
|
||||||
|
|
||||||
@@ -252,7 +264,7 @@ class PlaybackSession(
|
|||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun clone():PlaybackSession {
|
fun clone():PlaybackSession {
|
||||||
return PlaybackSession(id,userId,libraryItemId,episodeId,mediaType,mediaMetadata,chapters,displayTitle,displayAuthor,coverPath,duration,playMethod,startedAt,updatedAt,timeListening,audioTracks,currentTime,libraryItem,localLibraryItem,localEpisodeId,serverConnectionConfigId,serverAddress, mediaPlayer)
|
return PlaybackSession(id,userId,libraryItemId,episodeId,mediaType,mediaMetadata,deviceInfo,chapters,displayTitle,displayAuthor,coverPath,duration,playMethod,startedAt,updatedAt,timeListening,audioTracks,currentTime,libraryItem,localLibraryItem,localEpisodeId,serverConnectionConfigId,serverAddress, mediaPlayer)
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
package com.audiobookshelf.app.device
|
package com.audiobookshelf.app.device
|
||||||
|
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.audiobookshelf.app.MediaPlayerWidget
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.managers.DbManager
|
import com.audiobookshelf.app.managers.DbManager
|
||||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||||
|
import com.audiobookshelf.app.updateAppWidget
|
||||||
|
|
||||||
interface WidgetEventEmitter {
|
interface WidgetEventEmitter {
|
||||||
fun onPlayerChanged(pns:PlayerNotificationService)
|
fun onPlayerChanged(pns:PlayerNotificationService)
|
||||||
|
fun onPlayerClosed()
|
||||||
}
|
}
|
||||||
|
|
||||||
object DeviceManager {
|
object DeviceManager {
|
||||||
@@ -43,6 +48,19 @@ object DeviceManager {
|
|||||||
if (deviceData.deviceSettings?.autoSleepTimerAutoRewindTime == null) {
|
if (deviceData.deviceSettings?.autoSleepTimerAutoRewindTime == null) {
|
||||||
deviceData.deviceSettings?.autoSleepTimerAutoRewindTime = 300000L // 5 minutes
|
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 {
|
fun getBase64Id(id:String):String {
|
||||||
@@ -76,4 +94,31 @@ object DeviceManager {
|
|||||||
deviceData.lastPlaybackSession = playbackSession
|
deviceData.lastPlaybackSession = playbackSession
|
||||||
dbManager.saveDeviceData(deviceData)
|
dbManager.saveDeviceData(deviceData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun initializeWidgetUpdater(context:Context) {
|
||||||
|
Log.d(tag, "Initializing widget updater")
|
||||||
|
widgetUpdater = (object : WidgetEventEmitter {
|
||||||
|
override fun onPlayerChanged(pns: PlayerNotificationService) {
|
||||||
|
|
||||||
|
val isPlaying = pns.currentPlayer.isPlaying
|
||||||
|
|
||||||
|
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||||
|
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||||
|
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||||
|
val playbackSession = pns.getCurrentPlaybackSessionCopy()
|
||||||
|
|
||||||
|
for (widgetId in ids) {
|
||||||
|
updateAppWidget(context, appWidgetManager, widgetId, playbackSession, isPlaying, PlayerNotificationService.isClosed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun onPlayerClosed() {
|
||||||
|
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||||
|
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||||
|
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||||
|
for (widgetId in ids) {
|
||||||
|
updateAppWidget(context, appWidgetManager, widgetId, deviceData.lastPlaybackSession, false, PlayerNotificationService.isClosed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,11 @@ import android.net.Uri
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import com.anggrayudi.storage.file.*
|
import com.anggrayudi.storage.file.*
|
||||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
|
||||||
import com.arthenica.ffmpegkit.FFprobeKit
|
|
||||||
import com.arthenica.ffmpegkit.Level
|
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.models.DownloadItem
|
import com.audiobookshelf.app.models.DownloadItem
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import com.fasterxml.jackson.module.kotlin.readValue
|
import java.io.File
|
||||||
import com.getcapacitor.JSObject
|
|
||||||
import org.json.JSONException
|
|
||||||
|
|
||||||
class FolderScanner(var ctx: Context) {
|
class FolderScanner(var ctx: Context) {
|
||||||
private val tag = "FolderScanner"
|
private val tag = "FolderScanner"
|
||||||
@@ -26,213 +21,190 @@ class FolderScanner(var ctx: Context) {
|
|||||||
return "local_" + DeviceManager.getBase64Id(mediaItemId)
|
return "local_" + DeviceManager.getBase64Id(mediaItemId)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class ItemScanResult {
|
private fun scanInternalDownloadItem(downloadItem:DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
|
||||||
ADDED, REMOVED, UPDATED, UPTODATE
|
val localLibraryItemId = "local_${downloadItem.libraryItemId}"
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: CLEAN this monster! Divide into bite-size methods
|
var localEpisodeId:String? = null
|
||||||
fun scanForMediaItems(localFolder:LocalFolder, forceAudioProbe:Boolean):FolderScanResult? {
|
var localLibraryItem:LocalLibraryItem?
|
||||||
FFmpegKitConfig.enableLogCallback { log ->
|
if (downloadItem.mediaType == "book") {
|
||||||
if (log.level != Level.AV_LOG_STDERR) { // STDERR is filled with junk
|
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, downloadItem.itemFolderPath, downloadItem.itemFolderPath, "", false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true, downloadItem.serverConnectionConfigId, downloadItem.serverAddress, downloadItem.serverUserId, downloadItem.libraryItemId)
|
||||||
Log.d(tag, "FFmpeg-Kit Log: (${log.level}) ${log.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localFolder.contentUrl))
|
|
||||||
|
|
||||||
if (df == null) {
|
|
||||||
Log.e(tag, "Folder Doc File Invalid $localFolder.contentUrl")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
var mediaItemsUpdated = 0
|
|
||||||
var mediaItemsAdded = 0
|
|
||||||
var mediaItemsRemoved = 0
|
|
||||||
var mediaItemsUpToDate = 0
|
|
||||||
|
|
||||||
// Search for files in media item folder
|
|
||||||
val foldersFound = df.search(true, DocumentFileType.FOLDER)
|
|
||||||
|
|
||||||
// Match folders found with local library items already saved in db
|
|
||||||
var existingLocalLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id)
|
|
||||||
|
|
||||||
// Remove existing items no longer there
|
|
||||||
existingLocalLibraryItems = existingLocalLibraryItems.filter { lli ->
|
|
||||||
Log.d(tag, "scanForMediaItems Checking Existing LLI ${lli.id}")
|
|
||||||
val fileFound = foldersFound.find { f -> lli.id == getLocalLibraryItemId(f.id) }
|
|
||||||
if (fileFound == null) {
|
|
||||||
Log.d(tag, "Existing local library item is no longer in file system ${lli.media.metadata.title}")
|
|
||||||
DeviceManager.dbManager.removeLocalLibraryItem(lli.id)
|
|
||||||
mediaItemsRemoved++
|
|
||||||
}
|
|
||||||
fileFound != null
|
|
||||||
}
|
|
||||||
|
|
||||||
foldersFound.forEach { itemFolder ->
|
|
||||||
Log.d(tag, "Iterating over Folder Found ${itemFolder.name} | ${itemFolder.getSimplePath(ctx)} | URI: ${itemFolder.uri}")
|
|
||||||
val existingItem = existingLocalLibraryItems.find { emi -> emi.id == getLocalLibraryItemId(itemFolder.id) }
|
|
||||||
|
|
||||||
val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/*"))
|
|
||||||
|
|
||||||
// Do not scan folders that have no media items and not an existing item already
|
|
||||||
if (existingItem != null || filesInFolder.isNotEmpty()) {
|
|
||||||
when (scanLibraryItemFolder(itemFolder, filesInFolder, localFolder, existingItem, forceAudioProbe)) {
|
|
||||||
ItemScanResult.REMOVED -> mediaItemsRemoved++
|
|
||||||
ItemScanResult.UPDATED -> mediaItemsUpdated++
|
|
||||||
ItemScanResult.ADDED -> mediaItemsAdded++
|
|
||||||
else -> mediaItemsUpToDate++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(tag, "Folder $${localFolder.name} scan Results: $mediaItemsAdded Added | $mediaItemsUpdated Updated | $mediaItemsRemoved Removed | $mediaItemsUpToDate Up-to-date")
|
|
||||||
|
|
||||||
return if (mediaItemsAdded > 0 || mediaItemsUpdated > 0 || mediaItemsRemoved > 0) {
|
|
||||||
val folderLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id) // Get all local media items
|
|
||||||
FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, folderLibraryItems)
|
|
||||||
} else {
|
|
||||||
Log.d(tag, "No Media Items to save")
|
|
||||||
FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, mutableListOf())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scanLibraryItemFolder(itemFolder:DocumentFile, filesInFolder:List<DocumentFile>, localFolder:LocalFolder, existingItem:LocalLibraryItem?, forceAudioProbe:Boolean):ItemScanResult {
|
|
||||||
val itemFolderName = itemFolder.name ?: ""
|
|
||||||
val itemId = getLocalLibraryItemId(itemFolder.id)
|
|
||||||
|
|
||||||
val existingLocalFiles = existingItem?.localFiles ?: mutableListOf()
|
|
||||||
val existingAudioTracks = existingItem?.media?.getAudioTracks() ?: mutableListOf()
|
|
||||||
var isNewOrUpdated = existingItem == null
|
|
||||||
|
|
||||||
val audioTracks = mutableListOf<AudioTrack>()
|
|
||||||
val localFiles = mutableListOf<LocalFile>()
|
|
||||||
var index = 1
|
|
||||||
var startOffset = 0.0
|
|
||||||
var coverContentUrl:String? = null
|
|
||||||
var coverAbsolutePath:String? = null
|
|
||||||
var hasEBookFile = false
|
|
||||||
var newEBookFile:EBookFile? = null
|
|
||||||
|
|
||||||
val existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
|
|
||||||
filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder
|
|
||||||
}
|
|
||||||
if (existingLocalFilesRemoved.isNotEmpty()) {
|
|
||||||
Log.d(tag, "${existingLocalFilesRemoved.size} Local files were removed from local media item ${existingItem?.media?.metadata?.title}")
|
|
||||||
isNewOrUpdated = true
|
|
||||||
}
|
|
||||||
|
|
||||||
filesInFolder.forEach { file ->
|
|
||||||
val mimeType = file.mimeType ?: ""
|
|
||||||
val filename = file.name ?: ""
|
|
||||||
Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName")
|
|
||||||
|
|
||||||
val localFileId = DeviceManager.getBase64Id(file.id)
|
|
||||||
|
|
||||||
val localFile = LocalFile(localFileId,filename,file.uri.toString(),file.getBasePath(ctx), file.getAbsolutePath(ctx),file.getSimplePath(ctx),mimeType,file.length())
|
|
||||||
localFiles.add(localFile)
|
|
||||||
|
|
||||||
Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}")
|
|
||||||
|
|
||||||
if (localFile.isAudioFile()) {
|
|
||||||
val audioTrackToAdd:AudioTrack?
|
|
||||||
|
|
||||||
val existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId }
|
|
||||||
if (existingAudioTrack != null) { // Update existing audio track
|
|
||||||
if (existingAudioTrack.index != index) {
|
|
||||||
Log.d(tag, "scanLibraryItemFolder Updating Audio track index from ${existingAudioTrack.index} to $index")
|
|
||||||
existingAudioTrack.index = index
|
|
||||||
isNewOrUpdated = true
|
|
||||||
}
|
|
||||||
if (existingAudioTrack.startOffset != startOffset) {
|
|
||||||
Log.d(tag, "scanLibraryItemFolder Updating Audio track startOffset ${existingAudioTrack.startOffset} to $startOffset")
|
|
||||||
existingAudioTrack.startOffset = startOffset
|
|
||||||
isNewOrUpdated = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingAudioTrack == null || forceAudioProbe) {
|
|
||||||
Log.d(tag, "scanLibraryItemFolder Scanning Audio File Path ${localFile.absolutePath} | ForceAudioProbe=${forceAudioProbe}")
|
|
||||||
|
|
||||||
// TODO: Make asynchronous
|
|
||||||
val audioProbeResult = probeAudioFile(localFile.absolutePath)
|
|
||||||
|
|
||||||
if (existingAudioTrack != null) {
|
|
||||||
// Update audio probe data on existing audio track
|
|
||||||
existingAudioTrack.audioProbeResult = audioProbeResult
|
|
||||||
audioTrackToAdd = existingAudioTrack
|
|
||||||
} else {
|
|
||||||
// Create new audio track
|
|
||||||
val track = AudioTrack(index, startOffset, audioProbeResult?.duration ?: 0.0, filename, localFile.contentUrl, mimeType, null, true, localFileId, audioProbeResult, null)
|
|
||||||
audioTrackToAdd = track
|
|
||||||
}
|
|
||||||
|
|
||||||
startOffset += audioProbeResult?.duration ?: 0.0
|
|
||||||
isNewOrUpdated = true
|
|
||||||
} else {
|
|
||||||
audioTrackToAdd = existingAudioTrack
|
|
||||||
}
|
|
||||||
|
|
||||||
startOffset += audioTrackToAdd.duration
|
|
||||||
index++
|
|
||||||
audioTracks.add(audioTrackToAdd)
|
|
||||||
} else if (localFile.isEBookFile()) {
|
|
||||||
val existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId }
|
|
||||||
|
|
||||||
if (localFolder.mediaType == "book") {
|
|
||||||
hasEBookFile = true
|
|
||||||
if (existingLocalFile == null) {
|
|
||||||
newEBookFile = EBookFile(localFileId, null, localFile.getEBookFormat() ?: "", true, localFileId, localFile.contentUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId }
|
|
||||||
|
|
||||||
if (existingLocalFile == null) {
|
|
||||||
Log.d(tag, "scanLibraryItemFolder new local file found ${localFile.absolutePath}")
|
|
||||||
isNewOrUpdated = true
|
|
||||||
}
|
|
||||||
if (existingItem != null && existingItem.coverContentUrl == null) {
|
|
||||||
// Existing media item did not have a cover - cover found on scan
|
|
||||||
Log.d(tag, "scanLibraryItemFolder setting cover ${localFile.absolutePath}")
|
|
||||||
isNewOrUpdated = true
|
|
||||||
existingItem.coverAbsolutePath = localFile.absolutePath
|
|
||||||
existingItem.coverContentUrl = localFile.contentUrl
|
|
||||||
existingItem.media.coverPath = localFile.absolutePath
|
|
||||||
}
|
|
||||||
|
|
||||||
// First image file use as cover path
|
|
||||||
if (coverContentUrl == null) {
|
|
||||||
coverContentUrl = localFile.contentUrl
|
|
||||||
coverAbsolutePath = localFile.absolutePath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingItem != null && audioTracks.isEmpty() && !hasEBookFile) {
|
|
||||||
Log.d(tag, "Local library item ${existingItem.media.metadata.title} no longer has audio tracks - removing item")
|
|
||||||
DeviceManager.dbManager.removeLocalLibraryItem(existingItem.id)
|
|
||||||
return ItemScanResult.REMOVED
|
|
||||||
} else if (existingItem != null && !isNewOrUpdated) {
|
|
||||||
Log.d(tag, "Local library item ${existingItem.media.metadata.title} has no updates")
|
|
||||||
return ItemScanResult.UPTODATE
|
|
||||||
} else if (existingItem != null) {
|
|
||||||
Log.d(tag, "Updating local library item ${existingItem.media.metadata.title}")
|
|
||||||
existingItem.updateFromScan(audioTracks,localFiles)
|
|
||||||
DeviceManager.dbManager.saveLocalLibraryItem(existingItem)
|
|
||||||
return ItemScanResult.UPDATED
|
|
||||||
} else if (audioTracks.isNotEmpty() || newEBookFile != null) {
|
|
||||||
Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks and ${localFiles.size} local files")
|
|
||||||
val localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, itemFolder.uri.toString(), itemFolder.getSimplePath(ctx), itemFolder.getBasePath(ctx), itemFolder.getAbsolutePath(ctx),audioTracks,newEBookFile,localFiles,coverContentUrl,coverAbsolutePath)
|
|
||||||
val localLibraryItem = localMediaItem.getLocalLibraryItem()
|
|
||||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
|
||||||
return ItemScanResult.ADDED
|
|
||||||
} else {
|
} else {
|
||||||
return ItemScanResult.UPTODATE
|
// Lookup or create podcast local library item
|
||||||
|
localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||||
|
if (localLibraryItem == null) {
|
||||||
|
Log.d(tag, "[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}")
|
||||||
|
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, downloadItem.itemFolderPath, downloadItem.itemFolderPath, "", false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.libraryItemId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val audioTracks:MutableList<AudioTrack> = mutableListOf()
|
||||||
|
var foundEBookFile = false
|
||||||
|
|
||||||
|
downloadItem.downloadItemParts.forEach { downloadItemPart ->
|
||||||
|
Log.d(tag, "Scan internal storage item with finalDestinationUri=${downloadItemPart.finalDestinationUri}")
|
||||||
|
|
||||||
|
val file = File(downloadItemPart.finalDestinationPath)
|
||||||
|
Log.d(tag, "Scan internal storage item created file ${file.name}")
|
||||||
|
|
||||||
|
if (file == null) {
|
||||||
|
Log.e(tag, "scanInternalDownloadItem: Null docFile for path ${downloadItemPart.finalDestinationPath}")
|
||||||
|
} else {
|
||||||
|
if (downloadItemPart.audioTrack != null) {
|
||||||
|
val audioTrackFromServer = downloadItemPart.audioTrack
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"scanInternalDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}"
|
||||||
|
)
|
||||||
|
|
||||||
|
val localFileId = DeviceManager.getBase64Id(file.name)
|
||||||
|
Log.d(tag, "Scan internal file localFileId=$localFileId")
|
||||||
|
val localFile = LocalFile(
|
||||||
|
localFileId,
|
||||||
|
file.name,
|
||||||
|
downloadItemPart.finalDestinationUri.toString(),
|
||||||
|
file.getBasePath(ctx),
|
||||||
|
file.absolutePath,
|
||||||
|
file.getSimplePath(ctx),
|
||||||
|
file.mimeType,
|
||||||
|
file.length()
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
audioTrackFromServer.startOffset,
|
||||||
|
audioTrackFromServer.duration,
|
||||||
|
localFile.filename ?: "",
|
||||||
|
localFile.contentUrl,
|
||||||
|
localFile.mimeType ?: "",
|
||||||
|
trackFileMetadata,
|
||||||
|
true,
|
||||||
|
localFileId,
|
||||||
|
null,
|
||||||
|
audioTrackFromServer.index
|
||||||
|
)
|
||||||
|
audioTracks.add(track)
|
||||||
|
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"scanInternalDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add podcast episodes to library
|
||||||
|
downloadItemPart.episode?.let { podcastEpisode ->
|
||||||
|
val podcast = localLibraryItem.media as Podcast
|
||||||
|
val newEpisode = podcast.addEpisode(track, podcastEpisode)
|
||||||
|
localEpisodeId = newEpisode.id
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"scanInternalDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (downloadItemPart.ebookFile != null) {
|
||||||
|
foundEBookFile = true
|
||||||
|
Log.d(tag, "scanInternalDownloadItem: Ebook file found with mimetype=${file.mimeType}")
|
||||||
|
val localFileId = DeviceManager.getBase64Id(file.name)
|
||||||
|
val localFile = LocalFile(
|
||||||
|
localFileId,
|
||||||
|
file.name,
|
||||||
|
Uri.fromFile(file).toString(),
|
||||||
|
file.getBasePath(ctx),
|
||||||
|
file.absolutePath,
|
||||||
|
file.getSimplePath(ctx),
|
||||||
|
file.mimeType,
|
||||||
|
file.length()
|
||||||
|
)
|
||||||
|
localLibraryItem.localFiles.add(localFile)
|
||||||
|
|
||||||
|
val ebookFile = EBookFile(
|
||||||
|
downloadItemPart.ebookFile.ino,
|
||||||
|
downloadItemPart.ebookFile.metadata,
|
||||||
|
downloadItemPart.ebookFile.ebookFormat,
|
||||||
|
true,
|
||||||
|
localFileId,
|
||||||
|
localFile.contentUrl
|
||||||
|
)
|
||||||
|
(localLibraryItem.media as Book).ebookFile = ebookFile
|
||||||
|
Log.d(tag, "scanInternalDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
|
||||||
|
} else {
|
||||||
|
val localFileId = DeviceManager.getBase64Id(file.name)
|
||||||
|
val localFile = LocalFile(localFileId,file.name,Uri.fromFile(file).toString(),file.getBasePath(ctx),file.absolutePath,file.getSimplePath(ctx),file.mimeType,file.length())
|
||||||
|
|
||||||
|
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
||||||
|
localLibraryItem.coverContentUrl = localFile.contentUrl
|
||||||
|
localLibraryItem.localFiles.add(localFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioTracks.isEmpty() && !foundEBookFile) {
|
||||||
|
Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}")
|
||||||
|
return cb(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For books sort audio tracks then set
|
||||||
|
if (downloadItem.mediaType == "book") {
|
||||||
|
audioTracks.sortBy { it.index }
|
||||||
|
|
||||||
|
var indexCheck = 1
|
||||||
|
var startOffset = 0.0
|
||||||
|
audioTracks.forEach { audioTrack ->
|
||||||
|
if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) {
|
||||||
|
audioTrack.index = indexCheck
|
||||||
|
audioTrack.startOffset = startOffset
|
||||||
|
}
|
||||||
|
indexCheck++
|
||||||
|
startOffset += audioTrack.duration
|
||||||
|
}
|
||||||
|
|
||||||
|
localLibraryItem.media.setAudioTracks(audioTracks)
|
||||||
|
}
|
||||||
|
|
||||||
|
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem,null)
|
||||||
|
|
||||||
|
// If library item had media progress then make local media progress and save
|
||||||
|
downloadItem.userMediaProgress?.let { mediaProgress ->
|
||||||
|
val localMediaProgressId = if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
||||||
|
val newLocalMediaProgress = LocalMediaProgress(
|
||||||
|
id = localMediaProgressId,
|
||||||
|
localLibraryItemId = localLibraryItemId,
|
||||||
|
localEpisodeId = localEpisodeId,
|
||||||
|
duration = mediaProgress.duration,
|
||||||
|
progress = mediaProgress.progress,
|
||||||
|
currentTime = mediaProgress.currentTime,
|
||||||
|
isFinished = mediaProgress.isFinished,
|
||||||
|
ebookLocation = mediaProgress.ebookLocation,
|
||||||
|
ebookProgress = mediaProgress.ebookProgress,
|
||||||
|
lastUpdate = mediaProgress.lastUpdate,
|
||||||
|
startedAt = mediaProgress.startedAt,
|
||||||
|
finishedAt = mediaProgress.finishedAt,
|
||||||
|
serverConnectionConfigId = downloadItem.serverConnectionConfigId,
|
||||||
|
serverAddress = downloadItem.serverAddress,
|
||||||
|
serverUserId = downloadItem.serverUserId,
|
||||||
|
libraryItemId = downloadItem.libraryItemId,
|
||||||
|
episodeId = downloadItem.episodeId)
|
||||||
|
Log.d(tag, "scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}")
|
||||||
|
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
|
||||||
|
|
||||||
|
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||||
|
|
||||||
|
cb(downloadItemScanResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan item after download and create local library item
|
// Scan item after download and create local library item
|
||||||
fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
|
fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
|
||||||
|
// If downloading to internal storage handle separately
|
||||||
|
if (downloadItem.isInternalStorage) {
|
||||||
|
scanInternalDownloadItem(downloadItem, cb)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
|
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
|
||||||
val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
|
val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
|
||||||
|
|
||||||
@@ -303,7 +275,8 @@ class FolderScanner(var ctx: Context) {
|
|||||||
localLibraryItem.localFiles.add(localFile)
|
localLibraryItem.localFiles.add(localFile)
|
||||||
|
|
||||||
// Create new audio track
|
// 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)
|
audioTracks.add(track)
|
||||||
|
|
||||||
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
|
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
|
||||||
@@ -370,7 +343,7 @@ class FolderScanner(var ctx: Context) {
|
|||||||
duration = mediaProgress.duration,
|
duration = mediaProgress.duration,
|
||||||
progress = mediaProgress.progress,
|
progress = mediaProgress.progress,
|
||||||
currentTime = mediaProgress.currentTime,
|
currentTime = mediaProgress.currentTime,
|
||||||
isFinished = false,
|
isFinished = mediaProgress.isFinished,
|
||||||
ebookLocation = mediaProgress.ebookLocation,
|
ebookLocation = mediaProgress.ebookLocation,
|
||||||
ebookProgress = mediaProgress.ebookProgress,
|
ebookProgress = mediaProgress.ebookProgress,
|
||||||
lastUpdate = mediaProgress.lastUpdate,
|
lastUpdate = mediaProgress.lastUpdate,
|
||||||
@@ -382,6 +355,7 @@ class FolderScanner(var ctx: Context) {
|
|||||||
libraryItemId = downloadItem.libraryItemId,
|
libraryItemId = downloadItem.libraryItemId,
|
||||||
episodeId = downloadItem.episodeId)
|
episodeId = downloadItem.episodeId)
|
||||||
Log.d(tag, "scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}")
|
Log.d(tag, "scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}")
|
||||||
|
|
||||||
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
|
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
|
||||||
|
|
||||||
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
|
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
|
||||||
@@ -391,132 +365,4 @@ class FolderScanner(var ctx: Context) {
|
|||||||
|
|
||||||
cb(downloadItemScanResult)
|
cb(downloadItemScanResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? {
|
|
||||||
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localLibraryItem.contentUrl))
|
|
||||||
|
|
||||||
if (df == null) {
|
|
||||||
Log.e(tag, "Item Folder Doc File Invalid ${localLibraryItem.absolutePath}")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
Log.d(tag, "scanLocalLibraryItem starting for ${localLibraryItem.absolutePath} | ${df.uri}")
|
|
||||||
|
|
||||||
var wasUpdated = false
|
|
||||||
|
|
||||||
// Search for files in media item folder
|
|
||||||
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/*"))
|
|
||||||
Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}")
|
|
||||||
|
|
||||||
filesFound.forEach {
|
|
||||||
try {
|
|
||||||
Log.d(tag, "Checking file found ${it.name} | ${it.id}")
|
|
||||||
}catch(e:Exception) {
|
|
||||||
Log.d(tag, "Check file found exception", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val existingAudioTracks = localLibraryItem.media.getAudioTracks()
|
|
||||||
|
|
||||||
// Remove any files no longer found in library item folder
|
|
||||||
val existingLocalFileIds = localLibraryItem.localFiles.map { it.id }
|
|
||||||
existingLocalFileIds.forEach { localFileId ->
|
|
||||||
Log.d(tag, "Checking local file id is there $localFileId")
|
|
||||||
if (filesFound.find { DeviceManager.getBase64Id(it.id) == localFileId } == null) {
|
|
||||||
Log.d(tag, "scanLocalLibraryItem file $localFileId was removed from ${localLibraryItem.absolutePath}")
|
|
||||||
localLibraryItem.localFiles.removeIf { it.id == localFileId }
|
|
||||||
|
|
||||||
if (existingAudioTracks.find { it.localFileId == localFileId } != null) {
|
|
||||||
Log.d(tag, "scanLocalLibraryItem audio track file $localFileId was removed from ${localLibraryItem.absolutePath}")
|
|
||||||
localLibraryItem.media.removeAudioTrack(localFileId)
|
|
||||||
}
|
|
||||||
wasUpdated = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
filesFound.forEach { docFile ->
|
|
||||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
|
||||||
val existingLocalFile = localLibraryItem.localFiles.find { it.id == localFileId }
|
|
||||||
|
|
||||||
if (existingLocalFile == null || (existingLocalFile.isAudioFile() && forceAudioProbe)) {
|
|
||||||
|
|
||||||
val localFile = existingLocalFile ?: LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx), docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
|
||||||
if (existingLocalFile == null) {
|
|
||||||
localLibraryItem.localFiles.add(localFile)
|
|
||||||
Log.d(tag, "scanLocalLibraryItem new file found ${localFile.filename}")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (localFile.isAudioFile()) {
|
|
||||||
// TODO: Make asynchronous
|
|
||||||
val audioProbeResult = probeAudioFile(localFile.absolutePath)
|
|
||||||
|
|
||||||
val existingTrack = existingAudioTracks.find { audioTrack ->
|
|
||||||
audioTrack.localFileId == localFileId
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingTrack == null) {
|
|
||||||
// Create new audio track
|
|
||||||
val lastTrack = existingAudioTracks.lastOrNull()
|
|
||||||
val startOffset = (lastTrack?.startOffset ?: 0.0) + (lastTrack?.duration ?: 0.0)
|
|
||||||
val track = AudioTrack(existingAudioTracks.size, startOffset, audioProbeResult?.duration ?: 0.0, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, null)
|
|
||||||
localLibraryItem.media.addAudioTrack(track)
|
|
||||||
Log.d(tag, "Added New Audio Track ${track.title}")
|
|
||||||
wasUpdated = true
|
|
||||||
} else {
|
|
||||||
existingTrack.audioProbeResult = audioProbeResult
|
|
||||||
// TODO: Update data found from probe
|
|
||||||
|
|
||||||
Log.d(tag, "Updated Audio Track Probe Data ${existingTrack.title}")
|
|
||||||
|
|
||||||
wasUpdated = true
|
|
||||||
}
|
|
||||||
} else if (localFile.isEBookFile()) {
|
|
||||||
if (localLibraryItem.mediaType == "book") {
|
|
||||||
val existingEbookFile = (localLibraryItem.media as Book).ebookFile
|
|
||||||
if (existingEbookFile == null || existingEbookFile.localFileId != localFileId) {
|
|
||||||
val ebookFile = EBookFile(localFileId, null, localFile.getEBookFormat() ?: "", true, localFileId, localFile.contentUrl)
|
|
||||||
(localLibraryItem.media as Book).ebookFile = ebookFile
|
|
||||||
Log.d(tag, "scanLocalLibraryItem: Ebook file added to lli ${localFile.contentUrl}")
|
|
||||||
wasUpdated = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else { // Check if cover is empty
|
|
||||||
if (localLibraryItem.coverContentUrl == null) {
|
|
||||||
Log.d(tag, "scanLocalLibraryItem setting cover for ${localLibraryItem.media.metadata.title}")
|
|
||||||
localLibraryItem.coverContentUrl = localFile.contentUrl
|
|
||||||
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
|
||||||
wasUpdated = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wasUpdated) {
|
|
||||||
Log.d(tag, "Local library item was updated - saving it")
|
|
||||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
|
||||||
} else {
|
|
||||||
Log.d(tag, "Local library item was up-to-date")
|
|
||||||
}
|
|
||||||
return LocalLibraryItemScanResult(wasUpdated, localLibraryItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun probeAudioFile(absolutePath:String):AudioProbeResult? {
|
|
||||||
val session = FFprobeKit.execute("-i \"${absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet")
|
|
||||||
|
|
||||||
var probeObject:JSObject? = null
|
|
||||||
try {
|
|
||||||
probeObject = JSObject(session.output)
|
|
||||||
} catch(error:JSONException) {
|
|
||||||
Log.e(tag, "Failed to parse probe result $error")
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(tag, "FFprobe output $probeObject")
|
|
||||||
return if (probeObject == null || !probeObject.has("streams")) { // Check if output is empty
|
|
||||||
Log.d(tag, "probeAudioFile Probe audio file $absolutePath failed or invalid")
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output)
|
|
||||||
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}")
|
|
||||||
audioProbeResult
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import kotlinx.coroutines.Dispatchers
|
|||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
class DownloadItemManager(var downloadManager:DownloadManager, private var folderScanner: FolderScanner, var mainActivity: MainActivity, private var clientEventEmitter:DownloadEventEmitter) {
|
class DownloadItemManager(var downloadManager:DownloadManager, private var folderScanner: FolderScanner, var mainActivity: MainActivity, private var clientEventEmitter:DownloadEventEmitter) {
|
||||||
@@ -44,6 +46,11 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
|||||||
fun onDownloadItemComplete(jsobj:JSObject)
|
fun onDownloadItemComplete(jsobj:JSObject)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface InternalProgressCallback {
|
||||||
|
fun onProgress(totalBytesWritten:Long, progress: Long)
|
||||||
|
fun onComplete(failed: Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
var isDownloading:Boolean = false
|
var isDownloading:Boolean = false
|
||||||
}
|
}
|
||||||
@@ -65,11 +72,33 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
|||||||
|
|
||||||
if (nextDownloadItemParts.size > 0) {
|
if (nextDownloadItemParts.size > 0) {
|
||||||
nextDownloadItemParts.forEach {
|
nextDownloadItemParts.forEach {
|
||||||
val dlRequest = it.getDownloadRequest()
|
if (it.isInternalStorage) {
|
||||||
val downloadId = downloadManager.enqueue(dlRequest)
|
val file = File(it.finalDestinationPath)
|
||||||
it.downloadId = downloadId
|
file.parentFile?.mkdirs()
|
||||||
Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId")
|
|
||||||
currentDownloadItemParts.add(it)
|
val fileOutputStream = FileOutputStream(it.finalDestinationPath)
|
||||||
|
val internalProgressCallback = (object : InternalProgressCallback {
|
||||||
|
override fun onProgress(totalBytesWritten:Long, progress: Long) {
|
||||||
|
it.bytesDownloaded = totalBytesWritten
|
||||||
|
it.progress = progress
|
||||||
|
}
|
||||||
|
override fun onComplete(failed:Boolean) {
|
||||||
|
it.failed = failed
|
||||||
|
it.completed = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
Log.d(tag, "Start internal download to destination path ${it.finalDestinationPath} from ${it.serverUrl}")
|
||||||
|
InternalDownloadManager(fileOutputStream, internalProgressCallback).download(it.serverUrl)
|
||||||
|
it.downloadId = 1
|
||||||
|
currentDownloadItemParts.add(it)
|
||||||
|
} else {
|
||||||
|
val dlRequest = it.getDownloadRequest()
|
||||||
|
val downloadId = downloadManager.enqueue(dlRequest)
|
||||||
|
it.downloadId = downloadId
|
||||||
|
Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId")
|
||||||
|
currentDownloadItemParts.add(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,14 +118,25 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
|||||||
isDownloading = true
|
isDownloading = true
|
||||||
|
|
||||||
while (currentDownloadItemParts.size > 0) {
|
while (currentDownloadItemParts.size > 0) {
|
||||||
|
|
||||||
val itemParts = currentDownloadItemParts.filter { !it.isMoving }.map { it }
|
val itemParts = currentDownloadItemParts.filter { !it.isMoving }.map { it }
|
||||||
for (downloadItemPart in itemParts) {
|
for (downloadItemPart in itemParts) {
|
||||||
val downloadCheckStatus = checkDownloadItemPart(downloadItemPart)
|
if (downloadItemPart.isInternalStorage) {
|
||||||
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||||
|
|
||||||
// Will move to final destination, remove current item parts, and check if download item is finished
|
if (downloadItemPart.completed) {
|
||||||
handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart)
|
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
|
||||||
|
downloadItem?.let {
|
||||||
|
checkDownloadItemFinished(it)
|
||||||
|
}
|
||||||
|
currentDownloadItemParts.remove(downloadItemPart)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val downloadCheckStatus = checkDownloadItemPart(downloadItemPart)
|
||||||
|
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||||
|
|
||||||
|
// Will move to final destination, remove current item parts, and check if download item is finished
|
||||||
|
handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
delay(500)
|
delay(500)
|
||||||
@@ -166,7 +206,6 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
|||||||
Log.e(tag, "Download item part finished but download item not found ${downloadItemPart.filename}")
|
Log.e(tag, "Download item part finished but download item not found ${downloadItemPart.filename}")
|
||||||
currentDownloadItemParts.remove(downloadItemPart)
|
currentDownloadItemParts.remove(downloadItemPart)
|
||||||
} else if (downloadCheckStatus == DownloadCheckStatus.Successful) {
|
} else if (downloadCheckStatus == DownloadCheckStatus.Successful) {
|
||||||
|
|
||||||
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
|
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
|
||||||
Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}")
|
Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.audiobookshelf.app.managers
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.common.net.HttpHeaders.CONTENT_LENGTH
|
||||||
|
import okhttp3.*
|
||||||
|
import java.io.*
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class InternalDownloadManager(outputStream:FileOutputStream, private val progressCallback: DownloadItemManager.InternalProgressCallback) : AutoCloseable {
|
||||||
|
private val tag = "InternalDownloadManager"
|
||||||
|
|
||||||
|
private val client: OkHttpClient = OkHttpClient()
|
||||||
|
private val writer = BinaryFileWriter(outputStream, progressCallback)
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun download(url:String) {
|
||||||
|
val request: Request = Request.Builder().url(url).build()
|
||||||
|
client.newCall(request).enqueue(object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
Log.e(tag, "download URL $url FAILED")
|
||||||
|
progressCallback.onComplete(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
val responseBody: ResponseBody = response.body
|
||||||
|
?: throw IllegalStateException("Response doesn't contain a file")
|
||||||
|
|
||||||
|
val length: Long = (response.header(CONTENT_LENGTH, "1") ?: "0").toLong()
|
||||||
|
writer.write(responseBody.byteStream(), length)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(Exception::class)
|
||||||
|
override fun close() {
|
||||||
|
writer.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BinaryFileWriter(outputStream: OutputStream, progressCallback: DownloadItemManager.InternalProgressCallback) :
|
||||||
|
AutoCloseable {
|
||||||
|
private val outputStream: OutputStream
|
||||||
|
private val progressCallback: DownloadItemManager.InternalProgressCallback
|
||||||
|
|
||||||
|
init {
|
||||||
|
this.outputStream = outputStream
|
||||||
|
this.progressCallback = progressCallback
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun write(inputStream: InputStream?, length: Long): Long {
|
||||||
|
BufferedInputStream(inputStream).use { input ->
|
||||||
|
val dataBuffer = ByteArray(CHUNK_SIZE)
|
||||||
|
var readBytes: Int
|
||||||
|
var totalBytes: Long = 0
|
||||||
|
while (input.read(dataBuffer).also { readBytes = it } != -1) {
|
||||||
|
totalBytes += readBytes.toLong()
|
||||||
|
outputStream.write(dataBuffer, 0, readBytes)
|
||||||
|
progressCallback.onProgress(totalBytes, (totalBytes * 100L) / length)
|
||||||
|
}
|
||||||
|
progressCallback.onComplete(false)
|
||||||
|
return totalBytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Throws(IOException::class)
|
||||||
|
override fun close() {
|
||||||
|
outputStream.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CHUNK_SIZE = 1024
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,7 +111,10 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
cb(serverItemsInProgress)
|
cb(serverItemsInProgress)
|
||||||
} else {
|
} else {
|
||||||
apiHandler.getAllItemsInProgress { itemsInProgress ->
|
apiHandler.getAllItemsInProgress { itemsInProgress ->
|
||||||
serverItemsInProgress = itemsInProgress
|
serverItemsInProgress = itemsInProgress.filter {
|
||||||
|
val libraryItem = it.libraryItemWrapper as LibraryItem
|
||||||
|
libraryItem.checkHasTracks()
|
||||||
|
}
|
||||||
cb(serverItemsInProgress)
|
cb(serverItemsInProgress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,14 +247,18 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
|
private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
|
||||||
Log.d(tag, "checkSetValidServerConnectionConfig | $serverConfigIdUsed")
|
Log.d(tag, "checkSetValidServerConnectionConfig | serverConfigIdUsed=$serverConfigIdUsed | lastServerConnectionConfigId=${DeviceManager.deviceData.lastServerConnectionConfigId}")
|
||||||
|
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
if (!DeviceManager.checkConnectivity(ctx)) {
|
if (!DeviceManager.checkConnectivity(ctx)) {
|
||||||
serverUserMediaProgress = mutableListOf()
|
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)
|
cb(false)
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
var hasValidConn = false
|
var hasValidConn = false
|
||||||
var lookupMediaProgress = true
|
var lookupMediaProgress = true
|
||||||
|
|
||||||
@@ -329,6 +336,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
checkSetValidServerConnectionConfig { isConnected ->
|
checkSetValidServerConnectionConfig { isConnected ->
|
||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
serverConfigIdUsed = DeviceManager.serverConnectionConfigId
|
serverConfigIdUsed = DeviceManager.serverConnectionConfigId
|
||||||
|
Log.d(tag, "loadAndroidAutoItems: Connected to server config id=$serverConfigIdUsed")
|
||||||
|
|
||||||
loadLibraries { libraries ->
|
loadLibraries { libraries ->
|
||||||
if (libraries.isEmpty()) {
|
if (libraries.isEmpty()) {
|
||||||
@@ -355,6 +363,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else { // Not connected to server
|
} else { // Not connected to server
|
||||||
|
Log.d(tag, "loadAndroidAutoItems: Not connected to server")
|
||||||
cb()
|
cb()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,7 +403,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
|
|
||||||
fun play(libraryItemWrapper:LibraryItemWrapper, episode:PodcastEpisode?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) {
|
fun play(libraryItemWrapper:LibraryItemWrapper, episode:PodcastEpisode?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) {
|
||||||
if (libraryItemWrapper is LocalLibraryItem) {
|
if (libraryItemWrapper is LocalLibraryItem) {
|
||||||
cb(libraryItemWrapper.getPlaybackSession(episode))
|
cb(libraryItemWrapper.getPlaybackSession(episode, playItemRequestPayload.deviceInfo))
|
||||||
} else {
|
} else {
|
||||||
val libraryItem = libraryItemWrapper as LibraryItem
|
val libraryItem = libraryItemWrapper as LibraryItem
|
||||||
apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "", playItemRequestPayload) {
|
apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "", playItemRequestPayload) {
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
|||||||
var currentLocalMediaProgress: LocalMediaProgress? = null
|
var currentLocalMediaProgress: LocalMediaProgress? = null
|
||||||
|
|
||||||
private val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
|
private val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
|
||||||
private val currentIsLocal get() = currentPlaybackSession?.isLocal == true
|
val currentIsLocal get() = currentPlaybackSession?.isLocal == true
|
||||||
private val currentSessionId get() = currentPlaybackSession?.id ?: ""
|
val currentSessionId get() = currentPlaybackSession?.id ?: ""
|
||||||
private val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
|
private val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
|
||||||
|
|
||||||
fun start(playbackSession:PlaybackSession) {
|
fun start(playbackSession:PlaybackSession) {
|
||||||
@@ -215,7 +215,6 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
|||||||
val listeningTimeToAdd = diffSinceLastSync / 1000L
|
val listeningTimeToAdd = diffSinceLastSync / 1000L
|
||||||
|
|
||||||
val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
|
val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
|
||||||
|
|
||||||
currentPlaybackSession?.syncData(syncData)
|
currentPlaybackSession?.syncData(syncData)
|
||||||
|
|
||||||
if (currentPlaybackSession?.progress?.isNaN() == true) {
|
if (currentPlaybackSession?.progress?.isNaN() == true) {
|
||||||
@@ -239,15 +238,12 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
|||||||
saveLocalProgress(it)
|
saveLocalProgress(it)
|
||||||
lastSyncTime = System.currentTimeMillis()
|
lastSyncTime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
Log.d(tag, "Sync local device current serverConnectionConfigId=${DeviceManager.serverConnectionConfig?.id}")
|
||||||
|
|
||||||
// Local library item is linked to a server library item
|
// 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
|
// 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) {
|
if (hasNetworkConnection && shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
|
||||||
apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg ->
|
apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg ->
|
||||||
Log.d(
|
|
||||||
tag,
|
|
||||||
"Local progress sync data sent to server $currentDisplayTitle for time $currentTime"
|
|
||||||
)
|
|
||||||
|
|
||||||
if (syncSuccess) {
|
if (syncSuccess) {
|
||||||
failedSyncs = 0
|
failedSyncs = 0
|
||||||
playerNotificationService.alertSyncSuccess()
|
playerNotificationService.alertSyncSuccess()
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ data class DownloadItem(
|
|||||||
val media: MediaType,
|
val media: MediaType,
|
||||||
val downloadItemParts: MutableList<DownloadItemPart>
|
val downloadItemParts: MutableList<DownloadItemPart>
|
||||||
) {
|
) {
|
||||||
|
@get:JsonIgnore
|
||||||
|
val isInternalStorage get() = localFolder.id.startsWith("internal-")
|
||||||
|
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving }
|
val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving }
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,12 @@ data class DownloadItemPart(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@get:JsonIgnore
|
||||||
|
val isInternalStorage get() = localFolderId.startsWith("internal-")
|
||||||
|
|
||||||
|
@get:JsonIgnore
|
||||||
|
val serverUrl get() = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getDownloadRequest(): DownloadManager.Request {
|
fun getDownloadRequest(): DownloadManager.Request {
|
||||||
val dlRequest = DownloadManager.Request(uri)
|
val dlRequest = DownloadManager.Request(uri)
|
||||||
|
|||||||
+2
-4
@@ -15,8 +15,6 @@ import com.google.android.exoplayer2.Player
|
|||||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
|
|
||||||
const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px
|
|
||||||
|
|
||||||
class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, private val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
|
class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, private val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
|
||||||
private val tag = "MediaDescriptionAdapter"
|
private val tag = "MediaDescriptionAdapter"
|
||||||
|
|
||||||
@@ -77,7 +75,7 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
|||||||
.load(uri)
|
.load(uri)
|
||||||
.placeholder(R.drawable.icon)
|
.placeholder(R.drawable.icon)
|
||||||
.error(R.drawable.icon)
|
.error(R.drawable.icon)
|
||||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
.submit()
|
||||||
.get()
|
.get()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
@@ -85,7 +83,7 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
|||||||
Glide.with(playerNotificationService)
|
Glide.with(playerNotificationService)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.load(Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon))
|
.load(Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon))
|
||||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
.submit()
|
||||||
.get()
|
.get()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ class CastManager constructor(val mainActivity:Activity) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun startRouteScan(connListener:ChromecastListener) {
|
fun startRouteScan(connListener:ChromecastListener) {
|
||||||
var callback = object : ScanCallback() {
|
val callback = object : ScanCallback() {
|
||||||
override fun onRouteUpdate(routes: List<MediaRouter.RouteInfo>?) {
|
override fun onRouteUpdate(routes: List<MediaRouter.RouteInfo>?) {
|
||||||
Log.d(tag, "CAST On ROUTE UPDATED ${routes?.size} | ${getContext().castState}")
|
Log.d(tag, "CAST On ROUTE UPDATED ${routes?.size} | ${getContext().castState}")
|
||||||
// if the routes have changed, we may have an available device
|
// if the routes have changed, we may have an available device
|
||||||
@@ -136,7 +136,7 @@ class CastManager constructor(val mainActivity:Activity) {
|
|||||||
// Let the client know a receiver is available
|
// Let the client know a receiver is available
|
||||||
connListener.onReceiverAvailableUpdate(true)
|
connListener.onReceiverAvailableUpdate(true)
|
||||||
// Since we have a receiver we may also have an active session
|
// Since we have a receiver we may also have an active session
|
||||||
var session = getSessionManager()?.currentCastSession
|
val session = getSessionManager()?.currentCastSession
|
||||||
// If we do have a session
|
// If we do have a session
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
// Let the client know
|
// Let the client know
|
||||||
@@ -246,15 +246,15 @@ class CastManager constructor(val mainActivity:Activity) {
|
|||||||
onRouteUpdate(outRoutes)
|
onRouteUpdate(outRoutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRouteAdded(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
|
override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) {
|
||||||
onFilteredRouteUpdate()
|
onFilteredRouteUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRouteChanged(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
|
override fun onRouteChanged(router: MediaRouter, route: MediaRouter.RouteInfo) {
|
||||||
onFilteredRouteUpdate()
|
onFilteredRouteUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRouteRemoved(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
|
override fun onRouteRemoved(router: MediaRouter, route: MediaRouter.RouteInfo) {
|
||||||
onFilteredRouteUpdate()
|
onFilteredRouteUpdate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ import com.google.android.exoplayer2.*
|
|||||||
import com.google.android.exoplayer2.C.TrackType
|
import com.google.android.exoplayer2.C.TrackType
|
||||||
import com.google.android.exoplayer2.MediaMetadata
|
import com.google.android.exoplayer2.MediaMetadata
|
||||||
import com.google.android.exoplayer2.Player.*
|
import com.google.android.exoplayer2.Player.*
|
||||||
import com.google.android.exoplayer2.TracksInfo.TrackGroupInfo
|
import com.google.android.exoplayer2.Tracks.Group
|
||||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||||
import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener
|
import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener
|
||||||
import com.google.android.exoplayer2.source.TrackGroup
|
import com.google.android.exoplayer2.source.TrackGroup
|
||||||
import com.google.android.exoplayer2.source.TrackGroupArray
|
import com.google.android.exoplayer2.source.TrackGroupArray
|
||||||
import com.google.android.exoplayer2.text.Cue
|
import com.google.android.exoplayer2.text.CueGroup
|
||||||
import com.google.android.exoplayer2.trackselection.TrackSelection
|
import com.google.android.exoplayer2.trackselection.TrackSelection
|
||||||
import com.google.android.exoplayer2.trackselection.TrackSelectionArray
|
import com.google.android.exoplayer2.trackselection.TrackSelectionArray
|
||||||
import com.google.android.exoplayer2.trackselection.TrackSelectionParameters
|
import com.google.android.exoplayer2.trackselection.TrackSelectionParameters
|
||||||
@@ -49,7 +49,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
private var myPlayWhenReady:Boolean = false
|
private var myPlayWhenReady:Boolean = false
|
||||||
private var playbackParameters:PlaybackParameters = PlaybackParameters.DEFAULT
|
private var playbackParameters:PlaybackParameters = PlaybackParameters.DEFAULT
|
||||||
private var myAvailableCommands: Commands
|
private var myAvailableCommands: Commands
|
||||||
private var myCurrentTracksInfo: TracksInfo
|
private var myCurrentTracksInfo: Tracks
|
||||||
private var myCurrentTrackGroups: TrackGroupArray
|
private var myCurrentTrackGroups: TrackGroupArray
|
||||||
private var myCurrentTrackSelections: TrackSelectionArray
|
private var myCurrentTrackSelections: TrackSelectionArray
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
COMMAND_GET_MEDIA_ITEMS_METADATA,
|
COMMAND_GET_MEDIA_ITEMS_METADATA,
|
||||||
COMMAND_SET_MEDIA_ITEMS_METADATA,
|
COMMAND_SET_MEDIA_ITEMS_METADATA,
|
||||||
COMMAND_CHANGE_MEDIA_ITEMS,
|
COMMAND_CHANGE_MEDIA_ITEMS,
|
||||||
COMMAND_GET_TRACK_INFOS)
|
COMMAND_GET_TRACKS)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
var currentPlaybackState = Player.STATE_IDLE
|
var currentPlaybackState = Player.STATE_IDLE
|
||||||
@@ -95,7 +95,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
Log.d(tag, "Init CastPlayer")
|
Log.d(tag, "Init CastPlayer")
|
||||||
myCurrentTrackGroups = TrackGroupArray.EMPTY
|
myCurrentTrackGroups = TrackGroupArray.EMPTY
|
||||||
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
|
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
|
||||||
myCurrentTracksInfo = TracksInfo.EMPTY
|
myCurrentTracksInfo = Tracks.EMPTY
|
||||||
statusListener = StatusListener()
|
statusListener = StatusListener()
|
||||||
timelineTracker = CastTimelineTracker()
|
timelineTracker = CastTimelineTracker()
|
||||||
myCurrentTimeline = CastTimeline.EMPTY_CAST_TIMELINE
|
myCurrentTimeline = CastTimeline.EMPTY_CAST_TIMELINE
|
||||||
@@ -385,7 +385,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
}
|
}
|
||||||
if (updateTracksAndSelectionsAndNotifyIfChanged()) {
|
if (updateTracksAndSelectionsAndNotifyIfChanged()) {
|
||||||
listeners.queueEvent(
|
listeners.queueEvent(
|
||||||
EVENT_TRACKS_CHANGED) { listener: Listener -> listener.onTracksInfoChanged(currentTracksInfo) }
|
EVENT_TRACKS_CHANGED) { listener: Listener -> listener.onTracksChanged(currentTracks) }
|
||||||
}
|
}
|
||||||
updateAvailableCommandsAndNotifyIfChanged()
|
updateAvailableCommandsAndNotifyIfChanged()
|
||||||
listeners.flushEvents()
|
listeners.flushEvents()
|
||||||
@@ -405,7 +405,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
val hasChanged = !myCurrentTrackGroups.isEmpty
|
val hasChanged = !myCurrentTrackGroups.isEmpty
|
||||||
myCurrentTrackGroups = TrackGroupArray.EMPTY
|
myCurrentTrackGroups = TrackGroupArray.EMPTY
|
||||||
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
|
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
|
||||||
myCurrentTracksInfo = TracksInfo.EMPTY
|
myCurrentTracksInfo = Tracks.EMPTY
|
||||||
return hasChanged
|
return hasChanged
|
||||||
}
|
}
|
||||||
var activeTrackIds = mediaStatus.activeTrackIds
|
var activeTrackIds = mediaStatus.activeTrackIds
|
||||||
@@ -414,7 +414,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
}
|
}
|
||||||
val trackGroups = arrayOfNulls<TrackGroup>(castMediaTracks.size)
|
val trackGroups = arrayOfNulls<TrackGroup>(castMediaTracks.size)
|
||||||
val trackSelections = arrayOfNulls<TrackSelection>(RENDERER_COUNT)
|
val trackSelections = arrayOfNulls<TrackSelection>(RENDERER_COUNT)
|
||||||
val trackGroupInfos = arrayOfNulls<TrackGroupInfo>(castMediaTracks.size)
|
val trackGroupInfos = arrayOfNulls<Group>(castMediaTracks.size)
|
||||||
for (i in castMediaTracks.indices) {
|
for (i in castMediaTracks.indices) {
|
||||||
val mediaTrack = castMediaTracks[i]
|
val mediaTrack = castMediaTracks[i]
|
||||||
trackGroups[i] = TrackGroup( /* id= */i.toString(), mediaTrackToFormat(mediaTrack))
|
trackGroups[i] = TrackGroup( /* id= */i.toString(), mediaTrackToFormat(mediaTrack))
|
||||||
@@ -428,18 +428,16 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
}
|
}
|
||||||
val trackSupport = intArrayOf(if (supported) C.FORMAT_HANDLED else C.FORMAT_UNSUPPORTED_TYPE)
|
val trackSupport = intArrayOf(if (supported) C.FORMAT_HANDLED else C.FORMAT_UNSUPPORTED_TYPE)
|
||||||
val trackSelected = booleanArrayOf(selected)
|
val trackSelected = booleanArrayOf(selected)
|
||||||
trackGroupInfos[i] = TrackGroupInfo(trackGroups[i]!!, trackSupport, trackType, trackSelected)
|
trackGroupInfos[i] = Group(trackGroups[i]!!, false, trackSupport, trackSelected)
|
||||||
}
|
}
|
||||||
|
|
||||||
val tg = trackGroups.filterNotNull().toTypedArray()
|
val tg = trackGroups.filterNotNull().toTypedArray()
|
||||||
var ts = trackSelections.filterNotNull().toTypedArray()
|
val ts = trackSelections.filterNotNull().toTypedArray()
|
||||||
var tgi = trackGroupInfos.filterNotNull().toMutableList()
|
val tgi = trackGroupInfos.filterNotNull().toMutableList()
|
||||||
val newTrackGroups = TrackGroupArray(*tg)
|
val newTrackGroups = TrackGroupArray(*tg)
|
||||||
val newTrackSelections = TrackSelectionArray(*ts)
|
val newTrackSelections = TrackSelectionArray(*ts)
|
||||||
val newTracksInfo = TracksInfo(tgi)
|
val newTracksInfo = Tracks(tgi)
|
||||||
if (newTrackGroups != currentTrackGroups
|
if (newTracksInfo != currentTracks) {
|
||||||
|| newTrackSelections != currentTrackSelections
|
|
||||||
|| newTracksInfo != currentTracksInfo) {
|
|
||||||
myCurrentTrackSelections = newTrackSelections
|
myCurrentTrackSelections = newTrackSelections
|
||||||
myCurrentTrackGroups = newTrackGroups
|
myCurrentTrackGroups = newTrackGroups
|
||||||
myCurrentTracksInfo = newTracksInfo
|
myCurrentTracksInfo = newTracksInfo
|
||||||
@@ -593,7 +591,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
listeners.flushEvents()
|
listeners.flushEvents()
|
||||||
val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult> = if (playWhenReady) remoteMediaClient!!.play() else remoteMediaClient!!.pause()
|
val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult> = if (playWhenReady) remoteMediaClient!!.play() else remoteMediaClient!!.pause()
|
||||||
|
|
||||||
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
val resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
||||||
updatePlayerStateAndNotifyIfChanged()
|
updatePlayerStateAndNotifyIfChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -622,10 +620,10 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun seekTo(mediaItemIndex: Int, positionMs: Long) {
|
override fun seekTo(mediaItemIndex: Int, positionMs: Long, seekCommand: Int, isRepeatingCurrentItem: Boolean) {
|
||||||
Log.d(tag, "seekTo $mediaItemIndex position $positionMs")
|
Log.d(tag, "seekTo $mediaItemIndex position $positionMs")
|
||||||
|
|
||||||
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
val resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
||||||
val statusCode: Int = it.status.statusCode
|
val statusCode: Int = it.status.statusCode
|
||||||
if (statusCode != CastStatusCodes.SUCCESS && statusCode != CastStatusCodes.REPLACED) {
|
if (statusCode != CastStatusCodes.SUCCESS && statusCode != CastStatusCodes.REPLACED) {
|
||||||
Log.e(tag, "Seek failed. Error code $statusCode")
|
Log.e(tag, "Seek failed. Error code $statusCode")
|
||||||
@@ -645,14 +643,14 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
|
|
||||||
// We assume the default position is 0. There is no support for seeking to the default position
|
// We assume the default position is 0. There is no support for seeking to the default position
|
||||||
// in RemoteMediaClient.
|
// in RemoteMediaClient.
|
||||||
var positionMsFinal = if (positionMs != C.TIME_UNSET) positionMs else 0
|
val positionMsFinal = if (positionMs != C.TIME_UNSET) positionMs else 0
|
||||||
if (mediaStatus != null) {
|
if (mediaStatus != null) {
|
||||||
if (currentMediaItemIndex != mediaItemIndex) {
|
if (currentMediaItemIndex != mediaItemIndex) {
|
||||||
Log.d(tag, "seekTo: Changing media item index from $currentMediaItemIndex to $mediaItemIndex")
|
Log.d(tag, "seekTo: Changing media item index from $currentMediaItemIndex to $mediaItemIndex")
|
||||||
remoteMediaClient?.queueJumpToItem(myCurrentTimeline.getPeriod(mediaItemIndex, period).uid as Int, positionMsFinal, JSONObject())?.setResultCallback(resultCb)
|
remoteMediaClient?.queueJumpToItem(myCurrentTimeline.getPeriod(mediaItemIndex, period).uid as Int, positionMsFinal, JSONObject())?.setResultCallback(resultCb)
|
||||||
} else {
|
} else {
|
||||||
Log.d(tag, "seekTo: Same media index seek to position $positionMsFinal")
|
Log.d(tag, "seekTo: Same media index seek to position $positionMsFinal")
|
||||||
var mediaSeekOptions = MediaSeekOptions.Builder().setPosition(positionMsFinal).build()
|
val mediaSeekOptions = MediaSeekOptions.Builder().setPosition(positionMsFinal).build()
|
||||||
remoteMediaClient?.seek(mediaSeekOptions)?.setResultCallback(resultCb)
|
remoteMediaClient?.seek(mediaSeekOptions)?.setResultCallback(resultCb)
|
||||||
}
|
}
|
||||||
val oldPosition = getCurrentPositionInfo()
|
val oldPosition = getCurrentPositionInfo()
|
||||||
@@ -709,7 +707,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
|
|
||||||
val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult>? = remoteMediaClient?.setPlaybackRate(actualPlaybackParameters.speed.toDouble(), /* customData= */null)
|
val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult>? = remoteMediaClient?.setPlaybackRate(actualPlaybackParameters.speed.toDouble(), /* customData= */null)
|
||||||
|
|
||||||
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
val resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
||||||
updatePlaybackRateAndNotifyIfChanged()
|
updatePlaybackRateAndNotifyIfChanged()
|
||||||
listeners.flushEvents()
|
listeners.flushEvents()
|
||||||
}
|
}
|
||||||
@@ -736,15 +734,7 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
sessionManager.endCurrentSession(false)
|
sessionManager.endCurrentSession(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getCurrentTrackGroups(): TrackGroupArray {
|
override fun getCurrentTracks(): Tracks {
|
||||||
return this.myCurrentTrackGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getCurrentTrackSelections(): TrackSelectionArray {
|
|
||||||
return this.myCurrentTrackSelections
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getCurrentTracksInfo(): TracksInfo {
|
|
||||||
return this.myCurrentTracksInfo
|
return this.myCurrentTracksInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -858,8 +848,12 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|||||||
return VideoSize.UNKNOWN
|
return VideoSize.UNKNOWN
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getCurrentCues(): MutableList<Cue> {
|
override fun getSurfaceSize(): Size {
|
||||||
return mutableListOf()
|
return Size.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCurrentCues(): CueGroup {
|
||||||
|
return CueGroup.EMPTY_TIME_ZERO
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getDeviceInfo(): DeviceInfo {
|
override fun getDeviceInfo(): DeviceInfo {
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onSeekTo(pos: Long) {
|
override fun onSeekTo(pos: Long) {
|
||||||
playerNotificationService.seekPlayer(pos)
|
val currentTrackStartOffset = playerNotificationService.getCurrentTrackStartOffsetMs()
|
||||||
|
playerNotificationService.seekPlayer(currentTrackStartOffset + pos)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onChangeSpeed() {
|
private fun onChangeSpeed() {
|
||||||
@@ -115,15 +116,16 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
|
|
||||||
if (mediaId.isNullOrEmpty()) {
|
if (mediaId.isNullOrEmpty()) {
|
||||||
libraryItemWrapper = playerNotificationService.mediaManager.getFirstItem()
|
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 {
|
} else {
|
||||||
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
|
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
|
||||||
|
if (libraryItemWithEpisode != null) {
|
||||||
if (libraryItemWrapper == null) {
|
libraryItemWrapper = libraryItemWithEpisode.libraryItemWrapper
|
||||||
Log.e(tag, "onPlayFromMediaId: Media item not found $mediaId")
|
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?
|
val libraryItemWrapper: LibraryItemWrapper?
|
||||||
var podcastEpisode: PodcastEpisode? = null
|
var podcastEpisode: PodcastEpisode? = null
|
||||||
|
|
||||||
if (mediaId.startsWith("ep_") || mediaId.startsWith("local_ep_")) { // Playing podcast episode
|
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
|
||||||
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
|
if (libraryItemWithEpisode != null) {
|
||||||
libraryItemWrapper = libraryItemWithEpisode?.libraryItemWrapper
|
libraryItemWrapper = libraryItemWithEpisode.libraryItemWrapper
|
||||||
podcastEpisode = libraryItemWithEpisode?.episode
|
podcastEpisode = libraryItemWithEpisode.episode
|
||||||
} else {
|
} else {
|
||||||
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
|
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
|||||||
|
|
||||||
lazyIsPlaying = isPlaying
|
lazyIsPlaying = isPlaying
|
||||||
|
|
||||||
|
// Update widget
|
||||||
|
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
|
||||||
|
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
Log.d(tag, "SeekBackTime: Player is playing")
|
Log.d(tag, "SeekBackTime: Player is playing")
|
||||||
if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) {
|
if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) {
|
||||||
@@ -109,8 +112,6 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
|||||||
}
|
}
|
||||||
|
|
||||||
playerNotificationService.clientEventEmitter?.onPlayingUpdate(isPlaying)
|
playerNotificationService.clientEventEmitter?.onPlayingUpdate(isPlaying)
|
||||||
|
|
||||||
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onEvents(player: Player, events: Player.Events) {
|
override fun onEvents(player: Player, events: Player.Events) {
|
||||||
|
|||||||
+1
-5
@@ -44,11 +44,7 @@ class PlayerNotificationListener(var playerNotificationService:PlayerNotificatio
|
|||||||
} else {
|
} else {
|
||||||
Log.d(tag, "onNotificationCancelled not dismissed by user")
|
Log.d(tag, "onNotificationCancelled not dismissed by user")
|
||||||
|
|
||||||
// When stop button is pressed on the notification I guess it isn't considered "dismissedByUser" so we need to close playback ourselves
|
if (PlayerNotificationService.isSwitchingPlayer) {
|
||||||
if (!PlayerNotificationService.isClosed && !PlayerNotificationService.isSwitchingPlayer) {
|
|
||||||
Log.d(tag, "PNS is not closed - closing it now")
|
|
||||||
playerNotificationService.closePlayback()
|
|
||||||
} else if (PlayerNotificationService.isSwitchingPlayer) {
|
|
||||||
// When switching from cast player to exo player and vice versa the notification is cancelled and posted again
|
// When switching from cast player to exo player and vice versa the notification is cancelled and posted again
|
||||||
// so we don't want to cancel the playback during this switch
|
// so we don't want to cancel the playback during this switch
|
||||||
Log.d(tag, "PNS is switching player")
|
Log.d(tag, "PNS is switching player")
|
||||||
|
|||||||
+123
-75
@@ -2,8 +2,6 @@ package com.audiobookshelf.app.player
|
|||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.*
|
import android.app.*
|
||||||
import android.appwidget.AppWidgetManager
|
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
@@ -11,10 +9,7 @@ import android.graphics.Color
|
|||||||
import android.graphics.ImageDecoder
|
import android.graphics.ImageDecoder
|
||||||
import android.hardware.Sensor
|
import android.hardware.Sensor
|
||||||
import android.hardware.SensorManager
|
import android.hardware.SensorManager
|
||||||
import android.net.ConnectivityManager
|
import android.net.*
|
||||||
import android.net.Network
|
|
||||||
import android.net.NetworkCapabilities
|
|
||||||
import android.net.NetworkRequest
|
|
||||||
import android.os.*
|
import android.os.*
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
@@ -25,14 +20,11 @@ import android.support.v4.media.session.MediaControllerCompat
|
|||||||
import android.support.v4.media.session.MediaSessionCompat
|
import android.support.v4.media.session.MediaSessionCompat
|
||||||
import android.support.v4.media.session.PlaybackStateCompat
|
import android.support.v4.media.session.PlaybackStateCompat
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.View
|
|
||||||
import android.widget.RemoteViews
|
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.media.MediaBrowserServiceCompat
|
import androidx.media.MediaBrowserServiceCompat
|
||||||
import androidx.media.utils.MediaConstants
|
import androidx.media.utils.MediaConstants
|
||||||
import com.audiobookshelf.app.BuildConfig
|
|
||||||
import com.audiobookshelf.app.R
|
import com.audiobookshelf.app.R
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.data.DeviceInfo
|
import com.audiobookshelf.app.data.DeviceInfo
|
||||||
@@ -42,11 +34,14 @@ import com.audiobookshelf.app.managers.SleepTimerManager
|
|||||||
import com.audiobookshelf.app.media.MediaManager
|
import com.audiobookshelf.app.media.MediaManager
|
||||||
import com.audiobookshelf.app.media.MediaProgressSyncer
|
import com.audiobookshelf.app.media.MediaProgressSyncer
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
|
import com.audiobookshelf.app.BuildConfig
|
||||||
import com.google.android.exoplayer2.*
|
import com.google.android.exoplayer2.*
|
||||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.CustomActionProvider
|
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.CustomActionProvider
|
||||||
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
|
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
|
||||||
|
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory
|
||||||
|
import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor
|
||||||
import com.google.android.exoplayer2.source.MediaSource
|
import com.google.android.exoplayer2.source.MediaSource
|
||||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||||
import com.google.android.exoplayer2.source.hls.HlsMediaSource
|
import com.google.android.exoplayer2.source.hls.HlsMediaSource
|
||||||
@@ -70,7 +65,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
var isSwitchingPlayer = false // Used when switching between cast player and exoplayer
|
var isSwitchingPlayer = false // Used when switching between cast player and exoplayer
|
||||||
}
|
}
|
||||||
|
|
||||||
private val tag = "PlayerNotificationService"
|
private val tag = "PlayerNotificationServ"
|
||||||
|
|
||||||
interface ClientEventEmitter {
|
interface ClientEventEmitter {
|
||||||
fun onPlaybackSession(playbackSession:PlaybackSession)
|
fun onPlaybackSession(playbackSession:PlaybackSession)
|
||||||
@@ -203,6 +198,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
super.onCreate()
|
super.onCreate()
|
||||||
ctx = this
|
ctx = this
|
||||||
|
|
||||||
|
// Initialize Paper
|
||||||
|
DbManager.initialize(ctx)
|
||||||
|
|
||||||
|
// Initialize widget
|
||||||
|
DeviceManager.initializeWidgetUpdater(ctx)
|
||||||
|
|
||||||
// To listen for network change from metered to unmetered
|
// To listen for network change from metered to unmetered
|
||||||
val networkRequest = NetworkRequest.Builder()
|
val networkRequest = NetworkRequest.Builder()
|
||||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
@@ -264,7 +265,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
playerNotificationManager.setUseNextAction(false)
|
playerNotificationManager.setUseNextAction(false)
|
||||||
playerNotificationManager.setUsePreviousAction(false)
|
playerNotificationManager.setUsePreviousAction(false)
|
||||||
playerNotificationManager.setUseChronometer(false)
|
playerNotificationManager.setUseChronometer(false)
|
||||||
playerNotificationManager.setUseStopAction(true)
|
playerNotificationManager.setUseStopAction(false)
|
||||||
playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
|
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||||
playerNotificationManager.setUseFastForwardActionInCompactView(true)
|
playerNotificationManager.setUseFastForwardActionInCompactView(true)
|
||||||
@@ -288,10 +289,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
return MediaDescriptionCompat.Builder().build()
|
return MediaDescriptionCompat.Builder().build()
|
||||||
}
|
}
|
||||||
|
|
||||||
val coverUri = currentPlaybackSession!!.getCoverUri()
|
val coverUri = currentPlaybackSession!!.getCoverUri(ctx)
|
||||||
|
|
||||||
var bitmap:Bitmap? = null
|
var bitmap: Bitmap? = null
|
||||||
// Local covers get bitmap
|
// Local covers get bitmap
|
||||||
if (currentPlaybackSession!!.localLibraryItem?.coverContentUrl != null) {
|
if (currentPlaybackSession!!.localLibraryItem?.coverContentUrl != null) {
|
||||||
bitmap = if (Build.VERSION.SDK_INT < 28) {
|
bitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||||
@@ -329,15 +330,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaSessionConnector.setEnabledPlaybackActions(
|
setMediaSessionConnectorPlaybackActions()
|
||||||
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
|
|
||||||
)
|
|
||||||
mediaSessionConnector.setQueueNavigator(queueNavigator)
|
mediaSessionConnector.setQueueNavigator(queueNavigator)
|
||||||
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
|
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
|
||||||
|
|
||||||
@@ -362,7 +355,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
.build()
|
.build()
|
||||||
mPlayer.setHandleAudioBecomingNoisy(true)
|
mPlayer.setHandleAudioBecomingNoisy(true)
|
||||||
mPlayer.addListener(PlayerListener(this))
|
mPlayer.addListener(PlayerListener(this))
|
||||||
val audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build()
|
val audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.AUDIO_CONTENT_TYPE_SPEECH).build()
|
||||||
mPlayer.setAudioAttributes(audioAttributes, true)
|
mPlayer.setAudioAttributes(audioAttributes, true)
|
||||||
|
|
||||||
//attach player to playerNotificationManager
|
//attach player to playerNotificationManager
|
||||||
@@ -394,7 +387,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
val metadata = playbackSession.getMediaMetadataCompat(ctx)
|
val metadata = playbackSession.getMediaMetadataCompat(ctx)
|
||||||
mediaSession.setMetadata(metadata)
|
mediaSession.setMetadata(metadata)
|
||||||
val mediaItems = playbackSession.getMediaItems()
|
val mediaItems = playbackSession.getMediaItems(ctx)
|
||||||
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
|
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
|
||||||
initialPlaybackRate = playbackRate
|
initialPlaybackRate = playbackRate
|
||||||
|
|
||||||
@@ -438,12 +431,26 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
if (playbackSession.isLocal) {
|
if (playbackSession.isLocal) {
|
||||||
Log.d(tag, "Playing Local Item")
|
Log.d(tag, "Playing Local Item")
|
||||||
val dataSourceFactory = DefaultDataSource.Factory(ctx)
|
val dataSourceFactory = DefaultDataSource.Factory(ctx)
|
||||||
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
|
|
||||||
|
val extractorsFactory = DefaultExtractorsFactory()
|
||||||
|
if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) {
|
||||||
|
// @see https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files
|
||||||
|
extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_ENABLE_INDEX_SEEKING)
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory).createMediaSource(mediaItems[0])
|
||||||
} else if (!playbackSession.isHLS) {
|
} else if (!playbackSession.isHLS) {
|
||||||
Log.d(tag, "Direct Playing Item")
|
Log.d(tag, "Direct Playing Item")
|
||||||
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
||||||
|
|
||||||
|
val extractorsFactory = DefaultExtractorsFactory()
|
||||||
|
if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) {
|
||||||
|
// @see https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files
|
||||||
|
extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_ENABLE_INDEX_SEEKING)
|
||||||
|
}
|
||||||
|
|
||||||
dataSourceFactory.setUserAgent(channelId)
|
dataSourceFactory.setUserAgent(channelId)
|
||||||
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
|
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory).createMediaSource(mediaItems[0])
|
||||||
} else {
|
} else {
|
||||||
Log.d(tag, "Playing HLS Item")
|
Log.d(tag, "Playing HLS Item")
|
||||||
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
||||||
@@ -483,7 +490,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setMediaSessionConnectorCustomActions(playbackSession:PlaybackSession) {
|
private fun setMediaSessionConnectorCustomActions(playbackSession:PlaybackSession) {
|
||||||
val mediaItems = playbackSession.getMediaItems()
|
val mediaItems = playbackSession.getMediaItems(ctx)
|
||||||
val customActionProviders = mutableListOf(
|
val customActionProviders = mutableListOf(
|
||||||
JumpBackwardCustomActionProvider(),
|
JumpBackwardCustomActionProvider(),
|
||||||
JumpForwardCustomActionProvider(),
|
JumpForwardCustomActionProvider(),
|
||||||
@@ -498,6 +505,20 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray())
|
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) {
|
fun handlePlayerPlaybackError(errorMessage:String) {
|
||||||
// On error and was attempting to direct play - fallback to transcode
|
// On error and was attempting to direct play - fallback to transcode
|
||||||
currentPlaybackSession?.let { playbackSession ->
|
currentPlaybackSession?.let { playbackSession ->
|
||||||
@@ -638,16 +659,20 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentTime() : Long {
|
fun getCurrentTrackStartOffsetMs() : Long {
|
||||||
return if (currentPlayer.mediaItemCount > 1) {
|
return if (currentPlayer.mediaItemCount > 1) {
|
||||||
val windowIndex = currentPlayer.currentMediaItemIndex
|
val windowIndex = currentPlayer.currentMediaItemIndex
|
||||||
val currentTrackStartOffset = currentPlaybackSession?.getTrackStartOffsetMs(windowIndex) ?: 0L
|
val currentTrackStartOffset = currentPlaybackSession?.getTrackStartOffsetMs(windowIndex) ?: 0L
|
||||||
currentPlayer.currentPosition + currentTrackStartOffset
|
currentTrackStartOffset
|
||||||
} else {
|
} else {
|
||||||
currentPlayer.currentPosition
|
0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getCurrentTime() : Long {
|
||||||
|
return currentPlayer.currentPosition + getCurrentTrackStartOffsetMs()
|
||||||
|
}
|
||||||
|
|
||||||
fun getCurrentTimeSeconds() : Double {
|
fun getCurrentTimeSeconds() : Double {
|
||||||
return getCurrentTime() / 1000.0
|
return getCurrentTime() / 1000.0
|
||||||
}
|
}
|
||||||
@@ -863,10 +888,26 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
fun closePlayback(calledOnError:Boolean? = false) {
|
fun closePlayback(calledOnError:Boolean? = false) {
|
||||||
Log.d(tag, "closePlayback")
|
Log.d(tag, "closePlayback")
|
||||||
|
val isLocal = mediaProgressSyncer.currentIsLocal
|
||||||
|
val currentSessionId = mediaProgressSyncer.currentSessionId
|
||||||
if (mediaProgressSyncer.listeningTimerRunning) {
|
if (mediaProgressSyncer.listeningTimerRunning) {
|
||||||
Log.i(tag, "About to close playback so stopping media progress syncer first")
|
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)
|
mediaProgressSyncer.stop(calledOnError == false) { // If closing on error then do not sync progress (causes exception)
|
||||||
Log.d(tag, "Media Progress syncer stopped")
|
Log.d(tag, "Media Progress syncer stopped")
|
||||||
|
|
||||||
|
// If not local session then close on server
|
||||||
|
if (!isLocal && currentSessionId != "") {
|
||||||
|
apiHandler.closePlaybackSession(currentSessionId) {
|
||||||
|
Log.d(tag, "Closed playback session $currentSessionId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If not local session then close on server
|
||||||
|
if (!isLocal && currentSessionId != "") {
|
||||||
|
apiHandler.closePlaybackSession(currentSessionId) {
|
||||||
|
Log.d(tag, "Closed playback session $currentSessionId")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -880,8 +921,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
currentPlaybackSession = null
|
currentPlaybackSession = null
|
||||||
mediaProgressSyncer.reset()
|
mediaProgressSyncer.reset()
|
||||||
clientEventEmitter?.onPlaybackClosed()
|
clientEventEmitter?.onPlaybackClosed()
|
||||||
|
|
||||||
PlayerListener.lastPauseTime = 0
|
PlayerListener.lastPauseTime = 0
|
||||||
isClosed = true
|
isClosed = true
|
||||||
|
DeviceManager.widgetUpdater?.onPlayerClosed()
|
||||||
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
@@ -896,7 +939,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("HardwareIds")
|
@SuppressLint("HardwareIds")
|
||||||
private fun getDeviceInfo(): DeviceInfo {
|
fun getDeviceInfo(): DeviceInfo {
|
||||||
/* EXAMPLE
|
/* EXAMPLE
|
||||||
manufacturer: Google
|
manufacturer: Google
|
||||||
model: Pixel 6
|
model: Pixel 6
|
||||||
@@ -905,7 +948,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
appVersion: 0.9.46-beta
|
appVersion: 0.9.46-beta
|
||||||
*/
|
*/
|
||||||
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
|
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
|
||||||
return DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
return DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
|
private val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
|
||||||
@@ -962,13 +1005,16 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
val extras = Bundle()
|
val extras = Bundle()
|
||||||
extras.putBoolean(
|
extras.putBoolean(
|
||||||
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true)
|
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true
|
||||||
|
)
|
||||||
extras.putInt(
|
extras.putInt(
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM)
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM
|
||||||
|
)
|
||||||
extras.putInt(
|
extras.putInt(
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM)
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM
|
||||||
|
)
|
||||||
|
|
||||||
BrowserRoot(AUTO_MEDIA_ROOT, extras)
|
BrowserRoot(AUTO_MEDIA_ROOT, extras)
|
||||||
}
|
}
|
||||||
@@ -979,39 +1025,19 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
result.detach()
|
result.detach()
|
||||||
|
|
||||||
if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes
|
if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads
|
||||||
Log.d(tag, "Loading podcast episodes")
|
|
||||||
mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId, ctx) {
|
|
||||||
result.sendResult(it)
|
|
||||||
}
|
|
||||||
} else if (::browseTree.isInitialized && browseTree[parentMediaId] == null && mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library
|
|
||||||
|
|
||||||
mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
|
|
||||||
val children = libraryItems.map { libraryItem ->
|
|
||||||
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
|
|
||||||
val mediaDescription = libraryItem.getMediaDescription(null, ctx)
|
|
||||||
MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
|
||||||
} else {
|
|
||||||
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id }
|
|
||||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id)
|
|
||||||
libraryItem.localLibraryItemId = localLibraryItem?.id
|
|
||||||
val description = libraryItem.getMediaDescription(progress, ctx)
|
|
||||||
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
|
||||||
}
|
|
||||||
} else if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads
|
|
||||||
|
|
||||||
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
||||||
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
|
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
|
||||||
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
|
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
|
||||||
|
|
||||||
localBooks.forEach { localLibraryItem ->
|
localBooks.forEach { localLibraryItem ->
|
||||||
val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id)
|
if (localLibraryItem.media.getAudioTracks().isNotEmpty()) {
|
||||||
val description = localLibraryItem.getMediaDescription(progress, ctx)
|
val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id)
|
||||||
|
val description = localLibraryItem.getMediaDescription(progress, ctx)
|
||||||
|
|
||||||
localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
localPodcasts.forEach { localLibraryItem ->
|
localPodcasts.forEach { localLibraryItem ->
|
||||||
@@ -1027,19 +1053,19 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
val progress: MediaProgressWrapper?
|
val progress: MediaProgressWrapper?
|
||||||
val mediaDescription:MediaDescriptionCompat
|
val mediaDescription:MediaDescriptionCompat
|
||||||
if (itemInProgress.episode != null) {
|
if (itemInProgress.episode != null) {
|
||||||
if (itemInProgress.isLocal) {
|
if (itemInProgress.isLocal) {
|
||||||
progress = DeviceManager.dbManager.getLocalMediaProgress("${itemInProgress.libraryItemWrapper.id}-${itemInProgress.episode.id}")
|
progress = DeviceManager.dbManager.getLocalMediaProgress("${itemInProgress.libraryItemWrapper.id}-${itemInProgress.episode.id}")
|
||||||
} else {
|
} else {
|
||||||
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id && it.episodeId == itemInProgress.episode.id }
|
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id && it.episodeId == itemInProgress.episode.id }
|
||||||
|
|
||||||
// to show download icon
|
// to show download icon
|
||||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(itemInProgress.libraryItemWrapper.id)
|
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(itemInProgress.libraryItemWrapper.id)
|
||||||
localLibraryItem?.let { lli ->
|
localLibraryItem?.let { lli ->
|
||||||
val localEpisode = (lli.media as Podcast).episodes?.find { it.serverEpisodeId == itemInProgress.episode.id }
|
val localEpisode = (lli.media as Podcast).episodes?.find { it.serverEpisodeId == itemInProgress.episode.id }
|
||||||
itemInProgress.episode.localEpisodeId = localEpisode?.id
|
itemInProgress.episode.localEpisodeId = localEpisode?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
mediaDescription = itemInProgress.episode.getMediaDescription(itemInProgress.libraryItemWrapper, progress, ctx)
|
mediaDescription = itemInProgress.episode.getMediaDescription(itemInProgress.libraryItemWrapper, progress, ctx)
|
||||||
} else {
|
} else {
|
||||||
if (itemInProgress.isLocal) {
|
if (itemInProgress.isLocal) {
|
||||||
@@ -1055,16 +1081,38 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
}
|
}
|
||||||
result.sendResult(localBrowseItems)
|
result.sendResult(localBrowseItems)
|
||||||
} else {
|
} else if (parentMediaId == LIBRARIES_ROOT || parentMediaId == AUTO_MEDIA_ROOT) {
|
||||||
mediaManager.loadAndroidAutoItems {
|
mediaManager.loadAndroidAutoItems {
|
||||||
browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries)
|
browseTree = BrowseTree(this, mediaManager.serverItemsInProgress, mediaManager.serverLibraries)
|
||||||
|
|
||||||
val children = browseTree[parentMediaId]?.map { item ->
|
val children = browseTree[parentMediaId]?.map { item ->
|
||||||
Log.d(tag, "Loading Browser Media Item ${item.description.title}")
|
Log.d(tag, "Loading Browser Media Item ${item.description.title}")
|
||||||
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||||
}
|
}
|
||||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||||
}
|
}
|
||||||
|
} else if (mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library
|
||||||
|
Log.d(tag, "Loading items for library $parentMediaId")
|
||||||
|
mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
|
||||||
|
val children = libraryItems.map { libraryItem ->
|
||||||
|
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
|
||||||
|
val mediaDescription = libraryItem.getMediaDescription(null, ctx)
|
||||||
|
MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||||
|
} else {
|
||||||
|
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id }
|
||||||
|
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id)
|
||||||
|
libraryItem.localLibraryItemId = localLibraryItem?.id
|
||||||
|
val description = libraryItem.getMediaDescription(progress, ctx)
|
||||||
|
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.d(tag, "Loading podcast episodes for podcast $parentMediaId")
|
||||||
|
mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId, ctx) {
|
||||||
|
result.sendResult(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
|
|
||||||
Handler(Looper.getMainLooper()).post {
|
Handler(Looper.getMainLooper()).post {
|
||||||
Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}")
|
Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}")
|
||||||
val playbackSession = it.getPlaybackSession(episode)
|
val playbackSession = it.getPlaybackSession(episode, playerNotificationService.getDeviceInfo())
|
||||||
if (startTimeOverride != null) {
|
if (startTimeOverride != null) {
|
||||||
Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride")
|
Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride")
|
||||||
playbackSession.currentTime = startTimeOverride
|
playbackSession.currentTime = startTimeOverride
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.audiobookshelf.app.plugins
|
package com.audiobookshelf.app.plugins
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.MainActivity
|
import com.audiobookshelf.app.MainActivity
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
@@ -231,9 +233,15 @@ class AbsDatabase : Plugin() {
|
|||||||
if (!success) {
|
if (!success) {
|
||||||
call.resolve(JSObject("{\"error\":\"$errorMsg\"}"))
|
call.resolve(JSObject("{\"error\":\"$errorMsg\"}"))
|
||||||
} else {
|
} else {
|
||||||
|
// Remove all local sessions
|
||||||
|
savedSessions.forEach {
|
||||||
|
DeviceManager.dbManager.removePlaybackSession(it.id)
|
||||||
|
}
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
call.resolve()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -487,9 +495,16 @@ class AbsDatabase : Plugin() {
|
|||||||
fun updateDeviceSettings(call:PluginCall) { // Returns device data
|
fun updateDeviceSettings(call:PluginCall) { // Returns device data
|
||||||
Log.d(tag, "updateDeviceSettings ${call.data}")
|
Log.d(tag, "updateDeviceSettings ${call.data}")
|
||||||
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
|
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
DeviceManager.deviceData.deviceSettings = newDeviceSettings
|
DeviceManager.deviceData.deviceSettings = newDeviceSettings
|
||||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
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)))
|
call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.deviceData)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class AbsDownloader : Plugin() {
|
|||||||
val libraryItemId = call.data.getString("libraryItemId").toString()
|
val libraryItemId = call.data.getString("libraryItemId").toString()
|
||||||
var episodeId = call.data.getString("episodeId").toString()
|
var episodeId = call.data.getString("episodeId").toString()
|
||||||
if (episodeId == "null") episodeId = ""
|
if (episodeId == "null") episodeId = ""
|
||||||
val localFolderId = call.data.getString("localFolderId").toString()
|
var localFolderId = call.data.getString("localFolderId", "").toString()
|
||||||
Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId")
|
Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId")
|
||||||
|
|
||||||
val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId"
|
val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId"
|
||||||
@@ -72,9 +72,18 @@ class AbsDownloader : Plugin() {
|
|||||||
} else {
|
} else {
|
||||||
Log.d(tag, "Got library item from server ${libraryItem.id}")
|
Log.d(tag, "Got library item from server ${libraryItem.id}")
|
||||||
|
|
||||||
val localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
|
if (localFolderId == "") {
|
||||||
if (localFolder != null) {
|
localFolderId = "internal-${libraryItem.mediaType}"
|
||||||
|
}
|
||||||
|
var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
|
||||||
|
|
||||||
|
if (localFolder == null && localFolderId.startsWith("internal-")) {
|
||||||
|
Log.d(tag, "Creating new App Storage internal LocalFolder $localFolderId")
|
||||||
|
localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "", "internal", libraryItem.mediaType)
|
||||||
|
DeviceManager.dbManager.saveLocalFolder(localFolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localFolder != null) {
|
||||||
if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
|
if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
|
||||||
Log.e(tag, "Library item is not a podcast but episode was requested")
|
Log.e(tag, "Library item is not a podcast but episode was requested")
|
||||||
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
|
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
|
||||||
@@ -100,12 +109,6 @@ class AbsDownloader : Plugin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean folder path so it can be used in URL
|
|
||||||
private fun cleanRelPath(relPath: String): String {
|
|
||||||
val cleanedRelPath = relPath.replace("\\", "/").replace("%", "%25").replace("#", "%23")
|
|
||||||
return if (cleanedRelPath.startsWith("/")) cleanedRelPath.substring(1) else cleanedRelPath
|
|
||||||
}
|
|
||||||
|
|
||||||
// Item filenames could be the same if they are in sub-folders, this will make them unique
|
// Item filenames could be the same if they are in sub-folders, this will make them unique
|
||||||
private fun getFilenameFromRelPath(relPath: String): String {
|
private fun getFilenameFromRelPath(relPath: String): String {
|
||||||
var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
|
var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
|
||||||
@@ -127,7 +130,9 @@ class AbsDownloader : Plugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
|
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
|
||||||
val tempFolderPath = mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
val isInternal = localFolder.id.startsWith("internal-")
|
||||||
|
|
||||||
|
val tempFolderPath = if (isInternal) "${mainActivity.filesDir}/downloads/${libraryItem.id}" else mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
||||||
|
|
||||||
Log.d(tag, "downloadCacheDirectory=$tempFolderPath")
|
Log.d(tag, "downloadCacheDirectory=$tempFolderPath")
|
||||||
|
|
||||||
@@ -138,13 +143,13 @@ class AbsDownloader : Plugin() {
|
|||||||
val tracks = libraryItem.media.getAudioTracks()
|
val tracks = libraryItem.media.getAudioTracks()
|
||||||
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
|
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
|
||||||
val itemSubfolder = "$bookAuthor/$bookTitle"
|
val itemSubfolder = "$bookAuthor/$bookTitle"
|
||||||
val itemFolderPath = "${localFolder.absolutePath}/$itemSubfolder"
|
val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$itemSubfolder"
|
||||||
val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf())
|
val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf())
|
||||||
|
|
||||||
val book = libraryItem.media as Book
|
val book = libraryItem.media as Book
|
||||||
book.ebookFile?.let { ebookFile ->
|
book.ebookFile?.let { ebookFile ->
|
||||||
val fileSize = ebookFile.metadata?.size ?: 0
|
val fileSize = ebookFile.metadata?.size ?: 0
|
||||||
val serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(ebookFile.metadata?.relPath ?: "")}"
|
val serverPath = "/api/items/${libraryItem.id}/file/${ebookFile.ino}/download"
|
||||||
val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "")
|
val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "")
|
||||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||||
@@ -164,9 +169,14 @@ class AbsDownloader : Plugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create download item part for each audio track
|
// Create download item part for each audio track
|
||||||
|
val audioFiles = (libraryItem.media as Book).audioFiles ?: mutableListOf()
|
||||||
tracks.forEach { audioTrack ->
|
tracks.forEach { audioTrack ->
|
||||||
val fileSize = audioTrack.metadata?.size ?: 0
|
val fileSize = audioTrack.metadata?.size ?: 0
|
||||||
val serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack.relPath)}"
|
|
||||||
|
// TODO: Currently file ino is only stored on AudioFile. This should be updated server side to be in FileMetadata or on the AudioTrack
|
||||||
|
val audioFileIno = audioFiles.find { it.metadata.path == audioTrack.metadata?.path }?.ino
|
||||||
|
|
||||||
|
val serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
|
||||||
val destinationFilename = getFilenameFromRelPath(audioTrack.relPath)
|
val destinationFilename = getFilenameFromRelPath(audioTrack.relPath)
|
||||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
||||||
|
|
||||||
@@ -219,14 +229,15 @@ class AbsDownloader : Plugin() {
|
|||||||
val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
|
val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
|
||||||
|
|
||||||
val audioTrack = episode?.audioTrack
|
val audioTrack = episode?.audioTrack
|
||||||
|
val audioFileIno = episode?.audioFile?.ino
|
||||||
val fileSize = audioTrack?.metadata?.size ?: 0
|
val fileSize = audioTrack?.metadata?.size ?: 0
|
||||||
|
|
||||||
Log.d(tag, "Starting podcast episode download")
|
Log.d(tag, "Starting podcast episode download")
|
||||||
val itemFolderPath = localFolder.absolutePath + "/" + podcastTitle
|
val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$podcastTitle"
|
||||||
val downloadItemId = "${libraryItem.id}-${episode?.id}"
|
val downloadItemId = "${libraryItem.id}-${episode?.id}"
|
||||||
val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf())
|
val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf())
|
||||||
|
|
||||||
var serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack?.relPath ?: "")}"
|
var serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
|
||||||
var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
|
var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
|
||||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.audiobookshelf.app.plugins
|
package com.audiobookshelf.app.plugins
|
||||||
|
|
||||||
import android.app.AlertDialog
|
import android.app.AlertDialog
|
||||||
import android.database.Cursor
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -13,16 +12,11 @@ import com.anggrayudi.storage.callback.StorageAccessCallback
|
|||||||
import com.anggrayudi.storage.file.*
|
import com.anggrayudi.storage.file.*
|
||||||
import com.audiobookshelf.app.MainActivity
|
import com.audiobookshelf.app.MainActivity
|
||||||
import com.audiobookshelf.app.data.LocalFolder
|
import com.audiobookshelf.app.data.LocalFolder
|
||||||
import com.audiobookshelf.app.data.LocalLibraryItem
|
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.audiobookshelf.app.device.FolderScanner
|
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import com.getcapacitor.*
|
import com.getcapacitor.*
|
||||||
import com.getcapacitor.annotation.CapacitorPlugin
|
import com.getcapacitor.annotation.CapacitorPlugin
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@CapacitorPlugin(name = "AbsFileSystem")
|
@CapacitorPlugin(name = "AbsFileSystem")
|
||||||
@@ -166,23 +160,10 @@ class AbsFileSystem : Plugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun scanFolder(call: PluginCall) {
|
fun getSDKVersion(call: PluginCall) {
|
||||||
val folderId = call.data.getString("folderId", "").toString()
|
val jsObject = JSObject()
|
||||||
val forceAudioProbe = call.data.getBoolean("forceAudioProbe")
|
jsObject.put("version", Build.VERSION.SDK_INT)
|
||||||
Log.d(TAG, "Scan Folder $folderId | Force Audio Probe $forceAudioProbe")
|
call.resolve(jsObject)
|
||||||
|
|
||||||
val folder: LocalFolder? = DeviceManager.dbManager.getLocalFolder(folderId)
|
|
||||||
folder?.let {
|
|
||||||
val folderScanner = FolderScanner(context)
|
|
||||||
val folderScanResult = folderScanner.scanForMediaItems(it, forceAudioProbe)
|
|
||||||
if (folderScanResult == null) {
|
|
||||||
Log.d(TAG, "NO Scan DATA")
|
|
||||||
return call.resolve(JSObject())
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "Scan DATA ${jacksonMapper.writeValueAsString(folderScanResult)}")
|
|
||||||
return call.resolve(JSObject(jacksonMapper.writeValueAsString(folderScanResult)))
|
|
||||||
}
|
|
||||||
} ?: call.resolve(JSObject())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
@@ -199,27 +180,6 @@ class AbsFileSystem : Plugin() {
|
|||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun scanLocalLibraryItem(call: PluginCall) {
|
|
||||||
val localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
|
|
||||||
val forceAudioProbe = call.data.getBoolean("forceAudioProbe")
|
|
||||||
Log.d(TAG, "Scan Local library item $localLibraryItemId | Force Audio Probe $forceAudioProbe")
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
|
||||||
val localLibraryItem: LocalLibraryItem? = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
|
||||||
localLibraryItem?.let {
|
|
||||||
val folderScanner = FolderScanner(context)
|
|
||||||
val scanResult = folderScanner.scanLocalLibraryItem(it, forceAudioProbe)
|
|
||||||
if (scanResult == null) {
|
|
||||||
Log.d(TAG, "NO Scan DATA")
|
|
||||||
call.resolve(JSObject())
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "Scan DATA ${jacksonMapper.writeValueAsString(scanResult)}")
|
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(scanResult)))
|
|
||||||
}
|
|
||||||
} ?: call.resolve(JSObject())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun deleteItem(call: PluginCall) {
|
fun deleteItem(call: PluginCall) {
|
||||||
val localLibraryItemId = call.data.getString("id", "").toString()
|
val localLibraryItemId = call.data.getString("id", "").toString()
|
||||||
@@ -227,35 +187,44 @@ class AbsFileSystem : Plugin() {
|
|||||||
val contentUrl = call.data.getString("contentUrl", "").toString()
|
val contentUrl = call.data.getString("contentUrl", "").toString()
|
||||||
Log.d(tag, "deleteItem $absolutePath | $contentUrl")
|
Log.d(tag, "deleteItem $absolutePath | $contentUrl")
|
||||||
|
|
||||||
var subfolderPathToDelete = ""
|
|
||||||
|
|
||||||
// Check if should delete subfolder
|
// Check if should delete subfolder
|
||||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||||
localLibraryItem?.folderId?.let { folderId ->
|
|
||||||
val folder = DeviceManager.dbManager.getLocalFolder(folderId)
|
val success: Boolean
|
||||||
folder?.absolutePath?.let { folderPath ->
|
|
||||||
val splitAbsolutePath = absolutePath.split("/")
|
// If internal library item use File to delete
|
||||||
val fullSubDir = splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/")
|
if (localLibraryItem?.folderId?.startsWith("internal-") == true) {
|
||||||
if (fullSubDir != folderPath) {
|
Log.d(tag, "Deleting internal library item at absolutePath $absolutePath")
|
||||||
val subdirHasAnItem = DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem ->
|
val file = File(absolutePath)
|
||||||
if (_localLibraryItem.id == localLibraryItemId) {
|
success = file.deleteRecursively()
|
||||||
false
|
} else {
|
||||||
} else {
|
var subfolderPathToDelete = ""
|
||||||
_localLibraryItem.absolutePath.startsWith(fullSubDir)
|
localLibraryItem?.folderId?.let { folderId ->
|
||||||
|
val folder = DeviceManager.dbManager.getLocalFolder(folderId)
|
||||||
|
folder?.absolutePath?.let { folderPath ->
|
||||||
|
val splitAbsolutePath = absolutePath.split("/")
|
||||||
|
val fullSubDir = splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/")
|
||||||
|
if (fullSubDir != folderPath) {
|
||||||
|
val subdirHasAnItem = DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem ->
|
||||||
|
if (_localLibraryItem.id == localLibraryItemId) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
_localLibraryItem.absolutePath.startsWith(fullSubDir)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
subfolderPathToDelete = if (subdirHasAnItem) "" else fullSubDir
|
||||||
}
|
}
|
||||||
subfolderPathToDelete = if (subdirHasAnItem) "" else fullSubDir
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
|
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
|
||||||
val success = docfile?.delete() == true
|
success = docfile?.delete() == true
|
||||||
|
|
||||||
if (subfolderPathToDelete != "") {
|
if (subfolderPathToDelete != "") {
|
||||||
Log.d(tag, "Deleting empty subfolder at $subfolderPathToDelete")
|
Log.d(tag, "Deleting empty subfolder at $subfolderPathToDelete")
|
||||||
val docfilesub = DocumentFileCompat.fromFullPath(mainActivity, subfolderPathToDelete)
|
val docfilesub = DocumentFileCompat.fromFullPath(mainActivity, subfolderPathToDelete)
|
||||||
docfilesub?.delete()
|
docfilesub?.delete()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package com.audiobookshelf.app.server
|
package com.audiobookshelf.app.server
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
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.MediaProgressSyncData
|
||||||
import com.audiobookshelf.app.media.SyncResult
|
import com.audiobookshelf.app.media.SyncResult
|
||||||
import com.audiobookshelf.app.models.User
|
import com.audiobookshelf.app.models.User
|
||||||
|
import com.audiobookshelf.app.BuildConfig
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
@@ -17,6 +21,7 @@ import com.getcapacitor.JSObject
|
|||||||
import okhttp3.*
|
import okhttp3.*
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.internal.EMPTY_REQUEST
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONException
|
import org.json.JSONException
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
@@ -30,7 +35,7 @@ class ApiHandler(var ctx:Context) {
|
|||||||
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
|
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
|
||||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
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)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class LocalSessionSyncResult(val id:String, val success:Boolean, val progressSynced:Boolean?, val error:String?)
|
data class LocalSessionSyncResult(val id:String, val success:Boolean, val progressSynced:Boolean?, val error:String?)
|
||||||
data class LocalSessionsSyncResponsePayload(val results:List<LocalSessionSyncResult>)
|
data class LocalSessionsSyncResponsePayload(val results:List<LocalSessionSyncResult>)
|
||||||
@@ -45,11 +50,11 @@ class ApiHandler(var ctx:Context) {
|
|||||||
makeRequest(request, httpClient, cb)
|
makeRequest(request, httpClient, cb)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun postRequest(endpoint:String, payload: JSObject, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
|
private fun postRequest(endpoint:String, payload: JSObject?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
|
||||||
val address = config?.address ?: DeviceManager.serverAddress
|
val address = config?.address ?: DeviceManager.serverAddress
|
||||||
val token = config?.token ?: DeviceManager.token
|
val token = config?.token ?: DeviceManager.token
|
||||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||||
val requestBody = payload.toString().toRequestBody(mediaType)
|
val requestBody = payload?.toString()?.toRequestBody(mediaType) ?: EMPTY_REQUEST
|
||||||
val requestUrl = "${address}$endpoint"
|
val requestUrl = "${address}$endpoint"
|
||||||
Log.d(tag, "postRequest to $requestUrl")
|
Log.d(tag, "postRequest to $requestUrl")
|
||||||
val request = Request.Builder().post(requestBody)
|
val request = Request.Builder().post(requestBody)
|
||||||
@@ -292,6 +297,13 @@ class ApiHandler(var ctx:Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun closePlaybackSession(playbackSessionId:String, cb: (Boolean) -> Unit) {
|
||||||
|
Log.d(tag, "closePlaybackSession: playbackSessionId=$playbackSessionId")
|
||||||
|
postRequest("/api/session/$playbackSessionId/close", null, null) {
|
||||||
|
cb(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun authorize(config:ServerConnectionConfig, cb: (MutableList<MediaProgress>?) -> Unit) {
|
fun authorize(config:ServerConnectionConfig, cb: (MutableList<MediaProgress>?) -> Unit) {
|
||||||
Log.d(tag, "authorize: Authorizing ${config.address}")
|
Log.d(tag, "authorize: Authorizing ${config.address}")
|
||||||
postRequest("/api/authorize", JSObject(), config) {
|
postRequest("/api/authorize", JSObject(), config) {
|
||||||
@@ -314,19 +326,24 @@ class ApiHandler(var ctx:Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun sendSyncLocalSessions(playbackSessions:List<PlaybackSession>, cb: (Boolean, String?) -> Unit) {
|
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) {
|
postRequest("/api/session/local-all", payload, null) {
|
||||||
if (!it.getString("error").isNullOrEmpty()) {
|
if (!it.getString("error").isNullOrEmpty()) {
|
||||||
|
Log.e(tag, "Failed to sync local sessions")
|
||||||
cb(false, it.getString("error"))
|
cb(false, it.getString("error"))
|
||||||
} else {
|
} else {
|
||||||
val response = jacksonMapper.readValue<LocalSessionsSyncResponsePayload>(it.toString())
|
val response = jacksonMapper.readValue<LocalSessionsSyncResponsePayload>(it.toString())
|
||||||
response.results.forEach { localSessionSyncResult ->
|
response.results.forEach { localSessionSyncResult ->
|
||||||
|
Log.d(tag, "Synced session result ${localSessionSyncResult.id}|${localSessionSyncResult.progressSynced}|${localSessionSyncResult.success}")
|
||||||
playbackSessions.find { ps -> ps.id == localSessionSyncResult.id }?.let { session ->
|
playbackSessions.find { ps -> ps.id == localSessionSyncResult.id }?.let { session ->
|
||||||
if (localSessionSyncResult.progressSynced == true) {
|
if (localSessionSyncResult.progressSynced == true) {
|
||||||
val syncResult = SyncResult(true, true, "Progress synced on server")
|
val syncResult = SyncResult(true, true, "Progress synced on server")
|
||||||
MediaEventManager.saveEvent(session, syncResult)
|
MediaEventManager.saveEvent(session, syncResult)
|
||||||
DeviceManager.dbManager.removePlaybackSession(session.id)
|
|
||||||
Log.i(tag, "Successfully synced session ${session.displayTitle} with server")
|
Log.i(tag, "Successfully synced session ${session.displayTitle} with server")
|
||||||
} else if (!localSessionSyncResult.success) {
|
} else if (!localSessionSyncResult.success) {
|
||||||
Log.e(tag, "Failed to sync session ${session.displayTitle} with server. Error: ${localSessionSyncResult.error}")
|
Log.e(tag, "Failed to sync session ${session.displayTitle} with server. Error: ${localSessionSyncResult.error}")
|
||||||
@@ -356,6 +373,7 @@ class ApiHandler(var ctx:Context) {
|
|||||||
Log.d(tag, "Server progress for media item id=\"${mediaProgress.mediaItemId}\" is more recent then local. Updating local current time ${localMediaProgress.currentTime} to ${mediaProgress.currentTime}")
|
Log.d(tag, "Server progress for media item id=\"${mediaProgress.mediaItemId}\" is more recent then local. Updating local current time ${localMediaProgress.currentTime} to ${mediaProgress.currentTime}")
|
||||||
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
|
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
|
||||||
MediaEventManager.syncEvent(mediaProgress, "Sync on server connection")
|
MediaEventManager.syncEvent(mediaProgress, "Sync on server connection")
|
||||||
|
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||||
} else if (localMediaProgress.lastUpdate > mediaProgress.lastUpdate && localMediaProgress.ebookLocation != null && localMediaProgress.ebookLocation != mediaProgress.ebookLocation) {
|
} else if (localMediaProgress.lastUpdate > mediaProgress.lastUpdate && localMediaProgress.ebookLocation != null && localMediaProgress.ebookLocation != mediaProgress.ebookLocation) {
|
||||||
// Patch ebook progress to server
|
// Patch ebook progress to server
|
||||||
val endpoint = "/api/me/progress/${localMediaProgress.libraryItemId}"
|
val endpoint = "/api/me/progress/${localMediaProgress.libraryItemId}"
|
||||||
|
|||||||
@@ -2,4 +2,5 @@
|
|||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
<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">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
</adaptive-icon>
|
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<?xml version='1.0' encoding='utf-8'?>
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">audiobookshelf</string>
|
<string name="app_name">Audiobookshelf</string>
|
||||||
<string name="title_activity_main">audiobookshelf</string>
|
<string name="title_activity_main">Audiobookshelf</string>
|
||||||
<string name="package_name">com.audiobookshelf.app</string>
|
<string name="package_name">com.audiobookshelf.app</string>
|
||||||
<string name="custom_url_scheme">com.audiobookshelf.app</string>
|
<string name="custom_url_scheme">audiobookshelf</string>
|
||||||
<string name="add_widget">Add widget</string>
|
<string name="add_widget">Add widget</string>
|
||||||
<string name="app_widget_description">Simple widget for audiobookshelf playback</string>
|
<string name="app_widget_description">Simple widget for audiobookshelf playback</string>
|
||||||
<string name="action_jump_forward">Jump Forward</string>
|
<string name="action_jump_forward">Jump Forward</string>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
<paths>
|
||||||
<external-path name="my_images" path="." />
|
<files-path name="downloads" path="downloads/" />
|
||||||
<cache-path name="my_cache_images" path="." />
|
</paths>
|
||||||
</paths>
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ buildscript {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.google.gms:google-services:4.3.13'
|
classpath 'com.google.gms:google-services:4.4.0'
|
||||||
classpath 'com.android.tools.build:gradle:7.4.0'
|
classpath 'com.android.tools.build:gradle:8.1.1'
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
|
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
|
|||||||
@@ -2,9 +2,18 @@
|
|||||||
include ':capacitor-android'
|
include ':capacitor-android'
|
||||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
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-app'
|
include ':capacitor-app'
|
||||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||||
|
|
||||||
|
include ':capacitor-browser'
|
||||||
|
project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android')
|
||||||
|
|
||||||
|
include ':capacitor-clipboard'
|
||||||
|
project(':capacitor-clipboard').projectDir = new File('../node_modules/@capacitor/clipboard/android')
|
||||||
|
|
||||||
include ':capacitor-dialog'
|
include ':capacitor-dialog'
|
||||||
project(':capacitor-dialog').projectDir = new File('../node_modules/@capacitor/dialog/android')
|
project(':capacitor-dialog').projectDir = new File('../node_modules/@capacitor/dialog/android')
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ org.gradle.jvmargs=-Dfile.encoding=UTF-8 \
|
|||||||
# Android operating system, and which are packaged with your app's APK
|
# Android operating system, and which are packaged with your app's APK
|
||||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
# Automatically convert third-party libraries to use AndroidX
|
|
||||||
android.enableJetifier=true
|
|
||||||
|
|
||||||
kapt.use.worker.api=false
|
kapt.use.worker.api=false
|
||||||
|
|
||||||
|
android.defaults.buildfeatures.buildconfig=true
|
||||||
|
|||||||
BIN
Binary file not shown.
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
#Sun Apr 17 13:28:55 CDT 2022
|
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip
|
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStorePath=wrapper/dists
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.2-all.zip
|
||||||
|
networkTimeout=10000
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
Vendored
+172
-113
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env sh
|
#!/bin/sh
|
||||||
|
|
||||||
#
|
#
|
||||||
# Copyright 2015 the original author or authors.
|
# Copyright © 2015-2021 the original authors.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with the License.
|
# you may not use this file except in compliance with the License.
|
||||||
@@ -17,67 +17,101 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
##############################################################################
|
##############################################################################
|
||||||
##
|
#
|
||||||
## Gradle start up script for UN*X
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
##
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
##############################################################################
|
##############################################################################
|
||||||
|
|
||||||
# Attempt to set APP_HOME
|
# Attempt to set APP_HOME
|
||||||
# Resolve links: $0 may be a link
|
|
||||||
PRG="$0"
|
|
||||||
# Need this for relative symlinks.
|
|
||||||
while [ -h "$PRG" ] ; do
|
|
||||||
ls=`ls -ld "$PRG"`
|
|
||||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
|
||||||
if expr "$link" : '/.*' > /dev/null; then
|
|
||||||
PRG="$link"
|
|
||||||
else
|
|
||||||
PRG=`dirname "$PRG"`"/$link"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
SAVED="`pwd`"
|
|
||||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
|
||||||
APP_HOME="`pwd -P`"
|
|
||||||
cd "$SAVED" >/dev/null
|
|
||||||
|
|
||||||
APP_NAME="Gradle"
|
# Resolve links: $0 may be a link
|
||||||
APP_BASE_NAME=`basename "$0"`
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||||
|
|
||||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
MAX_FD="maximum"
|
MAX_FD=maximum
|
||||||
|
|
||||||
warn () {
|
warn () {
|
||||||
echo "$*"
|
echo "$*"
|
||||||
}
|
} >&2
|
||||||
|
|
||||||
die () {
|
die () {
|
||||||
echo
|
echo
|
||||||
echo "$*"
|
echo "$*"
|
||||||
echo
|
echo
|
||||||
exit 1
|
exit 1
|
||||||
}
|
} >&2
|
||||||
|
|
||||||
# OS specific support (must be 'true' or 'false').
|
# OS specific support (must be 'true' or 'false').
|
||||||
cygwin=false
|
cygwin=false
|
||||||
msys=false
|
msys=false
|
||||||
darwin=false
|
darwin=false
|
||||||
nonstop=false
|
nonstop=false
|
||||||
case "`uname`" in
|
case "$( uname )" in #(
|
||||||
CYGWIN* )
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
cygwin=true
|
Darwin* ) darwin=true ;; #(
|
||||||
;;
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
Darwin* )
|
NONSTOP* ) nonstop=true ;;
|
||||||
darwin=true
|
|
||||||
;;
|
|
||||||
MINGW* )
|
|
||||||
msys=true
|
|
||||||
;;
|
|
||||||
NONSTOP* )
|
|
||||||
nonstop=true
|
|
||||||
;;
|
|
||||||
esac
|
esac
|
||||||
|
|
||||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
@@ -87,9 +121,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
|||||||
if [ -n "$JAVA_HOME" ] ; then
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
# IBM's JDK on AIX uses strange locations for the executables
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
else
|
else
|
||||||
JAVACMD="$JAVA_HOME/bin/java"
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
fi
|
fi
|
||||||
if [ ! -x "$JAVACMD" ] ; then
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
@@ -98,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the
|
|||||||
location of your Java installation."
|
location of your Java installation."
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
JAVACMD="java"
|
JAVACMD=java
|
||||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
Please set the JAVA_HOME variable in your environment to match the
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
@@ -106,80 +140,105 @@ location of your Java installation."
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Increase the maximum file descriptors if we can.
|
# Increase the maximum file descriptors if we can.
|
||||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
MAX_FD_LIMIT=`ulimit -H -n`
|
case $MAX_FD in #(
|
||||||
if [ $? -eq 0 ] ; then
|
max*)
|
||||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
MAX_FD="$MAX_FD_LIMIT"
|
# shellcheck disable=SC3045
|
||||||
fi
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
ulimit -n $MAX_FD
|
warn "Could not query maximum file descriptor limit"
|
||||||
if [ $? -ne 0 ] ; then
|
esac
|
||||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
case $MAX_FD in #(
|
||||||
fi
|
'' | soft) :;; #(
|
||||||
else
|
*)
|
||||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
fi
|
# shellcheck disable=SC3045
|
||||||
fi
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
# For Darwin, add options to specify how the application appears in the dock
|
|
||||||
if $darwin; then
|
|
||||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
|
||||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
|
||||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
|
||||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
|
||||||
|
|
||||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
|
||||||
|
|
||||||
# We build the pattern for arguments to be converted via cygpath
|
|
||||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
|
||||||
SEP=""
|
|
||||||
for dir in $ROOTDIRSRAW ; do
|
|
||||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
|
||||||
SEP="|"
|
|
||||||
done
|
|
||||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
|
||||||
# Add a user-defined pattern to the cygpath arguments
|
|
||||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
|
||||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
|
||||||
fi
|
|
||||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
|
||||||
i=0
|
|
||||||
for arg in "$@" ; do
|
|
||||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
|
||||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
|
||||||
|
|
||||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
|
||||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
|
||||||
else
|
|
||||||
eval `echo args$i`="\"$arg\""
|
|
||||||
fi
|
|
||||||
i=`expr $i + 1`
|
|
||||||
done
|
|
||||||
case $i in
|
|
||||||
0) set -- ;;
|
|
||||||
1) set -- "$args0" ;;
|
|
||||||
2) set -- "$args0" "$args1" ;;
|
|
||||||
3) set -- "$args0" "$args1" "$args2" ;;
|
|
||||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
|
||||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
|
||||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
|
||||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
|
||||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
|
||||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
|
||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Escape application args
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
save () {
|
# * args from the command line
|
||||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
# * the main class name
|
||||||
echo " "
|
# * -classpath
|
||||||
}
|
# * -D...appname settings
|
||||||
APP_ARGS=`save "$@"`
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command;
|
||||||
|
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||||
|
# shell script including quotes and variable substitutions, so put them in
|
||||||
|
# double quotes to make sure that they get re-expanded; and
|
||||||
|
# * put everything else in single quotes, so that it's not re-expanded.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
exec "$JAVACMD" "$@"
|
exec "$JAVACMD" "$@"
|
||||||
|
|||||||
Vendored
+9
-6
@@ -14,7 +14,7 @@
|
|||||||
@rem limitations under the License.
|
@rem limitations under the License.
|
||||||
@rem
|
@rem
|
||||||
|
|
||||||
@if "%DEBUG%" == "" @echo off
|
@if "%DEBUG%"=="" @echo off
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
@rem
|
@rem
|
||||||
@rem Gradle startup script for Windows
|
@rem Gradle startup script for Windows
|
||||||
@@ -25,7 +25,8 @@
|
|||||||
if "%OS%"=="Windows_NT" setlocal
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
set DIRNAME=%~dp0
|
set DIRNAME=%~dp0
|
||||||
if "%DIRNAME%" == "" set DIRNAME=.
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
set APP_BASE_NAME=%~n0
|
set APP_BASE_NAME=%~n0
|
||||||
set APP_HOME=%DIRNAME%
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
|
|||||||
|
|
||||||
set JAVA_EXE=java.exe
|
set JAVA_EXE=java.exe
|
||||||
%JAVA_EXE% -version >NUL 2>&1
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
if "%ERRORLEVEL%" == "0" goto execute
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
@@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
|||||||
|
|
||||||
:end
|
:end
|
||||||
@rem End local scope for the variables with windows NT shell
|
@rem End local scope for the variables with windows NT shell
|
||||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
:fail
|
:fail
|
||||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
rem the _cmd.exe /c_ return code!
|
rem the _cmd.exe /c_ return code!
|
||||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
exit /b 1
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
:mainEnd
|
:mainEnd
|
||||||
if "%OS%"=="Windows_NT" endlocal
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|||||||
+14
-14
@@ -1,39 +1,39 @@
|
|||||||
ext {
|
ext {
|
||||||
minSdkVersion = 24
|
minSdkVersion = 24
|
||||||
compileSdkVersion = 33
|
compileSdkVersion = 34
|
||||||
targetSdkVersion = 32
|
targetSdkVersion = 34
|
||||||
androidxActivityVersion = '1.4.0'
|
androidxActivityVersion = '1.7.0'
|
||||||
androidxAppCompatVersion = '1.4.2'
|
androidxAppCompatVersion = '1.6.1'
|
||||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||||
androidxCoreVersion = '1.8.0'
|
androidxCoreVersion = '1.10.0'
|
||||||
androidPlayCore = '1.9.0'
|
androidPlayCore = '1.9.0'
|
||||||
androidxFragmentVersion = '1.4.1'
|
androidxFragmentVersion = '1.5.6'
|
||||||
junitVersion = '4.13.2'
|
junitVersion = '4.13.2'
|
||||||
androidxJunitVersion = '1.1.3'
|
androidxJunitVersion = '1.1.5'
|
||||||
androidxEspressoCoreVersion = '3.4.0'
|
androidxEspressoCoreVersion = '3.5.1'
|
||||||
cordovaAndroidVersion = '10.1.1'
|
cordovaAndroidVersion = '10.1.1'
|
||||||
androidx_car_version = '1.0.0-alpha7'
|
androidx_car_version = '1.0.0-alpha7'
|
||||||
androidx_core_ktx_version = '1.7.0'
|
androidx_core_ktx_version = '1.12.0'
|
||||||
androidx_media_version = '1.6.0'
|
androidx_media_version = '1.6.0'
|
||||||
androidx_preference_version = '1.1.1'
|
androidx_preference_version = '1.1.1'
|
||||||
androidx_test_runner_version = '1.3.0'
|
androidx_test_runner_version = '1.3.0'
|
||||||
arch_lifecycle_version = '2.2.0'
|
arch_lifecycle_version = '2.2.0'
|
||||||
constraint_layout_version = '2.0.1'
|
constraint_layout_version = '2.0.1'
|
||||||
espresso_version = '3.3.0'
|
espresso_version = '3.3.0'
|
||||||
exoplayer_version = '2.17.0'
|
exoplayer_version = '2.18.7'
|
||||||
fragment_version = '1.2.5'
|
fragment_version = '1.2.5'
|
||||||
glide_version = '4.11.0'
|
glide_version = '4.11.0'
|
||||||
gms_strict_version_matcher_version = '1.0.3'
|
gms_strict_version_matcher_version = '1.0.3'
|
||||||
gradle_version = '3.1.4'
|
gradle_version = '3.1.4'
|
||||||
gson_version = '2.8.5'
|
gson_version = '2.8.5'
|
||||||
junit_version = '4.13'
|
junit_version = '4.13'
|
||||||
kotlin_version = '1.7.20'
|
kotlin_version = '1.8.10'
|
||||||
kotlin_coroutines_version = '1.1.0'
|
kotlin_coroutines_version = '1.6.4'
|
||||||
multidex_version = '1.0.3'
|
multidex_version = '1.0.3'
|
||||||
play_services_auth_version = '18.1.0'
|
play_services_auth_version = '18.1.0'
|
||||||
recycler_view_version = '1.1.0'
|
recycler_view_version = '1.1.0'
|
||||||
robolectric_version = '4.2'
|
robolectric_version = '4.2'
|
||||||
test_runner_version = '1.1.0'
|
test_runner_version = '1.1.0'
|
||||||
coreSplashScreenVersion = '1.0.0-rc01'
|
coreSplashScreenVersion = '1.0.1'
|
||||||
androidxWebkitVersion = '1.4.0'
|
androidxWebkitVersion = '1.6.1'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
+25
-4
@@ -1,6 +1,8 @@
|
|||||||
|
@import "./tailwind.css";
|
||||||
@import "./fonts.css";
|
@import "./fonts.css";
|
||||||
@import './defaultStyles.css';
|
@import './defaultStyles.css';
|
||||||
@import './absicons.css';
|
@import './absicons.css';
|
||||||
|
@import './transitions.css';
|
||||||
|
|
||||||
* {
|
* {
|
||||||
-webkit-touch-callout: none;
|
-webkit-touch-callout: none;
|
||||||
@@ -15,10 +17,6 @@ textarea {
|
|||||||
user-select: auto;
|
user-select: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: #262626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-wrapper {
|
.layout-wrapper {
|
||||||
height: calc(100vh - env(safe-area-inset-top));
|
height: calc(100vh - env(safe-area-inset-top));
|
||||||
min-height: calc(100vh - env(safe-area-inset-top));
|
min-height: calc(100vh - env(safe-area-inset-top));
|
||||||
@@ -143,4 +141,27 @@ Bookshelf Label
|
|||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
-webkit-line-clamp: 4;
|
-webkit-line-clamp: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracksTable {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgb(var(--color-secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracksTable tr:nth-child(even) {
|
||||||
|
background-color: rgb(var(--color-secondary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracksTable tr {
|
||||||
|
background-color: rgb(var(--color-bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracksTable td {
|
||||||
|
padding: 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tracksTable th {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,5 +2,10 @@
|
|||||||
"appId": "com.audiobookshelf.app",
|
"appId": "com.audiobookshelf.app",
|
||||||
"appName": "audiobookshelf-app",
|
"appName": "audiobookshelf-app",
|
||||||
"webDir": "dist",
|
"webDir": "dist",
|
||||||
"bundledWebRuntime": false
|
"bundledWebRuntime": false,
|
||||||
|
"plugins": {
|
||||||
|
"CapacitorHttp": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,13 +4,13 @@
|
|||||||
<nuxt-link v-show="!showBack" to="/" class="mr-3">
|
<nuxt-link v-show="!showBack" to="/" class="mr-3">
|
||||||
<img src="/Logo.png" class="h-10 w-10" />
|
<img src="/Logo.png" class="h-10 w-10" />
|
||||||
</nuxt-link>
|
</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">
|
<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-white">arrow_back</span>
|
<span class="material-icons text-3xl text-fg">arrow_back</span>
|
||||||
</a>
|
</a>
|
||||||
<div v-if="user && currentLibrary">
|
<div v-if="user && currentLibrary && networkConnected">
|
||||||
<div class="pl-1.5 pr-2.5 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
|
<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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -54,14 +54,17 @@ export default {
|
|||||||
this.$store.commit('setCastAvailable', val)
|
this.$store.commit('setCastAvailable', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
networkConnected() {
|
||||||
|
return this.$store.state.networkConnected
|
||||||
|
},
|
||||||
currentLibrary() {
|
currentLibrary() {
|
||||||
return this.$store.getters['libraries/getCurrentLibrary']
|
return this.$store.getters['libraries/getCurrentLibrary']
|
||||||
},
|
},
|
||||||
currentLibraryName() {
|
currentLibraryName() {
|
||||||
return this.currentLibrary ? this.currentLibrary.name : ''
|
return this.currentLibrary?.name || ''
|
||||||
},
|
},
|
||||||
currentLibraryIcon() {
|
currentLibraryIcon() {
|
||||||
return this.currentLibrary ? this.currentLibrary.icon : 'database'
|
return this.currentLibrary?.icon || 'database'
|
||||||
},
|
},
|
||||||
showBack() {
|
showBack() {
|
||||||
if (!this.$route.name) return true
|
if (!this.$route.name) return true
|
||||||
@@ -71,7 +74,7 @@ export default {
|
|||||||
return this.$store.state.user.user
|
return this.$store.state.user.user
|
||||||
},
|
},
|
||||||
username() {
|
username() {
|
||||||
return this.user ? this.user.username : 'err'
|
return this.user?.username || 'err'
|
||||||
},
|
},
|
||||||
isCasting() {
|
isCasting() {
|
||||||
return this.$store.state.isCasting
|
return this.$store.state.isCasting
|
||||||
@@ -79,7 +82,7 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
castClick() {
|
castClick() {
|
||||||
if (this.$store.state.playerIsLocal) {
|
if (this.$store.getters['getIsCurrentSessionLocal']) {
|
||||||
this.$eventBus.$emit('cast-local-item')
|
this.$eventBus.$emit('cast-local-item')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+217
-111
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<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="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 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">
|
<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>
|
<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">
|
<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>
|
<span class="material-icons text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="showMoreMenuDialog = true">more_vert</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<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">
|
<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" />
|
<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>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<div class="h-1 w-full bg-gray-500 bg-opacity-50 relative rounded-full">
|
<div class="h-1 w-full bg-track/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="totalReadyTrack" class="h-full bg-track-buffered 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="totalBufferedTrack" class="h-full bg-track 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 ref="totalPlayedTrack" class="h-full bg-track-cursor absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,62 +41,66 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor">
|
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor">
|
||||||
<p class="title-text truncate">{{ title }}</p>
|
<div ref="titlewrapper" class="overflow-hidden relative">
|
||||||
<p class="author-text text-white text-opacity-75 truncate">{{ authorName }}</p>
|
<p class="title-text whitespace-nowrap"></p>
|
||||||
|
</div>
|
||||||
|
<p class="author-text text-fg text-opacity-75 truncate">{{ authorName }}</p>
|
||||||
</div>
|
</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 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 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">
|
<div class="flex items-center justify-between pointer-events-auto">
|
||||||
<span v-if="!isPodcast && isServerItem && 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 && networkConnected" 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 -->
|
<!-- 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 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>
|
<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-white text-opacity-75 cursor-pointer" fill="none" viewBox="0 0 24 24" stroke="currentColor" @click.stop="$emit('showSleepTimer')">
|
<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" />
|
<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>
|
</svg>
|
||||||
<div v-else class="h-7 w-7 flex items-center justify-around cursor-pointer" @click.stop="$emit('showSleepTimer')">
|
<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>
|
<p class="text-xl font-mono text-success">{{ sleepTimeRemainingPretty }}</p>
|
||||||
</div>
|
</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>
|
</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 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'">
|
<div class="flex items-center max-w-full" :class="playerSettings.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="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="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</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 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" />
|
<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>
|
<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" />
|
<widgets-spinner-icon v-else class="h-8 w-8" />
|
||||||
</div>
|
</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="!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 && !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="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>
|
</div>
|
||||||
|
|
||||||
<div id="playerTrack" class="absolute left-0 w-full px-6">
|
<div id="playerTrack" class="absolute left-0 w-full px-6">
|
||||||
<div class="flex pointer-events-none">
|
<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" />
|
<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>
|
||||||
<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="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-gray-600 absolute top-0 left-0 rounded-full pointer-events-none" />
|
<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-gray-500 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-gray-200 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-3.5 w-3.5 -top-1 rounded-full bg-gray-200 absolute pointer-events-auto" :class="{ 'opacity-0': lockUi || !showFullscreen }" @touchstart="touchstartCursor" />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -104,6 +108,7 @@
|
|||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
||||||
import { FastAverageColor } from 'fast-average-color'
|
import { FastAverageColor } from 'fast-average-color'
|
||||||
|
import WrappingMarquee from '@/assets/WrappingMarquee.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
@@ -113,7 +118,7 @@ export default {
|
|||||||
},
|
},
|
||||||
sleepTimerRunning: Boolean,
|
sleepTimerRunning: Boolean,
|
||||||
sleepTimeRemaining: Number,
|
sleepTimeRemaining: Number,
|
||||||
isServerItem: Boolean
|
serverLibraryItemId: String
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -143,10 +148,12 @@ export default {
|
|||||||
onPlaybackSpeedChangedListener: null,
|
onPlaybackSpeedChangedListener: null,
|
||||||
touchStartY: 0,
|
touchStartY: 0,
|
||||||
touchStartTime: 0,
|
touchStartTime: 0,
|
||||||
touchEndY: 0,
|
playerSettings: {
|
||||||
useChapterTrack: false,
|
useChapterTrack: false,
|
||||||
useTotalTrack: true,
|
useTotalTrack: true,
|
||||||
lockUi: false,
|
scaleElapsedTimeBySpeed: true,
|
||||||
|
lockUi: false
|
||||||
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isDraggingCursor: false,
|
isDraggingCursor: false,
|
||||||
draggingTouchStartX: 0,
|
draggingTouchStartX: 0,
|
||||||
@@ -155,16 +162,22 @@ export default {
|
|||||||
syncStatus: 0,
|
syncStatus: 0,
|
||||||
showMoreMenuDialog: false,
|
showMoreMenuDialog: false,
|
||||||
coverRgb: 'rgb(55, 56, 56)',
|
coverRgb: 'rgb(55, 56, 56)',
|
||||||
coverBgIsLight: false
|
coverBgIsLight: false,
|
||||||
|
titleMarquee: null,
|
||||||
|
isRefreshingUI: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
showFullscreen(val) {
|
showFullscreen(val) {
|
||||||
this.updateScreenSize()
|
this.updateScreenSize()
|
||||||
this.$store.commit('setPlayerFullscreen', !!val)
|
this.$store.commit('setPlayerFullscreen', !!val)
|
||||||
|
document.querySelector('body').style.backgroundColor = this.showFullscreen ? this.coverRgb : ''
|
||||||
},
|
},
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
this.updateScreenSize()
|
this.updateScreenSize()
|
||||||
|
},
|
||||||
|
title(val) {
|
||||||
|
if (this.titleMarquee) this.titleMarquee.init(val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -173,7 +186,7 @@ export default {
|
|||||||
// TODO: Implement on iOS
|
// TODO: Implement on iOS
|
||||||
if (this.$platform !== 'ios' && !this.isPodcast && this.mediaId) {
|
if (this.$platform !== 'ios' && !this.isPodcast && this.mediaId) {
|
||||||
items.push({
|
items.push({
|
||||||
text: 'History',
|
text: this.$strings.ButtonHistory,
|
||||||
value: 'history',
|
value: 'history',
|
||||||
icon: 'history'
|
icon: 'history'
|
||||||
})
|
})
|
||||||
@@ -182,22 +195,27 @@ export default {
|
|||||||
items.push(
|
items.push(
|
||||||
...[
|
...[
|
||||||
{
|
{
|
||||||
text: 'Total Track',
|
text: this.$strings.LabelTotalTrack,
|
||||||
value: 'total_track',
|
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',
|
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',
|
value: 'lock',
|
||||||
icon: this.lockUi ? 'lock' : 'lock_open'
|
icon: this.playerSettings.lockUi ? 'lock' : 'lock_open'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Close Player',
|
text: this.$strings.LabelClosePlayer,
|
||||||
value: 'close',
|
value: 'close',
|
||||||
icon: 'close'
|
icon: 'close'
|
||||||
}
|
}
|
||||||
@@ -254,30 +272,30 @@ export default {
|
|||||||
return this.mediaPlayer === 'cast-player'
|
return this.mediaPlayer === 'cast-player'
|
||||||
},
|
},
|
||||||
mediaPlayer() {
|
mediaPlayer() {
|
||||||
return this.playbackSession ? this.playbackSession.mediaPlayer : null
|
return this.playbackSession?.mediaPlayer || null
|
||||||
},
|
},
|
||||||
mediaType() {
|
mediaType() {
|
||||||
return this.playbackSession ? this.playbackSession.mediaType : null
|
return this.playbackSession?.mediaType || null
|
||||||
},
|
},
|
||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.mediaType === 'podcast'
|
return this.mediaType === 'podcast'
|
||||||
},
|
},
|
||||||
mediaMetadata() {
|
mediaMetadata() {
|
||||||
return this.playbackSession ? this.playbackSession.mediaMetadata : null
|
return this.playbackSession?.mediaMetadata || null
|
||||||
},
|
},
|
||||||
libraryItem() {
|
libraryItem() {
|
||||||
return this.playbackSession ? this.playbackSession.libraryItem || null : null
|
return this.playbackSession?.libraryItem || null
|
||||||
},
|
},
|
||||||
localLibraryItem() {
|
localLibraryItem() {
|
||||||
return this.playbackSession ? this.playbackSession.localLibraryItem || null : null
|
return this.playbackSession?.localLibraryItem || null
|
||||||
},
|
},
|
||||||
localLibraryItemCoverSrc() {
|
localLibraryItemCoverSrc() {
|
||||||
var localItemCover = this.localLibraryItem ? this.localLibraryItem.coverContentUrl : null
|
var localItemCover = this.localLibraryItem?.coverContentUrl || null
|
||||||
if (localItemCover) return Capacitor.convertFileSrc(localItemCover)
|
if (localItemCover) return Capacitor.convertFileSrc(localItemCover)
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
playMethod() {
|
playMethod() {
|
||||||
return this.playbackSession ? this.playbackSession.playMethod : null
|
return this.playbackSession?.playMethod || 0
|
||||||
},
|
},
|
||||||
isLocalPlayMethod() {
|
isLocalPlayMethod() {
|
||||||
return this.playMethod == this.$constants.PlayMethod.LOCAL
|
return this.playMethod == this.$constants.PlayMethod.LOCAL
|
||||||
@@ -286,19 +304,19 @@ export default {
|
|||||||
return this.playMethod == this.$constants.PlayMethod.DIRECTPLAY
|
return this.playMethod == this.$constants.PlayMethod.DIRECTPLAY
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
if (this.currentChapterTitle && this.showFullscreen) return this.currentChapterTitle
|
const mediaItemTitle = this.playbackSession?.displayTitle || this.mediaMetadata?.title || 'Title'
|
||||||
if (this.playbackSession) return this.playbackSession.displayTitle
|
if (this.currentChapterTitle) {
|
||||||
return this.mediaMetadata ? this.mediaMetadata.title : 'Title'
|
if (this.showFullscreen) return this.currentChapterTitle
|
||||||
|
return `${mediaItemTitle} | ${this.currentChapterTitle}`
|
||||||
|
}
|
||||||
|
return mediaItemTitle
|
||||||
},
|
},
|
||||||
authorName() {
|
authorName() {
|
||||||
if (this.playbackSession) return this.playbackSession.displayAuthor
|
if (this.playbackSession) return this.playbackSession.displayAuthor
|
||||||
return this.mediaMetadata ? this.mediaMetadata.authorName : 'Author'
|
return this.mediaMetadata?.authorName || 'Author'
|
||||||
},
|
},
|
||||||
chapters() {
|
chapters() {
|
||||||
if (this.playbackSession && this.playbackSession.chapters) {
|
return this.playbackSession?.chapters || []
|
||||||
return this.playbackSession.chapters
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
},
|
},
|
||||||
currentChapter() {
|
currentChapter() {
|
||||||
if (!this.chapters.length) return null
|
if (!this.chapters.length) return null
|
||||||
@@ -309,7 +327,7 @@ export default {
|
|||||||
return this.chapters.find((c) => Number(Number(c.start).toFixed(2)) > this.currentTime)
|
return this.chapters.find((c) => Number(Number(c.start).toFixed(2)) > this.currentTime)
|
||||||
},
|
},
|
||||||
currentChapterTitle() {
|
currentChapterTitle() {
|
||||||
return this.currentChapter ? this.currentChapter.title : ''
|
return this.currentChapter?.title || ''
|
||||||
},
|
},
|
||||||
currentChapterDuration() {
|
currentChapterDuration() {
|
||||||
return this.currentChapter ? this.currentChapter.end - this.currentChapter.start : this.totalDuration
|
return this.currentChapter ? this.currentChapter.end - this.currentChapter.start : this.totalDuration
|
||||||
@@ -319,11 +337,14 @@ export default {
|
|||||||
},
|
},
|
||||||
currentTimePretty() {
|
currentTimePretty() {
|
||||||
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
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() {
|
timeRemaining() {
|
||||||
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
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
|
var currChapTime = currentTimeToUse - this.currentChapter.start
|
||||||
return (this.currentChapterDuration - currChapTime) / this.currentPlaybackRate
|
return (this.currentChapterDuration - currChapTime) / this.currentPlaybackRate
|
||||||
}
|
}
|
||||||
@@ -389,7 +410,7 @@ export default {
|
|||||||
},
|
},
|
||||||
clickTitleAndAuthor() {
|
clickTitleAndAuthor() {
|
||||||
if (!this.showFullscreen) return
|
if (!this.showFullscreen) return
|
||||||
const llid = this.libraryItem ? this.libraryItem.id : this.localLibraryItem ? this.localLibraryItem.id : null
|
const llid = this.serverLibraryItemId || this.libraryItem?.id || this.localLibraryItem?.id
|
||||||
if (llid) {
|
if (llid) {
|
||||||
this.$router.push(`/item/${llid}`)
|
this.$router.push(`/item/${llid}`)
|
||||||
this.showFullscreen = false
|
this.showFullscreen = false
|
||||||
@@ -409,7 +430,11 @@ export default {
|
|||||||
AbsAudioPlayer.requestSession()
|
AbsAudioPlayer.requestSession()
|
||||||
},
|
},
|
||||||
clickContainer() {
|
clickContainer() {
|
||||||
|
this.expandToFullscreen()
|
||||||
|
},
|
||||||
|
expandToFullscreen() {
|
||||||
this.showFullscreen = true
|
this.showFullscreen = true
|
||||||
|
if (this.titleMarquee) this.titleMarquee.reset()
|
||||||
|
|
||||||
// Update track for total time bar if useChapterTrack is set
|
// Update track for total time bar if useChapterTrack is set
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
@@ -418,6 +443,8 @@ export default {
|
|||||||
},
|
},
|
||||||
collapseFullscreen() {
|
collapseFullscreen() {
|
||||||
this.showFullscreen = false
|
this.showFullscreen = false
|
||||||
|
if (this.titleMarquee) this.titleMarquee.reset()
|
||||||
|
|
||||||
this.forceCloseDropdownMenu()
|
this.forceCloseDropdownMenu()
|
||||||
},
|
},
|
||||||
async jumpNextChapter() {
|
async jumpNextChapter() {
|
||||||
@@ -491,7 +518,7 @@ export default {
|
|||||||
this.updateReadyTrack()
|
this.updateReadyTrack()
|
||||||
},
|
},
|
||||||
updateReadyTrack() {
|
updateReadyTrack() {
|
||||||
if (this.useChapterTrack) {
|
if (this.playerSettings.useChapterTrack) {
|
||||||
if (this.$refs.totalReadyTrack) {
|
if (this.$refs.totalReadyTrack) {
|
||||||
this.$refs.totalReadyTrack.style.width = this.readyTrackWidth + 'px'
|
this.$refs.totalReadyTrack.style.width = this.readyTrackWidth + 'px'
|
||||||
}
|
}
|
||||||
@@ -508,11 +535,14 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let currentTime = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
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)
|
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() {
|
timeupdate() {
|
||||||
if (!this.$refs.playedTrack) {
|
if (!this.$refs.playedTrack) {
|
||||||
@@ -540,7 +570,7 @@ export default {
|
|||||||
let bufferedPercent = this.bufferedTime / this.totalDuration
|
let bufferedPercent = this.bufferedTime / this.totalDuration
|
||||||
const totalBufferedPercent = bufferedPercent
|
const totalBufferedPercent = bufferedPercent
|
||||||
|
|
||||||
if (this.useChapterTrack && this.currentChapter) {
|
if (this.playerSettings.useChapterTrack && this.currentChapter) {
|
||||||
const currChapTime = currentTimeToUse - this.currentChapter.start
|
const currChapTime = currentTimeToUse - this.currentChapter.start
|
||||||
percentDone = currChapTime / this.currentChapterDuration
|
percentDone = currChapTime / this.currentChapterDuration
|
||||||
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration))
|
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration))
|
||||||
@@ -551,10 +581,10 @@ export default {
|
|||||||
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
|
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
|
||||||
|
|
||||||
if (this.$refs.trackCursor) {
|
if (this.$refs.trackCursor) {
|
||||||
this.$refs.trackCursor.style.left = ptWidth - 7 + 'px'
|
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.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'
|
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px'
|
||||||
}
|
}
|
||||||
@@ -572,8 +602,8 @@ export default {
|
|||||||
AbsAudioPlayer.seek({ value: Math.floor(time) })
|
AbsAudioPlayer.seek({ value: Math.floor(time) })
|
||||||
|
|
||||||
if (this.$refs.playedTrack) {
|
if (this.$refs.playedTrack) {
|
||||||
var perc = time / this.totalDuration
|
const perc = time / this.totalDuration
|
||||||
var ptWidth = Math.round(perc * this.trackWidth)
|
const ptWidth = Math.round(perc * this.trackWidth)
|
||||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||||
|
|
||||||
this.$refs.playedTrack.classList.remove('bg-gray-200')
|
this.$refs.playedTrack.classList.remove('bg-gray-200')
|
||||||
@@ -581,7 +611,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async touchstartCursor(e) {
|
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()
|
await this.$hapticsImpact()
|
||||||
this.isDraggingCursor = true
|
this.isDraggingCursor = true
|
||||||
@@ -622,38 +652,49 @@ export default {
|
|||||||
resetStream(startTime) {
|
resetStream(startTime) {
|
||||||
this.closePlayback()
|
this.closePlayback()
|
||||||
},
|
},
|
||||||
handleGesture() {
|
|
||||||
var touchDistance = this.touchEndY - this.touchStartY
|
|
||||||
if (touchDistance > 100) {
|
|
||||||
this.collapseFullscreen()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
touchstart(e) {
|
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
|
// for ios
|
||||||
if (this.touchStartY > window.innerHeight / 3) {
|
if (!this.showFullscreen && e.pageX < 20) {
|
||||||
// console.log('touch too low')
|
e.preventDefault()
|
||||||
return
|
e.stopImmediatePropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.touchStartY = touchPosY
|
||||||
this.touchStartTime = Date.now()
|
this.touchStartTime = Date.now()
|
||||||
},
|
},
|
||||||
touchend(e) {
|
touchend(e) {
|
||||||
if (!e.changedTouches) return
|
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.isDraggingCursor) {
|
||||||
if (this.draggingCurrentTime !== this.currentTime) {
|
if (this.draggingCurrentTime !== this.currentTime) {
|
||||||
this.seek(this.draggingCurrentTime)
|
this.seek(this.draggingCurrentTime)
|
||||||
}
|
}
|
||||||
this.isDraggingCursor = false
|
this.isDraggingCursor = false
|
||||||
} else if (this.showFullscreen) {
|
} else {
|
||||||
this.touchEndY = e.changedTouches[0].screenY
|
|
||||||
var touchDuration = Date.now() - this.touchStartTime
|
|
||||||
if (touchDuration > 1200) {
|
if (touchDuration > 1200) {
|
||||||
// console.log('touch too long', touchDuration)
|
// console.log('touch too long', touchDuration)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.handleGesture()
|
if (this.showFullscreen) {
|
||||||
|
// Touch start higher than touchend
|
||||||
|
if (touchDistanceY > 100) {
|
||||||
|
this.collapseFullscreen()
|
||||||
|
}
|
||||||
|
} else if (touchDistanceY < -100) {
|
||||||
|
this.expandToFullscreen()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
touchmove(e) {
|
touchmove(e) {
|
||||||
@@ -663,7 +704,7 @@ export default {
|
|||||||
let duration = this.totalDuration
|
let duration = this.totalDuration
|
||||||
let minTime = 0
|
let minTime = 0
|
||||||
let maxTime = duration
|
let maxTime = duration
|
||||||
if (this.useChapterTrack && this.currentChapter) {
|
if (this.playerSettings.useChapterTrack && this.currentChapter) {
|
||||||
duration = this.currentChapterDuration
|
duration = this.currentChapterDuration
|
||||||
minTime = this.currentChapter.start
|
minTime = this.currentChapter.start
|
||||||
maxTime = minTime + duration
|
maxTime = minTime + duration
|
||||||
@@ -683,30 +724,42 @@ export default {
|
|||||||
if (action === 'history') {
|
if (action === 'history') {
|
||||||
this.$router.push(`/media/${this.mediaId}/history?title=${this.title}`)
|
this.$router.push(`/media/${this.mediaId}/history?title=${this.title}`)
|
||||||
this.showFullscreen = false
|
this.showFullscreen = false
|
||||||
|
} else if (action === 'scale_elapsed_time') {
|
||||||
|
this.playerSettings.scaleElapsedTimeBySpeed = !this.playerSettings.scaleElapsedTimeBySpeed
|
||||||
|
this.updateTimestamp()
|
||||||
|
this.savePlayerSettings()
|
||||||
} else if (action === 'lock') {
|
} else if (action === 'lock') {
|
||||||
this.lockUi = !this.lockUi
|
this.playerSettings.lockUi = !this.playerSettings.lockUi
|
||||||
this.$localStore.setPlayerLock(this.lockUi)
|
this.savePlayerSettings()
|
||||||
} else if (action === 'chapter_track') {
|
} else if (action === 'chapter_track') {
|
||||||
this.useChapterTrack = !this.useChapterTrack
|
this.playerSettings.useChapterTrack = !this.playerSettings.useChapterTrack
|
||||||
this.useTotalTrack = !this.useChapterTrack || this.useTotalTrack
|
this.playerSettings.useTotalTrack = !this.playerSettings.useChapterTrack || this.playerSettings.useTotalTrack
|
||||||
|
|
||||||
this.updateTimestamp()
|
this.updateTimestamp()
|
||||||
this.updateTrack()
|
this.updateTrack()
|
||||||
this.updateReadyTrack()
|
this.updateReadyTrack()
|
||||||
this.$localStore.setUseChapterTrack(this.useChapterTrack)
|
this.updateUseChapterTrack()
|
||||||
|
this.savePlayerSettings()
|
||||||
} else if (action === 'total_track') {
|
} else if (action === 'total_track') {
|
||||||
this.useTotalTrack = !this.useTotalTrack
|
this.playerSettings.useTotalTrack = !this.playerSettings.useTotalTrack
|
||||||
this.useChapterTrack = !this.useTotalTrack || this.useChapterTrack
|
this.playerSettings.useChapterTrack = !this.playerSettings.useTotalTrack || this.playerSettings.useChapterTrack
|
||||||
|
|
||||||
this.updateTimestamp()
|
this.updateTimestamp()
|
||||||
this.updateTrack()
|
this.updateTrack()
|
||||||
this.updateReadyTrack()
|
this.updateReadyTrack()
|
||||||
this.$localStore.setUseTotalTrack(this.useTotalTrack)
|
this.updateUseChapterTrack()
|
||||||
|
this.savePlayerSettings()
|
||||||
} else if (action === 'close') {
|
} else if (action === 'close') {
|
||||||
this.closePlayback()
|
this.closePlayback()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
updateUseChapterTrack() {
|
||||||
|
// Chapter track in NowPlaying only supported on iOS for now
|
||||||
|
if (this.$platform === 'ios') {
|
||||||
|
AbsAudioPlayer.setChapterTrack({ enabled: this.playerSettings.useChapterTrack })
|
||||||
|
}
|
||||||
|
},
|
||||||
forceCloseDropdownMenu() {
|
forceCloseDropdownMenu() {
|
||||||
if (this.$refs.dropdownMenu && this.$refs.dropdownMenu.closeMenu) {
|
if (this.$refs.dropdownMenu && this.$refs.dropdownMenu.closeMenu) {
|
||||||
this.$refs.dropdownMenu.closeMenu()
|
this.$refs.dropdownMenu.closeMenu()
|
||||||
@@ -717,12 +770,36 @@ export default {
|
|||||||
AbsAudioPlayer.closePlayback()
|
AbsAudioPlayer.closePlayback()
|
||||||
},
|
},
|
||||||
endPlayback() {
|
endPlayback() {
|
||||||
this.$store.commit('setPlayerItem', null)
|
this.$store.commit('setPlaybackSession', null)
|
||||||
this.showFullscreen = false
|
this.showFullscreen = false
|
||||||
this.isEnded = false
|
this.isEnded = false
|
||||||
this.isLoading = false
|
this.isLoading = false
|
||||||
this.playbackSession = null
|
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
|
// Listeners from audio AbsAudioPlayer
|
||||||
//
|
//
|
||||||
@@ -763,10 +840,14 @@ export default {
|
|||||||
this.isEnded = false
|
this.isEnded = false
|
||||||
this.isLoading = true
|
this.isLoading = true
|
||||||
this.syncStatus = 0
|
this.syncStatus = 0
|
||||||
this.$store.commit('setPlayerItem', this.playbackSession)
|
this.$store.commit('setPlaybackSession', this.playbackSession)
|
||||||
|
|
||||||
// Set track width
|
// Set track width
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
|
if (this.titleMarquee) this.titleMarquee.reset()
|
||||||
|
this.titleMarquee = new WrappingMarquee(this.$refs.titlewrapper)
|
||||||
|
this.titleMarquee.init(this.title)
|
||||||
|
|
||||||
if (this.$refs.track) {
|
if (this.$refs.track) {
|
||||||
this.trackWidth = this.$refs.track.clientWidth
|
this.trackWidth = this.$refs.track.clientWidth
|
||||||
} else {
|
} else {
|
||||||
@@ -789,8 +870,7 @@ export default {
|
|||||||
this.updateTimestamp()
|
this.updateTimestamp()
|
||||||
},
|
},
|
||||||
async init() {
|
async init() {
|
||||||
this.useChapterTrack = await this.$localStore.getUseChapterTrack()
|
await this.loadPlayerSettings()
|
||||||
this.lockUi = await this.$localStore.getPlayerLock()
|
|
||||||
|
|
||||||
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
|
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
|
||||||
this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
|
this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
|
||||||
@@ -801,26 +881,51 @@ export default {
|
|||||||
this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
|
this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
|
||||||
this.onPlaybackSpeedChangedListener = AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged)
|
this.onPlaybackSpeedChangedListener = AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged)
|
||||||
},
|
},
|
||||||
screenOrientationChange() {
|
async screenOrientationChange() {
|
||||||
setTimeout(() => {
|
if (this.isRefreshingUI) return
|
||||||
this.updateScreenSize()
|
this.isRefreshingUI = true
|
||||||
if (this.$refs.track) {
|
const windowWidth = window.innerWidth
|
||||||
this.trackWidth = this.$refs.track.clientWidth
|
this.refreshUI()
|
||||||
this.updateTrack()
|
|
||||||
this.updateReadyTrack()
|
// 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() {
|
updateScreenSize() {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.titleMarquee) this.titleMarquee.init(this.title)
|
||||||
|
}, 500)
|
||||||
|
|
||||||
this.windowHeight = window.innerHeight
|
this.windowHeight = window.innerHeight
|
||||||
this.windowWidth = window.innerWidth
|
this.windowWidth = window.innerWidth
|
||||||
const coverHeight = this.fullscreenBookCoverWidth * this.bookCoverAspectRatio
|
const coverHeight = this.fullscreenBookCoverWidth * this.bookCoverAspectRatio
|
||||||
const coverImageWidthCollapsed = 46 / 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-width', this.fullscreenBookCoverWidth + 'px')
|
||||||
document.documentElement.style.setProperty('--cover-image-height', coverHeight + '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-width-collapsed', coverImageWidthCollapsed + 'px')
|
||||||
document.documentElement.style.setProperty('--cover-image-height-collapsed', 46 + '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() {
|
minimizePlayerEvt() {
|
||||||
this.collapseFullscreen()
|
this.collapseFullscreen()
|
||||||
@@ -843,7 +948,7 @@ export default {
|
|||||||
window.addEventListener('resize', this.screenOrientationChange)
|
window.addEventListener('resize', this.screenOrientationChange)
|
||||||
|
|
||||||
this.$eventBus.$on('minimize-player', this.minimizePlayerEvt)
|
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('touchend', this.touchend)
|
||||||
document.body.addEventListener('touchmove', this.touchmove)
|
document.body.addEventListener('touchmove', this.touchmove)
|
||||||
this.$nextTick(this.init)
|
this.$nextTick(this.init)
|
||||||
@@ -888,6 +993,7 @@ export default {
|
|||||||
--cover-image-width-collapsed: 46px;
|
--cover-image-width-collapsed: 46px;
|
||||||
--cover-image-height-collapsed: 46px;
|
--cover-image-height-collapsed: 46px;
|
||||||
--title-author-left-offset-collapsed: 80px;
|
--title-author-left-offset-collapsed: 80px;
|
||||||
|
--title-author-width-collapsed: 40%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playerContainer {
|
.playerContainer {
|
||||||
@@ -935,7 +1041,7 @@ export default {
|
|||||||
transition-property: left, bottom, width, height;
|
transition-property: left, bottom, width, height;
|
||||||
transform-origin: left bottom;
|
transform-origin: left bottom;
|
||||||
|
|
||||||
width: 40%;
|
width: var(--title-author-width-collapsed);
|
||||||
bottom: 76px;
|
bottom: 76px;
|
||||||
left: var(--title-author-left-offset-collapsed);
|
left: var(--title-author-left-offset-collapsed);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<app-audio-player ref="audioPlayer" :bookmarks="bookmarks" :sleep-timer-running="isSleepTimerRunning" :sleep-time-remaining="sleepTimeRemaining" :is-server-item="!!serverLibraryItemId" @selectPlaybackSpeed="showPlaybackSpeedModal = true" @updateTime="(t) => (currentTime = t)" @showSleepTimer="showSleepTimer" @showBookmarks="showBookmarks" />
|
<app-audio-player ref="audioPlayer" :bookmarks="bookmarks" :sleep-timer-running="isSleepTimerRunning" :sleep-time-remaining="sleepTimeRemaining" :serverLibraryItemId="serverLibraryItemId" @selectPlaybackSpeed="showPlaybackSpeedModal = true" @updateTime="(t) => (currentTime = t)" @showSleepTimer="showSleepTimer" @showBookmarks="showBookmarks" />
|
||||||
|
|
||||||
<modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-rate.sync="playbackSpeed" @update:playbackRate="updatePlaybackSpeed" @change="changePlaybackSpeed" />
|
<modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-rate.sync="playbackSpeed" @update:playbackRate="updatePlaybackSpeed" @change="changePlaybackSpeed" />
|
||||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :current-time="sleepTimeRemaining" :sleep-timer-running="isSleepTimerRunning" :current-end-of-chapter-time="currentEndOfChapterTime" :is-auto="isAutoSleepTimer" @change="selectSleepTimeout" @cancel="cancelSleepTimer" @increase="increaseSleepTimer" @decrease="decreaseSleepTimer" />
|
<modals-sleep-timer-modal v-model="showSleepTimerModal" :current-time="sleepTimeRemaining" :sleep-timer-running="isSleepTimerRunning" :current-end-of-chapter-time="currentEndOfChapterTime" :is-auto="isAutoSleepTimer" @change="selectSleepTimeout" @cancel="cancelSleepTimer" @increase="increaseSleepTimer" @decrease="decreaseSleepTimer" />
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
||||||
import { Dialog } from '@capacitor/dialog'
|
import { Dialog } from '@capacitor/dialog'
|
||||||
|
import CellularPermissionHelpers from '@/mixins/cellularPermissionHelpers'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
@@ -39,6 +40,7 @@ export default {
|
|||||||
serverEpisodeId: null
|
serverEpisodeId: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
mixins: [CellularPermissionHelpers],
|
||||||
computed: {
|
computed: {
|
||||||
bookmarks() {
|
bookmarks() {
|
||||||
if (!this.serverLibraryItemId) return []
|
if (!this.serverLibraryItemId) return []
|
||||||
@@ -110,7 +112,7 @@ export default {
|
|||||||
},
|
},
|
||||||
streamProgress(data) {
|
streamProgress(data) {
|
||||||
if (!data.numSegments) return
|
if (!data.numSegments) return
|
||||||
var chunks = data.chunks
|
const chunks = data.chunks
|
||||||
if (this.$refs.audioPlayer) {
|
if (this.$refs.audioPlayer) {
|
||||||
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
|
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
|
||||||
}
|
}
|
||||||
@@ -193,17 +195,27 @@ export default {
|
|||||||
const startTime = payload.startTime
|
const startTime = payload.startTime
|
||||||
const startWhenReady = !payload.paused
|
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
|
// 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
|
// then store the server library item id so it can be used if a cast is made
|
||||||
const serverLibraryItemId = payload.serverLibraryItemId || null
|
const serverLibraryItemId = payload.serverLibraryItemId || null
|
||||||
const serverEpisodeId = payload.serverEpisodeId || 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({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Warning',
|
title: 'Warning',
|
||||||
message: `Cannot cast downloaded media items. Confirm to close cast and play on your device.`
|
message: `Cannot cast downloaded media items. Confirm to close cast and play on your device.`
|
||||||
})
|
})
|
||||||
if (!value) {
|
if (!value) {
|
||||||
|
this.$store.commit('setPlayerDoneStartingPlayback')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,6 +229,7 @@ export default {
|
|||||||
} else if (this.$refs.audioPlayer) {
|
} else if (this.$refs.audioPlayer) {
|
||||||
this.$refs.audioPlayer.play()
|
this.$refs.audioPlayer.play()
|
||||||
}
|
}
|
||||||
|
this.$store.commit('setPlayerDoneStartingPlayback')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,9 +267,12 @@ export default {
|
|||||||
console.error('Failed', error)
|
console.error('Failed', error)
|
||||||
this.$toast.error('Failed to play')
|
this.$toast.error('Failed to play')
|
||||||
})
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.$store.commit('setPlayerDoneStartingPlayback')
|
||||||
|
})
|
||||||
},
|
},
|
||||||
pauseItem() {
|
pauseItem() {
|
||||||
if (this.$refs.audioPlayer && !this.$refs.audioPlayer.isPaused) {
|
if (this.$refs.audioPlayer && this.$refs.audioPlayer.isPlaying) {
|
||||||
this.$refs.audioPlayer.pause()
|
this.$refs.audioPlayer.pause()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -265,8 +281,7 @@ export default {
|
|||||||
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
||||||
},
|
},
|
||||||
onMediaPlayerChanged(data) {
|
onMediaPlayerChanged(data) {
|
||||||
var mediaPlayer = data.value
|
this.$store.commit('setMediaPlayer', data.value)
|
||||||
this.$store.commit('setMediaPlayer', mediaPlayer)
|
|
||||||
},
|
},
|
||||||
onReady() {
|
onReady() {
|
||||||
// The UI is reporting elsewhere we are ready
|
// The UI is reporting elsewhere we are ready
|
||||||
@@ -274,7 +289,7 @@ export default {
|
|||||||
this.notifyOnReady()
|
this.notifyOnReady()
|
||||||
},
|
},
|
||||||
notifyOnReady() {
|
notifyOnReady() {
|
||||||
// TODO: iOS opens last active playback session on app launch. Should be consistent with Android
|
// TODO: was used on iOS to open last played media. May be removed
|
||||||
if (!this.isIos) return
|
if (!this.isIos) return
|
||||||
|
|
||||||
// If settings aren't loaded yet, native player will receive incorrect settings
|
// If settings aren't loaded yet, native player will receive incorrect settings
|
||||||
@@ -283,6 +298,56 @@ export default {
|
|||||||
this.$store.commit('setIsFirstAudioLoad', false) // Only run this once on app launch
|
this.$store.commit('setIsFirstAudioLoad', false) // Only run this once on app launch
|
||||||
AbsAudioPlayer.onReady()
|
AbsAudioPlayer.onReady()
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
playbackTimeUpdate(currentTime) {
|
||||||
|
this.$refs.audioPlayer?.seek(currentTime)
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* When device gains focus then refresh the timestamps in the audio player
|
||||||
|
*/
|
||||||
|
deviceFocused(hasFocus) {
|
||||||
|
if (!this.$store.state.currentPlaybackSession) return
|
||||||
|
|
||||||
|
if (hasFocus) {
|
||||||
|
if (!this.$refs.audioPlayer?.isPlaying) {
|
||||||
|
const playbackSession = this.$store.state.currentPlaybackSession
|
||||||
|
if (this.$refs.audioPlayer.isLocalPlayMethod) {
|
||||||
|
const localLibraryItemId = playbackSession.localLibraryItem?.id
|
||||||
|
const localEpisodeId = playbackSession.localEpisodeId
|
||||||
|
if (!localLibraryItemId) {
|
||||||
|
console.error('[AudioPlayerContainer] device visibility: no local library item for session', JSON.stringify(playbackSession))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const localMediaProgress = this.$store.state.globals.localMediaProgress.find((mp) => {
|
||||||
|
if (localEpisodeId) return mp.localEpisodeId === localEpisodeId
|
||||||
|
return mp.localLibraryItemId === localLibraryItemId
|
||||||
|
})
|
||||||
|
if (localMediaProgress) {
|
||||||
|
console.log('[AudioPlayerContainer] device visibility: found local media progress', localMediaProgress.currentTime, 'last time in player is', this.currentTime)
|
||||||
|
this.$refs.audioPlayer.currentTime = localMediaProgress.currentTime
|
||||||
|
this.$refs.audioPlayer.timeupdate()
|
||||||
|
} else {
|
||||||
|
console.error('[AudioPlayerContainer] device visibility: Local media progress not found')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const libraryItemId = playbackSession.libraryItemId
|
||||||
|
const episodeId = playbackSession.episodeId
|
||||||
|
const url = episodeId ? `/api/me/progress/${libraryItemId}/${episodeId}` : `/api/me/progress/${libraryItemId}`
|
||||||
|
this.$nativeHttp
|
||||||
|
.get(url)
|
||||||
|
.then((data) => {
|
||||||
|
if (!this.$refs.audioPlayer?.isPlaying && data.libraryItemId === libraryItemId) {
|
||||||
|
console.log('[AudioPlayerContainer] device visibility: got server media progress', data.currentTime, 'last time in player is', this.currentTime)
|
||||||
|
this.$refs.audioPlayer.currentTime = data.currentTime
|
||||||
|
this.$refs.audioPlayer.timeupdate()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('[AudioPlayerContainer] device visibility: Failed to get progress', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
@@ -300,6 +365,8 @@ export default {
|
|||||||
this.$eventBus.$on('close-stream', this.closeStreamOnly)
|
this.$eventBus.$on('close-stream', this.closeStreamOnly)
|
||||||
this.$eventBus.$on('cast-local-item', this.castLocalItem)
|
this.$eventBus.$on('cast-local-item', this.castLocalItem)
|
||||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||||
|
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
|
||||||
|
this.$eventBus.$on('device-focus-update', this.deviceFocused)
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove()
|
if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove()
|
||||||
@@ -313,6 +380,8 @@ export default {
|
|||||||
this.$eventBus.$off('close-stream', this.closeStreamOnly)
|
this.$eventBus.$off('close-stream', this.closeStreamOnly)
|
||||||
this.$eventBus.$off('cast-local-item', this.castLocalItem)
|
this.$eventBus.$off('cast-local-item', this.castLocalItem)
|
||||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||||
|
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
|
||||||
|
this.$eventBus.$off('device-focus-update', this.deviceFocused)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fixed top-0 left-0 right-0 layout-wrapper w-full z-50 overflow-hidden pointer-events-none">
|
<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 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">
|
<div class="px-6 mb-4">
|
||||||
<p v-if="user" class="text-base">
|
<p v-if="user" class="text-base">
|
||||||
Welcome,
|
Welcome,
|
||||||
@@ -11,22 +11,26 @@
|
|||||||
|
|
||||||
<div class="w-full overflow-y-auto">
|
<div class="w-full overflow-y-auto">
|
||||||
<template v-for="item in navItems">
|
<template v-for="item in navItems">
|
||||||
<nuxt-link :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' : ''">
|
<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/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>
|
<span class="text-lg" :class="item.iconOutlined ? 'material-icons-outlined' : 'material-icons'">{{ item.icon }}</span>
|
||||||
<p class="pl-4">{{ item.text }}</p>
|
<p class="pl-4">{{ item.text }}</p>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</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">
|
<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>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<p class="text-xs">{{ $config.version }}</p>
|
<p class="text-xs">{{ $config.version }}</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<div v-if="user" class="flex items-center" @click="logout">
|
<div v-if="user" class="flex items-center" @click="disconnect">
|
||||||
<p class="text-xs pr-2">Logout</p>
|
<p class="text-xs pr-2">{{ $strings.ButtonDisconnect }}</p>
|
||||||
<span class="material-icons text-sm">logout</span>
|
<i class="material-icons text-sm -mb-0.5">cloud_off</i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,13 +79,16 @@ export default {
|
|||||||
return this.$store.state.serverSettings || {}
|
return this.$store.state.serverSettings || {}
|
||||||
},
|
},
|
||||||
username() {
|
username() {
|
||||||
return this.user ? this.user.username : ''
|
return this.user?.username || ''
|
||||||
|
},
|
||||||
|
userIsAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
navItems() {
|
navItems() {
|
||||||
var items = [
|
var items = [
|
||||||
{
|
{
|
||||||
icon: 'home',
|
icon: 'home',
|
||||||
text: 'Home',
|
text: this.$strings.ButtonHome,
|
||||||
to: '/bookshelf'
|
to: '/bookshelf'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -89,19 +96,19 @@ export default {
|
|||||||
items = [
|
items = [
|
||||||
{
|
{
|
||||||
icon: 'cloud_off',
|
icon: 'cloud_off',
|
||||||
text: 'Connect to Server',
|
text: this.$strings.ButtonConnectToServer,
|
||||||
to: '/connect'
|
to: '/connect'
|
||||||
}
|
}
|
||||||
].concat(items)
|
].concat(items)
|
||||||
} else {
|
} else {
|
||||||
items.push({
|
items.push({
|
||||||
icon: 'person',
|
icon: 'person',
|
||||||
text: 'Account',
|
text: this.$strings.HeaderAccount,
|
||||||
to: '/account'
|
to: '/account'
|
||||||
})
|
})
|
||||||
items.push({
|
items.push({
|
||||||
icon: 'equalizer',
|
icon: 'equalizer',
|
||||||
text: 'User Stats',
|
text: this.$strings.ButtonUserStats,
|
||||||
to: '/stats'
|
to: '/stats'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -110,22 +117,37 @@ export default {
|
|||||||
items.push({
|
items.push({
|
||||||
icon: 'folder',
|
icon: 'folder',
|
||||||
iconOutlined: true,
|
iconOutlined: true,
|
||||||
text: 'Local Media',
|
text: this.$strings.ButtonLocalMedia,
|
||||||
to: '/localMedia/folders'
|
to: '/localMedia/folders'
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
items.push({
|
items.push({
|
||||||
icon: 'download',
|
icon: 'download',
|
||||||
iconOutlined: false,
|
iconOutlined: false,
|
||||||
text: 'Downloads',
|
text: this.$strings.HeaderDownloads,
|
||||||
to: '/downloads'
|
to: '/downloads'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
items.push({
|
items.push({
|
||||||
icon: 'settings',
|
icon: 'settings',
|
||||||
text: 'Settings',
|
text: this.$strings.HeaderSettings,
|
||||||
to: '/settings'
|
to: '/settings'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (this.serverConnectionConfig) {
|
||||||
|
items.push({
|
||||||
|
icon: 'language',
|
||||||
|
text: this.$strings.ButtonGoToWebClient,
|
||||||
|
action: 'openWebClient'
|
||||||
|
})
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
icon: 'login',
|
||||||
|
text: this.$strings.ButtonSwitchServerUser,
|
||||||
|
action: 'logout'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return items
|
return items
|
||||||
},
|
},
|
||||||
currentRoutePath() {
|
currentRoutePath() {
|
||||||
@@ -133,14 +155,24 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
async clickAction(action) {
|
||||||
|
await this.$hapticsImpact()
|
||||||
|
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() {
|
clickBackground() {
|
||||||
this.show = false
|
this.show = false
|
||||||
},
|
},
|
||||||
async logout() {
|
async logout() {
|
||||||
await this.$hapticsImpact()
|
|
||||||
if (this.user) {
|
if (this.user) {
|
||||||
await this.$axios.$post('/logout').catch((error) => {
|
await this.$nativeHttp.post('/logout').catch((error) => {
|
||||||
console.error(error)
|
console.error('Failed to logout', error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +180,17 @@ export default {
|
|||||||
await this.$db.logout()
|
await this.$db.logout()
|
||||||
this.$localStore.removeLastLibraryId()
|
this.$localStore.removeLastLibraryId()
|
||||||
this.$store.commit('user/logout')
|
this.$store.commit('user/logout')
|
||||||
this.$router.push('/connect')
|
this.$store.commit('libraries/setCurrentLibrary', null)
|
||||||
|
},
|
||||||
|
async disconnect() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
|
await this.logout()
|
||||||
|
|
||||||
|
if (this.$route.name !== 'bookshelf') {
|
||||||
|
this.$router.replace('/bookshelf')
|
||||||
|
} else {
|
||||||
|
location.reload()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
touchstart(e) {
|
touchstart(e) {
|
||||||
this.touchEvent = new TouchEvent(e)
|
this.touchEvent = new TouchEvent(e)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<div v-show="!entities.length && initialized" class="w-full py-16 text-center text-xl">
|
<div v-show="!entities.length && initialized" class="w-full py-16 text-center text-xl">
|
||||||
<div class="py-4 capitalize">No {{ entityName }}</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -72,11 +72,14 @@ export default {
|
|||||||
showBookshelfListView() {
|
showBookshelfListView() {
|
||||||
return this.isBookEntity && this.bookshelfListView
|
return this.isBookEntity && this.bookshelfListView
|
||||||
},
|
},
|
||||||
|
sortingIgnorePrefix() {
|
||||||
|
return this.$store.getters['getServerSetting']('sortingIgnorePrefix')
|
||||||
|
},
|
||||||
entityName() {
|
entityName() {
|
||||||
return this.page
|
return this.page
|
||||||
},
|
},
|
||||||
hasFilter() {
|
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'
|
return this.filterBy !== 'all'
|
||||||
},
|
},
|
||||||
orderBy() {
|
orderBy() {
|
||||||
@@ -164,9 +167,9 @@ export default {
|
|||||||
|
|
||||||
const entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
|
const entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
|
||||||
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
||||||
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
|
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed,numEpisodesIncomplete`
|
||||||
|
|
||||||
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
|
const payload = await this.$nativeHttp.get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
|
||||||
console.error('failed to fetch books', error)
|
console.error('failed to fetch books', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
@@ -332,6 +335,12 @@ export default {
|
|||||||
},
|
},
|
||||||
async init() {
|
async init() {
|
||||||
if (this.isFirstInit) return
|
if (this.isFirstInit) return
|
||||||
|
if (!this.user) {
|
||||||
|
// Offline support not available
|
||||||
|
await this.resetEntities()
|
||||||
|
this.$eventBus.$emit('bookshelf-total-entities', 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
this.localLibraryItems = await this.$db.getLocalLibraryItems(this.currentLibraryMediaType)
|
this.localLibraryItems = await this.$db.getLocalLibraryItems(this.currentLibraryMediaType)
|
||||||
console.log('Local library items loaded for lazy bookshelf', this.localLibraryItems.length)
|
console.log('Local library items loaded for lazy bookshelf', this.localLibraryItems.length)
|
||||||
@@ -358,8 +367,14 @@ export default {
|
|||||||
this.handleScroll(scrollTop)
|
this.handleScroll(scrollTop)
|
||||||
},
|
},
|
||||||
buildSearchParams() {
|
buildSearchParams() {
|
||||||
if (this.page === 'search' || this.page === 'series' || this.page === 'collections') {
|
if (this.page === 'search' || this.page === 'collections') {
|
||||||
return ''
|
return ''
|
||||||
|
} else if (this.page === 'series') {
|
||||||
|
// Sort by name ascending
|
||||||
|
let searchParams = new URLSearchParams()
|
||||||
|
searchParams.set('sort', 'name')
|
||||||
|
searchParams.set('desc', 0)
|
||||||
|
return searchParams.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
let searchParams = new URLSearchParams()
|
let searchParams = new URLSearchParams()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full relative">
|
<div class="w-full relative">
|
||||||
<div v-if="altViewEnabled" class="px-5 pb-3 pt-4">
|
<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>
|
||||||
|
|
||||||
<div class="flex items-end px-3 max-w-full overflow-x-auto" :class="altViewEnabled ? '' : 'bookshelfRow'" :style="{ height: shelfHeight + 'px', paddingBottom: entityPaddingBottom + 'px' }">
|
<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 -->
|
<!-- 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">
|
<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 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 }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} {{ $strings.LabelBooks }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading spinner -->
|
<!-- Loading spinner -->
|
||||||
@@ -58,24 +58,7 @@ export default {
|
|||||||
return this._author.numBooks || 0
|
return this._author.numBooks || 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {},
|
||||||
async searchAuthor() {
|
|
||||||
this.searching = true
|
|
||||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.name }).catch((error) => {
|
|
||||||
console.error('Failed', error)
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
if (!response) {
|
|
||||||
this.$toast.error('Author not found')
|
|
||||||
} else if (response.updated) {
|
|
||||||
if (response.author.imagePath) this.$toast.success('Author was updated')
|
|
||||||
else this.$toast.success('Author was updated (no image found)')
|
|
||||||
} else {
|
|
||||||
this.$toast.info('No updates were made for Author')
|
|
||||||
}
|
|
||||||
this.searching = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -5,10 +5,10 @@
|
|||||||
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
|
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
|
||||||
<p v-else class="truncate text-sm" v-html="matchHtml" />
|
<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-if="matchKey !== 'authors'" class="text-xs text-fg truncate">by {{ authorName }}</p>
|
||||||
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
|
<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 v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode' || matchKey === 'narrators'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,23 +7,26 @@
|
|||||||
|
|
||||||
<!-- Alternative bookshelf title/author/sort -->
|
<!-- Alternative bookshelf title/author/sort -->
|
||||||
<div v-if="isAltViewEnabled" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
<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' }">
|
<div :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }" class="flex items-center">
|
||||||
{{ displayTitle }}
|
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||||
</p>
|
{{ displayTitle }}
|
||||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || ' ' }}</p>
|
</p>
|
||||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</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>
|
||||||
|
|
||||||
<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">
|
<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>
|
||||||
<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">
|
<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>
|
||||||
|
|
||||||
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
|
<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' }">
|
<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>
|
</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 }" />
|
<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 }" />
|
||||||
@@ -37,17 +40,29 @@
|
|||||||
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
|
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
|
||||||
<p class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
|
<p class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showPlayButton" class="absolute -bottom-16 -right-16 rotate-45 w-32 h-32 p-2 bg-gradient-to-r from-transparent to-black to-40% inline-flex justify-start items-center">
|
||||||
|
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto -rotate-45" @click.stop.prevent="play">
|
||||||
|
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">{{ streamIsPlaying ? 'pause_circle' : 'play_circle_filled' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Play/pause button for podcast episode -->
|
<!-- 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">
|
<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 class="material-icons text-6xl text-black text-opacity-80">{{ streamIsPlaying ? 'pause_circle' : 'play_circle_filled' }}</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- No progress shown for collapsed series in library -->
|
<!-- 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>
|
<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>
|
||||||
|
|
||||||
<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` }">
|
<!-- Downloaded icon -->
|
||||||
|
<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>
|
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -56,6 +71,11 @@
|
|||||||
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
|
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- rss feed icon -->
|
||||||
|
<div v-if="rssFeed" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }">
|
||||||
|
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.5 + 'rem' }">rss_feed</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Series sequence -->
|
<!-- Series sequence -->
|
||||||
<div v-if="seriesSequence && showSequence && !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` }">
|
<div v-if="seriesSequence && showSequence && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
|
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
|
||||||
@@ -63,14 +83,19 @@
|
|||||||
|
|
||||||
<!-- Podcast Episode # -->
|
<!-- 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` }">
|
<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>
|
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Podcast Num Episodes -->
|
<!-- Podcast Num Episodes -->
|
||||||
<div v-else-if="numEpisodes && !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' }">
|
<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 class="text-white" :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodesIncomplete }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -106,7 +131,6 @@ export default {
|
|||||||
isProcessingReadUpdate: false,
|
isProcessingReadUpdate: false,
|
||||||
libraryItem: null,
|
libraryItem: null,
|
||||||
imageReady: false,
|
imageReady: false,
|
||||||
rescanning: false,
|
|
||||||
selected: false,
|
selected: false,
|
||||||
isSelectionMode: false,
|
isSelectionMode: false,
|
||||||
showCoverBg: false,
|
showCoverBg: false,
|
||||||
@@ -123,9 +147,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
_libraryItem() {
|
_libraryItem() {
|
||||||
return this.libraryItem || {}
|
return this.libraryItem || {}
|
||||||
},
|
},
|
||||||
@@ -174,6 +195,10 @@ export default {
|
|||||||
if (this.isLocal && this.isPodcast && this.media.episodes) return this.media.episodes.length
|
if (this.isLocal && this.isPodcast && this.media.episodes) return this.media.episodes.length
|
||||||
return this.media.numEpisodes
|
return this.media.numEpisodes
|
||||||
},
|
},
|
||||||
|
numEpisodesIncomplete() {
|
||||||
|
if (this.isLocal) return 0
|
||||||
|
return this._libraryItem.numEpisodesIncomplete || 0
|
||||||
|
},
|
||||||
processingBatch() {
|
processingBatch() {
|
||||||
return this.store.state.processingBatch
|
return this.store.state.processingBatch
|
||||||
},
|
},
|
||||||
@@ -212,7 +237,7 @@ export default {
|
|||||||
return this.mediaMetadata.series
|
return this.mediaMetadata.series
|
||||||
},
|
},
|
||||||
seriesSequence() {
|
seriesSequence() {
|
||||||
return this.series ? this.series.sequence : null
|
return this.series?.sequence || null
|
||||||
},
|
},
|
||||||
recentEpisode() {
|
recentEpisode() {
|
||||||
// Only added to item when getting currently listening podcasts
|
// Only added to item when getting currently listening podcasts
|
||||||
@@ -231,14 +256,14 @@ export default {
|
|||||||
},
|
},
|
||||||
booksInSeries() {
|
booksInSeries() {
|
||||||
// Only added to item object when collapseSeries is enabled
|
// Only added to item object when collapseSeries is enabled
|
||||||
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
|
return this.collapsedSeries?.numBooks || 0
|
||||||
},
|
},
|
||||||
seriesSequenceList() {
|
seriesSequenceList() {
|
||||||
return this.collapsedSeries ? this.collapsedSeries.seriesSequenceList : null
|
return this.collapsedSeries?.seriesSequenceList || null
|
||||||
},
|
},
|
||||||
libraryItemIdsInSeries() {
|
libraryItemIdsInSeries() {
|
||||||
// Only added to item object when collapseSeries is enabled
|
// Only added to item object when collapseSeries is enabled
|
||||||
return this.collapsedSeries ? this.collapsedSeries.libraryItemIds || [] : []
|
return this.collapsedSeries?.libraryItemIds || []
|
||||||
},
|
},
|
||||||
displayTitle() {
|
displayTitle() {
|
||||||
if (this.recentEpisode) return this.recentEpisode.title
|
if (this.recentEpisode) return this.recentEpisode.title
|
||||||
@@ -282,34 +307,36 @@ export default {
|
|||||||
},
|
},
|
||||||
userProgressPercent() {
|
userProgressPercent() {
|
||||||
if (this.useEBookProgress) return Math.max(Math.min(1, this.userProgress.ebookProgress), 0)
|
if (this.useEBookProgress) return Math.max(Math.min(1, this.userProgress.ebookProgress), 0)
|
||||||
return this.userProgress ? Math.max(Math.min(1, this.userProgress.progress), 0) || 0 : 0
|
return Math.max(Math.min(1, this.userProgress?.progress || 0), 0) || 0
|
||||||
},
|
},
|
||||||
itemIsFinished() {
|
itemIsFinished() {
|
||||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
return !!this.userProgress?.isFinished
|
||||||
},
|
},
|
||||||
showError() {
|
showError() {
|
||||||
return this.numMissingParts || this.isMissing || this.isInvalid
|
return this.isMissing || this.isInvalid
|
||||||
},
|
|
||||||
playerIsLocal() {
|
|
||||||
return !!this.$store.state.playerIsLocal
|
|
||||||
},
|
},
|
||||||
localLibraryItemId() {
|
localLibraryItemId() {
|
||||||
if (this.isLocal) return this.libraryItemId
|
if (this.isLocal) return this.libraryItemId
|
||||||
return this.localLibraryItem ? this.localLibraryItem.id : null
|
return this.localLibraryItem?.id || null
|
||||||
|
},
|
||||||
|
localEpisode() {
|
||||||
|
if (!this.recentEpisode || !this.localLibraryItem) return null
|
||||||
|
// Current recentEpisode is only implemented server side so this will always be the serverEpisodeId
|
||||||
|
return this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
|
||||||
},
|
},
|
||||||
isStreaming() {
|
isStreaming() {
|
||||||
if (this.isPodcast) {
|
return this.store.getters['getIsMediaStreaming'](this.libraryItemId, this.recentEpisode?.id)
|
||||||
if (this.playerIsLocal) {
|
|
||||||
// Check is streaming local version of this episode
|
|
||||||
return false // episode cards not implemented for local yet
|
|
||||||
}
|
|
||||||
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.recentEpisode.id)
|
|
||||||
} else {
|
|
||||||
return false // not yet necessary for books
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
streamIsPlaying() {
|
streamIsPlaying() {
|
||||||
return this.$store.state.playerIsPlaying && this.isStreaming
|
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() {
|
isMissing() {
|
||||||
return this._libraryItem.isMissing
|
return this._libraryItem.isMissing
|
||||||
@@ -317,9 +344,8 @@ export default {
|
|||||||
isInvalid() {
|
isInvalid() {
|
||||||
return this._libraryItem.isInvalid
|
return this._libraryItem.isInvalid
|
||||||
},
|
},
|
||||||
numMissingParts() {
|
isExplicit() {
|
||||||
if (this.isPodcast) return 0
|
return !!this.mediaMetadata.explicit
|
||||||
return this.media.numMissingParts
|
|
||||||
},
|
},
|
||||||
overlayWrapperClasslist() {
|
overlayWrapperClasslist() {
|
||||||
var classes = []
|
var classes = []
|
||||||
@@ -342,9 +368,6 @@ export default {
|
|||||||
userCanDownload() {
|
userCanDownload() {
|
||||||
return this.store.getters['user/getUserCanDownload']
|
return this.store.getters['user/getUserCanDownload']
|
||||||
},
|
},
|
||||||
userIsRoot() {
|
|
||||||
return this.store.getters['user/getIsRoot']
|
|
||||||
},
|
|
||||||
titleFontSize() {
|
titleFontSize() {
|
||||||
return 0.75 * this.sizeMultiplier
|
return 0.75 * this.sizeMultiplier
|
||||||
},
|
},
|
||||||
@@ -379,13 +402,20 @@ export default {
|
|||||||
showHasLocalDownload() {
|
showHasLocalDownload() {
|
||||||
if (this.localLibraryItem || this.isLocal) {
|
if (this.localLibraryItem || this.isLocal) {
|
||||||
if (this.recentEpisode && !this.isLocal) {
|
if (this.recentEpisode && !this.isLocal) {
|
||||||
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
|
return !!this.localEpisode
|
||||||
return !!localEpisode
|
|
||||||
} else {
|
} else {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
},
|
||||||
|
rssFeed() {
|
||||||
|
if (this.booksInSeries) return null
|
||||||
|
return this._libraryItem.rssFeed || null
|
||||||
|
},
|
||||||
|
showPlayButton() {
|
||||||
|
return false
|
||||||
|
// return !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -427,7 +457,10 @@ export default {
|
|||||||
// Server books may have a local library item
|
// Server books may have a local library item
|
||||||
this.localLibraryItem = localLibraryItem
|
this.localLibraryItem = localLibraryItem
|
||||||
},
|
},
|
||||||
|
async play() {},
|
||||||
async playEpisode() {
|
async playEpisode() {
|
||||||
|
if (this.playerIsStartingPlayback) return
|
||||||
|
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
const eventBus = this.$eventBus || this.$nuxt.$eventBus
|
const eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||||
if (this.streamIsPlaying) {
|
if (this.streamIsPlaying) {
|
||||||
@@ -435,18 +468,16 @@ export default {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.localLibraryItem) {
|
this.store.commit('setPlayerIsStartingPlayback', this.recentEpisode.id)
|
||||||
const localEpisode = this.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === this.recentEpisode.id)
|
if (this.localEpisode) {
|
||||||
if (localEpisode) {
|
// Play episode locally
|
||||||
// Play episode locally
|
eventBus.$emit('play-item', {
|
||||||
eventBus.$emit('play-item', {
|
libraryItemId: this.localLibraryItemId,
|
||||||
libraryItemId: this.localLibraryItemId,
|
episodeId: this.localEpisode.id,
|
||||||
episodeId: localEpisode.id,
|
serverLibraryItemId: this.libraryItemId,
|
||||||
serverLibraryItemId: this.libraryItemId,
|
serverEpisodeId: this.recentEpisode.id
|
||||||
serverEpisodeId: this.recentEpisode.id
|
})
|
||||||
})
|
return
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
|
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
|
||||||
@@ -461,55 +492,18 @@ export default {
|
|||||||
if (router) {
|
if (router) {
|
||||||
if (this.recentEpisode) router.push(`/item/${this.libraryItemId}/${this.recentEpisode.id}`)
|
if (this.recentEpisode) router.push(`/item/${this.libraryItemId}/${this.recentEpisode.id}`)
|
||||||
else if (this.collapsedSeries) router.push(`/bookshelf/series/${this.collapsedSeries.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}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
editClick() {
|
editClick() {
|
||||||
this.$emit('edit', this.libraryItem)
|
this.$emit('edit', this.libraryItem)
|
||||||
},
|
},
|
||||||
toggleFinished() {
|
|
||||||
var updatePayload = {
|
|
||||||
isFinished: !this.itemIsFinished
|
|
||||||
}
|
|
||||||
this.isProcessingReadUpdate = true
|
|
||||||
var toast = this.$toast || this.$nuxt.$toast
|
|
||||||
var axios = this.$axios || this.$nuxt.$axios
|
|
||||||
axios
|
|
||||||
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
|
|
||||||
.then(() => {
|
|
||||||
this.isProcessingReadUpdate = false
|
|
||||||
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed', error)
|
|
||||||
this.isProcessingReadUpdate = false
|
|
||||||
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
rescan() {
|
|
||||||
this.rescanning = true
|
|
||||||
this.$axios
|
|
||||||
.$get(`/api/items/${this.libraryItemId}/scan`)
|
|
||||||
.then((data) => {
|
|
||||||
this.rescanning = false
|
|
||||||
var result = data.result
|
|
||||||
if (!result) {
|
|
||||||
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
|
|
||||||
} else if (result === 'UPDATED') {
|
|
||||||
this.$toast.success(`Re-Scan complete item was updated`)
|
|
||||||
} else if (result === 'UPTODATE') {
|
|
||||||
this.$toast.success(`Re-Scan complete item was up to date`)
|
|
||||||
} else if (result === 'REMOVED') {
|
|
||||||
this.$toast.error(`Re-Scan complete item was removed`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to scan library item', error)
|
|
||||||
this.$toast.error('Failed to scan library item')
|
|
||||||
this.rescanning = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
showEditModalTracks() {
|
showEditModalTracks() {
|
||||||
// More menu func
|
// More menu func
|
||||||
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'tracks' })
|
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'tracks' })
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||||
</div>
|
</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` }">
|
<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>
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||||
</div>
|
</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 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>
|
||||||
<div class="flex-grow px-2">
|
<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 }}
|
<span v-if="seriesSequence">#{{ seriesSequence }} </span>{{ displayTitle }}
|
||||||
</p>
|
</p>
|
||||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">by {{ displayAuthor }}</p>
|
<p class="truncate text-fg-muted" :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="displaySortLine" class="truncate text-fg-muted" :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="duration" class="truncate text-fg-muted" :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 v-if="episodes" class="truncate text-fg-muted" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ episodes }}</p>
|
||||||
</div>
|
</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` }">
|
<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` }">
|
||||||
@@ -62,7 +62,6 @@ export default {
|
|||||||
isProcessingReadUpdate: false,
|
isProcessingReadUpdate: false,
|
||||||
libraryItem: null,
|
libraryItem: null,
|
||||||
imageReady: false,
|
imageReady: false,
|
||||||
rescanning: false,
|
|
||||||
selected: false,
|
selected: false,
|
||||||
isSelectionMode: false,
|
isSelectionMode: false,
|
||||||
showCoverBg: false,
|
showCoverBg: false,
|
||||||
@@ -82,9 +81,6 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
_libraryItem() {
|
_libraryItem() {
|
||||||
return this.libraryItem || {}
|
return this.libraryItem || {}
|
||||||
},
|
},
|
||||||
@@ -180,7 +176,7 @@ export default {
|
|||||||
return this.mediaMetadata.series
|
return this.mediaMetadata.series
|
||||||
},
|
},
|
||||||
seriesSequence() {
|
seriesSequence() {
|
||||||
return this.series ? this.series.sequence : null
|
return this.series?.sequence || null
|
||||||
},
|
},
|
||||||
collapsedSeries() {
|
collapsedSeries() {
|
||||||
// Only added to item object when collapseSeries is enabled
|
// Only added to item object when collapseSeries is enabled
|
||||||
@@ -188,20 +184,21 @@ export default {
|
|||||||
},
|
},
|
||||||
booksInSeries() {
|
booksInSeries() {
|
||||||
// Only added to item object when collapseSeries is enabled
|
// Only added to item object when collapseSeries is enabled
|
||||||
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
|
return this.collapsedSeries?.numBooks || 0
|
||||||
},
|
},
|
||||||
displayTitle() {
|
displayTitle() {
|
||||||
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) {
|
const ignorePrefix = this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix
|
||||||
return this.title.substr(4) + ', The'
|
if (this.collapsedSeries) return ignorePrefix ? this.collapsedSeries.nameIgnorePrefix : this.collapsedSeries.name
|
||||||
}
|
return ignorePrefix ? this.mediaMetadata.titleIgnorePrefix : this.title
|
||||||
return this.title
|
|
||||||
},
|
},
|
||||||
displayAuthor() {
|
displayAuthor() {
|
||||||
if (this.isPodcast) return this.author
|
if (this.isPodcast) return this.author
|
||||||
|
if (this.collapsedSeries) return `${this.booksInSeries} books in series`
|
||||||
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
|
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
|
||||||
return this.author
|
return this.author
|
||||||
},
|
},
|
||||||
displaySortLine() {
|
displaySortLine() {
|
||||||
|
if (this.collapsedSeries) return null
|
||||||
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
|
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
|
||||||
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
|
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
|
||||||
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
|
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
|
||||||
@@ -212,25 +209,25 @@ export default {
|
|||||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||||
},
|
},
|
||||||
userProgressPercent() {
|
userProgressPercent() {
|
||||||
return this.userProgress ? this.userProgress.progress || 0 : 0
|
return this.userProgress?.progress || 0
|
||||||
},
|
},
|
||||||
itemIsFinished() {
|
itemIsFinished() {
|
||||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
return !!this.userProgress?.isFinished
|
||||||
},
|
},
|
||||||
showError() {
|
showError() {
|
||||||
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid
|
return this.isMissing || this.isInvalid
|
||||||
},
|
},
|
||||||
isStreaming() {
|
isStreaming() {
|
||||||
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
|
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
|
||||||
},
|
},
|
||||||
showReadButton() {
|
showReadButton() {
|
||||||
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
|
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook
|
||||||
},
|
},
|
||||||
showPlayButton() {
|
showPlayButton() {
|
||||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
|
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
|
||||||
},
|
},
|
||||||
showSmallEBookIcon() {
|
showSmallEBookIcon() {
|
||||||
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
|
return !this.isSelectionMode && this.hasEbook
|
||||||
},
|
},
|
||||||
isMissing() {
|
isMissing() {
|
||||||
return this._libraryItem.isMissing
|
return this._libraryItem.isMissing
|
||||||
@@ -238,85 +235,9 @@ export default {
|
|||||||
isInvalid() {
|
isInvalid() {
|
||||||
return this._libraryItem.isInvalid
|
return this._libraryItem.isInvalid
|
||||||
},
|
},
|
||||||
hasMissingParts() {
|
|
||||||
return this._libraryItem.hasMissingParts
|
|
||||||
},
|
|
||||||
hasInvalidParts() {
|
|
||||||
return this._libraryItem.hasInvalidParts
|
|
||||||
},
|
|
||||||
errorText() {
|
|
||||||
if (this.isMissing) return 'Item directory is missing!'
|
|
||||||
else if (this.isInvalid) return 'Item has no media files'
|
|
||||||
var txt = ''
|
|
||||||
if (this.hasMissingParts) {
|
|
||||||
txt = `${this.hasMissingParts} missing parts.`
|
|
||||||
}
|
|
||||||
if (this.hasInvalidParts) {
|
|
||||||
if (this.hasMissingParts) txt += ' '
|
|
||||||
txt += `${this.hasInvalidParts} invalid parts.`
|
|
||||||
}
|
|
||||||
return txt || 'Unknown Error'
|
|
||||||
},
|
|
||||||
overlayWrapperClasslist() {
|
|
||||||
var classes = []
|
|
||||||
if (this.isSelectionMode) classes.push('bg-opacity-60')
|
|
||||||
else classes.push('bg-opacity-40')
|
|
||||||
if (this.selected) {
|
|
||||||
classes.push('border-2 border-yellow-400')
|
|
||||||
}
|
|
||||||
return classes
|
|
||||||
},
|
|
||||||
store() {
|
store() {
|
||||||
return this.$store || this.$nuxt.$store
|
return this.$store || this.$nuxt.$store
|
||||||
},
|
},
|
||||||
userCanUpdate() {
|
|
||||||
return this.store.getters['user/getUserCanUpdate']
|
|
||||||
},
|
|
||||||
userCanDelete() {
|
|
||||||
return this.store.getters['user/getUserCanDelete']
|
|
||||||
},
|
|
||||||
userCanDownload() {
|
|
||||||
return this.store.getters['user/getUserCanDownload']
|
|
||||||
},
|
|
||||||
userIsRoot() {
|
|
||||||
return this.store.getters['user/getIsRoot']
|
|
||||||
},
|
|
||||||
titleFontSize() {
|
|
||||||
return 0.75 * this.sizeMultiplier
|
|
||||||
},
|
|
||||||
authorFontSize() {
|
|
||||||
return 0.6 * this.sizeMultiplier
|
|
||||||
},
|
|
||||||
placeholderCoverPadding() {
|
|
||||||
return 0.8 * this.sizeMultiplier
|
|
||||||
},
|
|
||||||
authorBottom() {
|
|
||||||
return 0.75 * this.sizeMultiplier
|
|
||||||
},
|
|
||||||
titleCleaned() {
|
|
||||||
if (!this.title) return ''
|
|
||||||
if (this.title.length > 60) {
|
|
||||||
return this.title.slice(0, 57) + '...'
|
|
||||||
}
|
|
||||||
return this.title
|
|
||||||
},
|
|
||||||
authorCleaned() {
|
|
||||||
if (!this.author) return ''
|
|
||||||
if (this.author.length > 30) {
|
|
||||||
return this.author.slice(0, 27) + '...'
|
|
||||||
}
|
|
||||||
return this.author
|
|
||||||
},
|
|
||||||
isAlternativeBookshelfView() {
|
|
||||||
return false
|
|
||||||
// var constants = this.$constants || this.$nuxt.$constants
|
|
||||||
// return this.bookshelfView === constants.BookshelfView.TITLES
|
|
||||||
},
|
|
||||||
titleDisplayBottomOffset() {
|
|
||||||
if (!this.isAlternativeBookshelfView) return 0
|
|
||||||
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
|
|
||||||
return 4.25 * this.sizeMultiplier
|
|
||||||
},
|
|
||||||
coverWidth() {
|
coverWidth() {
|
||||||
return 80 / this.bookCoverAspectRatio
|
return 80 / this.bookCoverAspectRatio
|
||||||
}
|
}
|
||||||
@@ -341,7 +262,7 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
var router = this.$router || this.$nuxt.$router
|
var router = this.$router || this.$nuxt.$router
|
||||||
if (router) {
|
if (router) {
|
||||||
if (this.collapsedSeries) router.push(`/library/${this.libraryId}/series/${this.collapsedSeries.id}`)
|
if (this.collapsedSeries) router.push(`/bookshelf/series/${this.collapsedSeries.id}`)
|
||||||
else router.push(`/item/${this.libraryItemId}`)
|
else router.push(`/item/${this.libraryItemId}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,48 +270,6 @@ export default {
|
|||||||
editClick() {
|
editClick() {
|
||||||
this.$emit('edit', this.libraryItem)
|
this.$emit('edit', this.libraryItem)
|
||||||
},
|
},
|
||||||
toggleFinished() {
|
|
||||||
var updatePayload = {
|
|
||||||
isFinished: !this.itemIsFinished
|
|
||||||
}
|
|
||||||
this.isProcessingReadUpdate = true
|
|
||||||
var toast = this.$toast || this.$nuxt.$toast
|
|
||||||
var axios = this.$axios || this.$nuxt.$axios
|
|
||||||
axios
|
|
||||||
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
|
|
||||||
.then(() => {
|
|
||||||
this.isProcessingReadUpdate = false
|
|
||||||
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed', error)
|
|
||||||
this.isProcessingReadUpdate = false
|
|
||||||
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
rescan() {
|
|
||||||
this.rescanning = true
|
|
||||||
this.$axios
|
|
||||||
.$get(`/api/items/${this.libraryItemId}/scan`)
|
|
||||||
.then((data) => {
|
|
||||||
this.rescanning = false
|
|
||||||
var result = data.result
|
|
||||||
if (!result) {
|
|
||||||
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
|
|
||||||
} else if (result === 'UPDATED') {
|
|
||||||
this.$toast.success(`Re-Scan complete item was updated`)
|
|
||||||
} else if (result === 'UPTODATE') {
|
|
||||||
this.$toast.success(`Re-Scan complete item was up to date`)
|
|
||||||
} else if (result === 'REMOVED') {
|
|
||||||
this.$toast.error(`Re-Scan complete item was removed`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Failed to scan library item', error)
|
|
||||||
this.$toast.error('Failed to scan library item')
|
|
||||||
this.rescanning = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
showEditModalTracks() {
|
showEditModalTracks() {
|
||||||
// More menu func
|
// More menu func
|
||||||
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'tracks' })
|
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'tracks' })
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||||
<covers-playlist-cover ref="cover" :items="items" :width="width" :height="height" />
|
<covers-playlist-cover ref="cover" :items="items" :width="width" :height="height" />
|
||||||
</div>
|
</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` }">
|
<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>
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||||
</div>
|
</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">
|
<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>
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||||
</div>
|
</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` }">
|
<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>
|
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full px-1 overflow-hidden">
|
||||||
|
<div class="w-10 h-10 flex items-center justify-center">
|
||||||
|
<span class="material-icons text-2xl text-fg">local_offer</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow px-2 tagSearchCardContent h-full">
|
||||||
|
<p class="truncate text-sm">{{ tag }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
tag: String
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tagSearchCardContent {
|
||||||
|
width: calc(100% - 40px);
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,66 +1,68 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full max-w-md mx-auto px-2 sm:px-4 lg:px-8 z-10">
|
<div class="w-full max-w-md mx-auto px-2 sm:px-4 lg:px-8 z-10">
|
||||||
<div v-show="!loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg px-4 py-6 w-full">
|
<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">
|
<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)">
|
<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-gray-300">dns</span>
|
<span class="material-icons-outlined text-xl text-fg-muted">dns</span>
|
||||||
<p class="pl-3 pr-6 text-base text-gray-200">{{ config.name }}</p>
|
<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)">
|
<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>
|
</div>
|
||||||
<div class="my-1 py-4 w-full">
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- form to add a new server connection config -->
|
||||||
<div v-else class="w-full">
|
<div v-else class="w-full">
|
||||||
<form v-show="!showAuth" @submit.prevent="submit" novalidate class="w-full">
|
<!-- 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">
|
<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>
|
</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" />
|
<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">
|
<div class="flex justify-end items-center mt-6">
|
||||||
<!-- <div class="relative flex">
|
<ui-btn :disabled="processing || !networkConnected" type="submit" :padding-x="3" class="h-10">{{ networkConnected ? $strings.ButtonSubmit : $strings.MessageNoNetworkConnection }}</ui-btn>
|
||||||
<button class="outline-none uppercase tracking-wide font-semibold text-xs text-gray-300" type="button" @click="addCustomHeaders">Add Custom Headers</button>
|
|
||||||
<div v-if="numCustomHeaders" class="rounded-full h-5 w-5 flex items-center justify-center text-xs bg-success bg-opacity-40 leading-3 font-semibold font-mono ml-1">{{ numCustomHeaders }}</div>
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
<ui-btn :disabled="processing || !networkConnected" type="submit" :padding-x="3" class="h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<template v-if="showAuth">
|
<!-- username/password and auth methods -->
|
||||||
|
<template v-else>
|
||||||
<div v-if="serverConfig.id" class="flex items-center mb-4" @click="showServerList">
|
<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>
|
||||||
|
|
||||||
<div class="flex items-center">
|
<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" />
|
<div class="flex-grow" />
|
||||||
<span v-if="!serverConfig.id" class="material-icons" style="font-size: 1.1rem" @click="editServerAddress">edit</span>
|
<span v-if="!serverConfig.id" class="material-icons" style="font-size: 1.1rem" @click="editServerAddress">edit</span>
|
||||||
</div>
|
</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 @submit.prevent="submitAuth" class="pt-3">
|
<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="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="password" 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">
|
<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" />
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- auth error message -->
|
||||||
<div v-show="error" class="w-full rounded-lg bg-red-600 bg-opacity-10 border border-error border-opacity-50 py-3 px-2 flex items-center mt-4">
|
<div v-show="error" class="w-full rounded-lg bg-red-600 bg-opacity-10 border border-error border-opacity-50 py-3 px-2 flex items-center mt-4">
|
||||||
<span class="material-icons mr-2 text-error" style="font-size: 1.1rem">warning</span>
|
<span class="material-icons mr-2 text-error" style="font-size: 1.1rem">warning</span>
|
||||||
<p class="text-error">{{ error }}</p>
|
<p class="text-error">{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
<div class="absolute top-0 left-0 w-full p-6 flex items-center flex-col justify-center z-0 short:hidden">
|
<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" />
|
<img src="/Logo.png" class="h-20 w-20 mb-2" />
|
||||||
@@ -71,13 +73,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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" />
|
<modals-custom-headers-modal v-model="showAddCustomHeaders" :custom-headers.sync="serverConfig.customHeaders" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { Browser } from '@capacitor/browser'
|
||||||
|
import { CapacitorHttp } from '@capacitor/core'
|
||||||
import { Dialog } from '@capacitor/dialog'
|
import { Dialog } from '@capacitor/dialog'
|
||||||
|
|
||||||
|
// TODO: when backend ready. See validateLoginFormResponse()
|
||||||
|
//const requiredServerVersion = '2.5.0'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -86,38 +95,298 @@ export default {
|
|||||||
processing: false,
|
processing: false,
|
||||||
serverConfig: {
|
serverConfig: {
|
||||||
address: null,
|
address: null,
|
||||||
|
version: null,
|
||||||
username: null,
|
username: null,
|
||||||
customHeaders: null
|
customHeaders: null
|
||||||
},
|
},
|
||||||
password: null,
|
password: null,
|
||||||
error: null,
|
error: null,
|
||||||
showForm: false,
|
showForm: false,
|
||||||
showAddCustomHeaders: false
|
showAddCustomHeaders: false,
|
||||||
|
authMethods: [],
|
||||||
|
oauth: {
|
||||||
|
state: null,
|
||||||
|
verifier: null,
|
||||||
|
challenge: null,
|
||||||
|
buttonText: 'Login with OpenID',
|
||||||
|
enforceHTTPs: true // RFC 6749, Section 10.9 requires https
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
deviceData() {
|
deviceData() {
|
||||||
return this.$store.state.deviceData || {}
|
return this.$store.state.deviceData || {}
|
||||||
},
|
},
|
||||||
|
deviceSettings() {
|
||||||
|
return this.deviceData.deviceSettings || {}
|
||||||
|
},
|
||||||
networkConnected() {
|
networkConnected() {
|
||||||
return this.$store.state.networkConnected
|
return this.$store.state.networkConnected
|
||||||
},
|
},
|
||||||
serverConnectionConfigs() {
|
serverConnectionConfigs() {
|
||||||
return this.deviceData ? this.deviceData.serverConnectionConfigs || [] : []
|
return this.deviceData?.serverConnectionConfigs || []
|
||||||
},
|
},
|
||||||
lastServerConnectionConfigId() {
|
lastServerConnectionConfigId() {
|
||||||
return this.deviceData ? this.deviceData.lastServerConnectionConfigId : null
|
return this.deviceData?.lastServerConnectionConfigId || null
|
||||||
},
|
},
|
||||||
lastServerConnectionConfig() {
|
lastServerConnectionConfig() {
|
||||||
if (!this.lastServerConnectionConfigId || !this.serverConnectionConfigs.length) return null
|
if (!this.lastServerConnectionConfigId || !this.serverConnectionConfigs.length) return null
|
||||||
return this.serverConnectionConfigs.find((s) => s.id == this.lastServerConnectionConfigId)
|
return this.serverConnectionConfigs.find((s) => s.id == this.lastServerConnectionConfigId)
|
||||||
},
|
},
|
||||||
numCustomHeaders() {
|
isLocalAuthEnabled() {
|
||||||
if (!this.serverConfig.customHeaders) return 0
|
return this.authMethods.includes('local') || !this.authMethods.length
|
||||||
return Object.keys(this.serverConfig.customHeaders).length
|
},
|
||||||
|
isOpenIDAuthEnabled() {
|
||||||
|
return this.authMethods.includes('openid')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
/**
|
||||||
|
* Initiates the login process using OpenID via OAuth2.0.
|
||||||
|
* 1. Verifying the server's address
|
||||||
|
* 2. Calling oauthRequest() to obtain the special OpenID redirect URL
|
||||||
|
* including a challenge and specying audiobookshelf://oauth as redirect URL
|
||||||
|
* 3. Open this redirect URL in browser (which is a website of the SSO provider)
|
||||||
|
*
|
||||||
|
* When the browser is open, the following flow is expected:
|
||||||
|
* a. The user authenticates and the provider redirects back to custom URL audiobookshelf://oauth
|
||||||
|
* b. The app calls appUrlOpen() when `audiobookshelf://oauth` is called
|
||||||
|
* b. appUrlOpen() handles the incoming URL and extracts the authorization code from GET parameter
|
||||||
|
* c. oauthExchangeCodeForToken() exchanges the authorization code for an access token
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @throws Will log a console error if the browser fails to open the URL and display errors via this.error to the user.
|
||||||
|
*/
|
||||||
|
async clickLoginWithOpenId() {
|
||||||
|
// oauth standard requires https explicitly
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// First request that we want to do oauth/openid and get the URL which a browser window should open
|
||||||
|
const redirectUrl = await this.oauthRequest(this.serverConfig.address)
|
||||||
|
if (!redirectUrl) {
|
||||||
|
// error message handled by oauthRequest
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actually we should be able to use the redirectUrl directly for Browser.open below
|
||||||
|
// However it seems that when directly using it there is a malformation and leads to the error
|
||||||
|
// Unhandled Promise Rejection: DataCloneError: The object can not be cloned.
|
||||||
|
// (On calling Browser.open)
|
||||||
|
// Which is hard to debug
|
||||||
|
// So we simply extract the important elements and build the required URL ourselves
|
||||||
|
// which also has the advantage that we can replace the callbackurl with the app url
|
||||||
|
|
||||||
|
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 || !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:' && 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need to verify if the state is the same later
|
||||||
|
this.oauth.state = state
|
||||||
|
|
||||||
|
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..."
|
||||||
|
|
||||||
|
// Open the browser. The browser/identity provider in turn will redirect to an in-app link supplementing a code
|
||||||
|
try {
|
||||||
|
await Browser.open({ url: buildUrl })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error opening browser', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Requests the OAuth/OpenID URL from the backend server to open in browser
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {string} url - The base URL of the server to append the OAuth request parameters to.
|
||||||
|
* @return {Promise<URL|null>} OAuth URL which should be opened in a browser
|
||||||
|
* @throws Logs an error and displays a toast notification if the token exchange fails.
|
||||||
|
*/
|
||||||
|
async oauthRequest(url) {
|
||||||
|
// Generate oauth2 PKCE challenge
|
||||||
|
// In accordance to RFC 7636 Section 4
|
||||||
|
function base64URLEncode(arrayBuffer) {
|
||||||
|
let base64String = btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)))
|
||||||
|
return base64String.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256(plain) {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const data = encoder.encode(plain)
|
||||||
|
return await window.crypto.subtle.digest('SHA-256', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateRandomString() {
|
||||||
|
var array = new Uint32Array(42)
|
||||||
|
window.crypto.getRandomValues(array)
|
||||||
|
return Array.from(array, (dec) => ('0' + dec.toString(16)).slice(-2)).join('') // hex
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifier = generateRandomString()
|
||||||
|
|
||||||
|
const challenge = base64URLEncode(await sha256(verifier))
|
||||||
|
|
||||||
|
this.oauth.verifier = verifier
|
||||||
|
this.oauth.challenge = challenge
|
||||||
|
|
||||||
|
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({
|
||||||
|
url: backendEndpoint,
|
||||||
|
disableRedirects: true,
|
||||||
|
webFetchExtra: {
|
||||||
|
redirect: 'manual'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Every kind of redirection is allowed [RFC6749 - 1.7]
|
||||||
|
if (!(response.status >= 300 && response.status < 400)) {
|
||||||
|
throw new Error(`Unexpected response from server: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Depending on iOS or Android, it can be location or Location...
|
||||||
|
const locationHeader = response.headers[Object.keys(response.headers).find((key) => key.toLowerCase() === 'location')]
|
||||||
|
if (!locationHeader) {
|
||||||
|
throw new Error(`No location header in SSO answer`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(locationHeader)
|
||||||
|
return url
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[SSO] ${error.message}`)
|
||||||
|
this.$toast.error(`SSO Error: ${error.message}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Handles the callback received from the OAuth/OpenID provider.
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @function appUrlOpen
|
||||||
|
* @param {string} url - The callback URL received from the OAuth/OpenID provider.
|
||||||
|
* @throws Logs a warning and displays a toast notification if the URL is invalid or the state doesn't match.
|
||||||
|
*/
|
||||||
|
async appUrlOpen(url) {
|
||||||
|
if (!url) return
|
||||||
|
|
||||||
|
// Handle the OAuth callback
|
||||||
|
const urlObj = new URL(url)
|
||||||
|
|
||||||
|
// audiobookshelf://oauth?code...
|
||||||
|
// urlObj.hostname for iOS and urlObj.pathname for android
|
||||||
|
if (url.startsWith('audiobookshelf://oauth')) {
|
||||||
|
// Extract possible errors thrown by the SSO provider
|
||||||
|
const authError = urlObj.searchParams.get('error')
|
||||||
|
if (authError) {
|
||||||
|
console.warn(`[SSO] Received the following error: ${authError}`)
|
||||||
|
this.$toast.error(`SSO: Received the following error: ${authError}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract oauth2 code to be exchanged for a token
|
||||||
|
const authCode = urlObj.searchParams.get('code')
|
||||||
|
// Extract the state variable
|
||||||
|
const state = urlObj.searchParams.get('state')
|
||||||
|
|
||||||
|
if (this.oauth.state !== state) {
|
||||||
|
console.warn(`[SSO] Wrong state returned by SSO Provider`)
|
||||||
|
this.$toast.error(`SSO: The response from the SSO Provider was invalid (wrong state)`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear the state variable from the component config
|
||||||
|
this.oauth.state = null
|
||||||
|
|
||||||
|
if (authCode) {
|
||||||
|
await this.oauthExchangeCodeForToken(authCode, state)
|
||||||
|
} else {
|
||||||
|
console.warn(`[SSO] No code received`)
|
||||||
|
this.$toast.error(`SSO: The response from the SSO Provider did not include a code (authentication error?)`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`[ServerConnectForm] appUrlOpen: Unknown url: ${url} - host: ${urlObj.hostname} - path: ${urlObj.pathname}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Exchanges an oauth2 authorization code for a JWT token.
|
||||||
|
* And uses that token to finalise the log in process using authenticateToken()
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @function oauthExchangeCodeForToken
|
||||||
|
* @param {string} code - The authorization code provided by the OpenID provider.
|
||||||
|
* @param {string} state - The state value used to associate a client session with an ID token.
|
||||||
|
* @throws Logs an error and displays a toast notification if the token exchange fails.
|
||||||
|
*/
|
||||||
|
async oauthExchangeCodeForToken(code, state) {
|
||||||
|
// We need to read the url directly from this.serverConfig.address as the callback which is called via the external browser does not pass us that info
|
||||||
|
const backendEndpoint = `${this.serverConfig.address}/auth/openid/callback?state=${encodeURIComponent(state)}&code=${encodeURIComponent(code)}&code_verifier=${encodeURIComponent(this.oauth.verifier)}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
// We can close the browser at this point (does not work on Android)
|
||||||
|
if (this.$platform === 'ios' || this.$platform === 'web') {
|
||||||
|
await Browser.close()
|
||||||
|
}
|
||||||
|
} catch (error) {} // No Error handling needed
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await CapacitorHttp.get({
|
||||||
|
url: backendEndpoint
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.data || !response.data.user || !response.data.user.token) {
|
||||||
|
throw new Error('Token data is missing in the response.')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.serverConfig.token = response.data.user.token
|
||||||
|
const payload = await this.authenticateToken()
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
throw new Error('Authentication failed with the provided token.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicateConfig = this.serverConnectionConfigs.find((scc) => scc.address === this.serverConfig.address && scc.username === payload.user.username)
|
||||||
|
if (duplicateConfig) {
|
||||||
|
throw new Error('Config already exists for this address and username.')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setUserAndConnection(payload)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SSO] Error in exchangeCodeForToken: ', error)
|
||||||
|
this.$toast.error(`SSO error: ${error.message || error}`)
|
||||||
|
} finally {
|
||||||
|
// We don't need the oauth verifier any more
|
||||||
|
this.oauth.verifier = null
|
||||||
|
this.oauth.challenge = null
|
||||||
|
}
|
||||||
|
},
|
||||||
addCustomHeaders() {
|
addCustomHeaders() {
|
||||||
this.showAddCustomHeaders = true
|
this.showAddCustomHeaders = true
|
||||||
},
|
},
|
||||||
@@ -206,10 +475,18 @@ export default {
|
|||||||
this.error = null
|
this.error = null
|
||||||
this.showAuth = false
|
this.showAuth = false
|
||||||
},
|
},
|
||||||
validateServerUrl(url) {
|
/**
|
||||||
|
* Validates a URL and reconstructs it with an optional protocol override.
|
||||||
|
* If the URL is invalid, null is returned.
|
||||||
|
*
|
||||||
|
* @param {string} url - The URL to validate.
|
||||||
|
* @param {string|null} [protocolOverride=null] - (Optional) Protocol to override the URL's original protocol.
|
||||||
|
* @returns {string|null} The validated URL with the original or overridden protocol, or null if invalid.
|
||||||
|
*/
|
||||||
|
validateServerUrl(url, protocolOverride = null) {
|
||||||
try {
|
try {
|
||||||
var urlObject = new URL(url)
|
var urlObject = new URL(url)
|
||||||
var address = `${urlObject.protocol}//${urlObject.hostname}`
|
var address = `${protocolOverride ? protocolOverride : urlObject.protocol}//${urlObject.hostname}`
|
||||||
if (urlObject.port) address += ':' + urlObject.port
|
if (urlObject.port) address += ':' + urlObject.port
|
||||||
return address
|
return address
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -217,27 +494,94 @@ export default {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
pingServerAddress(address, customHeaders) {
|
/**
|
||||||
const options = { timeout: 3000 }
|
* Sends a GET request to the specified URL with the provided headers and timeout.
|
||||||
if (customHeaders) {
|
* If the response is successful (HTTP 200), the response object is returned.
|
||||||
options.headers = customHeaders
|
* Otherwise, throws an error object containing code.
|
||||||
|
* code can be either a number, which is then a HTTP status code or
|
||||||
|
* a string, which is then a keyword like NSURLErrorBadURL when the TCP connection could not be established.
|
||||||
|
* When code is a string, error.message contains the human readable error by the OS or
|
||||||
|
* the http body of the non-200 answer.
|
||||||
|
*
|
||||||
|
* @async
|
||||||
|
* @param {string} url - The URL to which the GET request will be sent.
|
||||||
|
* @param {Object} headers - HTTP headers to be included in the request.
|
||||||
|
* @param {number} [connectTimeout=6000] - Timeout for the request in milliseconds.
|
||||||
|
* @returns {Promise<HttpResponse>} The HTTP response object if the request is successful.
|
||||||
|
* @throws {Error} An error with 'code' property set to the HTTP status code if the response is not successful.
|
||||||
|
* @throws {Error} An error with 'code' property set to the error code if the request fails.
|
||||||
|
*/
|
||||||
|
async getRequest(url, headers, connectTimeout = 6000) {
|
||||||
|
const options = {
|
||||||
|
url,
|
||||||
|
headers,
|
||||||
|
connectTimeout
|
||||||
}
|
}
|
||||||
return this.$axios
|
try {
|
||||||
.$get(`${address}/ping`, options)
|
const response = await CapacitorHttp.get(options)
|
||||||
.then((data) => data.success)
|
console.log('[ServerConnectForm] GET request response', response)
|
||||||
|
if (response.status == 200) {
|
||||||
|
return response
|
||||||
|
} else {
|
||||||
|
// Put the HTTP error code inside the cause
|
||||||
|
let errorObj = new Error(response.data)
|
||||||
|
errorObj.code = response.status
|
||||||
|
throw errorObj
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Put the error name inside the cause (a string)
|
||||||
|
let errorObj = new Error(error.message)
|
||||||
|
errorObj.code = error.code
|
||||||
|
throw errorObj
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async postRequest(url, data, headers, connectTimeout = 6000) {
|
||||||
|
if (!headers) headers = {}
|
||||||
|
if (!headers['Content-Type'] && data) {
|
||||||
|
headers['Content-Type'] = 'application/json'
|
||||||
|
}
|
||||||
|
const options = {
|
||||||
|
url,
|
||||||
|
headers,
|
||||||
|
data,
|
||||||
|
connectTimeout
|
||||||
|
}
|
||||||
|
const response = await CapacitorHttp.post(options)
|
||||||
|
console.log('[ServerConnectForm] POST request response', response)
|
||||||
|
if (response.status >= 400) {
|
||||||
|
throw new Error(response.data)
|
||||||
|
} else {
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Get request to server /status api endpoint
|
||||||
|
*
|
||||||
|
* @param {string} address
|
||||||
|
* @returns {Promise<HttpResponse>}
|
||||||
|
* HttpResponse.data is {isInit:boolean, language:string, authMethods:string[]}>
|
||||||
|
*/
|
||||||
|
async getServerAddressStatus(address) {
|
||||||
|
return this.getRequest(`${address}/status`)
|
||||||
|
},
|
||||||
|
pingServerAddress(address, customHeaders) {
|
||||||
|
return this.getRequest(`${address}/ping`, customHeaders)
|
||||||
|
.then((response) => {
|
||||||
|
return response.data.success
|
||||||
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Server check failed', error)
|
console.error('Server ping failed', error)
|
||||||
|
const errorMsg = error.message || error
|
||||||
this.error = 'Failed to ping server'
|
this.error = 'Failed to ping server'
|
||||||
|
if (typeof errorMsg === 'string') {
|
||||||
|
this.error += ` (${errorMsg})`
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
requestServerLogin() {
|
requestServerLogin() {
|
||||||
const options = {}
|
return this.postRequest(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password || '' }, this.serverConfig.customHeaders, 20000)
|
||||||
if (this.serverConfig.customHeaders) {
|
|
||||||
options.headers = this.serverConfig.customHeaders
|
|
||||||
}
|
|
||||||
return this.$axios
|
|
||||||
.$post(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password }, options)
|
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (!data.user) {
|
if (!data.user) {
|
||||||
console.error(data.error)
|
console.error(data.error)
|
||||||
@@ -248,30 +592,187 @@ export default {
|
|||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error('Server auth failed', error)
|
console.error('Server auth failed', error)
|
||||||
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
const errorMsg = error.message || error
|
||||||
this.error = errorMsg
|
this.error = 'Failed to login'
|
||||||
|
if (typeof errorMsg === 'string') {
|
||||||
|
this.error += ` (${errorMsg})`
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
async submit() {
|
async submit() {
|
||||||
if (!this.networkConnected) return
|
if (!this.networkConnected) return
|
||||||
if (!this.serverConfig.address) return
|
if (!this.serverConfig.address) return
|
||||||
if (!this.serverConfig.address.startsWith('http')) {
|
|
||||||
this.serverConfig.address = 'http://' + this.serverConfig.address
|
|
||||||
}
|
|
||||||
var validServerAddress = this.validateServerUrl(this.serverConfig.address)
|
|
||||||
if (!validServerAddress) {
|
|
||||||
this.error = 'Invalid server address'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.serverConfig.address = validServerAddress
|
const initialAddress = this.serverConfig.address
|
||||||
|
// Did the user specify a protocol?
|
||||||
|
const protocolProvided = initialAddress.startsWith('http://') || initialAddress.startsWith('https://')
|
||||||
|
// Add https:// if not provided
|
||||||
|
this.serverConfig.address = this.prependProtocolIfNeeded(initialAddress)
|
||||||
|
|
||||||
this.processing = true
|
this.processing = true
|
||||||
this.error = null
|
this.error = null
|
||||||
|
this.authMethods = []
|
||||||
|
|
||||||
var success = await this.pingServerAddress(this.serverConfig.address, this.serverConfig.customHeaders)
|
try {
|
||||||
this.processing = false
|
// Try the server URL. If it fails and the protocol was not provided, try with http instead of https
|
||||||
if (success) this.showAuth = true
|
const statusData = await this.tryServerUrl(this.serverConfig.address, !protocolProvided)
|
||||||
|
if (this.validateLoginFormResponse(statusData, this.serverConfig.address, protocolProvided)) {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.handleLoginFormError(error)
|
||||||
|
} finally {
|
||||||
|
this.processing = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/** Validates the login form response from the server.
|
||||||
|
*
|
||||||
|
* Ensure the request has not been redirected to an unexpected hostname and check if it is Audiobookshelf
|
||||||
|
*
|
||||||
|
* @param {object} statusData - The data received from the server's response, including data and url.
|
||||||
|
* @param {string} initialAddressWithProtocol - The initial server address including the protocol used for the request.
|
||||||
|
* @param {boolean} protocolProvided - Indicates whether the protocol was explicitly provided in the initial address.
|
||||||
|
*
|
||||||
|
* @returns {boolean} - Returns `true` if the response is valid, otherwise `false` and sets this.error.
|
||||||
|
*/
|
||||||
|
validateLoginFormResponse(statusData, initialAddressWithProtocol, protocolProvided) {
|
||||||
|
// We have a 200 status code at this point
|
||||||
|
|
||||||
|
// Check if we got redirected to a different hostname, we don't allow this
|
||||||
|
const initialAddressUrl = new URL(initialAddressWithProtocol)
|
||||||
|
const currentAddressUrl = new URL(statusData.url)
|
||||||
|
if (initialAddressUrl.hostname !== currentAddressUrl.hostname) {
|
||||||
|
this.error = `Server redirected somewhere else (to ${currentAddressUrl.hostname})`
|
||||||
|
console.error(`[ServerConnectForm] Server redirected somewhere else (to ${currentAddressUrl.hostname})`)
|
||||||
|
return false
|
||||||
|
} // We don't allow a redirection back from https to http if the user used https:// explicitly
|
||||||
|
else if (protocolProvided && initialAddressWithProtocol.startsWith('https://') && currentAddressUrl.protocol === 'http') {
|
||||||
|
this.error = `You specified https:// but the Server redirected back to plain http`
|
||||||
|
console.error(`[ServerConnectForm] User specified https:// but server redirected to http`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check content of response now
|
||||||
|
if (!statusData || !statusData.data || Object.keys(statusData).length === 0) {
|
||||||
|
this.error = 'Response from server was empty' // Usually some kind of config error on server side
|
||||||
|
console.error('[ServerConnectForm] Received empty response')
|
||||||
|
return false
|
||||||
|
} else if (!('isInit' in statusData.data) || !('language' in statusData.data)) {
|
||||||
|
this.error = 'This does not seem to be a Audiobookshelf server'
|
||||||
|
console.error('[ServerConnectForm] Received as response from Server:\n', statusData)
|
||||||
|
return false
|
||||||
|
// TODO: delete the if above and comment the ones below out, as soon as the backend is ready to introduce a version check
|
||||||
|
// } else if (!('app' in statusData.data) || statusData.data.app.toLowerCase() !== 'audiobookshelf') {
|
||||||
|
// this.error = 'This does not seem to be a Audiobookshelf server'
|
||||||
|
// console.error('[ServerConnectForm] Received as response from Server:\n', statusData)
|
||||||
|
// return false
|
||||||
|
// } else if (!this.isValidVersion(statusData.data.serverVersion, requiredServerVersion)) {
|
||||||
|
// this.error = `Server version is below minimum required version of ${requiredServerVersion} (${statusData.data.serverVersion})`
|
||||||
|
// console.error('[ServerConnectForm] Server version is too low: ', statusData.data.serverVersion)
|
||||||
|
// return false
|
||||||
|
} else if (!statusData.data.isInit) {
|
||||||
|
this.error = 'Server is not initialized'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we got redirected from http to https, we allow this
|
||||||
|
// Also there is the possibility that https was tried (with protocolProvided false) but only http was successfull
|
||||||
|
// So set the correct protocol for the config
|
||||||
|
const configUrl = new URL(this.serverConfig.address)
|
||||||
|
configUrl.protocol = currentAddressUrl.protocol
|
||||||
|
// Remove trailing slash
|
||||||
|
this.serverConfig.address = configUrl.toString().replace(/\/$/, '')
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Handles errors received during the login form process, providing user-friendly error messages.
|
||||||
|
*
|
||||||
|
* @param {Object} error - The error object received from a failed login attempt.
|
||||||
|
*/
|
||||||
|
handleLoginFormError(error) {
|
||||||
|
console.error('[ServerConnectForm] Received invalid status', error)
|
||||||
|
|
||||||
|
if (error.code === 404) {
|
||||||
|
this.error = `This does not seem to be an Audiobookshelf server. (Error: 404 querying /status)`
|
||||||
|
} else if (typeof error.code === 'number') {
|
||||||
|
// Error with HTTP Code
|
||||||
|
this.error = `Failed to retrieve status of server: ${error.code}`
|
||||||
|
} else {
|
||||||
|
// error is usually a meaningful error like "Server timed out"
|
||||||
|
this.error = `Failed to contact server. (${error})`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Attempts to retrieve the server address status for the given URL.
|
||||||
|
* If the initial attempt fails, it retries with HTTP if allowed.
|
||||||
|
*
|
||||||
|
* @param {string} address - The URL address to validate and check.
|
||||||
|
* @param {boolean} shouldRetryWithHttp - Flag to indicate if the function should retry with HTTP on failure.
|
||||||
|
* @returns {Promise<HttpResponse>}
|
||||||
|
* HttpResponse.data is {isInit:boolean, language:string, authMethods:string[]}>
|
||||||
|
* @throws Will throw an error if the URL has a wrong format or if both HTTPS and HTTP (if retried) requests fail.
|
||||||
|
*/
|
||||||
|
async tryServerUrl(address, shouldRetryWithHttp) {
|
||||||
|
const validatedUrl = this.validateServerUrl(address)
|
||||||
|
if (!validatedUrl) {
|
||||||
|
throw new Error('URL has wrong format')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.getServerAddressStatus(validatedUrl)
|
||||||
|
} catch (error) {
|
||||||
|
// We only retry when the user did not specify a protocol
|
||||||
|
// Also for security reasons, we only retry when the https request did not
|
||||||
|
// return a http status code (so only retry when the TCP connection could not be established)
|
||||||
|
if (shouldRetryWithHttp && typeof error.code !== 'number') {
|
||||||
|
console.log('[ServerConnectForm] https failed, trying to connect with http...')
|
||||||
|
const validatedHttpUrl = this.validateServerUrl(address, 'http:')
|
||||||
|
if (validatedHttpUrl) {
|
||||||
|
return await this.getServerAddressStatus(validatedHttpUrl)
|
||||||
|
}
|
||||||
|
// else if validatedHttpUrl is false return the original error below
|
||||||
|
}
|
||||||
|
// rethrow original error
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Ensures that a protocol is prepended to the given address if it does not already start with http:// or https://.
|
||||||
|
*
|
||||||
|
* @param {string} address - The server address that may or may not have a protocol.
|
||||||
|
* @returns {string} The address with a protocol prepended if it was missing.
|
||||||
|
*/
|
||||||
|
prependProtocolIfNeeded(address) {
|
||||||
|
return address.startsWith('http://') || address.startsWith('https://') ? address : `https://${address}`
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Compares two semantic versioning strings to determine if the current version meets
|
||||||
|
* or exceeds the minimum version requirement.
|
||||||
|
*
|
||||||
|
* @param {string} currentVersion - The current version string to compare, e.g., "1.2.3".
|
||||||
|
* @param {string} minVersion - The minimum version string required, e.g., "1.0.0".
|
||||||
|
* @returns {boolean} - Returns true if the current version is greater than or equal
|
||||||
|
* to the minimum version, false otherwise.
|
||||||
|
*/
|
||||||
|
isValidVersion(currentVersion, minVersion) {
|
||||||
|
const currentParts = currentVersion.split('.').map(Number)
|
||||||
|
const minParts = minVersion.split('.').map(Number)
|
||||||
|
|
||||||
|
for (let i = 0; i < minParts.length; i++) {
|
||||||
|
if (currentParts[i] > minParts[i]) return true
|
||||||
|
if (currentParts[i] < minParts[i]) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
},
|
},
|
||||||
async submitAuth() {
|
async submitAuth() {
|
||||||
if (!this.networkConnected) return
|
if (!this.networkConnected) return
|
||||||
@@ -295,12 +796,14 @@ export default {
|
|||||||
this.setUserAndConnection(payload)
|
this.setUserAndConnection(payload)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async setUserAndConnection({ user, userDefaultLibraryId, serverSettings }) {
|
async setUserAndConnection({ user, userDefaultLibraryId, serverSettings, ereaderDevices }) {
|
||||||
if (!user) return
|
if (!user) return
|
||||||
|
|
||||||
console.log('Successfully logged in', JSON.stringify(user))
|
console.log('Successfully logged in', JSON.stringify(user))
|
||||||
|
|
||||||
this.$store.commit('setServerSettings', serverSettings)
|
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
|
// Set library - Use last library if set and available fallback to default user library
|
||||||
var lastLibraryId = await this.$localStore.getLastLibraryId()
|
var lastLibraryId = await this.$localStore.getLastLibraryId()
|
||||||
@@ -312,9 +815,24 @@ export default {
|
|||||||
|
|
||||||
this.serverConfig.userId = user.id
|
this.serverConfig.userId = user.id
|
||||||
this.serverConfig.token = user.token
|
this.serverConfig.token = user.token
|
||||||
|
this.serverConfig.username = user.username
|
||||||
|
delete this.serverConfig.version
|
||||||
|
|
||||||
var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig)
|
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/setUser', user)
|
||||||
this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig)
|
this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig)
|
||||||
|
|
||||||
@@ -330,16 +848,23 @@ export default {
|
|||||||
|
|
||||||
this.error = null
|
this.error = null
|
||||||
this.processing = true
|
this.processing = true
|
||||||
var authRes = await this.$axios.$post(`${this.serverConfig.address}/api/authorize`, null, { headers: { Authorization: `Bearer ${this.serverConfig.token}` } }).catch((error) => {
|
|
||||||
console.error('[Server] Server auth failed', error)
|
const authRes = await this.postRequest(`${this.serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${this.serverConfig.token}` }).catch((error) => {
|
||||||
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
console.error('[ServerConnectForm] Server auth failed', error)
|
||||||
this.error = errorMsg
|
const errorMsg = error.message || error
|
||||||
|
this.error = 'Failed to authorize'
|
||||||
|
if (typeof errorMsg === 'string') {
|
||||||
|
this.error += ` (${errorMsg})`
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
console.log('[ServerConnectForm] authRes=', authRes)
|
||||||
|
|
||||||
this.processing = false
|
this.processing = false
|
||||||
return authRes
|
return authRes
|
||||||
},
|
},
|
||||||
async init() {
|
init() {
|
||||||
if (this.lastServerConnectionConfig) {
|
if (this.lastServerConnectionConfig) {
|
||||||
this.connectToServer(this.lastServerConnectionConfig)
|
this.connectToServer(this.lastServerConnectionConfig)
|
||||||
} else {
|
} else {
|
||||||
@@ -348,7 +873,11 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
this.$eventBus.$on('url-open', this.appUrlOpen)
|
||||||
this.init()
|
this.init()
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$eventBus.$off('url-open', this.appUrlOpen)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export default {
|
|||||||
return this.libraryItem.isLocal
|
return this.libraryItem.isLocal
|
||||||
},
|
},
|
||||||
localCover() {
|
localCover() {
|
||||||
return this.libraryItem ? this.libraryItem.coverContentUrl : null
|
return this.libraryItem?.coverContentUrl || null
|
||||||
},
|
},
|
||||||
squareAspectRatio() {
|
squareAspectRatio() {
|
||||||
return this.bookCoverAspectRatio === 1
|
return this.bookCoverAspectRatio === 1
|
||||||
|
|||||||
@@ -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 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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -51,9 +51,6 @@ export default {
|
|||||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||||
return this.width / 240
|
return this.width / 240
|
||||||
},
|
},
|
||||||
showExperimentalFeatures() {
|
|
||||||
return this.store.state.showExperimentalFeatures
|
|
||||||
},
|
|
||||||
store() {
|
store() {
|
||||||
return this.$store || this.$nuxt.$store
|
return this.$store || this.$nuxt.$store
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,24 +2,24 @@
|
|||||||
<div class="w-full px-2">
|
<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" />
|
<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">
|
<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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -140,15 +140,15 @@ export default {
|
|||||||
console.log('Podcast payload', podcastPayload)
|
console.log('Podcast payload', podcastPayload)
|
||||||
|
|
||||||
this._processing = true
|
this._processing = true
|
||||||
this.$axios
|
this.$nativeHttp
|
||||||
.$post('/api/podcasts', podcastPayload)
|
.post('/api/podcasts', podcastPayload)
|
||||||
.then((libraryItem) => {
|
.then((libraryItem) => {
|
||||||
this._processing = false
|
this._processing = false
|
||||||
this.$toast.success('Podcast added')
|
this.$toast.success(this.$strings.ToastPodcastCreateSuccess)
|
||||||
this.$router.push(`/item/${libraryItem.id}`)
|
this.$router.push(`/item/${libraryItem.id}`)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.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)
|
console.error('Failed to create podcast', error)
|
||||||
this._processing = false
|
this._processing = false
|
||||||
this.$toast.error(errorMsg)
|
this.$toast.error(errorMsg)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-9 bg-bg relative">
|
<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">
|
<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-gray-400'">
|
<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>
|
<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-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>
|
<span v-else :class="`${item.iconPack} ${item.iconClass || ''}`">{{ item.icon }}</span>
|
||||||
@@ -20,7 +20,7 @@ export default {
|
|||||||
return this.$store.getters['libraries/getCurrentLibrary']
|
return this.$store.getters['libraries/getCurrentLibrary']
|
||||||
},
|
},
|
||||||
currentLibraryIcon() {
|
currentLibraryIcon() {
|
||||||
return this.currentLibrary ? this.currentLibrary.icon : 'database'
|
return this.currentLibrary?.icon || 'database'
|
||||||
},
|
},
|
||||||
userHasPlaylists() {
|
userHasPlaylists() {
|
||||||
return this.$store.state.libraries.numUserPlaylists
|
return this.$store.state.libraries.numUserPlaylists
|
||||||
@@ -38,7 +38,7 @@ export default {
|
|||||||
iconPack: 'abs-icons',
|
iconPack: 'abs-icons',
|
||||||
icon: 'home',
|
icon: 'home',
|
||||||
iconClass: 'text-xl',
|
iconClass: 'text-xl',
|
||||||
text: 'Home'
|
text: this.$strings.ButtonHome
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/bookshelf/latest',
|
to: '/bookshelf/latest',
|
||||||
@@ -46,7 +46,7 @@ export default {
|
|||||||
iconPack: 'abs-icons',
|
iconPack: 'abs-icons',
|
||||||
icon: 'list',
|
icon: 'list',
|
||||||
iconClass: 'text-xl',
|
iconClass: 'text-xl',
|
||||||
text: 'Latest'
|
text: this.$strings.ButtonLatest
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/bookshelf/library',
|
to: '/bookshelf/library',
|
||||||
@@ -54,18 +54,18 @@ export default {
|
|||||||
iconPack: 'abs-icons',
|
iconPack: 'abs-icons',
|
||||||
icon: this.currentLibraryIcon,
|
icon: this.currentLibraryIcon,
|
||||||
iconClass: 'text-lg',
|
iconClass: 'text-lg',
|
||||||
text: 'Library'
|
text: this.$strings.ButtonLibrary
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
if (this.userIsAdminOrUp) {
|
if (this.userIsAdminOrUp) {
|
||||||
items.push({
|
items.push({
|
||||||
to: '/bookshelf/search',
|
to: '/bookshelf/add-podcast',
|
||||||
routeName: 'bookshelf-search',
|
routeName: 'bookshelf-add-podcast',
|
||||||
iconPack: 'material-icons',
|
iconPack: 'material-icons',
|
||||||
icon: 'podcasts',
|
icon: 'podcasts',
|
||||||
iconClass: 'text-xl',
|
iconClass: 'text-xl',
|
||||||
text: 'Search'
|
text: this.$strings.ButtonAdd
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -76,7 +76,7 @@ export default {
|
|||||||
iconPack: 'abs-icons',
|
iconPack: 'abs-icons',
|
||||||
icon: 'home',
|
icon: 'home',
|
||||||
iconClass: 'text-xl',
|
iconClass: 'text-xl',
|
||||||
text: 'Home'
|
text: this.$strings.ButtonHome
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/bookshelf/library',
|
to: '/bookshelf/library',
|
||||||
@@ -84,7 +84,7 @@ export default {
|
|||||||
iconPack: 'abs-icons',
|
iconPack: 'abs-icons',
|
||||||
icon: this.currentLibraryIcon,
|
icon: this.currentLibraryIcon,
|
||||||
iconClass: 'text-lg',
|
iconClass: 'text-lg',
|
||||||
text: 'Library'
|
text: this.$strings.ButtonLibrary
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/bookshelf/series',
|
to: '/bookshelf/series',
|
||||||
@@ -92,7 +92,7 @@ export default {
|
|||||||
iconPack: 'abs-icons',
|
iconPack: 'abs-icons',
|
||||||
icon: 'columns',
|
icon: 'columns',
|
||||||
iconClass: 'text-lg pt-px',
|
iconClass: 'text-lg pt-px',
|
||||||
text: 'Series'
|
text: this.$strings.ButtonSeries
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/bookshelf/collections',
|
to: '/bookshelf/collections',
|
||||||
@@ -100,7 +100,7 @@ export default {
|
|||||||
iconPack: 'material-icons-outlined',
|
iconPack: 'material-icons-outlined',
|
||||||
icon: 'collections_bookmark',
|
icon: 'collections_bookmark',
|
||||||
iconClass: 'text-xl',
|
iconClass: 'text-xl',
|
||||||
text: 'Collections'
|
text: this.$strings.ButtonCollections
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/bookshelf/authors',
|
to: '/bookshelf/authors',
|
||||||
@@ -108,7 +108,7 @@ export default {
|
|||||||
iconPack: 'abs-icons',
|
iconPack: 'abs-icons',
|
||||||
icon: 'authors',
|
icon: 'authors',
|
||||||
iconClass: 'text-2xl',
|
iconClass: 'text-2xl',
|
||||||
text: 'Authors'
|
text: this.$strings.ButtonAuthors
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -119,7 +119,7 @@ export default {
|
|||||||
routeName: 'bookshelf-playlists',
|
routeName: 'bookshelf-playlists',
|
||||||
iconPack: 'material-icons',
|
iconPack: 'material-icons',
|
||||||
icon: 'queue_music',
|
icon: 'queue_music',
|
||||||
text: 'Playlists'
|
text: this.$strings.ButtonPlaylists
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,16 +64,16 @@ export default {
|
|||||||
return this.$route.query || {}
|
return this.$route.query || {}
|
||||||
},
|
},
|
||||||
entityTitle() {
|
entityTitle() {
|
||||||
if (this.isPodcast) return 'Podcasts'
|
if (this.page === 'library') {
|
||||||
if (this.page === 'library') return 'Books'
|
return this.isPodcast ? this.$strings.LabelPodcasts : this.$strings.LabelBooks
|
||||||
else if (this.page === 'series') {
|
|
||||||
return 'Series'
|
|
||||||
} else if (this.page === 'collections') {
|
|
||||||
return 'Collections'
|
|
||||||
} else if (this.page === 'playlists') {
|
} 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') {
|
} else if (this.page === 'authors') {
|
||||||
return 'Authors'
|
return this.$strings.LabelAuthors
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
},
|
},
|
||||||
@@ -92,7 +92,7 @@ export default {
|
|||||||
if (this.seriesBookPage) {
|
if (this.seriesBookPage) {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
text: 'Collapse Sub-Series',
|
text: this.$strings.LabelCollapseSeries,
|
||||||
value: 'collapse_subseries',
|
value: 'collapse_subseries',
|
||||||
icon: this.settings.collapseBookSeries ? 'check_box' : 'check_box_outline_blank'
|
icon: this.settings.collapseBookSeries ? 'check_box' : 'check_box_outline_blank'
|
||||||
}
|
}
|
||||||
@@ -100,7 +100,7 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
text: 'Collapse Series',
|
text: this.$strings.LabelCollapseSeries,
|
||||||
value: 'collapse_series',
|
value: 'collapse_series',
|
||||||
icon: this.settings.collapseSeries ? 'check_box' : 'check_box_outline_blank'
|
icon: this.settings.collapseSeries ? 'check_box' : 'check_box_outline_blank'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="200" height="100%">
|
<modals-modal v-model="show" :width="200" height="100%">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-8 left-4 z-40">
|
<div class="absolute top-11 left-4 z-40">
|
||||||
<p class="text-white text-2xl truncate">Auto Rewind Time</p>
|
<p class="text-white text-2xl truncate capitalize">{{ $strings.LabelAutoRewindTime }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div
|
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="
|
||||||
class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center"
|
|
||||||
@click="
|
|
||||||
show = false
|
show = false
|
||||||
manualTimerModal = 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 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 v-if="manualTimerModal" class="p-4">
|
||||||
<div class="flex mb-4" @click="manualTimerModal = false">
|
<div class="flex mb-4" @click="manualTimerModal = false">
|
||||||
<span class="material-icons text-3xl">arrow_back</span>
|
<span class="material-icons text-3xl">arrow_back</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex my-2 justify-between">
|
<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>
|
<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>
|
</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>
|
</div>
|
||||||
<ul v-else class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul v-else class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="timeout in timeouts">
|
<template v-for="timeout in timeouts">
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="manualTimerModal = true">
|
<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">
|
<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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="300" height="100%">
|
<modals-modal v-model="show" :width="400" height="100%">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-8 left-4 z-40">
|
<div class="absolute top-11 left-4 z-40">
|
||||||
<p class="text-white text-2xl truncate">Bookmarks</p>
|
<p class="text-white text-2xl truncate">{{ $strings.LabelYourBookmarks }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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 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="w-full h-full p-4" v-show="showBookmarkTitleInput">
|
||||||
<div class="flex mb-4 items-center">
|
<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">
|
<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>
|
</div>
|
||||||
<p class="text-xl pl-2">{{ selectedBookmark ? 'Edit Bookmark' : 'New Bookmark' }}</p>
|
<p class="text-xl pl-2">{{ selectedBookmark ? 'Edit Bookmark' : 'New Bookmark' }}</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p class="text-xl font-mono">
|
<p class="text-xl font-mono">{{ this.$secondsToTimestamp(currentTime) }}</p>
|
||||||
{{ this.$secondsToTimestamp(currentTime) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ui-text-input-with-label v-model="newBookmarkTitle" label="Note" />
|
<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" />
|
<modals-bookmarks-bookmark-item :key="bookmark.id" :highlight="currentTime === bookmark.time" :bookmark="bookmark" @click="clickBookmark" @edit="editBookmark" @delete="deleteBookmark" />
|
||||||
</template>
|
</template>
|
||||||
<div v-if="!bookmarks.length" class="flex h-32 items-center justify-center">
|
<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>
|
||||||
<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>
|
<span class="material-icons">add</span>
|
||||||
<p class="text-base pl-2">Create Bookmark</p>
|
<p class="text-base pl-2">{{ $strings.ButtonCreateBookmark }}</p>
|
||||||
<p class="text-sm font-mono">
|
<p class="text-sm font-mono">{{ this.$secondsToTimestamp(currentTime) }}</p>
|
||||||
{{ this.$secondsToTimestamp(currentTime) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,53 +94,51 @@ export default {
|
|||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Remove Bookmark',
|
title: 'Remove Bookmark',
|
||||||
message: `Are you sure you want to remove bookmark?`
|
message: this.$strings.MessageConfirmRemoveBookmark
|
||||||
})
|
})
|
||||||
if (!value) return
|
if (!value) return
|
||||||
|
|
||||||
this.$axios
|
this.$nativeHttp
|
||||||
.$delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`)
|
.delete(`/api/me/item/${this.libraryItemId}/bookmark/${bm.time}`)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success('Bookmark removed')
|
this.$store.commit('user/deleteBookmark', { libraryItemId: this.libraryItemId, time: bm.time })
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
this.$toast.error(`Failed to remove bookmark`)
|
this.$toast.error(this.$strings.ToastBookmarkRemoveFailed)
|
||||||
console.error(error)
|
console.error(error)
|
||||||
})
|
})
|
||||||
this.show = false
|
|
||||||
},
|
},
|
||||||
async clickBookmark(bm) {
|
async clickBookmark(bm) {
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
this.$emit('select', bm)
|
this.$emit('select', bm)
|
||||||
},
|
},
|
||||||
submitUpdateBookmark(updatedBookmark) {
|
submitUpdateBookmark(updatedBookmark) {
|
||||||
var bookmark = { ...updatedBookmark }
|
this.$nativeHttp
|
||||||
this.$axios
|
.patch(`/api/me/item/${this.libraryItemId}/bookmark`, updatedBookmark)
|
||||||
.$patch(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
.then((bookmark) => {
|
||||||
.then(() => {
|
this.$store.commit('user/updateBookmark', bookmark)
|
||||||
this.$toast.success('Bookmark updated')
|
this.showBookmarkTitleInput = false
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
this.$toast.error(`Failed to update bookmark`)
|
this.$toast.error(this.$strings.ToastBookmarkUpdateFailed)
|
||||||
console.error(error)
|
console.error(error)
|
||||||
})
|
})
|
||||||
this.show = false
|
|
||||||
},
|
},
|
||||||
submitCreateBookmark() {
|
submitCreateBookmark() {
|
||||||
if (!this.newBookmarkTitle) {
|
if (!this.newBookmarkTitle) {
|
||||||
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
||||||
}
|
}
|
||||||
var bookmark = {
|
const bookmark = {
|
||||||
title: this.newBookmarkTitle,
|
title: this.newBookmarkTitle,
|
||||||
time: Math.floor(this.currentTime)
|
time: Math.floor(this.currentTime)
|
||||||
}
|
}
|
||||||
this.$axios
|
this.$nativeHttp
|
||||||
.$post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
.post(`/api/me/item/${this.libraryItemId}/bookmark`, bookmark)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.$toast.success('Bookmark added')
|
this.$toast.success('Bookmark added')
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
this.$toast.error(`Failed to create bookmark`)
|
this.$toast.error(this.$strings.ToastBookmarkCreateFailed)
|
||||||
console.error(error)
|
console.error(error)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="350" height="100%">
|
<modals-modal v-model="show" :width="400" height="100%">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div v-if="currentChapter" class="absolute top-8 left-4 z-40 pt-0.5" style="max-width: 80%">
|
<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">Current: {{ currentChapterTitle }}</p>
|
<p class="text-white text-lg truncate">{{ chapters.length }} {{ $strings.LabelChapters }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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 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 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">
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="(chapter, index) in chapters">
|
<template v-for="chapter in chapters">
|
||||||
<li :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="currentChapterId === chapter.id ? 'bg-bg bg-opacity-80' : ''" role="option" @click="clickedOption(chapter)">
|
<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">
|
<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">
|
<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) }}</span>
|
<span class="font-mono text-fg-muted leading-3 text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start / _playbackRate) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -38,14 +38,17 @@ export default {
|
|||||||
currentChapter: {
|
currentChapter: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
}
|
},
|
||||||
|
playbackRate: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
value(newVal) {
|
value(newVal) {
|
||||||
this.$nextTick(this.scrollToChapter)
|
if (newVal) {
|
||||||
|
this.$nextTick(this.scrollToChapter)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -57,11 +60,15 @@ export default {
|
|||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
_playbackRate() {
|
||||||
|
if (!this.playbackRate || isNaN(this.playbackRate)) return 1
|
||||||
|
return this.playbackRate
|
||||||
|
},
|
||||||
currentChapterId() {
|
currentChapterId() {
|
||||||
return this.currentChapter ? this.currentChapter.id : null
|
return this.currentChapter?.id
|
||||||
},
|
},
|
||||||
currentChapterTitle() {
|
currentChapterTitle() {
|
||||||
return this.currentChapter ? this.currentChapter.title : null
|
return this.currentChapter?.title || null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -71,12 +78,12 @@ export default {
|
|||||||
scrollToChapter() {
|
scrollToChapter() {
|
||||||
if (!this.currentChapterId) return
|
if (!this.currentChapterId) return
|
||||||
|
|
||||||
var container = this.$refs.container
|
const container = this.$refs.container
|
||||||
if (container) {
|
if (container) {
|
||||||
var currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
|
const currChapterEl = document.getElementById(`chapter-row-${this.currentChapterId}`)
|
||||||
if (currChapterEl) {
|
if (currChapterEl) {
|
||||||
var offsetTop = currChapterEl.offsetTop
|
const offsetTop = currChapterEl.offsetTop
|
||||||
var containerHeight = container.clientHeight
|
const containerHeight = container.clientHeight
|
||||||
container.scrollTo({ top: offsetTop - containerHeight / 2 })
|
container.scrollTo({ top: offsetTop - containerHeight / 2 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="300" height="100%">
|
<modals-modal v-model="show" :width="width" height="100%">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div v-if="title" class="absolute top-8 left-4 z-40" style="max-width: 80%">
|
<div v-if="title" class="absolute top-10 left-4 z-40 pt-1 pb-1.5" style="max-width: 80%">
|
||||||
<p class="text-white text-xl truncate">{{ title }}</p>
|
<p class="text-white text-xl truncate">{{ title }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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 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 border-opacity-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">
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="item in items">
|
<template v-for="item in itemsToShow">
|
||||||
<slot :name="item.value" :item="item" :selected="item.value === selected">
|
<slot :name="item.value" :item="item" :selected="item.value === selected">
|
||||||
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" 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">
|
<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>
|
<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-white text-opacity-80">{{ item.text }}</p>
|
<p class="font-normal block truncate text-base text-fg text-opacity-80">{{ item.text }}</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</slot>
|
</slot>
|
||||||
@@ -34,11 +34,27 @@ export default {
|
|||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
selected: String // optional
|
selected: [String, Number], // optional
|
||||||
|
itemPaddingY: {
|
||||||
|
type: String,
|
||||||
|
default: '16px'
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: 300
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
show: {
|
||||||
|
immediate: true,
|
||||||
|
handler(newVal) {
|
||||||
|
if (newVal) this.$nextTick(this.init)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
show: {
|
show: {
|
||||||
get() {
|
get() {
|
||||||
@@ -47,11 +63,32 @@ export default {
|
|||||||
set(val) {
|
set(val) {
|
||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
itemsToShow() {
|
||||||
|
return this.items.map((i) => {
|
||||||
|
if (typeof i === 'string') {
|
||||||
|
return {
|
||||||
|
text: i,
|
||||||
|
value: i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clickedOption(action) {
|
clickedOption(action) {
|
||||||
this.$emit('action', action)
|
this.$emit('action', action)
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
if (this.selected && this.$refs[`item-${this.selected}`]?.[0]) {
|
||||||
|
// Set scroll position so that selected item is in the center
|
||||||
|
const containerOffset = this.$refs.container.offsetTop + this.$refs.container.clientHeight / 2
|
||||||
|
const scrollAmount = this.$refs[`item-${this.selected}`][0].offsetTop - containerOffset
|
||||||
|
this.$refs.container.scrollTo({
|
||||||
|
top: scrollAmount
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" width="90%" height="100%">
|
<modals-modal v-model="show" width="90%" height="100%">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div v-show="selected !== 'all'" class="absolute top-10 left-4 z-40">
|
<div v-show="selected !== 'all'" class="absolute top-12 left-4 z-40">
|
||||||
<ui-btn class="text-xl border-yellow-400 border-opacity-40" @click="clearSelected">Clear</ui-btn>
|
<ui-btn class="text-lg border-yellow-400 border-opacity-40 h-10" :padding-y="0" @click="clearSelected">{{ $strings.ButtonClearFilter }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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 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">
|
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="item in items">
|
<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">
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-normal ml-3 block truncate text-lg">{{ item.text }}</span>
|
<span class="font-normal ml-3 block truncate text-lg">{{ item.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -20,12 +20,12 @@
|
|||||||
</template>
|
</template>
|
||||||
</ul>
|
</ul>
|
||||||
<ul v-show="sublist" class="h-full w-full rounded-lg" role="listbox" aria-labelledby="listbox-label">
|
<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">
|
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
|
||||||
<span class="material-icons text-2xl">arrow_left</span>
|
<span class="material-icons text-2xl">arrow_left</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
|
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<template v-for="item in sublistItems">
|
<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">
|
<div class="flex items-center">
|
||||||
<span class="font-normal truncate py-3 text-base">{{ item.text }}</span>
|
<span class="font-normal truncate py-3 text-base">{{ item.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,62 +57,67 @@ export default {
|
|||||||
sublist: null,
|
sublist: null,
|
||||||
bookItems: [
|
bookItems: [
|
||||||
{
|
{
|
||||||
text: 'All',
|
text: this.$strings.LabelAll,
|
||||||
value: 'all'
|
value: 'all'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Genre',
|
text: this.$strings.LabelGenre,
|
||||||
value: 'genres',
|
value: 'genres',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Tag',
|
text: this.$strings.LabelTag,
|
||||||
value: 'tags',
|
value: 'tags',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Series',
|
text: this.$strings.LabelSeries,
|
||||||
value: 'series',
|
value: 'series',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Authors',
|
text: this.$strings.LabelAuthor,
|
||||||
value: 'authors',
|
value: 'authors',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Narrator',
|
text: this.$strings.LabelNarrator,
|
||||||
value: 'narrators',
|
value: 'narrators',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Language',
|
text: this.$strings.LabelLanguage,
|
||||||
value: 'languages',
|
value: 'languages',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Progress',
|
text: this.$strings.LabelProgress,
|
||||||
value: 'progress',
|
value: 'progress',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Issues',
|
text: this.$strings.LabelEbooks,
|
||||||
|
value: 'ebooks',
|
||||||
|
sublist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.ButtonIssues,
|
||||||
value: 'issues',
|
value: 'issues',
|
||||||
sublist: false
|
sublist: false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
podcastItems: [
|
podcastItems: [
|
||||||
{
|
{
|
||||||
text: 'All',
|
text: this.$strings.LabelAll,
|
||||||
value: 'all'
|
value: 'all'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Genre',
|
text: this.$strings.LabelGenre,
|
||||||
value: 'genres',
|
value: 'genres',
|
||||||
sublist: true
|
sublist: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Tag',
|
text: this.$strings.LabelTag,
|
||||||
value: 'tags',
|
value: 'tags',
|
||||||
sublist: true
|
sublist: true
|
||||||
}
|
}
|
||||||
@@ -176,24 +181,36 @@ export default {
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'finished',
|
id: 'finished',
|
||||||
name: 'Finished'
|
name: this.$strings.LabelFinished
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'in-progress',
|
id: 'in-progress',
|
||||||
name: 'In Progress'
|
name: this.$strings.LabelInProgress
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'not-started',
|
id: 'not-started',
|
||||||
name: 'Not Started'
|
name: this.$strings.LabelNotStarted
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'not-finished',
|
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() {
|
sublistItems() {
|
||||||
return (this[this.sublist] || []).map((item) => {
|
const sublistItems = (this[this.sublist] || []).map((item) => {
|
||||||
if (typeof item === 'string') {
|
if (typeof item === 'string') {
|
||||||
return {
|
return {
|
||||||
text: item,
|
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() {
|
filterData() {
|
||||||
return this.$store.state.libraries.filterData || {}
|
return this.$store.state.libraries.filterData || {}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<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] 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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
processing: Boolean,
|
||||||
|
halfScreen: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
el: null,
|
||||||
|
content: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
show(newVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.setShow()
|
||||||
|
} else {
|
||||||
|
this.setHide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
setShow() {
|
||||||
|
this.$store.commit('globals/setIsModalOpen', true)
|
||||||
|
|
||||||
|
document.body.appendChild(this.el)
|
||||||
|
setTimeout(() => {
|
||||||
|
this.content.style.transform = 'translateY(0)'
|
||||||
|
}, 10)
|
||||||
|
document.documentElement.classList.add('modal-open')
|
||||||
|
},
|
||||||
|
setHide() {
|
||||||
|
this.$store.commit('globals/setIsModalOpen', false)
|
||||||
|
|
||||||
|
this.content.style.transform = 'translateY(100vh)'
|
||||||
|
setTimeout(() => {
|
||||||
|
this.el.remove()
|
||||||
|
document.documentElement.classList.remove('modal-open')
|
||||||
|
}, 250)
|
||||||
|
},
|
||||||
|
closeModalEvt() {
|
||||||
|
console.log('Close modal event')
|
||||||
|
this.show = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.$eventBus.$on('close-modal', this.closeModalEvt)
|
||||||
|
this.el = this.$refs.wrapper
|
||||||
|
this.content = this.$refs.content
|
||||||
|
this.content.style.transform = 'translateY(100vh)'
|
||||||
|
this.content.style.transition = 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)'
|
||||||
|
this.el.remove()
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.$eventBus.$off('close-modal', this.closeModalEvt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="400" height="100%">
|
<modals-modal v-model="show" :width="400" height="100%">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-8 left-4 z-40">
|
<div class="absolute top-11 left-4 z-40">
|
||||||
<p class="text-white text-2xl truncate">Details</p>
|
<p class="text-white text-2xl truncate">{{ $strings.HeaderDetails }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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 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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
|
|||||||
@@ -0,0 +1,511 @@
|
|||||||
|
<template>
|
||||||
|
<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="$strings.LabelSelectADevice" :items="ereaderDeviceItems" @action="sendEbookToDeviceAction" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Dialog } from '@capacitor/dialog'
|
||||||
|
import { AbsFileSystem } from '@/plugins/capacitor'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: Boolean,
|
||||||
|
processing: Boolean,
|
||||||
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
episode: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
|
rssFeed: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
},
|
||||||
|
playlist: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
},
|
||||||
|
hideRssFeedOption: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showDetailsModal: false,
|
||||||
|
showSendEbookDevicesModal: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
show: {
|
||||||
|
get() {
|
||||||
|
return this.value
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this.$emit('input', val)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
userIsAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
},
|
||||||
|
moreMenuItems() {
|
||||||
|
const items = []
|
||||||
|
|
||||||
|
// TODO: Implement on iOS
|
||||||
|
if (this.$platform !== 'ios' && !this.isPodcast) {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.ButtonHistory,
|
||||||
|
value: 'history',
|
||||||
|
icon: 'history'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isPodcast || this.episode) {
|
||||||
|
if (!this.userIsFinished) {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.MessageMarkAsFinished,
|
||||||
|
value: 'markFinished',
|
||||||
|
icon: 'beenhere'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.progressPercent > 0) {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.MessageDiscardProgress,
|
||||||
|
value: 'discardProgress',
|
||||||
|
icon: 'backspace'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((!this.isPodcast && this.serverLibraryItemId) || (this.episode && this.serverEpisodeId)) {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.LabelAddToPlaylist,
|
||||||
|
value: 'playlist',
|
||||||
|
icon: 'playlist_add'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.ereaderDeviceItems.length) {
|
||||||
|
items.push({
|
||||||
|
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 ? this.$strings.HeaderRSSFeed : this.$strings.HeaderOpenRSSFeed,
|
||||||
|
value: 'rssFeed',
|
||||||
|
icon: 'rss_feed'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.localLibraryItemId) {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.ButtonManageLocalFiles,
|
||||||
|
value: 'manageLocal',
|
||||||
|
icon: 'folder'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!this.isPodcast) {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.ButtonDeleteLocalItem,
|
||||||
|
value: 'deleteLocal',
|
||||||
|
icon: 'delete'
|
||||||
|
})
|
||||||
|
} else if (this.localEpisodeId) {
|
||||||
|
items.push({
|
||||||
|
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: this.$strings.LabelMoreInfo,
|
||||||
|
value: 'details',
|
||||||
|
icon: 'info'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
},
|
||||||
|
ereaderDeviceItems() {
|
||||||
|
if (!this.ebookFile || !this.$store.state.libraries.ereaderDevices?.length) return []
|
||||||
|
return this.$store.state.libraries.ereaderDevices.map((d) => {
|
||||||
|
return {
|
||||||
|
text: d.name,
|
||||||
|
value: d.name
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
isConnectedToServer() {
|
||||||
|
if (!this.isLocal) return true
|
||||||
|
if (!this.libraryItem?.serverAddress) return false
|
||||||
|
return this.$store.getters['user/getServerAddress'] === this.libraryItem.serverAddress
|
||||||
|
},
|
||||||
|
isLocal() {
|
||||||
|
return !!this.libraryItem?.isLocal
|
||||||
|
},
|
||||||
|
localLibraryItem() {
|
||||||
|
if (this.isLocal) return this.libraryItem
|
||||||
|
return this.libraryItem?.localLibraryItem || null
|
||||||
|
},
|
||||||
|
localLibraryItemId() {
|
||||||
|
return this.localLibraryItem?.id || null
|
||||||
|
},
|
||||||
|
serverLibraryItemId() {
|
||||||
|
if (!this.isLocal) return this.libraryItem?.id
|
||||||
|
if (this.isConnectedToServer) {
|
||||||
|
return this.libraryItem.libraryItemId
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
localEpisode() {
|
||||||
|
if (this.isLocal) return this.episode
|
||||||
|
return this.episode?.localEpisode
|
||||||
|
},
|
||||||
|
localEpisodeId() {
|
||||||
|
return this.localEpisode?.id || null
|
||||||
|
},
|
||||||
|
serverEpisodeId() {
|
||||||
|
if (!this.isLocal) return this.episode?.id
|
||||||
|
if (this.isConnectedToServer) {
|
||||||
|
return this.episode.serverEpisodeId
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
mediaType() {
|
||||||
|
return this.libraryItem?.mediaType
|
||||||
|
},
|
||||||
|
isPodcast() {
|
||||||
|
return this.mediaType == 'podcast'
|
||||||
|
},
|
||||||
|
media() {
|
||||||
|
return this.libraryItem?.media || {}
|
||||||
|
},
|
||||||
|
mediaMetadata() {
|
||||||
|
return this.media.metadata || {}
|
||||||
|
},
|
||||||
|
title() {
|
||||||
|
return this.mediaMetadata.title
|
||||||
|
},
|
||||||
|
tracks() {
|
||||||
|
return this.media.tracks || []
|
||||||
|
},
|
||||||
|
episodes() {
|
||||||
|
return this.media.episodes || []
|
||||||
|
},
|
||||||
|
ebookFile() {
|
||||||
|
return this.media.ebookFile
|
||||||
|
},
|
||||||
|
localItemProgress() {
|
||||||
|
if (this.isPodcast) {
|
||||||
|
if (!this.localEpisodeId) return null
|
||||||
|
return this.$store.getters['globals/getLocalMediaProgressById'](this.localLibraryItemId, this.localEpisodeId)
|
||||||
|
}
|
||||||
|
return this.$store.getters['globals/getLocalMediaProgressById'](this.localLibraryItemId)
|
||||||
|
},
|
||||||
|
serverItemProgress() {
|
||||||
|
if (this.isPodcast) {
|
||||||
|
if (!this.serverEpisodeId) return null
|
||||||
|
return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId, this.serverEpisodeId)
|
||||||
|
}
|
||||||
|
return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId)
|
||||||
|
},
|
||||||
|
userItemProgress() {
|
||||||
|
if (this.isLocal) return this.localItemProgress
|
||||||
|
return this.serverItemProgress
|
||||||
|
},
|
||||||
|
userIsFinished() {
|
||||||
|
return !!this.userItemProgress?.isFinished
|
||||||
|
},
|
||||||
|
useEBookProgress() {
|
||||||
|
if (!this.userItemProgress || this.userItemProgress.progress) return false
|
||||||
|
return this.userItemProgress.ebookProgress > 0
|
||||||
|
},
|
||||||
|
progressPercent() {
|
||||||
|
if (this.useEBookProgress) return Math.max(Math.min(1, this.userItemProgress.ebookProgress), 0)
|
||||||
|
return Math.max(Math.min(1, this.userItemProgress?.progress || 0), 0)
|
||||||
|
},
|
||||||
|
showRSSFeedOption() {
|
||||||
|
if (this.hideRssFeedOption) return false
|
||||||
|
if (!this.serverLibraryItemId) return false
|
||||||
|
if (!this.rssFeed && !this.episodes.length && !this.tracks.length) return false // Cannot open RSS feed with no episodes/tracks
|
||||||
|
|
||||||
|
// If rss feed is open then show feed url to users otherwise just show to admins
|
||||||
|
return this.userIsAdminOrUp || this.rssFeed
|
||||||
|
},
|
||||||
|
mediaId() {
|
||||||
|
if (this.isPodcast) return null
|
||||||
|
return this.serverLibraryItemId || this.localLibraryItemId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
moreMenuAction(action) {
|
||||||
|
this.show = false
|
||||||
|
if (action === 'manageLocal') {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$router.push(`/localMedia/item/${this.localLibraryItemId}`)
|
||||||
|
})
|
||||||
|
} else if (action === 'details') {
|
||||||
|
this.showDetailsModal = true
|
||||||
|
} 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()
|
||||||
|
} else if (action === 'history') {
|
||||||
|
this.$router.push(`/media/${this.mediaId}/history?title=${this.title}`)
|
||||||
|
} else if (action === 'discardProgress') {
|
||||||
|
this.clearProgressClick()
|
||||||
|
} else if (action === 'deleteLocal') {
|
||||||
|
this.deleteLocalItem()
|
||||||
|
} else if (action === 'deleteLocalEpisode') {
|
||||||
|
this.deleteLocalEpisode()
|
||||||
|
} else if (action === 'rssFeed') {
|
||||||
|
this.clickRSSFeed()
|
||||||
|
} else if (action === 'sendEbook') {
|
||||||
|
this.showSendEbookDevicesModal = true
|
||||||
|
} else if (action === 'openWebClient') {
|
||||||
|
this.$store.dispatch('user/openWebClient', `/item/${this.serverLibraryItemId}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async toggleFinished() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
|
|
||||||
|
// Show confirm if item has progress since it will reset
|
||||||
|
if (this.userItemProgress && this.userItemProgress.progress > 0 && !this.userIsFinished) {
|
||||||
|
const { value } = await Dialog.confirm({
|
||||||
|
title: 'Confirm',
|
||||||
|
message: this.$strings.MessageConfirmMarkAsFinished
|
||||||
|
})
|
||||||
|
if (!value) return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$emit('update:processing', true)
|
||||||
|
if (this.isLocal) {
|
||||||
|
const isFinished = !this.userIsFinished
|
||||||
|
const payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId: this.localLibraryItemId, isFinished })
|
||||||
|
console.log('toggleFinished payload', JSON.stringify(payload))
|
||||||
|
if (payload?.error) {
|
||||||
|
this.$toast.error(payload?.error || 'Unknown error')
|
||||||
|
} else {
|
||||||
|
const localMediaProgress = payload.localMediaProgress
|
||||||
|
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
|
||||||
|
if (localMediaProgress) {
|
||||||
|
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const updatePayload = {
|
||||||
|
isFinished: !this.userIsFinished
|
||||||
|
}
|
||||||
|
await this.$nativeHttp.patch(`/api/me/progress/${this.serverLibraryItemId}`, updatePayload).catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
this.$toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.$emit('update:processing', false)
|
||||||
|
},
|
||||||
|
async toggleEpisodeFinished() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
|
|
||||||
|
this.$emit('update:processing', true)
|
||||||
|
if (this.isLocal || this.localEpisode) {
|
||||||
|
const isFinished = !this.userIsFinished
|
||||||
|
const localLibraryItemId = this.localLibraryItemId
|
||||||
|
const localEpisodeId = this.localEpisodeId
|
||||||
|
const payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished })
|
||||||
|
console.log('toggleFinished payload', JSON.stringify(payload))
|
||||||
|
|
||||||
|
if (payload?.error) {
|
||||||
|
this.$toast.error(payload?.error || 'Unknown error')
|
||||||
|
} else {
|
||||||
|
const localMediaProgress = payload.localMediaProgress
|
||||||
|
console.log('toggleFinished localMediaProgress', JSON.stringify(localMediaProgress))
|
||||||
|
if (localMediaProgress) {
|
||||||
|
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const updatePayload = {
|
||||||
|
isFinished: !this.userIsFinished
|
||||||
|
}
|
||||||
|
await this.$nativeHttp.patch(`/api/me/progress/${this.serverLibraryItemId}/${this.serverEpisodeId}`, updatePayload).catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
this.$toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.$emit('update:processing', false)
|
||||||
|
},
|
||||||
|
async clearProgressClick() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
|
|
||||||
|
const { value } = await Dialog.confirm({
|
||||||
|
title: 'Confirm',
|
||||||
|
message: this.$strings.MessageConfirmDiscardProgress
|
||||||
|
})
|
||||||
|
if (value) {
|
||||||
|
this.$emit('update:processing', true)
|
||||||
|
const serverMediaProgressId = this.serverItemProgress?.id
|
||||||
|
if (this.localItemProgress) {
|
||||||
|
await this.$db.removeLocalMediaProgress(this.localItemProgress.id)
|
||||||
|
this.$store.commit('globals/removeLocalMediaProgressForItem', this.localItemProgress.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverMediaProgressId) {
|
||||||
|
await this.$nativeHttp
|
||||||
|
.delete(`/api/me/progress/${serverMediaProgressId}`)
|
||||||
|
.then(() => {
|
||||||
|
console.log('Progress reset complete')
|
||||||
|
this.$toast.success(`Your progress was reset`)
|
||||||
|
this.$store.commit('user/removeMediaProgress', serverMediaProgressId)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Progress reset failed', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$emit('update:processing', false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async deleteLocalEpisode() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
|
|
||||||
|
const localEpisodeAudioTrack = this.localEpisode.audioTrack
|
||||||
|
const localFile = this.localLibraryItem.localFiles.find((lf) => lf.id === localEpisodeAudioTrack.localFileId)
|
||||||
|
if (!localFile) {
|
||||||
|
this.$toast.error('Audio track does not have matching local file..')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { value } = await Dialog.confirm({
|
||||||
|
title: 'Confirm',
|
||||||
|
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) {
|
||||||
|
if (this.isLocal) {
|
||||||
|
// If this is local episode then redirect to server episode when available
|
||||||
|
if (this.serverEpisodeId) {
|
||||||
|
this.$router.replace(`/item/${this.serverLibraryItemId}/${this.serverEpisodeId}`)
|
||||||
|
} else {
|
||||||
|
this.$router.replace(`/item/${this.localLibraryItemId}`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Update local library item and local episode
|
||||||
|
this.libraryItem.localLibraryItem = res
|
||||||
|
this.$delete(this.episode, 'localEpisode')
|
||||||
|
}
|
||||||
|
} else this.$toast.error('Failed to delete')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async deleteLocalItem() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
|
|
||||||
|
const { value } = await Dialog.confirm({
|
||||||
|
title: 'Confirm',
|
||||||
|
message: this.$strings.MessageConfirmDeleteLocalFiles
|
||||||
|
})
|
||||||
|
if (value) {
|
||||||
|
const res = await AbsFileSystem.deleteItem(this.localLibraryItem)
|
||||||
|
if (res?.success) {
|
||||||
|
if (this.isLocal) {
|
||||||
|
// If local then redirect to server version when available
|
||||||
|
if (this.serverLibraryItemId) {
|
||||||
|
this.$router.replace(`/item/${this.serverLibraryItemId}`)
|
||||||
|
} else {
|
||||||
|
this.$router.replace('/bookshelf')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Remove localLibraryItem
|
||||||
|
this.$delete(this.libraryItem, 'localLibraryItem')
|
||||||
|
}
|
||||||
|
} else this.$toast.error('Failed to delete')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clickRSSFeed() {
|
||||||
|
this.$store.commit('globals/setRSSFeedOpenCloseModal', {
|
||||||
|
id: this.serverLibraryItemId,
|
||||||
|
name: this.title,
|
||||||
|
type: 'item',
|
||||||
|
feed: this.rssFeed,
|
||||||
|
hasEpisodesWithoutPubDate: this.episodes.some((ep) => !ep.pubDate)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
sendEbookToDeviceAction(deviceName) {
|
||||||
|
this.showSendEbookDevicesModal = false
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
libraryItemId: this.serverLibraryItemId,
|
||||||
|
deviceName
|
||||||
|
}
|
||||||
|
this.$emit('update:processing', true)
|
||||||
|
this.$nativeHttp
|
||||||
|
.post(`/api/emails/send-ebook-to-device`, payload)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Ebook sent successfully')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to send ebook to device', error)
|
||||||
|
this.$toast.error('Failed to send ebook to device')
|
||||||
|
})
|
||||||
|
.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() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="300" :processing="processing" height="100%">
|
<modals-modal v-model="show" :width="300" :processing="processing" height="100%">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-8 left-4 z-40" style="max-width: 80%">
|
<div class="absolute top-11 left-4 z-40" style="max-width: 80%">
|
||||||
<p class="text-white text-2xl truncate">Libraries</p>
|
<p class="text-white text-2xl truncate">{{ $strings.HeaderLibraries }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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 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-secondary rounded-lg border border-border" style="max-height: 75%" @click.stop>
|
||||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="library in libraries">
|
<template v-for="library in libraries">
|
||||||
<li :key="library.id" class="text-gray-50 select-none relative py-3 cursor-pointer hover:bg-black-400" :class="currentLibraryId === library.id ? 'bg-bg 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 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">
|
<div class="flex items-center px-3">
|
||||||
<ui-library-icon :icon="library.icon" />
|
<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 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 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-12 w-12 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>
|
<span class="material-icons text-4xl">close</span>
|
||||||
</div>
|
</div>
|
||||||
<slot name="outer" />
|
<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 />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" width="90%">
|
<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">
|
<ul class="w-full rounded-lg text-base" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="item in items">
|
<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">
|
<div class="flex items-center">
|
||||||
<span class="font-normal ml-3 block truncate text-lg">{{ item.text }}</span>
|
<span class="font-normal ml-3 block truncate text-lg">{{ item.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -29,83 +29,91 @@ export default {
|
|||||||
return {
|
return {
|
||||||
bookItems: [
|
bookItems: [
|
||||||
{
|
{
|
||||||
text: 'Title',
|
text: this.$strings.LabelTitle,
|
||||||
value: 'media.metadata.title'
|
value: 'media.metadata.title'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Author (First Last)',
|
text: this.$strings.LabelAuthorFirstLast,
|
||||||
value: 'media.metadata.authorName'
|
value: 'media.metadata.authorName'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Author (Last, First)',
|
text: this.$strings.LabelAuthorLastFirst,
|
||||||
value: 'media.metadata.authorNameLF'
|
value: 'media.metadata.authorNameLF'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Published Year',
|
text: this.$strings.LabelPublishYear,
|
||||||
value: 'media.metadata.publishedYear'
|
value: 'media.metadata.publishedYear'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Added At',
|
text: this.$strings.LabelAddedAt,
|
||||||
value: 'addedAt'
|
value: 'addedAt'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Size',
|
text: this.$strings.LabelSize,
|
||||||
value: 'size'
|
value: 'size'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Duration',
|
text: this.$strings.LabelDuration,
|
||||||
value: 'media.duration'
|
value: 'media.duration'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'File Birthtime',
|
text: this.$strings.LabelFileBirthtime,
|
||||||
value: 'birthtimeMs'
|
value: 'birthtimeMs'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'File Modified',
|
text: this.$strings.LabelFileModified,
|
||||||
value: 'mtimeMs'
|
value: 'mtimeMs'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelRandomly,
|
||||||
|
value: 'random'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
podcastItems: [
|
podcastItems: [
|
||||||
{
|
{
|
||||||
text: 'Title',
|
text: this.$strings.LabelTitle,
|
||||||
value: 'media.metadata.title'
|
value: 'media.metadata.title'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Author',
|
text: this.$strings.LabelAuthor,
|
||||||
value: 'media.metadata.author'
|
value: 'media.metadata.author'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Added At',
|
text: this.$strings.LabelAddedAt,
|
||||||
value: 'addedAt'
|
value: 'addedAt'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Size',
|
text: this.$strings.LabelSize,
|
||||||
value: 'size'
|
value: 'size'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'File Birthtime',
|
text: this.$strings.LabelFileBirthtime,
|
||||||
value: 'birthtimeMs'
|
value: 'birthtimeMs'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'File Modified',
|
text: this.$strings.LabelFileModified,
|
||||||
value: 'mtimeMs'
|
value: 'mtimeMs'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelRandomly,
|
||||||
|
value: 'random'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
episodeItems: [
|
episodeItems: [
|
||||||
{
|
{
|
||||||
text: 'Pub Date',
|
text: this.$strings.LabelPubDate,
|
||||||
value: 'publishedAt'
|
value: 'publishedAt'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Title',
|
text: this.$strings.LabelTitle,
|
||||||
value: 'title'
|
value: 'title'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Season',
|
text: this.$strings.LabelSeason,
|
||||||
value: 'season'
|
value: 'season'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: 'Episode',
|
text: this.$strings.LabelEpisode,
|
||||||
value: 'episode'
|
value: 'episode'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,29 +2,29 @@
|
|||||||
<modals-modal v-model="show" @input="modalInput" :width="200" height="100%">
|
<modals-modal v-model="show" @input="modalInput" :width="200" height="100%">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-8 left-4 z-40">
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center">
|
<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">
|
<ul class="w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="rate in rates">
|
<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">
|
<div class="flex items-center justify-center">
|
||||||
<span class="font-normal block truncate text-lg">{{ rate }}x</span>
|
<span class="font-normal block truncate text-lg">{{ rate }}x</span>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="flex items-center justify-center py-3 border-t border-white border-opacity-10">
|
<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-white text-opacity-75 rounded border border-white border-opacity-20 flex items-center justify-center">
|
<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>
|
<span class="material-icons">remove</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="w-24 text-center">
|
<div class="w-24 text-center">
|
||||||
<p class="text-xl">{{ playbackRate }}<span class="text-lg">⨯</span></p>
|
<p class="text-xl">{{ playbackRate }}<span class="text-lg">⨯</span></p>
|
||||||
</div>
|
</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>
|
<span class="material-icons">add</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,7 +71,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
rates() {
|
rates() {
|
||||||
return [0.5, 1, 1.2, 1.5, 2, 3]
|
return [0.5, 1, 1.2, 1.5, 1.7, 2, 3]
|
||||||
},
|
},
|
||||||
canIncrement() {
|
canIncrement() {
|
||||||
return this.playbackRate + 0.1 <= this.MAX_SPEED
|
return this.playbackRate + 0.1 <= this.MAX_SPEED
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user