mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-07-25 05:58:34 +02:00
Compare commits
406
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87614bc78a | ||
|
|
224d75fac5 | ||
|
|
f4e0a6121f | ||
|
|
80ee88b488 | ||
|
|
79d8ccbf52 | ||
|
|
b29401909f | ||
|
|
beb5e1a56c | ||
|
|
be08efeca3 | ||
|
|
4534ffaead | ||
|
|
d35dd2df1a | ||
|
|
2f3a9a5d96 | ||
|
|
fab94cd363 | ||
|
|
b06274866d | ||
|
|
5766c49f61 | ||
|
|
bc927d4c35 | ||
|
|
5804c54656 | ||
|
|
52f86cbce9 | ||
|
|
f6e2e4010f | ||
|
|
44613e12f1 | ||
|
|
b99e0b112b | ||
|
|
80b565c23f | ||
|
|
bee88c43e5 | ||
|
|
467fedbfe7 | ||
|
|
ba4e9ab7e3 | ||
|
|
1b4fa5e44a | ||
|
|
e92c9b2990 | ||
|
|
f23f8e9ade | ||
|
|
dc00aeece4 | ||
|
|
d8cdb7073e | ||
|
|
4fdea9b0ec | ||
|
|
3ded7687f7 | ||
|
|
42119425af | ||
|
|
93c7e1c44a | ||
|
|
381fd84839 | ||
|
|
67bab72783 | ||
|
|
b9c084a442 | ||
|
|
a77f345596 | ||
|
|
6a2f487ed5 | ||
|
|
616cf9029d | ||
|
|
3f21b4172b | ||
|
|
62f6a11522 | ||
|
|
ae1dce455d | ||
|
|
7b5dc52620 | ||
|
|
0bf50de54d | ||
|
|
1eec470902 | ||
|
|
867dc69479 | ||
|
|
9792f4ebc1 | ||
|
|
a44fc41caa | ||
|
|
1927dbf0ad | ||
|
|
7052674f32 | ||
|
|
9a487f3bb6 | ||
|
|
d6242c350d | ||
|
|
4cc4aceb41 | ||
|
|
dd8f4d6201 | ||
|
|
f44dcdf124 | ||
|
|
65e8f2317e | ||
|
|
1c4f43db7c | ||
|
|
24b9df6fbd | ||
|
|
c46891ecb0 | ||
|
|
e723601f6b | ||
|
|
77545df174 | ||
|
|
7e136b17a5 | ||
|
|
a970a06f57 | ||
|
|
95dd78d4aa | ||
|
|
eeaf33be20 | ||
|
|
7ed0cd26fe | ||
|
|
d26403c800 | ||
|
|
5ed6e3a6a3 | ||
|
|
7e83405370 | ||
|
|
a5de31da2b | ||
|
|
39c06005ed | ||
|
|
9479160044 | ||
|
|
46ec7369e5 | ||
|
|
424e7d742b | ||
|
|
44036399b9 | ||
|
|
e4394da467 | ||
|
|
b837ada3f8 | ||
|
|
a623de89aa | ||
|
|
2768407907 | ||
|
|
64ed35d2b1 | ||
|
|
b92f4dfa02 | ||
|
|
e4f02b7a51 | ||
|
|
24c3f45018 | ||
|
|
fcf55eb581 | ||
|
|
9ade7add32 | ||
|
|
d16dfcb4b4 | ||
|
|
37c58a5d32 | ||
|
|
e6cdde4f70 | ||
|
|
93c0adecbd | ||
|
|
33d44a5181 | ||
|
|
80e6fa7e7a | ||
|
|
b8eed48112 | ||
|
|
8d563dcfed | ||
|
|
1cf36e5549 | ||
|
|
1357a0628f | ||
|
|
c4fe0680f3 | ||
|
|
16694aa932 | ||
|
|
ea59ad2953 | ||
|
|
cf2f684e80 | ||
|
|
01522eda43 | ||
|
|
a200ee1310 | ||
|
|
579a07baa2 | ||
|
|
a82dd88b1f | ||
|
|
36ba98920a | ||
|
|
47cc0c34eb | ||
|
|
53eaca8724 | ||
|
|
8a2814b391 | ||
|
|
345c8ab217 | ||
|
|
7c010895e0 | ||
|
|
e716cfd19b | ||
|
|
461a6c8be5 | ||
|
|
3360f1a3f6 | ||
|
|
8b939e8a02 | ||
|
|
3a8998081c | ||
|
|
9b986a7b15 | ||
|
|
69ab395798 | ||
|
|
7aa3c33348 | ||
|
|
734328dd1f | ||
|
|
7d6c30e733 | ||
|
|
3e682dfe50 | ||
|
|
50efda3858 | ||
|
|
ca1b9e2bdb | ||
|
|
aa3a91b9d9 | ||
|
|
7117608445 | ||
|
|
6de640f53f | ||
|
|
75627ea40f | ||
|
|
0fa04d7d9a | ||
|
|
1e76ebe075 | ||
|
|
965da2fee3 | ||
|
|
b03f59ace3 | ||
|
|
79c7244b24 | ||
|
|
0843be3f4e | ||
|
|
6911f368e5 | ||
|
|
42167ea738 | ||
|
|
1c57d3506a | ||
|
|
8adc6fae4c | ||
|
|
e9746577ba | ||
|
|
9f68730622 | ||
|
|
f019b67b0c | ||
|
|
0f650c0572 | ||
|
|
dfc77ea0d0 | ||
|
|
67f514524f | ||
|
|
d97c6a0872 | ||
|
|
e7ad62760f | ||
|
|
fd34ea8124 | ||
|
|
5db94bf5b8 | ||
|
|
796d6d79d4 | ||
|
|
03aafafe1c | ||
|
|
3f303abc19 | ||
|
|
85d6958025 | ||
|
|
669bd7827b | ||
|
|
2b48f0c6a9 | ||
|
|
2c44d38906 | ||
|
|
750726ff6a | ||
|
|
dafab492fe | ||
|
|
6419c8dc3a | ||
|
|
3bb5ce5924 | ||
|
|
882c2749ab | ||
|
|
26b0fae0fb | ||
|
|
fe921fd5b1 | ||
|
|
88e1877742 | ||
|
|
74758c7762 | ||
|
|
2000534e37 | ||
|
|
390388fe83 | ||
|
|
b9e3ccd0c1 | ||
|
|
23e0a44e54 | ||
|
|
e788f8767a | ||
|
|
d09e88f138 | ||
|
|
6e1eee48f0 | ||
|
|
99c8949861 | ||
|
|
ce44ee8514 | ||
|
|
490b45d476 | ||
|
|
e403304e46 | ||
|
|
2bc00bb7f7 | ||
|
|
2785b1d09a | ||
|
|
1d703f8c87 | ||
|
|
93748a917f | ||
|
|
46d5e1d96c | ||
|
|
1e791f9601 | ||
|
|
ea2e8c7cc9 | ||
|
|
9ca160f1da | ||
|
|
1b0843d12e | ||
|
|
b1c4ceb40a | ||
|
|
ff626a3609 | ||
|
|
0862aecfc9 | ||
|
|
e119672336 | ||
|
|
71f6f53111 | ||
|
|
8cf757c080 | ||
|
|
6e0f67b19c | ||
|
|
fa2400871a | ||
|
|
7ef3f1895e | ||
|
|
637911e6b7 | ||
|
|
1b0d7c2463 | ||
|
|
dfb793247c | ||
|
|
a730ca38f2 | ||
|
|
e59a955e6b | ||
|
|
0fbe5f6c86 | ||
|
|
312b04acc9 | ||
|
|
8c20b7985b | ||
|
|
c10a82842e | ||
|
|
7a608bb2b2 | ||
|
|
a8493b2778 | ||
|
|
02a6dd71d4 | ||
|
|
bb5e1e6d71 | ||
|
|
bfbb04dc93 | ||
|
|
8f1b92add5 | ||
|
|
cab98eff3d | ||
|
|
4877d41ff2 | ||
|
|
01c5c03653 | ||
|
|
be31f265b8 | ||
|
|
6ef9721b7d | ||
|
|
13a50c990b | ||
|
|
67e2f2366d | ||
|
|
0947c2fe6f | ||
|
|
7c8d5658f3 | ||
|
|
66cf4639e6 | ||
|
|
8b370da5b7 | ||
|
|
a6d49fc926 | ||
|
|
8aedd1cd95 | ||
|
|
647bd8193b | ||
|
|
b6349fb3a7 | ||
|
|
675c10f7f7 | ||
|
|
5ab5609397 | ||
|
|
6de4bc14af | ||
|
|
8579536eff | ||
|
|
36ec19e25e | ||
|
|
b6b54e04b8 | ||
|
|
5e4ac06493 | ||
|
|
1f05e18ce4 | ||
|
|
cd059542c6 | ||
|
|
b207c18abe | ||
|
|
6b7d3775e4 | ||
|
|
7e1d942e0a | ||
|
|
5cb49daaba | ||
|
|
4e132dcc28 | ||
|
|
288ef8f368 | ||
|
|
66206adbe0 | ||
|
|
c6529ed8a3 | ||
|
|
6ddb44afb6 | ||
|
|
7f84bbe43c | ||
|
|
51b8eece7e | ||
|
|
c891c227a6 | ||
|
|
03457ef138 | ||
|
|
36fb117f89 | ||
|
|
605b52df0e | ||
|
|
45d3a15c68 | ||
|
|
78d7ba69df | ||
|
|
c72f7cddc8 | ||
|
|
e7c3242765 | ||
|
|
35d60ecaa0 | ||
|
|
bcf40c0b76 | ||
|
|
922601eab7 | ||
|
|
bc4267ea1d | ||
|
|
f65d3213fe | ||
|
|
bf2107c01b | ||
|
|
33c738873f | ||
|
|
5bf724b2a2 | ||
|
|
769ce0ade9 | ||
|
|
6f01eafd30 | ||
|
|
5ec27932b0 | ||
|
|
d79ff6e6d5 | ||
|
|
b0e7d3af2c | ||
|
|
f6966efff7 | ||
|
|
79e71a6978 | ||
|
|
5beed3e824 | ||
|
|
c6ea9daa3a | ||
|
|
95440951e5 | ||
|
|
7a45da2293 | ||
|
|
8ae8390339 | ||
|
|
27bc1f4a66 | ||
|
|
b887052d65 | ||
|
|
90bd2fc54c | ||
|
|
e433f7173f | ||
|
|
a1ce605719 | ||
|
|
665cbddcf5 | ||
|
|
6c0a39099f | ||
|
|
b2576d0b35 | ||
|
|
7107ea4aff | ||
|
|
2c43a71850 | ||
|
|
16b93bc490 | ||
|
|
d56a77acdf | ||
|
|
5da1dd1756 | ||
|
|
66e57e8d8f | ||
|
|
db1d7ae549 | ||
|
|
3d85c4085b | ||
|
|
da23b2cc51 | ||
|
|
38926a5b4f | ||
|
|
b39003d519 | ||
|
|
c79ea6ffd2 | ||
|
|
a7f1ead7fe | ||
|
|
3c56087432 | ||
|
|
1e98dea091 | ||
|
|
d239d06c3e | ||
|
|
339393e904 | ||
|
|
40cfb2593c | ||
|
|
346b418908 | ||
|
|
03c2850c38 | ||
|
|
1c29a08679 | ||
|
|
db89ac7743 | ||
|
|
412333cfc4 | ||
|
|
033b0b6ebf | ||
|
|
ab02ce5601 | ||
|
|
d46777595d | ||
|
|
669ced862e | ||
|
|
ba66cc02b7 | ||
|
|
1e840250b9 | ||
|
|
2b615a51fb | ||
|
|
f0c92be5f2 | ||
|
|
20206d6e14 | ||
|
|
7c6e098014 | ||
|
|
1e0f1f329f | ||
|
|
b2ebeafed5 | ||
|
|
9492975a74 | ||
|
|
e194df455b | ||
|
|
1e01139028 | ||
|
|
1141c6f7a5 | ||
|
|
5a1951b495 | ||
|
|
3b3f94124a | ||
|
|
bb56a55143 | ||
|
|
853a12b0dd | ||
|
|
840641681e | ||
|
|
022951f406 | ||
|
|
b6c9df62eb | ||
|
|
27a14a6e3b | ||
|
|
45e3620634 | ||
|
|
d770d6aac7 | ||
|
|
37d60252fd | ||
|
|
1cdcb48c1f | ||
|
|
f9920a0b6b | ||
|
|
00f524db16 | ||
|
|
51401e333f | ||
|
|
7692c29a81 | ||
|
|
d7c755235e | ||
|
|
8589efdf6e | ||
|
|
02b83f02d6 | ||
|
|
e4e7679414 | ||
|
|
abdf51d045 | ||
|
|
5fd21c8393 | ||
|
|
e7e03697d6 | ||
|
|
9e7a76bd97 | ||
|
|
d4090d15be | ||
|
|
973dca83a2 | ||
|
|
c7f51e815c | ||
|
|
1c302a7ac1 | ||
|
|
5d29efe1d5 | ||
|
|
b066df4efc | ||
|
|
fa6b71afae | ||
|
|
3d0c064d41 | ||
|
|
50755ead18 | ||
|
|
79f7fa32ab | ||
|
|
703ab710e9 | ||
|
|
bc782ba9ee | ||
|
|
f2eef64d84 | ||
|
|
abb3a7a3a9 | ||
|
|
ef61e020b6 | ||
|
|
66c87f37e6 | ||
|
|
56caab19f1 | ||
|
|
27eb5f72f7 | ||
|
|
23a80a60b9 | ||
|
|
0520cbd538 | ||
|
|
c79ecbb92e | ||
|
|
08651d28ef | ||
|
|
3060d186a1 | ||
|
|
0509d7105e | ||
|
|
b6ab7dc8a7 | ||
|
|
822ca65349 | ||
|
|
4b4a2b46c1 | ||
|
|
13b020732f | ||
|
|
fb9ca7f5f3 | ||
|
|
e21d37b20f | ||
|
|
161614f6c9 | ||
|
|
16472e1de8 | ||
|
|
d81e47204c | ||
|
|
7ec93cd91e | ||
|
|
af516991a9 | ||
|
|
2cac88cf7c | ||
|
|
1546a85195 | ||
|
|
9eba3e4dc4 | ||
|
|
b568e2ecf1 | ||
|
|
9dce119530 | ||
|
|
24f9b134cd | ||
|
|
c3e6bdd73d | ||
|
|
80e11c1492 | ||
|
|
bdebb4972e | ||
|
|
9b5f9e00d9 | ||
|
|
57167b8a10 | ||
|
|
98889a0baf | ||
|
|
0c535bb2d8 | ||
|
|
f4c34a3102 | ||
|
|
1f4b4d30ee | ||
|
|
1b1dc91c72 | ||
|
|
5a6a76bd63 | ||
|
|
e5263f7719 | ||
|
|
267229f929 | ||
|
|
79ee8b09d6 | ||
|
|
7824693254 | ||
|
|
0b2319fbaa | ||
|
|
dea3844090 | ||
|
|
be2c5759f2 | ||
|
|
c8b14d7822 | ||
|
|
1b54016035 | ||
|
|
50fe370585 | ||
|
|
e423ad168a | ||
|
|
1d7670830c | ||
|
|
d05dbfd5fb | ||
|
|
7e3f1f1ffa |
@@ -12,6 +12,16 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: 'Join the [discord server](https://discord.gg/HQgCbd6E75) for questions or if you are not sure about a bug.'
|
||||
- type: dropdown
|
||||
id: confirm-check
|
||||
attributes:
|
||||
label: I have verified that the [bug is not already awaiting release](https://github.com/advplyr/audiobookshelf-app/issues?q=is%3Aissue%20label%3A%22awaiting%20release%22)
|
||||
multiple: false
|
||||
options:
|
||||
- 'Yes'
|
||||
- 'No'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
@@ -44,7 +54,7 @@ body:
|
||||
attributes:
|
||||
label: Phone Model
|
||||
description: What kind of phone are you using?
|
||||
placeholder: e.g. Pixel 6, iPhone 14, Samusung Galaxy s23, etc
|
||||
placeholder: e.g. Pixel 6, iPhone 14, Samsung Galaxy s23, etc
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
@@ -62,10 +72,12 @@ body:
|
||||
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.77
|
||||
- iOS App - 0.9.77
|
||||
- Android App - 0.9.76
|
||||
- iOS App - 0.9.76
|
||||
- 'Android App - 0.9.81'
|
||||
- 'iOS App - 0.9.81'
|
||||
- 'Android App - 0.9.80'
|
||||
- 'iOS App - 0.9.80'
|
||||
- 'Android App - 0.9.79'
|
||||
- 'iOS App - 0.9.79'
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
@@ -74,10 +86,10 @@ body:
|
||||
label: Installation Source
|
||||
multiple: true
|
||||
options:
|
||||
- Google Play Store
|
||||
- Testflight
|
||||
- SideStore
|
||||
- Other (List in "Additional Notes")
|
||||
- 'Google Play Store'
|
||||
- 'Testflight'
|
||||
- 'SideStore'
|
||||
- 'Other (List in "Additional Notes")'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discord
|
||||
url: https://discord.gg/HQgCbd6E75
|
||||
about: Ask questions, get help troubleshooting, and join the Abs community here.
|
||||
+6
-4
@@ -43,10 +43,12 @@ body:
|
||||
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.77
|
||||
- iOS App - 0.9.77
|
||||
- Android App - 0.9.76
|
||||
- iOS App - 0.9.76
|
||||
- 'Android App - 0.9.81'
|
||||
- 'iOS App - 0.9.81'
|
||||
- 'Android App - 0.9.80'
|
||||
- 'iOS App - 0.9.80'
|
||||
- 'Android App - 0.9.79'
|
||||
- 'iOS App - 0.9.79'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
@@ -0,0 +1,41 @@
|
||||
<!--
|
||||
For Work In Progress Pull Requests, please use the Draft PR feature,
|
||||
see https://github.blog/2019-02-14-introducing-draft-pull-requests/ for further details.
|
||||
|
||||
If you do not follow this template, the PR may be closed without review.
|
||||
|
||||
Please ensure all checks pass.
|
||||
If you are a new contributor, the workflows will need to be manually approved before they run.
|
||||
-->
|
||||
|
||||
## Brief summary
|
||||
|
||||
<!-- Please provide a brief summary of what your PR attempts to achieve. -->
|
||||
|
||||
## Which issue is fixed?
|
||||
|
||||
<!-- Which issue number does this PR fix? Ex: "Fixes #1234" -->
|
||||
|
||||
## Pull Request Type
|
||||
|
||||
<!--
|
||||
Does this affect only Android, only iOS, or both?
|
||||
Does this change the frontend or the backend of the apps?
|
||||
-->
|
||||
|
||||
## In-depth Description
|
||||
|
||||
<!--
|
||||
Describe your solution in more depth.
|
||||
How does it work? Why is this the best solution?
|
||||
Does it solve a problem that affects multiple users or is this an edge case for your setup?
|
||||
-->
|
||||
|
||||
## How have you tested this?
|
||||
|
||||
<!-- Please describe in detail with reproducible steps how you tested your changes. -->
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- If your PR includes any changes to the front-end, please include screenshots or a
|
||||
short video from before and after your changes. -->
|
||||
@@ -13,41 +13,41 @@ jobs:
|
||||
main:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout sources
|
||||
uses: actions/checkout@v3
|
||||
- name: checkout sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: use Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
- name: use Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: 17
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
|
||||
- name: install dependencies
|
||||
run: npm ci
|
||||
- name: install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: build Nuxt project
|
||||
run: npm run generate
|
||||
- name: build Nuxt project
|
||||
run: npm run generate
|
||||
|
||||
- name: copy to Android project
|
||||
run: npx cap sync
|
||||
- name: copy to Android project
|
||||
run: npx cap sync
|
||||
|
||||
- name: build Android app
|
||||
run: ./android/gradlew assembleDebug -p android --no-daemon
|
||||
- name: build Android app
|
||||
run: ./android/gradlew assembleDebug -p android --no-daemon
|
||||
|
||||
- name: rename apk
|
||||
working-directory: android/app/build/outputs/apk/debug/
|
||||
run: |
|
||||
build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
|
||||
name="audiobookshelf-${build}.apk"
|
||||
mv -v app-debug.apk "${name}"
|
||||
- name: rename apk
|
||||
working-directory: android/app/build/outputs/apk/debug/
|
||||
run: |
|
||||
build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
|
||||
name="audiobookshelf-${build}.apk"
|
||||
mv -v app-debug.apk "${name}"
|
||||
|
||||
- name: upload app
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: audiobookshelf-apk
|
||||
path: android/app/build/outputs/apk/debug/*.apk
|
||||
- name: upload app
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: audiobookshelf-apk
|
||||
path: android/app/build/outputs/apk/debug/*.apk
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Close Issues not using a template
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
close_issue:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check issue headings
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const issueBody = context.payload.issue.body || "";
|
||||
|
||||
// Match Markdown headings (e.g., # Heading, ## Heading)
|
||||
const headingRegex = /^(#{1,6})\s.+/gm;
|
||||
const headings = [...issueBody.matchAll(headingRegex)];
|
||||
|
||||
if (headings.length < 3) {
|
||||
// Post a comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
body: "Thank you for opening an issue! To help us review your request efficiently, please use one of the provided issue templates. If you're seeking information or have a general question, consider opening a Discussion or joining the conversation on our Discord. Thanks!"
|
||||
});
|
||||
|
||||
// Close the issue
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
state: "closed"
|
||||
});
|
||||
}
|
||||
@@ -12,57 +12,57 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout sources
|
||||
uses: actions/checkout@v3
|
||||
- name: checkout sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: use Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
- name: use Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: 17
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
|
||||
- name: install dependencies
|
||||
run: npm ci
|
||||
- name: install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: build Nuxt project
|
||||
run: npm run generate
|
||||
- name: build Nuxt project
|
||||
run: npm run generate
|
||||
|
||||
- name: copy to Android project
|
||||
run: npx cap sync
|
||||
- name: copy to Android project
|
||||
run: npx cap sync
|
||||
|
||||
- name: build Android app
|
||||
run: ./android/gradlew assembleDebug -p android --no-daemon
|
||||
- name: build Android app
|
||||
run: ./android/gradlew assembleDebug -p android --no-daemon
|
||||
|
||||
- name: rename apk
|
||||
working-directory: android/app/build/outputs/apk/debug/
|
||||
run: |
|
||||
build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
|
||||
name="audiobookshelf-${build}.apk"
|
||||
mv -v app-debug.apk "${name}"
|
||||
- name: rename apk
|
||||
working-directory: android/app/build/outputs/apk/debug/
|
||||
run: |
|
||||
build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
|
||||
name="audiobookshelf-${build}.apk"
|
||||
mv -v app-debug.apk "${name}"
|
||||
|
||||
- name: prepare test page ressources
|
||||
run: |
|
||||
mkdir ghpages
|
||||
cp android/app/build/outputs/apk/debug/*apk ghpages/
|
||||
cp static/Logo.png ghpages/logo.png
|
||||
cp .github/testing-page-template.html ghpages/index.html
|
||||
- name: prepare test page ressources
|
||||
run: |
|
||||
mkdir ghpages
|
||||
cp android/app/build/outputs/apk/debug/*apk ghpages/
|
||||
cp static/Logo.png ghpages/logo.png
|
||||
cp .github/testing-page-template.html ghpages/index.html
|
||||
|
||||
- name: build test page
|
||||
working-directory: ghpages
|
||||
run: |
|
||||
sed -i "s/__DATE__/$(date)/g" index.html
|
||||
sed -i "s/__COMMIT__/$(git rev-parse --short HEAD)/g" index.html
|
||||
sed -i "s/__APK__/$(ls *apk)/g" index.html
|
||||
- name: build test page
|
||||
working-directory: ghpages
|
||||
run: |
|
||||
sed -i "s/__DATE__/$(date)/g" index.html
|
||||
sed -i "s/__COMMIT__/$(git rev-parse --short HEAD)/g" index.html
|
||||
sed -i "s/__APK__/$(ls *apk)/g" index.html
|
||||
|
||||
- name: upload test page artifact
|
||||
uses: actions/upload-pages-artifact@v1
|
||||
with:
|
||||
path: ./ghpages
|
||||
- name: upload test page artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: ./ghpages
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
@@ -79,4 +79,4 @@ jobs:
|
||||
steps:
|
||||
- name: deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v1
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"java.project.root": "android",
|
||||
"java.import.gradle.enabled": true,
|
||||
"java.import.gradle.wrapper.enabled": true,
|
||||
"java.import.gradle.user.home": "${workspaceFolder}/.gradle"
|
||||
}
|
||||
@@ -36,8 +36,8 @@ android {
|
||||
applicationId "com.audiobookshelf.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 109
|
||||
versionName "0.9.78-beta"
|
||||
versionCode 112
|
||||
versionName "0.9.81-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
manifestPlaceholders = [
|
||||
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
||||
@@ -86,7 +86,7 @@ dependencies {
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation project(':capacitor-android')
|
||||
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.2.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
|
||||
@@ -9,7 +9,7 @@ android {
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':byteowls-capacitor-filesharer')
|
||||
implementation project(':webnativellc-capacitor-filesharer')
|
||||
implementation project(':capacitor-community-keep-awake')
|
||||
implementation project(':capacitor-community-volume-buttons')
|
||||
implementation project(':capacitor-app')
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||
android:exported="true"
|
||||
android:label="@string/title_activity_main"
|
||||
android:launchMode="singleTask"
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
"plugins": {
|
||||
"CapacitorHttp": {
|
||||
"enabled": false
|
||||
},
|
||||
"StatusBar": {
|
||||
"backgroundColor": "#232323",
|
||||
"style": "DARK"
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[
|
||||
{
|
||||
"pkg": "@byteowls/capacitor-filesharer",
|
||||
"pkg": "@webnativellc/capacitor-filesharer",
|
||||
"classpath": "com.byteowls.capacitor.filesharer.FileSharerPlugin"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,10 +6,15 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowInsets
|
||||
import android.webkit.WebView
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import com.anggrayudi.storage.SimpleStorage
|
||||
import com.anggrayudi.storage.SimpleStorageHelper
|
||||
import com.audiobookshelf.app.managers.DbManager
|
||||
@@ -18,6 +23,7 @@ import com.audiobookshelf.app.plugins.AbsAudioPlayer
|
||||
import com.audiobookshelf.app.plugins.AbsDatabase
|
||||
import com.audiobookshelf.app.plugins.AbsDownloader
|
||||
import com.audiobookshelf.app.plugins.AbsFileSystem
|
||||
import com.audiobookshelf.app.plugins.AbsLogger
|
||||
import com.getcapacitor.BridgeActivity
|
||||
|
||||
|
||||
@@ -39,29 +45,58 @@ class MainActivity : BridgeActivity() {
|
||||
)
|
||||
|
||||
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||
// TODO: Optimize using strict mode logs
|
||||
// StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder()
|
||||
// .detectDiskReads()
|
||||
// .detectDiskWrites().detectAll()
|
||||
// .detectNetwork() // or .detectAll() for all detectable problems
|
||||
// .penaltyLog()
|
||||
// .build())
|
||||
// StrictMode.setVmPolicy(VmPolicy.Builder()
|
||||
// .detectLeakedSqlLiteObjects()
|
||||
// .detectLeakedClosableObjects()
|
||||
// .penaltyLog()
|
||||
// .build())
|
||||
DbManager.initialize(applicationContext)
|
||||
|
||||
registerPlugin(AbsAudioPlayer::class.java)
|
||||
registerPlugin(AbsDownloader::class.java)
|
||||
registerPlugin(AbsFileSystem::class.java)
|
||||
registerPlugin(AbsDatabase::class.java)
|
||||
registerPlugin(AbsLogger::class.java)
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
Log.d(tag, "onCreate")
|
||||
|
||||
// Update the margins to handle edge-to-edge enforced in SDK 35
|
||||
// See: https://developer.android.com/develop/ui/views/layout/edge-to-edge
|
||||
val webView: WebView = findViewById(R.id.webview)
|
||||
webView.setOnApplyWindowInsetsListener { v, insets ->
|
||||
val (left, top, right, bottom) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val sysInsets = insets.getInsets(WindowInsets.Type.systemBars())
|
||||
Log.d(tag, "safe sysInsets: $sysInsets")
|
||||
arrayOf(sysInsets.left, sysInsets.top, sysInsets.right, sysInsets.bottom)
|
||||
} else {
|
||||
arrayOf(
|
||||
insets.systemWindowInsetLeft,
|
||||
insets.systemWindowInsetTop,
|
||||
insets.systemWindowInsetRight,
|
||||
insets.systemWindowInsetBottom
|
||||
)
|
||||
}
|
||||
|
||||
// Inject as CSS variables
|
||||
// NOTE: Possibly able to use in the future to support edge-to-edge better.
|
||||
val js = """
|
||||
document.documentElement.style.setProperty('--safe-area-inset-top', '${top}px');
|
||||
document.documentElement.style.setProperty('--safe-area-inset-bottom', '${bottom}px');
|
||||
document.documentElement.style.setProperty('--safe-area-inset-left', '${left}px');
|
||||
document.documentElement.style.setProperty('--safe-area-inset-right', '${right}px');
|
||||
""".trimIndent()
|
||||
webView.evaluateJavascript(js, null)
|
||||
|
||||
// Set margins
|
||||
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||
leftMargin = left
|
||||
bottomMargin = bottom
|
||||
rightMargin = right
|
||||
topMargin = top
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
WindowInsets.CONSUMED
|
||||
} else {
|
||||
insets
|
||||
}
|
||||
}
|
||||
|
||||
val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
if (permission != PackageManager.PERMISSION_GRANTED) {
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioProbeStream(
|
||||
val index:Int,
|
||||
val codec_name:String,
|
||||
val codec_long_name:String,
|
||||
val channels:Int,
|
||||
val channel_layout:String,
|
||||
val duration:Double,
|
||||
val bit_rate:Double
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioProbeChapterTags(
|
||||
val title:String
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioProbeChapter(
|
||||
val id:Int,
|
||||
val start:Long,
|
||||
val end:Long,
|
||||
val tags:AudioProbeChapterTags?
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun getBookChapter():BookChapter {
|
||||
val startS = start / 1000.0
|
||||
val endS = end / 1000.0
|
||||
val title = tags?.title ?: "Chapter $id"
|
||||
return BookChapter(id, startS, endS, title)
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioProbeFormatTags(
|
||||
val artist:String?,
|
||||
val album:String?,
|
||||
val comment:String?,
|
||||
val date:String?,
|
||||
val genre:String?,
|
||||
val title:String?
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioProbeFormat(
|
||||
val filename:String,
|
||||
val format_name:String,
|
||||
val duration:Double,
|
||||
val size:Long,
|
||||
val bit_rate:Double,
|
||||
val tags:AudioProbeFormatTags?
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class AudioProbeResult (
|
||||
val streams:MutableList<AudioProbeStream>,
|
||||
val chapters:MutableList<AudioProbeChapter>,
|
||||
val format:AudioProbeFormat) {
|
||||
|
||||
val duration get() = format.duration
|
||||
val size get() = format.size
|
||||
val title get() = format.tags?.title ?: format.filename.split("/").last()
|
||||
val artist get() = format.tags?.artist ?: ""
|
||||
|
||||
@JsonIgnore
|
||||
fun getBookChapters(): List<BookChapter> {
|
||||
if (chapters.isEmpty()) return mutableListOf()
|
||||
return chapters.map { it.getBookChapter() }
|
||||
}
|
||||
}
|
||||
@@ -5,30 +5,34 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioTrack(
|
||||
var index:Int,
|
||||
var startOffset:Double,
|
||||
var duration:Double,
|
||||
var title:String,
|
||||
var contentUrl:String,
|
||||
var mimeType:String,
|
||||
var metadata:FileMetadata?,
|
||||
var isLocal:Boolean,
|
||||
var localFileId:String?,
|
||||
var audioProbeResult:AudioProbeResult?,
|
||||
var serverIndex:Int? // Need to know if server track index is different
|
||||
var index: Int,
|
||||
var startOffset: Double,
|
||||
var duration: Double,
|
||||
var title: String,
|
||||
var contentUrl: String,
|
||||
var mimeType: String,
|
||||
var metadata: FileMetadata?,
|
||||
var isLocal: Boolean,
|
||||
var localFileId: String?,
|
||||
// TODO: This should no longer be necessary
|
||||
var serverIndex: Int? // Need to know if server track index is different
|
||||
) {
|
||||
|
||||
@get:JsonIgnore
|
||||
val startOffsetMs get() = (startOffset * 1000L).toLong()
|
||||
val startOffsetMs
|
||||
get() = (startOffset * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val durationMs get() = (duration * 1000L).toLong()
|
||||
val durationMs
|
||||
get() = (duration * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val endOffsetMs get() = startOffsetMs + durationMs
|
||||
val endOffsetMs
|
||||
get() = startOffsetMs + durationMs
|
||||
@get:JsonIgnore
|
||||
val relPath get() = metadata?.relPath ?: ""
|
||||
val relPath
|
||||
get() = metadata?.relPath ?: ""
|
||||
|
||||
@JsonIgnore
|
||||
fun getBookChapter():BookChapter {
|
||||
return BookChapter(index + 1,startOffset, startOffset + duration, title)
|
||||
fun getBookChapter(): BookChapter {
|
||||
return BookChapter(index + 1, startOffset, startOffset + duration, title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,11 +32,14 @@ enum class AndroidAutoBrowseSeriesSequenceOrderSetting {
|
||||
ASC, DESC
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class ServerConnectionConfig(
|
||||
var id:String,
|
||||
var index:Int,
|
||||
var name:String,
|
||||
var address:String,
|
||||
// version added after 0.9.81-beta
|
||||
var version:String?,
|
||||
var userId:String,
|
||||
var username:String,
|
||||
var token:String,
|
||||
@@ -135,6 +138,7 @@ data class DeviceSettings(
|
||||
var sleepTimerLength: Long, // Time in milliseconds
|
||||
var disableSleepTimerFadeOut: Boolean,
|
||||
var disableSleepTimerResetFeedback: Boolean,
|
||||
var enableSleepTimerAlmostDoneChime: Boolean,
|
||||
var languageCode: String,
|
||||
var downloadUsingCellular: DownloadUsingCellularSetting,
|
||||
var streamingUsingCellular: StreamingUsingCellularSetting,
|
||||
@@ -163,6 +167,7 @@ data class DeviceSettings(
|
||||
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
|
||||
disableSleepTimerFadeOut = false,
|
||||
disableSleepTimerResetFeedback = false,
|
||||
enableSleepTimerAlmostDoneChime = false,
|
||||
languageCode = "en-us",
|
||||
downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS,
|
||||
streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS,
|
||||
@@ -188,9 +193,9 @@ data class DeviceSettings(
|
||||
|
||||
@JsonIgnore
|
||||
fun getShakeThresholdGravity() : Float { // Used in ShakeDetector
|
||||
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.2f
|
||||
else if (shakeSensitivity == ShakeSensitivitySetting.HIGH) 1.4f
|
||||
else if (shakeSensitivity == ShakeSensitivitySetting.MEDIUM) 1.6f
|
||||
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.1f
|
||||
else if (shakeSensitivity == ShakeSensitivitySetting.HIGH) 1.3f
|
||||
else if (shakeSensitivity == ShakeSensitivitySetting.MEDIUM) 1.5f
|
||||
else if (shakeSensitivity == ShakeSensitivitySetting.LOW) 2f
|
||||
else if (shakeSensitivity == ShakeSensitivitySetting.VERY_LOW) 2.7f
|
||||
else {
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import org.json.JSONObject
|
||||
|
||||
@@ -18,8 +17,7 @@ data class ItemInProgress(
|
||||
val isLocal: Boolean
|
||||
) {
|
||||
companion object {
|
||||
fun makeFromServerObject(serverItem: JSONObject):ItemInProgress {
|
||||
val jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
fun makeFromServerObject(serverItem: JSONObject, jacksonMapper: ObjectMapper):ItemInProgress {
|
||||
val libraryItem = jacksonMapper.readValue<LibraryItem>(serverItem.toString())
|
||||
|
||||
var episode:PodcastEpisode? = null
|
||||
|
||||
@@ -53,6 +53,11 @@ class LibraryItem(
|
||||
return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
}
|
||||
|
||||
// As of v2.17.0 token is not needed with cover image requests
|
||||
if (DeviceManager.isServerVersionGreaterThanOrEqualTo("2.17.0")) {
|
||||
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover")
|
||||
}
|
||||
|
||||
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
|
||||
}
|
||||
|
||||
|
||||
@@ -4,70 +4,65 @@ import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
/*
|
||||
Used as a helper class to generate LocalLibraryItem from scan results
|
||||
*/
|
||||
Used as a helper class to generate LocalLibraryItem from scan results
|
||||
*/
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LocalMediaItem(
|
||||
var id:String,
|
||||
var name: String,
|
||||
var mediaType:String,
|
||||
var folderId:String,
|
||||
var contentUrl:String,
|
||||
var simplePath: String,
|
||||
var basePath:String,
|
||||
var absolutePath:String,
|
||||
var audioTracks:MutableList<AudioTrack>,
|
||||
var ebookFile:EBookFile?,
|
||||
var localFiles:MutableList<LocalFile>,
|
||||
var coverContentUrl:String?,
|
||||
var coverAbsolutePath:String?
|
||||
var id: String,
|
||||
var name: String,
|
||||
var mediaType: String,
|
||||
var folderId: String,
|
||||
var contentUrl: String,
|
||||
var simplePath: String,
|
||||
var basePath: String,
|
||||
var absolutePath: String,
|
||||
var audioTracks: MutableList<AudioTrack>,
|
||||
var ebookFile: EBookFile?,
|
||||
var localFiles: MutableList<LocalFile>,
|
||||
var coverContentUrl: String?,
|
||||
var coverAbsolutePath: String?
|
||||
) {
|
||||
|
||||
@JsonIgnore
|
||||
fun getDuration():Double {
|
||||
fun getDuration(): Double {
|
||||
var total = 0.0
|
||||
audioTracks.forEach{ total += it.duration }
|
||||
audioTracks.forEach { total += it.duration }
|
||||
return total
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getTotalSize():Long {
|
||||
fun getTotalSize(): Long {
|
||||
var total = 0L
|
||||
localFiles.forEach { total += it.size }
|
||||
return total
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getMediaMetadata():MediaTypeMetadata {
|
||||
fun getMediaMetadata(): MediaTypeMetadata {
|
||||
return if (mediaType == "book") {
|
||||
BookMetadata(name,null, mutableListOf(), mutableListOf(), mutableListOf(),null,null,null,null,null,null,null,false,null,null,null, null, null)
|
||||
BookMetadata(
|
||||
name,
|
||||
null,
|
||||
mutableListOf(),
|
||||
mutableListOf(),
|
||||
mutableListOf(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
} else {
|
||||
PodcastMetadata(name,null,null, mutableListOf(), false)
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getAudiobookChapters():List<BookChapter> {
|
||||
if (mediaType != "book" || audioTracks.isEmpty()) return mutableListOf()
|
||||
if (audioTracks.size == 1) { // Single track audiobook look for chapters from ffprobe
|
||||
return audioTracks[0].audioProbeResult?.getBookChapters() ?: mutableListOf()
|
||||
}
|
||||
// Multi-track make chapters from tracks
|
||||
return audioTracks.map { it.getBookChapter() }
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getLocalLibraryItem():LocalLibraryItem {
|
||||
val mediaMetadata = getMediaMetadata()
|
||||
if (mediaType == "book") {
|
||||
val chapters = getAudiobookChapters()
|
||||
val book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,ebookFile,getTotalSize(),getDuration(),audioTracks.size)
|
||||
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null)
|
||||
} else {
|
||||
val podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false, 0)
|
||||
podcast.setAudioTracks(audioTracks) // Builds episodes from audio tracks
|
||||
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false, mediaType, podcast,localFiles,coverContentUrl, coverAbsolutePath, true, null,null,null,null)
|
||||
PodcastMetadata(name, null, null, mutableListOf(), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,14 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class MediaItemHistory(
|
||||
var id: String, // media id
|
||||
var mediaDisplayTitle: String,
|
||||
var libraryItemId: String,
|
||||
var episodeId: String?,
|
||||
var isLocal:Boolean,
|
||||
var serverConnectionConfigId:String?,
|
||||
var serverAddress:String?,
|
||||
var serverUserId:String?,
|
||||
var createdAt: Long,
|
||||
var events:MutableList<MediaItemEvent>,
|
||||
var id: String, // media id
|
||||
var mediaDisplayTitle: String,
|
||||
var libraryItemId: String,
|
||||
var episodeId: String?,
|
||||
var isLocal: Boolean,
|
||||
var serverConnectionConfigId: String?,
|
||||
var serverAddress: String?,
|
||||
var serverUserId: String?,
|
||||
var createdAt: Long,
|
||||
var events: MutableList<MediaItemEvent>,
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.media.MediaProgressSyncData
|
||||
import com.audiobookshelf.app.player.*
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
@@ -19,61 +20,70 @@ import com.google.android.exoplayer2.MediaMetadata
|
||||
import com.google.android.gms.cast.MediaInfo
|
||||
import com.google.android.gms.cast.MediaQueueItem
|
||||
import com.google.android.gms.common.images.WebImage
|
||||
import com.audiobookshelf.app.player.*
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class PlaybackSession(
|
||||
var id:String,
|
||||
var userId:String?,
|
||||
var libraryItemId:String?,
|
||||
var episodeId:String?,
|
||||
var mediaType:String,
|
||||
var mediaMetadata:MediaTypeMetadata,
|
||||
var deviceInfo:DeviceInfo,
|
||||
var chapters:List<BookChapter>,
|
||||
var displayTitle: String?,
|
||||
var displayAuthor: String?,
|
||||
var coverPath:String?,
|
||||
var duration:Double,
|
||||
var playMethod:Int,
|
||||
var startedAt:Long,
|
||||
var updatedAt:Long,
|
||||
var timeListening:Long,
|
||||
var audioTracks:MutableList<AudioTrack>,
|
||||
var currentTime:Double,
|
||||
var libraryItem:LibraryItem?,
|
||||
var localLibraryItem:LocalLibraryItem?,
|
||||
var localEpisodeId:String?,
|
||||
var serverConnectionConfigId:String?,
|
||||
var serverAddress:String?,
|
||||
var mediaPlayer:String?
|
||||
var id: String,
|
||||
var userId: String?,
|
||||
var libraryItemId: String?,
|
||||
var episodeId: String?,
|
||||
var mediaType: String,
|
||||
var mediaMetadata: MediaTypeMetadata,
|
||||
var deviceInfo: DeviceInfo,
|
||||
var chapters: List<BookChapter>,
|
||||
var displayTitle: String?,
|
||||
var displayAuthor: String?,
|
||||
var coverPath: String?,
|
||||
var duration: Double,
|
||||
var playMethod: Int,
|
||||
var startedAt: Long,
|
||||
var updatedAt: Long,
|
||||
var timeListening: Long,
|
||||
var audioTracks: MutableList<AudioTrack>,
|
||||
var currentTime: Double,
|
||||
var libraryItem: LibraryItem?,
|
||||
var localLibraryItem: LocalLibraryItem?,
|
||||
var localEpisodeId: String?,
|
||||
var serverConnectionConfigId: String?,
|
||||
var serverAddress: String?,
|
||||
var mediaPlayer: String?
|
||||
) {
|
||||
|
||||
@get:JsonIgnore
|
||||
val isHLS get() = playMethod == PLAYMETHOD_TRANSCODE
|
||||
val isHLS
|
||||
get() = playMethod == PLAYMETHOD_TRANSCODE
|
||||
@get:JsonIgnore
|
||||
val isDirectPlay get() = playMethod == PLAYMETHOD_DIRECTPLAY
|
||||
val isDirectPlay
|
||||
get() = playMethod == PLAYMETHOD_DIRECTPLAY
|
||||
@get:JsonIgnore
|
||||
val isLocal get() = playMethod == PLAYMETHOD_LOCAL
|
||||
val isLocal
|
||||
get() = playMethod == PLAYMETHOD_LOCAL
|
||||
@get:JsonIgnore
|
||||
val isPodcastEpisode get() = mediaType == "podcast"
|
||||
val isPodcastEpisode
|
||||
get() = mediaType == "podcast"
|
||||
@get:JsonIgnore
|
||||
val currentTimeMs get() = (currentTime * 1000L).toLong()
|
||||
val currentTimeMs
|
||||
get() = (currentTime * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val totalDurationMs get() = (getTotalDuration() * 1000L).toLong()
|
||||
val totalDurationMs
|
||||
get() = (getTotalDuration() * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val localLibraryItemId get() = localLibraryItem?.id ?: ""
|
||||
val localLibraryItemId
|
||||
get() = localLibraryItem?.id ?: ""
|
||||
@get:JsonIgnore
|
||||
val localMediaProgressId get() = if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
||||
val localMediaProgressId
|
||||
get() =
|
||||
if (localEpisodeId.isNullOrEmpty()) localLibraryItemId
|
||||
else "$localLibraryItemId-$localEpisodeId"
|
||||
@get:JsonIgnore
|
||||
val progress get() = currentTime / getTotalDuration()
|
||||
val progress
|
||||
get() = currentTime / getTotalDuration()
|
||||
@get:JsonIgnore
|
||||
val isLocalLibraryItemOnly get() = localLibraryItemId != "" && libraryItemId == null
|
||||
@get:JsonIgnore
|
||||
val mediaItemId get() = if (isLocalLibraryItemOnly) localMediaProgressId else if (episodeId.isNullOrEmpty()) libraryItemId ?: "" else "$libraryItemId-$episodeId"
|
||||
val mediaItemId
|
||||
get() = if (episodeId.isNullOrEmpty()) libraryItemId ?: "" else "$libraryItemId-$episodeId"
|
||||
|
||||
@JsonIgnore
|
||||
fun getCurrentTrackIndex():Int {
|
||||
fun getCurrentTrackIndex(): Int {
|
||||
for (i in 0 until audioTracks.size) {
|
||||
val track = audioTracks[i]
|
||||
if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs > currentTimeMs)) {
|
||||
@@ -84,7 +94,7 @@ class PlaybackSession(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getNextTrackIndex():Int {
|
||||
fun getNextTrackIndex(): Int {
|
||||
for (i in 0 until audioTracks.size) {
|
||||
val track = audioTracks[i]
|
||||
if (currentTimeMs < track.startOffsetMs) {
|
||||
@@ -95,68 +105,95 @@ class PlaybackSession(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getChapterForTime(time:Long):BookChapter? {
|
||||
fun getChapterForTime(time: Long): BookChapter? {
|
||||
if (chapters.isEmpty()) return null
|
||||
return chapters.find { time >= it.startMs && it.endMs > time}
|
||||
return chapters.find { time >= it.startMs && it.endMs > time }
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getCurrentTrackEndTime():Long {
|
||||
fun getCurrentTrackEndTime(): Long {
|
||||
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
|
||||
return currentTrack.startOffsetMs + currentTrack.durationMs
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getNextChapterForTime(time:Long):BookChapter? {
|
||||
fun getNextChapterForTime(time: Long): BookChapter? {
|
||||
if (chapters.isEmpty()) return null
|
||||
return chapters.find { time < it.startMs } // First chapter where start time is > then time
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getNextTrackEndTime():Long {
|
||||
fun getNextTrackEndTime(): Long {
|
||||
val currentTrack = audioTracks[this.getNextTrackIndex()]
|
||||
return currentTrack.startOffsetMs + currentTrack.durationMs
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getCurrentTrackTimeMs():Long {
|
||||
fun getCurrentTrackTimeMs(): Long {
|
||||
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
|
||||
val time = currentTime - currentTrack.startOffset
|
||||
return (time * 1000L).toLong()
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getTrackStartOffsetMs(index:Int):Long {
|
||||
fun getTrackStartOffsetMs(index: Int): Long {
|
||||
if (index < 0 || index >= audioTracks.size) return 0L
|
||||
val currentTrack = audioTracks[index]
|
||||
return (currentTrack.startOffset * 1000L).toLong()
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getTotalDuration():Double {
|
||||
fun getTotalDuration(): Double {
|
||||
var total = 0.0
|
||||
audioTracks.forEach { total += it.duration }
|
||||
return total
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getCoverUri(ctx:Context): Uri {
|
||||
fun checkIsServerVersionGte(compareVersion: String): Boolean {
|
||||
// Safety check this playback session is the same one currently connected (should always be)
|
||||
if (DeviceManager.serverConnectionConfigId != serverConnectionConfigId) {
|
||||
return false
|
||||
}
|
||||
|
||||
return DeviceManager.isServerVersionGreaterThanOrEqualTo(compareVersion)
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getCoverUri(ctx: Context): Uri {
|
||||
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())
|
||||
coverUri =
|
||||
FileProvider.getUriForFile(
|
||||
ctx,
|
||||
"${BuildConfig.APPLICATION_ID}.fileprovider",
|
||||
coverUri.toFile()
|
||||
)
|
||||
}
|
||||
|
||||
return coverUri ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
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)
|
||||
|
||||
// As of v2.17.0 token is not needed with cover image requests
|
||||
if (checkIsServerVersionGte("2.17.0")) {
|
||||
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover")
|
||||
}
|
||||
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getContentUri(audioTrack:AudioTrack): Uri {
|
||||
fun getContentUri(audioTrack: AudioTrack): Uri {
|
||||
if (isLocal) return Uri.parse(audioTrack.contentUrl) // Local content url
|
||||
// As of v2.22.0 tracks use a different endpoint
|
||||
// See: https://github.com/advplyr/audiobookshelf/pull/4263
|
||||
if (checkIsServerVersionGte("2.22.0")) {
|
||||
return Uri.parse("$serverAddress/public/session/$id/track/${audioTrack.index}")
|
||||
}
|
||||
return Uri.parse("$serverAddress${audioTrack.contentUrl}?token=${DeviceManager.token}")
|
||||
}
|
||||
|
||||
@@ -164,28 +201,34 @@ class PlaybackSession(
|
||||
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
|
||||
val coverUri = getCoverUri(ctx)
|
||||
|
||||
val metadataBuilder = MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
|
||||
val metadataBuilder =
|
||||
MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
|
||||
.putString(
|
||||
MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI,
|
||||
coverUri.toString()
|
||||
)
|
||||
|
||||
// Local covers get bitmap
|
||||
if (localLibraryItem?.coverContentUrl != null) {
|
||||
val bitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||
} else {
|
||||
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
}
|
||||
val bitmap =
|
||||
if (Build.VERSION.SDK_INT < 28) {
|
||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||
} else {
|
||||
val source: ImageDecoder.Source =
|
||||
ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
}
|
||||
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
|
||||
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
|
||||
}
|
||||
@@ -194,26 +237,27 @@ class PlaybackSession(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getExoMediaMetadata(ctx:Context): MediaMetadata {
|
||||
fun getExoMediaMetadata(ctx: Context): MediaMetadata {
|
||||
val coverUri = getCoverUri(ctx)
|
||||
|
||||
val metadataBuilder = MediaMetadata.Builder()
|
||||
.setTitle(displayTitle)
|
||||
.setDisplayTitle(displayTitle)
|
||||
.setArtist(displayAuthor)
|
||||
.setAlbumArtist(displayAuthor)
|
||||
.setSubtitle(displayAuthor)
|
||||
.setAlbumTitle(displayAuthor)
|
||||
.setDescription(displayAuthor)
|
||||
.setArtworkUri(coverUri)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_AUDIO_BOOK)
|
||||
val metadataBuilder =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(displayTitle)
|
||||
.setDisplayTitle(displayTitle)
|
||||
.setArtist(displayAuthor)
|
||||
.setAlbumArtist(displayAuthor)
|
||||
.setSubtitle(displayAuthor)
|
||||
.setAlbumTitle(displayAuthor)
|
||||
.setDescription(displayAuthor)
|
||||
.setArtworkUri(coverUri)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_AUDIO_BOOK)
|
||||
|
||||
return metadataBuilder.build()
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getMediaItems(ctx:Context):List<MediaItem> {
|
||||
val mediaItems:MutableList<MediaItem> = mutableListOf()
|
||||
fun getMediaItems(ctx: Context): List<MediaItem> {
|
||||
val mediaItems: MutableList<MediaItem> = mutableListOf()
|
||||
|
||||
for (audioTrack in audioTracks) {
|
||||
val mediaMetadata = this.getExoMediaMetadata(ctx)
|
||||
@@ -221,50 +265,107 @@ class PlaybackSession(
|
||||
val mimeType = audioTrack.mimeType
|
||||
|
||||
val queueItem = getQueueItem(audioTrack) // Queue item used in exo player CastManager
|
||||
val mediaItem = MediaItem.Builder().setUri(mediaUri).setTag(queueItem).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build()
|
||||
val mediaItem =
|
||||
MediaItem.Builder()
|
||||
.setUri(mediaUri)
|
||||
.setTag(queueItem)
|
||||
.setMediaMetadata(mediaMetadata)
|
||||
.setMimeType(mimeType)
|
||||
.build()
|
||||
mediaItems.add(mediaItem)
|
||||
}
|
||||
return mediaItems
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getCastMediaMetadata(audioTrack:AudioTrack):com.google.android.gms.cast.MediaMetadata {
|
||||
val castMetadata = com.google.android.gms.cast.MediaMetadata(com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER)
|
||||
fun getCastMediaMetadata(audioTrack: AudioTrack): com.google.android.gms.cast.MediaMetadata {
|
||||
val castMetadata =
|
||||
com.google.android.gms.cast.MediaMetadata(
|
||||
com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER
|
||||
)
|
||||
|
||||
// As of v2.17.0 token is not needed with cover image requests
|
||||
val coverUri = if (checkIsServerVersionGte("2.17.0")) {
|
||||
Uri.parse("$serverAddress/api/items/$libraryItemId/cover")
|
||||
} else {
|
||||
Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
|
||||
}
|
||||
|
||||
// Cast always uses server cover uri
|
||||
coverPath?.let {
|
||||
castMetadata.addImage(WebImage(Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")))
|
||||
castMetadata.addImage(WebImage(coverUri))
|
||||
}
|
||||
|
||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "")
|
||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ARTIST, displayAuthor ?: "")
|
||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ALBUM_TITLE, displayAuthor ?: "")
|
||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_CHAPTER_TITLE, audioTrack.title)
|
||||
castMetadata.putString(
|
||||
com.google.android.gms.cast.MediaMetadata.KEY_ARTIST,
|
||||
displayAuthor ?: ""
|
||||
)
|
||||
castMetadata.putString(
|
||||
com.google.android.gms.cast.MediaMetadata.KEY_ALBUM_TITLE,
|
||||
displayAuthor ?: ""
|
||||
)
|
||||
castMetadata.putString(
|
||||
com.google.android.gms.cast.MediaMetadata.KEY_CHAPTER_TITLE,
|
||||
audioTrack.title
|
||||
)
|
||||
|
||||
castMetadata.putInt(com.google.android.gms.cast.MediaMetadata.KEY_TRACK_NUMBER, audioTrack.index)
|
||||
castMetadata.putInt(
|
||||
com.google.android.gms.cast.MediaMetadata.KEY_TRACK_NUMBER,
|
||||
audioTrack.index
|
||||
)
|
||||
return castMetadata
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getQueueItem(audioTrack:AudioTrack):MediaQueueItem {
|
||||
fun getQueueItem(audioTrack: AudioTrack): MediaQueueItem {
|
||||
val castMetadata = getCastMediaMetadata(audioTrack)
|
||||
|
||||
val mediaUri = getContentUri(audioTrack)
|
||||
|
||||
val mediaInfo = MediaInfo.Builder(mediaUri.toString()).apply {
|
||||
setContentUrl(mediaUri.toString())
|
||||
setContentType(audioTrack.mimeType)
|
||||
setMetadata(castMetadata)
|
||||
setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
|
||||
}.build()
|
||||
val mediaInfo =
|
||||
MediaInfo.Builder(mediaUri.toString())
|
||||
.apply {
|
||||
setContentUrl(mediaUri.toString())
|
||||
setContentType(audioTrack.mimeType)
|
||||
setMetadata(castMetadata)
|
||||
setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
|
||||
}
|
||||
.build()
|
||||
|
||||
return MediaQueueItem.Builder(mediaInfo).apply {
|
||||
setPlaybackDuration(audioTrack.duration)
|
||||
}.build()
|
||||
return MediaQueueItem.Builder(mediaInfo)
|
||||
.apply { setPlaybackDuration(audioTrack.duration) }
|
||||
.build()
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun clone():PlaybackSession {
|
||||
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)
|
||||
fun clone(): PlaybackSession {
|
||||
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
|
||||
@@ -275,7 +376,25 @@ class PlaybackSession(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getNewLocalMediaProgress():LocalMediaProgress {
|
||||
return LocalMediaProgress(localMediaProgressId,localLibraryItemId,localEpisodeId,getTotalDuration(),progress,currentTime,false,null,null,updatedAt,startedAt,null,serverConnectionConfigId,serverAddress,userId,libraryItemId,episodeId)
|
||||
fun getNewLocalMediaProgress(): LocalMediaProgress {
|
||||
return LocalMediaProgress(
|
||||
localMediaProgressId,
|
||||
localLibraryItemId,
|
||||
localEpisodeId,
|
||||
getTotalDuration(),
|
||||
progress,
|
||||
currentTime,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
updatedAt,
|
||||
startedAt,
|
||||
null,
|
||||
serverConnectionConfigId,
|
||||
serverAddress,
|
||||
userId,
|
||||
libraryItemId,
|
||||
episodeId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,19 @@ import com.audiobookshelf.app.managers.DbManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.updateAppWidget
|
||||
|
||||
/** Interface for widget event handling. */
|
||||
interface WidgetEventEmitter {
|
||||
fun onPlayerChanged(pns:PlayerNotificationService)
|
||||
/**
|
||||
* Called when the player state changes.
|
||||
* @param pns The PlayerNotificationService instance.
|
||||
*/
|
||||
fun onPlayerChanged(pns: PlayerNotificationService)
|
||||
|
||||
/** Called when the player is closed. */
|
||||
fun onPlayerClosed()
|
||||
}
|
||||
|
||||
/** Singleton object for managing device-related operations. */
|
||||
object DeviceManager {
|
||||
const val tag = "DeviceManager"
|
||||
|
||||
@@ -25,20 +33,29 @@ object DeviceManager {
|
||||
var serverConnectionConfig: ServerConnectionConfig? = null
|
||||
|
||||
val serverConnectionConfigId get() = serverConnectionConfig?.id ?: ""
|
||||
val serverAddress get() = serverConnectionConfig?.address ?: ""
|
||||
val serverUserId get() = serverConnectionConfig?.userId ?: ""
|
||||
val token get() = serverConnectionConfig?.token ?: ""
|
||||
val isConnectedToServer get() = serverConnectionConfig != null
|
||||
val serverConnectionConfigName get() = serverConnectionConfig?.name ?: ""
|
||||
val serverConnectionConfigString get() = serverConnectionConfig?.name ?: "No server connection"
|
||||
val serverAddress
|
||||
get() = serverConnectionConfig?.address ?: ""
|
||||
val serverUserId
|
||||
get() = serverConnectionConfig?.userId ?: ""
|
||||
val token
|
||||
get() = serverConnectionConfig?.token ?: ""
|
||||
val serverVersion get() = serverConnectionConfig?.version ?: ""
|
||||
val isConnectedToServer
|
||||
get() = serverConnectionConfig != null
|
||||
|
||||
var widgetUpdater:WidgetEventEmitter? = null
|
||||
var widgetUpdater: WidgetEventEmitter? = null
|
||||
|
||||
init {
|
||||
Log.d(tag, "Device Manager Singleton invoked")
|
||||
|
||||
// Initialize new sleep timer settings and shake sensitivity added in v0.9.61
|
||||
if (deviceData.deviceSettings?.autoSleepTimerStartTime == null || deviceData.deviceSettings?.autoSleepTimerEndTime == null) {
|
||||
if (deviceData.deviceSettings?.autoSleepTimerStartTime == null ||
|
||||
deviceData.deviceSettings?.autoSleepTimerEndTime == null
|
||||
) {
|
||||
deviceData.deviceSettings?.autoSleepTimerStartTime = "22:00"
|
||||
deviceData.deviceSettings?.autoSleepTimerStartTime = "06:00"
|
||||
deviceData.deviceSettings?.autoSleepTimerEndTime = "06:00"
|
||||
deviceData.deviceSettings?.sleepTimerLength = 900000L
|
||||
}
|
||||
if (deviceData.deviceSettings?.shakeSensitivity == null) {
|
||||
@@ -48,6 +65,10 @@ object DeviceManager {
|
||||
if (deviceData.deviceSettings?.autoSleepTimerAutoRewindTime == null) {
|
||||
deviceData.deviceSettings?.autoSleepTimerAutoRewindTime = 300000L // 5 minutes
|
||||
}
|
||||
// Initialize sleep timer almost done chime added in v0.9.81
|
||||
if (deviceData.deviceSettings?.enableSleepTimerAlmostDoneChime == null) {
|
||||
deviceData.deviceSettings?.enableSleepTimerAlmostDoneChime = false
|
||||
}
|
||||
|
||||
// Language added in v0.9.69
|
||||
if (deviceData.deviceSettings?.languageCode == null) {
|
||||
@@ -65,21 +86,75 @@ object DeviceManager {
|
||||
deviceData.deviceSettings?.androidAutoBrowseLimitForGrouping = 100
|
||||
}
|
||||
if (deviceData.deviceSettings?.androidAutoBrowseSeriesSequenceOrder == null) {
|
||||
deviceData.deviceSettings?.androidAutoBrowseSeriesSequenceOrder = AndroidAutoBrowseSeriesSequenceOrderSetting.ASC
|
||||
deviceData.deviceSettings?.androidAutoBrowseSeriesSequenceOrder =
|
||||
AndroidAutoBrowseSeriesSequenceOrderSetting.ASC
|
||||
}
|
||||
}
|
||||
|
||||
fun getBase64Id(id:String):String {
|
||||
return android.util.Base64.encodeToString(id.toByteArray(), android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP)
|
||||
/**
|
||||
* Encodes the given ID to a Base64 string.
|
||||
* @param id The ID to encode.
|
||||
* @return The Base64 encoded string.
|
||||
*/
|
||||
fun getBase64Id(id: String): String {
|
||||
return android.util.Base64.encodeToString(
|
||||
id.toByteArray(),
|
||||
android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP
|
||||
)
|
||||
}
|
||||
|
||||
fun getServerConnectionConfig(id:String?):ServerConnectionConfig? {
|
||||
if (id == null) return null
|
||||
return deviceData.serverConnectionConfigs.find { it.id == id }
|
||||
/**
|
||||
* Retrieves the server connection configuration for the given ID.
|
||||
* @param id The ID of the server connection configuration.
|
||||
* @return The ServerConnectionConfig instance or null if not found.
|
||||
*/
|
||||
fun getServerConnectionConfig(id: String?): ServerConnectionConfig? {
|
||||
return id?.let { deviceData.serverConnectionConfigs.find { it.id == id } }
|
||||
}
|
||||
|
||||
fun checkConnectivity(ctx:Context): Boolean {
|
||||
val connectivityManager = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
/**
|
||||
* Check if the currently connected server version is >= compareVersion
|
||||
* Abs server only uses major.minor.patch
|
||||
* Note: Version is returned in Abs auth payloads starting v2.6.0
|
||||
* Note: Version is saved with the server connection config starting after v0.9.81
|
||||
*
|
||||
* @example
|
||||
* serverVersion=2.25.1
|
||||
* isServerVersionGreaterThanOrEqualTo("2.26.0") = false
|
||||
*
|
||||
* serverVersion=2.26.1
|
||||
* isServerVersionGreaterThanOrEqualTo("2.26.0") = true
|
||||
*/
|
||||
fun isServerVersionGreaterThanOrEqualTo(compareVersion:String):Boolean {
|
||||
if (serverVersion == "") return false
|
||||
if (compareVersion == "") return true
|
||||
|
||||
val serverVersionParts = serverVersion.split(".").map { it.toIntOrNull() ?: 0 }
|
||||
val compareVersionParts = compareVersion.split(".").map { it.toIntOrNull() ?: 0 }
|
||||
|
||||
// Compare major, minor, and patch components
|
||||
for (i in 0 until maxOf(serverVersionParts.size, compareVersionParts.size)) {
|
||||
val serverVersionComponent = serverVersionParts.getOrElse(i) { 0 }
|
||||
val compareVersionComponent = compareVersionParts.getOrElse(i) { 0 }
|
||||
|
||||
if (serverVersionComponent < compareVersionComponent) {
|
||||
return false // Server version is less than compareVersion
|
||||
} else if (serverVersionComponent > compareVersionComponent) {
|
||||
return true // Server version is greater than compareVersion
|
||||
}
|
||||
}
|
||||
|
||||
return true // versions are equal in major, minor, and patch
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the network connectivity status.
|
||||
* @param ctx The context to use for checking connectivity.
|
||||
* @return True if connected to the internet, false otherwise.
|
||||
*/
|
||||
fun checkConnectivity(ctx: Context): Boolean {
|
||||
val connectivityManager =
|
||||
ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
|
||||
if (capabilities != null) {
|
||||
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
|
||||
@@ -96,35 +171,58 @@ object DeviceManager {
|
||||
return false
|
||||
}
|
||||
|
||||
fun setLastPlaybackSession(playbackSession:PlaybackSession) {
|
||||
/**
|
||||
* Sets the last playback session.
|
||||
* @param playbackSession The playback session to set.
|
||||
*/
|
||||
fun setLastPlaybackSession(playbackSession: PlaybackSession) {
|
||||
deviceData.lastPlaybackSession = playbackSession
|
||||
dbManager.saveDeviceData(deviceData)
|
||||
}
|
||||
|
||||
fun initializeWidgetUpdater(context:Context) {
|
||||
/**
|
||||
* Initializes the widget updater.
|
||||
* @param context The context to use for initializing the widget updater.
|
||||
*/
|
||||
fun initializeWidgetUpdater(context: Context) {
|
||||
Log.d(tag, "Initializing widget updater")
|
||||
widgetUpdater = (object : WidgetEventEmitter {
|
||||
override fun onPlayerChanged(pns: PlayerNotificationService) {
|
||||
widgetUpdater =
|
||||
(object : WidgetEventEmitter {
|
||||
override fun onPlayerChanged(pns: PlayerNotificationService) {
|
||||
val isPlaying = pns.currentPlayer.isPlaying
|
||||
|
||||
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()
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,83 +13,144 @@ import java.io.File
|
||||
|
||||
class FolderScanner(var ctx: Context) {
|
||||
private val tag = "FolderScanner"
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
private var jacksonMapper =
|
||||
jacksonObjectMapper()
|
||||
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
data class DownloadItemScanResult(val localLibraryItem:LocalLibraryItem, var localMediaProgress:LocalMediaProgress?)
|
||||
data class DownloadItemScanResult(
|
||||
val localLibraryItem: LocalLibraryItem,
|
||||
var localMediaProgress: LocalMediaProgress?
|
||||
)
|
||||
|
||||
private fun getLocalLibraryItemId(mediaItemId:String):String {
|
||||
private fun getLocalLibraryItemId(mediaItemId: String): String {
|
||||
return "local_" + DeviceManager.getBase64Id(mediaItemId)
|
||||
}
|
||||
|
||||
private fun scanInternalDownloadItem(downloadItem:DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
|
||||
private fun scanInternalDownloadItem(
|
||||
downloadItem: DownloadItem,
|
||||
cb: (DownloadItemScanResult?) -> Unit
|
||||
) {
|
||||
val localLibraryItemId = "local_${downloadItem.libraryItemId}"
|
||||
|
||||
var localEpisodeId:String? = null
|
||||
var localLibraryItem:LocalLibraryItem?
|
||||
var localEpisodeId: String? = null
|
||||
var localLibraryItem: LocalLibraryItem?
|
||||
if (downloadItem.mediaType == "book") {
|
||||
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)
|
||||
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
|
||||
)
|
||||
} else {
|
||||
// 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)
|
||||
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()
|
||||
val audioTracks: MutableList<AudioTrack> = mutableListOf()
|
||||
var foundEBookFile = false
|
||||
|
||||
downloadItem.downloadItemParts.forEach { downloadItemPart ->
|
||||
Log.d(tag, "Scan internal storage item with finalDestinationUri=${downloadItemPart.finalDestinationUri}")
|
||||
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}")
|
||||
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}"
|
||||
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()
|
||||
)
|
||||
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())
|
||||
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
|
||||
)
|
||||
val track =
|
||||
AudioTrack(
|
||||
audioTrackFromServer.index,
|
||||
audioTrackFromServer.startOffset,
|
||||
audioTrackFromServer.duration,
|
||||
localFile.filename ?: "",
|
||||
localFile.contentUrl,
|
||||
localFile.mimeType ?: "",
|
||||
trackFileMetadata,
|
||||
true,
|
||||
localFileId,
|
||||
audioTrackFromServer.index
|
||||
)
|
||||
audioTracks.add(track)
|
||||
|
||||
Log.d(
|
||||
tag,
|
||||
"scanInternalDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}"
|
||||
tag,
|
||||
"scanInternalDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}"
|
||||
)
|
||||
|
||||
// Add podcast episodes to library
|
||||
@@ -98,40 +159,51 @@ class FolderScanner(var ctx: Context) {
|
||||
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}"
|
||||
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()
|
||||
)
|
||||
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
|
||||
)
|
||||
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())
|
||||
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
|
||||
@@ -141,7 +213,10 @@ class FolderScanner(var ctx: Context) {
|
||||
}
|
||||
|
||||
if (audioTracks.isEmpty() && !foundEBookFile) {
|
||||
Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}")
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}"
|
||||
)
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
@@ -163,30 +238,37 @@ class FolderScanner(var ctx: Context) {
|
||||
localLibraryItem.media.setAudioTracks(audioTracks)
|
||||
}
|
||||
|
||||
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem,null)
|
||||
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}")
|
||||
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
|
||||
@@ -206,7 +288,7 @@ class FolderScanner(var ctx: Context) {
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
var itemFolderId = ""
|
||||
var itemFolderUrl = ""
|
||||
@@ -235,72 +317,188 @@ class FolderScanner(var ctx: Context) {
|
||||
}
|
||||
|
||||
val localLibraryItemId = getLocalLibraryItemId(itemFolderId)
|
||||
Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId")
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId"
|
||||
)
|
||||
|
||||
// Search for files in media item folder
|
||||
// m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154
|
||||
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/*"))
|
||||
val filesFound =
|
||||
df.search(
|
||||
false,
|
||||
DocumentFileType.FILE,
|
||||
arrayOf("audio/*", "image/*", "video/mp4", "application/*")
|
||||
)
|
||||
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
|
||||
|
||||
var localEpisodeId:String? = null
|
||||
var localLibraryItem:LocalLibraryItem?
|
||||
var localEpisodeId: String? = null
|
||||
var localLibraryItem: LocalLibraryItem?
|
||||
if (downloadItem.mediaType == "book") {
|
||||
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true, downloadItem.serverConnectionConfigId, downloadItem.serverAddress, downloadItem.serverUserId, downloadItem.libraryItemId)
|
||||
localLibraryItem =
|
||||
LocalLibraryItem(
|
||||
localLibraryItemId,
|
||||
downloadItem.localFolder.id,
|
||||
itemFolderBasePath,
|
||||
itemFolderAbsolutePath,
|
||||
itemFolderUrl,
|
||||
false,
|
||||
downloadItem.mediaType,
|
||||
downloadItem.media.getLocalCopy(),
|
||||
mutableListOf(),
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
downloadItem.serverConnectionConfigId,
|
||||
downloadItem.serverAddress,
|
||||
downloadItem.serverUserId,
|
||||
downloadItem.libraryItemId
|
||||
)
|
||||
} else {
|
||||
// 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, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.libraryItemId)
|
||||
Log.d(
|
||||
tag,
|
||||
"[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}"
|
||||
)
|
||||
localLibraryItem =
|
||||
LocalLibraryItem(
|
||||
localLibraryItemId,
|
||||
downloadItem.localFolder.id,
|
||||
itemFolderBasePath,
|
||||
itemFolderAbsolutePath,
|
||||
itemFolderUrl,
|
||||
false,
|
||||
downloadItem.mediaType,
|
||||
downloadItem.media.getLocalCopy(),
|
||||
mutableListOf(),
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
downloadItem.serverConnectionConfigId,
|
||||
downloadItem.serverAddress,
|
||||
downloadItem.serverUserId,
|
||||
downloadItem.libraryItemId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val audioTracks:MutableList<AudioTrack> = mutableListOf()
|
||||
val audioTracks: MutableList<AudioTrack> = mutableListOf()
|
||||
var foundEBookFile = false
|
||||
|
||||
filesFound.forEach { docFile ->
|
||||
val itemPart = downloadItem.downloadItemParts.find { itemPart ->
|
||||
itemPart.filename == docFile.name
|
||||
}
|
||||
val itemPart =
|
||||
downloadItem.downloadItemParts.find { itemPart -> itemPart.filename == docFile.name }
|
||||
if (itemPart == null) {
|
||||
if (downloadItem.mediaType == "book") { // for books every download item should be a file found
|
||||
Log.e(tag, "scanDownloadItem: Item part not found for doc file ${docFile.name} | ${docFile.getAbsolutePath(ctx)} | ${docFile.uri}")
|
||||
if (downloadItem.mediaType == "book"
|
||||
) { // for books every download item should be a file found
|
||||
Log.e(
|
||||
tag,
|
||||
"scanDownloadItem: Item part not found for doc file ${docFile.name} | ${docFile.getAbsolutePath(ctx)} | ${docFile.uri}"
|
||||
)
|
||||
}
|
||||
} else if (itemPart.audioTrack != null) { // Is audio track
|
||||
val audioTrackFromServer = itemPart.audioTrack
|
||||
Log.d(tag, "scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}")
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}"
|
||||
)
|
||||
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
docFile.name,
|
||||
docFile.uri.toString(),
|
||||
docFile.getBasePath(ctx),
|
||||
docFile.getAbsolutePath(ctx),
|
||||
docFile.getSimplePath(ctx),
|
||||
docFile.mimeType,
|
||||
docFile.length()
|
||||
)
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
// Create new audio track
|
||||
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)
|
||||
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,
|
||||
audioTrackFromServer.index
|
||||
)
|
||||
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}"
|
||||
)
|
||||
|
||||
// Add podcast episodes to library
|
||||
itemPart.episode?.let { podcastEpisode ->
|
||||
val podcast = localLibraryItem.media as Podcast
|
||||
val newEpisode = podcast.addEpisode(track, podcastEpisode)
|
||||
localEpisodeId = newEpisode.id
|
||||
Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}")
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}"
|
||||
)
|
||||
}
|
||||
} else if (itemPart.ebookFile != null) { // Ebook
|
||||
foundEBookFile = true
|
||||
Log.d(tag, "scanDownloadItem: Ebook file found with mimetype=${docFile.mimeType}")
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
docFile.name,
|
||||
docFile.uri.toString(),
|
||||
docFile.getBasePath(ctx),
|
||||
docFile.getAbsolutePath(ctx),
|
||||
docFile.getSimplePath(ctx),
|
||||
docFile.mimeType,
|
||||
docFile.length()
|
||||
)
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
val ebookFile = EBookFile(itemPart.ebookFile.ino, itemPart.ebookFile.metadata, itemPart.ebookFile.ebookFormat, true, localFileId, localFile.contentUrl)
|
||||
val ebookFile =
|
||||
EBookFile(
|
||||
itemPart.ebookFile.ino,
|
||||
itemPart.ebookFile.metadata,
|
||||
itemPart.ebookFile.ebookFormat,
|
||||
true,
|
||||
localFileId,
|
||||
localFile.contentUrl
|
||||
)
|
||||
(localLibraryItem.media as Book).ebookFile = ebookFile
|
||||
Log.d(tag, "scanDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
|
||||
} else { // Cover image
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
docFile.name,
|
||||
docFile.uri.toString(),
|
||||
docFile.getBasePath(ctx),
|
||||
docFile.getAbsolutePath(ctx),
|
||||
docFile.getSimplePath(ctx),
|
||||
docFile.mimeType,
|
||||
docFile.length()
|
||||
)
|
||||
|
||||
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
||||
localLibraryItem.coverContentUrl = localFile.contentUrl
|
||||
@@ -309,7 +507,10 @@ class FolderScanner(var ctx: Context) {
|
||||
}
|
||||
|
||||
if (audioTracks.isEmpty() && !foundEBookFile) {
|
||||
Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}")
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}"
|
||||
)
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
@@ -331,30 +532,37 @@ class FolderScanner(var ctx: Context) {
|
||||
localLibraryItem.media.setAudioTracks(audioTracks)
|
||||
}
|
||||
|
||||
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem,null)
|
||||
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}")
|
||||
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)
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.audiobookshelf.app.plugins.AbsLog
|
||||
import com.audiobookshelf.app.plugins.AbsLogger
|
||||
import io.paperdb.Paper
|
||||
import java.io.File
|
||||
|
||||
@@ -22,45 +24,47 @@ class DbManager {
|
||||
}
|
||||
|
||||
fun getDeviceData(): DeviceData {
|
||||
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, DeviceSettings.default(), null)
|
||||
return Paper.book("device").read("data")
|
||||
?: DeviceData(mutableListOf(), null, DeviceSettings.default(), null)
|
||||
}
|
||||
fun saveDeviceData(deviceData: DeviceData) {
|
||||
Paper.book("device").write("data", deviceData)
|
||||
}
|
||||
|
||||
fun getLocalLibraryItems(mediaType:String? = null):MutableList<LocalLibraryItem> {
|
||||
val localLibraryItems:MutableList<LocalLibraryItem> = mutableListOf()
|
||||
fun getLocalLibraryItems(mediaType: String? = null): MutableList<LocalLibraryItem> {
|
||||
val localLibraryItems: MutableList<LocalLibraryItem> = mutableListOf()
|
||||
Paper.book("localLibraryItems").allKeys.forEach {
|
||||
val localLibraryItem: LocalLibraryItem? = Paper.book("localLibraryItems").read(it)
|
||||
if (localLibraryItem != null && (mediaType.isNullOrEmpty() || mediaType == localLibraryItem.mediaType)) {
|
||||
if (localLibraryItem != null &&
|
||||
(mediaType.isNullOrEmpty() || mediaType == localLibraryItem.mediaType)
|
||||
) {
|
||||
localLibraryItems.add(localLibraryItem)
|
||||
}
|
||||
}
|
||||
return localLibraryItems
|
||||
}
|
||||
|
||||
fun getLocalLibraryItemsInFolder(folderId:String):List<LocalLibraryItem> {
|
||||
fun getLocalLibraryItemsInFolder(folderId: String): List<LocalLibraryItem> {
|
||||
val localLibraryItems = getLocalLibraryItems()
|
||||
return localLibraryItems.filter {
|
||||
it.folderId == folderId
|
||||
}
|
||||
return localLibraryItems.filter { it.folderId == folderId }
|
||||
}
|
||||
|
||||
fun getLocalLibraryItemByLId(libraryItemId:String): LocalLibraryItem? {
|
||||
fun getLocalLibraryItemByLId(libraryItemId: String): LocalLibraryItem? {
|
||||
return getLocalLibraryItems().find { it.libraryItemId == libraryItemId }
|
||||
}
|
||||
|
||||
fun getLocalLibraryItem(localLibraryItemId:String): LocalLibraryItem? {
|
||||
fun getLocalLibraryItem(localLibraryItemId: String): LocalLibraryItem? {
|
||||
return Paper.book("localLibraryItems").read(localLibraryItemId)
|
||||
}
|
||||
|
||||
fun getLocalLibraryItemWithEpisode(podcastEpisodeId:String): LibraryItemWithEpisode? {
|
||||
fun getLocalLibraryItemWithEpisode(podcastEpisodeId: String): LibraryItemWithEpisode? {
|
||||
var podcastEpisode: PodcastEpisode? = null
|
||||
val localLibraryItem = getLocalLibraryItems("podcast").find { localLibraryItem ->
|
||||
val podcast = localLibraryItem.media as Podcast
|
||||
podcastEpisode = podcast.episodes?.find { it.id == podcastEpisodeId }
|
||||
podcastEpisode != null
|
||||
}
|
||||
val localLibraryItem =
|
||||
getLocalLibraryItems("podcast").find { localLibraryItem ->
|
||||
val podcast = localLibraryItem.media as Podcast
|
||||
podcastEpisode = podcast.episodes?.find { it.id == podcastEpisodeId }
|
||||
podcastEpisode != null
|
||||
}
|
||||
return if (localLibraryItem != null) {
|
||||
LibraryItemWithEpisode(localLibraryItem, podcastEpisode!!)
|
||||
} else {
|
||||
@@ -68,14 +72,12 @@ class DbManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun removeLocalLibraryItem(localLibraryItemId:String) {
|
||||
fun removeLocalLibraryItem(localLibraryItemId: String) {
|
||||
Paper.book("localLibraryItems").delete(localLibraryItemId)
|
||||
}
|
||||
|
||||
fun saveLocalLibraryItems(localLibraryItems:List<LocalLibraryItem>) {
|
||||
localLibraryItems.map {
|
||||
Paper.book("localLibraryItems").write(it.id, it)
|
||||
}
|
||||
fun saveLocalLibraryItems(localLibraryItems: List<LocalLibraryItem>) {
|
||||
localLibraryItems.map { Paper.book("localLibraryItems").write(it.id, it) }
|
||||
}
|
||||
|
||||
fun saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) {
|
||||
@@ -83,28 +85,24 @@ class DbManager {
|
||||
}
|
||||
|
||||
fun saveLocalFolder(localFolder: LocalFolder) {
|
||||
Paper.book("localFolders").write(localFolder.id,localFolder)
|
||||
Paper.book("localFolders").write(localFolder.id, localFolder)
|
||||
}
|
||||
|
||||
fun getLocalFolder(folderId:String): LocalFolder? {
|
||||
fun getLocalFolder(folderId: String): LocalFolder? {
|
||||
return Paper.book("localFolders").read(folderId)
|
||||
}
|
||||
|
||||
fun getAllLocalFolders():List<LocalFolder> {
|
||||
val localFolders:MutableList<LocalFolder> = mutableListOf()
|
||||
fun getAllLocalFolders(): List<LocalFolder> {
|
||||
val localFolders: MutableList<LocalFolder> = mutableListOf()
|
||||
Paper.book("localFolders").allKeys.forEach { localFolderId ->
|
||||
Paper.book("localFolders").read<LocalFolder>(localFolderId)?.let {
|
||||
localFolders.add(it)
|
||||
}
|
||||
Paper.book("localFolders").read<LocalFolder>(localFolderId)?.let { localFolders.add(it) }
|
||||
}
|
||||
return localFolders
|
||||
}
|
||||
|
||||
fun removeLocalFolder(folderId:String) {
|
||||
fun removeLocalFolder(folderId: String) {
|
||||
val localLibraryItems = getLocalLibraryItemsInFolder(folderId)
|
||||
localLibraryItems.forEach {
|
||||
Paper.book("localLibraryItems").delete(it.id)
|
||||
}
|
||||
localLibraryItems.forEach { Paper.book("localLibraryItems").delete(it.id) }
|
||||
Paper.book("localFolders").delete(folderId)
|
||||
}
|
||||
|
||||
@@ -112,29 +110,28 @@ class DbManager {
|
||||
Paper.book("downloadItems").write(downloadItem.id, downloadItem)
|
||||
}
|
||||
|
||||
fun removeDownloadItem(downloadItemId:String) {
|
||||
fun removeDownloadItem(downloadItemId: String) {
|
||||
Paper.book("downloadItems").delete(downloadItemId)
|
||||
}
|
||||
|
||||
fun getDownloadItems():List<DownloadItem> {
|
||||
val downloadItems:MutableList<DownloadItem> = mutableListOf()
|
||||
fun getDownloadItems(): List<DownloadItem> {
|
||||
val downloadItems: MutableList<DownloadItem> = mutableListOf()
|
||||
Paper.book("downloadItems").allKeys.forEach { downloadItemId ->
|
||||
Paper.book("downloadItems").read<DownloadItem>(downloadItemId)?.let {
|
||||
downloadItems.add(it)
|
||||
}
|
||||
Paper.book("downloadItems").read<DownloadItem>(downloadItemId)?.let { downloadItems.add(it) }
|
||||
}
|
||||
return downloadItems
|
||||
}
|
||||
|
||||
fun saveLocalMediaProgress(mediaProgress: LocalMediaProgress) {
|
||||
Paper.book("localMediaProgress").write(mediaProgress.id,mediaProgress)
|
||||
Paper.book("localMediaProgress").write(mediaProgress.id, mediaProgress)
|
||||
}
|
||||
// For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}"
|
||||
fun getLocalMediaProgress(localMediaProgressId:String): LocalMediaProgress? {
|
||||
// For books this will just be the localLibraryItemId for podcast episodes this will be
|
||||
// "{localLibraryItemId}-{episodeId}"
|
||||
fun getLocalMediaProgress(localMediaProgressId: String): LocalMediaProgress? {
|
||||
return Paper.book("localMediaProgress").read(localMediaProgressId)
|
||||
}
|
||||
fun getAllLocalMediaProgress():List<LocalMediaProgress> {
|
||||
val mediaProgress:MutableList<LocalMediaProgress> = mutableListOf()
|
||||
fun getAllLocalMediaProgress(): List<LocalMediaProgress> {
|
||||
val mediaProgress: MutableList<LocalMediaProgress> = mutableListOf()
|
||||
Paper.book("localMediaProgress").allKeys.forEach { localMediaProgressId ->
|
||||
Paper.book("localMediaProgress").read<LocalMediaProgress>(localMediaProgressId)?.let {
|
||||
mediaProgress.add(it)
|
||||
@@ -142,7 +139,7 @@ class DbManager {
|
||||
}
|
||||
return mediaProgress
|
||||
}
|
||||
fun removeLocalMediaProgress(localMediaProgressId:String) {
|
||||
fun removeLocalMediaProgress(localMediaProgressId: String) {
|
||||
Paper.book("localMediaProgress").delete(localMediaProgressId)
|
||||
}
|
||||
|
||||
@@ -158,35 +155,50 @@ class DbManager {
|
||||
var hasUpdates = false
|
||||
|
||||
// Check local files
|
||||
lli.localFiles = lli.localFiles.filter { localFile ->
|
||||
|
||||
val file = File(localFile.absolutePath)
|
||||
if (!file.exists()) {
|
||||
Log.d(tag, "cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}")
|
||||
hasUpdates = true
|
||||
}
|
||||
file.exists()
|
||||
} as MutableList<LocalFile>
|
||||
lli.localFiles =
|
||||
lli.localFiles.filter { localFile ->
|
||||
val file = File(localFile.absolutePath)
|
||||
if (!file.exists()) {
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}"
|
||||
)
|
||||
hasUpdates = true
|
||||
}
|
||||
file.exists()
|
||||
} as
|
||||
MutableList<LocalFile>
|
||||
|
||||
// Check audio tracks and episodes
|
||||
if (lli.isPodcast) {
|
||||
val podcast = lli.media as Podcast
|
||||
podcast.episodes = podcast.episodes?.filter { ep ->
|
||||
if (lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } == null) {
|
||||
Log.d(tag, "cleanLocalLibraryItems: Podcast episode ${ep.title} was removed from library item ${lli.media.metadata.title}")
|
||||
hasUpdates = true
|
||||
}
|
||||
ep.audioTrack != null && lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } != null
|
||||
} as MutableList<PodcastEpisode>
|
||||
podcast.episodes =
|
||||
podcast.episodes?.filter { ep ->
|
||||
if (lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } == null) {
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalLibraryItems: Podcast episode ${ep.title} was removed from library item ${lli.media.metadata.title}"
|
||||
)
|
||||
hasUpdates = true
|
||||
}
|
||||
ep.audioTrack != null &&
|
||||
lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } != null
|
||||
} as
|
||||
MutableList<PodcastEpisode>
|
||||
} else {
|
||||
val book = lli.media as Book
|
||||
book.tracks = book.tracks?.filter { track ->
|
||||
if (lli.localFiles.find { lf -> lf.id == track.localFileId } == null) {
|
||||
Log.d(tag, "cleanLocalLibraryItems: Audio track ${track.title} was removed from library item ${lli.media.metadata.title}")
|
||||
hasUpdates = true
|
||||
}
|
||||
lli.localFiles.find { lf -> lf.id == track.localFileId } != null
|
||||
} as MutableList<AudioTrack>
|
||||
book.tracks =
|
||||
book.tracks?.filter { track ->
|
||||
if (lli.localFiles.find { lf -> lf.id == track.localFileId } == null) {
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalLibraryItems: Audio track ${track.title} was removed from library item ${lli.media.metadata.title}"
|
||||
)
|
||||
hasUpdates = true
|
||||
}
|
||||
lli.localFiles.find { lf -> lf.id == track.localFileId } != null
|
||||
} as
|
||||
MutableList<AudioTrack>
|
||||
}
|
||||
|
||||
// Check cover still there
|
||||
@@ -194,14 +206,22 @@ class DbManager {
|
||||
val coverFile = File(it)
|
||||
|
||||
if (!coverFile.exists()) {
|
||||
Log.d(tag, "cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}")
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}"
|
||||
)
|
||||
lli.coverAbsolutePath = null
|
||||
lli.coverContentUrl = null
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
if (lli.serverConnectionConfigId == null) {
|
||||
// Local-only item support was removed in app version 0.9.67, remove any remaining local
|
||||
// only items beginning in 0.9.80
|
||||
Log.d(tag, "cleanLocalLibraryItems: Local only item ${lli.id} - removing from ABS")
|
||||
Paper.book("localLibraryItems").delete(lli.id)
|
||||
} else if (hasUpdates) {
|
||||
Log.d(tag, "cleanLocalLibraryItems: Saving local library item ${lli.id}")
|
||||
Paper.book("localLibraryItems").write(lli.id, lli)
|
||||
}
|
||||
@@ -215,11 +235,18 @@ class DbManager {
|
||||
localMediaProgress.forEach {
|
||||
val matchingLLI = localLibraryItems.find { lli -> lli.id == it.localLibraryItemId }
|
||||
if (!it.id.startsWith("local")) {
|
||||
// A bug on the server when syncing local media progress was replacing the media progress id causing duplicate progress. Remove them.
|
||||
Log.d(tag, "cleanLocalMediaProgress: Invalid local media progress does not start with 'local' (fixed on server 2.0.24)")
|
||||
// A bug on the server when syncing local media progress was replacing the media progress id
|
||||
// causing duplicate progress. Remove them.
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalMediaProgress: Invalid local media progress does not start with 'local' (fixed on server 2.0.24)"
|
||||
)
|
||||
Paper.book("localMediaProgress").delete(it.id)
|
||||
} else if (matchingLLI == null) {
|
||||
Log.d(tag, "cleanLocalMediaProgress: No matching local library item for local media progress ${it.id} - removing")
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalMediaProgress: No matching local library item for local media progress ${it.id} - removing"
|
||||
)
|
||||
Paper.book("localMediaProgress").delete(it.id)
|
||||
} else if (matchingLLI.isPodcast) {
|
||||
if (it.localEpisodeId.isNullOrEmpty()) {
|
||||
@@ -229,7 +256,10 @@ class DbManager {
|
||||
val podcast = matchingLLI.media as Podcast
|
||||
val matchingLEp = podcast.episodes?.find { ep -> ep.id == it.localEpisodeId }
|
||||
if (matchingLEp == null) {
|
||||
Log.d(tag, "cleanLocalMediaProgress: Podcast media progress for episode ${it.localEpisodeId} not found - removing")
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalMediaProgress: Podcast media progress for episode ${it.localEpisodeId} not found - removing"
|
||||
)
|
||||
Paper.book("localMediaProgress").delete(it.id)
|
||||
}
|
||||
}
|
||||
@@ -238,20 +268,20 @@ class DbManager {
|
||||
}
|
||||
|
||||
fun saveMediaItemHistory(mediaItemHistory: MediaItemHistory) {
|
||||
Paper.book("mediaItemHistory").write(mediaItemHistory.id,mediaItemHistory)
|
||||
Paper.book("mediaItemHistory").write(mediaItemHistory.id, mediaItemHistory)
|
||||
}
|
||||
fun getMediaItemHistory(id:String): MediaItemHistory? {
|
||||
fun getMediaItemHistory(id: String): MediaItemHistory? {
|
||||
return Paper.book("mediaItemHistory").read(id)
|
||||
}
|
||||
|
||||
fun savePlaybackSession(playbackSession: PlaybackSession) {
|
||||
Paper.book("playbackSession").write(playbackSession.id,playbackSession)
|
||||
Paper.book("playbackSession").write(playbackSession.id, playbackSession)
|
||||
}
|
||||
fun removePlaybackSession(playbackSessionId:String) {
|
||||
fun removePlaybackSession(playbackSessionId: String) {
|
||||
Paper.book("playbackSession").delete(playbackSessionId)
|
||||
}
|
||||
fun getPlaybackSessions():List<PlaybackSession> {
|
||||
val sessions:MutableList<PlaybackSession> = mutableListOf()
|
||||
fun getPlaybackSessions(): List<PlaybackSession> {
|
||||
val sessions: MutableList<PlaybackSession> = mutableListOf()
|
||||
Paper.book("playbackSession").allKeys.forEach { playbackSessionId ->
|
||||
Paper.book("playbackSession").read<PlaybackSession>(playbackSessionId)?.let {
|
||||
sessions.add(it)
|
||||
@@ -259,4 +289,35 @@ class DbManager {
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
fun saveLog(log:AbsLog) {
|
||||
Paper.book("log").write(log.id, log)
|
||||
}
|
||||
fun getAllLogs() : List<AbsLog> {
|
||||
val logs:MutableList<AbsLog> = mutableListOf()
|
||||
Paper.book("log").allKeys.forEach { logId ->
|
||||
Paper.book("log").read<AbsLog>(logId)?.let {
|
||||
logs.add(it)
|
||||
}
|
||||
}
|
||||
return logs.sortedBy { it.timestamp }
|
||||
}
|
||||
fun removeAllLogs() {
|
||||
Paper.book("log").destroy()
|
||||
}
|
||||
fun cleanLogs() {
|
||||
val numberOfHoursToKeep = 48
|
||||
val keepLogCutoff = System.currentTimeMillis() - (3600000 * numberOfHoursToKeep)
|
||||
val allLogs = getAllLogs()
|
||||
var logsRemoved = 0
|
||||
allLogs.forEach {
|
||||
if (it.timestamp < keepLogCutoff) {
|
||||
Paper.book("log").delete(it.id)
|
||||
logsRemoved++
|
||||
}
|
||||
}
|
||||
if (logsRemoved > 0) {
|
||||
AbsLogger.info("DbManager", "cleanLogs: Removed $logsRemoved logs older than $numberOfHoursToKeep hours")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+220
-128
@@ -18,18 +18,26 @@ import com.audiobookshelf.app.models.DownloadItemPart
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.getcapacitor.JSObject
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.*
|
||||
|
||||
class DownloadItemManager(var downloadManager:DownloadManager, private var folderScanner: FolderScanner, var mainActivity: MainActivity, private var clientEventEmitter:DownloadEventEmitter) {
|
||||
/** Manages download items and their parts. */
|
||||
class DownloadItemManager(
|
||||
var downloadManager: DownloadManager,
|
||||
private var folderScanner: FolderScanner,
|
||||
var mainActivity: MainActivity,
|
||||
private var clientEventEmitter: DownloadEventEmitter
|
||||
) {
|
||||
val tag = "DownloadItemManager"
|
||||
private val maxSimultaneousDownloads = 3
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
private var jacksonMapper =
|
||||
jacksonObjectMapper()
|
||||
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
enum class DownloadCheckStatus {
|
||||
InProgress,
|
||||
@@ -37,25 +45,28 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
Failed
|
||||
}
|
||||
|
||||
var downloadItemQueue: MutableList<DownloadItem> = mutableListOf() // All pending and downloading items
|
||||
var currentDownloadItemParts: MutableList<DownloadItemPart> = mutableListOf() // Item parts currently being downloaded
|
||||
var downloadItemQueue: MutableList<DownloadItem> =
|
||||
mutableListOf() // All pending and downloading items
|
||||
var currentDownloadItemParts: MutableList<DownloadItemPart> =
|
||||
mutableListOf() // Item parts currently being downloaded
|
||||
|
||||
interface DownloadEventEmitter {
|
||||
fun onDownloadItem(downloadItem:DownloadItem)
|
||||
fun onDownloadItemPartUpdate(downloadItemPart:DownloadItemPart)
|
||||
fun onDownloadItemComplete(jsobj:JSObject)
|
||||
fun onDownloadItem(downloadItem: DownloadItem)
|
||||
fun onDownloadItemPartUpdate(downloadItemPart: DownloadItemPart)
|
||||
fun onDownloadItemComplete(jsobj: JSObject)
|
||||
}
|
||||
|
||||
interface InternalProgressCallback {
|
||||
fun onProgress(totalBytesWritten:Long, progress: Long)
|
||||
fun onProgress(totalBytesWritten: Long, progress: Long)
|
||||
fun onComplete(failed: Boolean)
|
||||
}
|
||||
|
||||
companion object {
|
||||
var isDownloading:Boolean = false
|
||||
var isDownloading: Boolean = false
|
||||
}
|
||||
|
||||
fun addDownloadItem(downloadItem:DownloadItem) {
|
||||
/** Adds a download item to the queue and starts processing the queue. */
|
||||
fun addDownloadItem(downloadItem: DownloadItem) {
|
||||
DeviceManager.dbManager.saveDownloadItem(downloadItem)
|
||||
Log.i(tag, "Add download item ${downloadItem.media.metadata.title}")
|
||||
|
||||
@@ -64,42 +75,18 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
checkUpdateDownloadQueue()
|
||||
}
|
||||
|
||||
/** Checks and updates the download queue. */
|
||||
private fun checkUpdateDownloadQueue() {
|
||||
for (downloadItem in downloadItemQueue) {
|
||||
val numPartsToGet = maxSimultaneousDownloads - currentDownloadItemParts.size
|
||||
val nextDownloadItemParts = downloadItem.getNextDownloadItemParts(numPartsToGet)
|
||||
Log.d(tag, "checkUpdateDownloadQueue: numPartsToGet=$numPartsToGet, nextDownloadItemParts=${nextDownloadItemParts.size}")
|
||||
Log.d(
|
||||
tag,
|
||||
"checkUpdateDownloadQueue: numPartsToGet=$numPartsToGet, nextDownloadItemParts=${nextDownloadItemParts.size}"
|
||||
)
|
||||
|
||||
if (nextDownloadItemParts.size > 0) {
|
||||
nextDownloadItemParts.forEach {
|
||||
if (it.isInternalStorage) {
|
||||
val file = File(it.finalDestinationPath)
|
||||
file.parentFile?.mkdirs()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
if (nextDownloadItemParts.isNotEmpty()) {
|
||||
processDownloadItemParts(nextDownloadItemParts)
|
||||
}
|
||||
|
||||
if (currentDownloadItemParts.size >= maxSimultaneousDownloads) {
|
||||
@@ -107,9 +94,59 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
}
|
||||
}
|
||||
|
||||
if (currentDownloadItemParts.size > 0) startWatchingDownloads()
|
||||
if (currentDownloadItemParts.isNotEmpty()) startWatchingDownloads()
|
||||
}
|
||||
|
||||
/** Processes the download item parts. */
|
||||
private fun processDownloadItemParts(nextDownloadItemParts: List<DownloadItemPart>) {
|
||||
nextDownloadItemParts.forEach {
|
||||
if (it.isInternalStorage) {
|
||||
startInternalDownload(it)
|
||||
} else {
|
||||
startExternalDownload(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts an internal download. */
|
||||
private fun startInternalDownload(downloadItemPart: DownloadItemPart) {
|
||||
val file = File(downloadItemPart.finalDestinationPath)
|
||||
file.parentFile?.mkdirs()
|
||||
|
||||
val fileOutputStream = FileOutputStream(downloadItemPart.finalDestinationPath)
|
||||
val internalProgressCallback =
|
||||
object : InternalProgressCallback {
|
||||
override fun onProgress(totalBytesWritten: Long, progress: Long) {
|
||||
downloadItemPart.bytesDownloaded = totalBytesWritten
|
||||
downloadItemPart.progress = progress
|
||||
}
|
||||
|
||||
override fun onComplete(failed: Boolean) {
|
||||
downloadItemPart.failed = failed
|
||||
downloadItemPart.completed = true
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(
|
||||
tag,
|
||||
"Start internal download to destination path ${downloadItemPart.finalDestinationPath} from ${downloadItemPart.serverUrl}"
|
||||
)
|
||||
InternalDownloadManager(fileOutputStream, internalProgressCallback)
|
||||
.download(downloadItemPart.serverUrl)
|
||||
downloadItemPart.downloadId = 1
|
||||
currentDownloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
/** Starts an external download. */
|
||||
private fun startExternalDownload(downloadItemPart: DownloadItemPart) {
|
||||
val dlRequest = downloadItemPart.getDownloadRequest()
|
||||
val downloadId = downloadManager.enqueue(dlRequest)
|
||||
downloadItemPart.downloadId = downloadId
|
||||
Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId")
|
||||
currentDownloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
/** Starts watching the downloads. */
|
||||
private fun startWatchingDownloads() {
|
||||
if (isDownloading) return // Already watching
|
||||
|
||||
@@ -117,25 +154,13 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
Log.d(tag, "Starting watching downloads")
|
||||
isDownloading = true
|
||||
|
||||
while (currentDownloadItemParts.size > 0) {
|
||||
val itemParts = currentDownloadItemParts.filter { !it.isMoving }.map { it }
|
||||
while (currentDownloadItemParts.isNotEmpty()) {
|
||||
val itemParts = currentDownloadItemParts.filter { !it.isMoving }
|
||||
for (downloadItemPart in itemParts) {
|
||||
if (downloadItemPart.isInternalStorage) {
|
||||
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||
|
||||
if (downloadItemPart.completed) {
|
||||
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
|
||||
downloadItem?.let {
|
||||
checkDownloadItemFinished(it)
|
||||
}
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
handleInternalDownloadPart(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)
|
||||
handleExternalDownloadPart(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +176,29 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkDownloadItemPart(downloadItemPart:DownloadItemPart):DownloadCheckStatus {
|
||||
/** Handles an internal download part. */
|
||||
private fun handleInternalDownloadPart(downloadItemPart: DownloadItemPart) {
|
||||
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||
|
||||
if (downloadItemPart.completed) {
|
||||
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
|
||||
downloadItem?.let { checkDownloadItemFinished(it) }
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles an external download part. */
|
||||
private fun handleExternalDownloadPart(downloadItemPart: DownloadItemPart) {
|
||||
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)
|
||||
}
|
||||
|
||||
/** Checks the status of a download item part. */
|
||||
private fun checkDownloadItemPart(downloadItemPart: DownloadItemPart): DownloadCheckStatus {
|
||||
val downloadId = downloadItemPart.downloadId ?: return DownloadCheckStatus.Failed
|
||||
|
||||
val query = DownloadManager.Query().setFilterById(downloadId)
|
||||
@@ -159,12 +206,17 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
if (it.moveToFirst()) {
|
||||
val bytesColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
|
||||
val statusColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
||||
val bytesDownloadedColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
|
||||
val bytesDownloadedColumnIndex =
|
||||
it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
|
||||
|
||||
val totalBytes = if (bytesColumnIndex >= 0) it.getInt(bytesColumnIndex) else 0
|
||||
val downloadStatus = if (statusColumnIndex >= 0) it.getInt(statusColumnIndex) else 0
|
||||
val bytesDownloadedSoFar = if (bytesDownloadedColumnIndex >= 0) it.getLong(bytesDownloadedColumnIndex) else 0
|
||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} bytes $totalBytes | bytes dled $bytesDownloadedSoFar | downloadStatus $downloadStatus")
|
||||
val bytesDownloadedSoFar =
|
||||
if (bytesDownloadedColumnIndex >= 0) it.getLong(bytesDownloadedColumnIndex) else 0
|
||||
Log.d(
|
||||
tag,
|
||||
"checkDownloads Download ${downloadItemPart.filename} bytes $totalBytes | bytes dled $bytesDownloadedSoFar | downloadStatus $downloadStatus"
|
||||
)
|
||||
|
||||
return when (downloadStatus) {
|
||||
DownloadManager.STATUS_SUCCESSFUL -> {
|
||||
@@ -183,8 +235,12 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
DownloadCheckStatus.Failed
|
||||
}
|
||||
else -> {
|
||||
val percentProgress = if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0
|
||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Progress = $percentProgress%")
|
||||
val percentProgress =
|
||||
if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0
|
||||
Log.d(
|
||||
tag,
|
||||
"checkDownloads Download ${downloadItemPart.filename} Progress = $percentProgress%"
|
||||
)
|
||||
downloadItemPart.progress = percentProgress
|
||||
downloadItemPart.bytesDownloaded = bytesDownloadedSoFar
|
||||
|
||||
@@ -200,84 +256,120 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDownloadItemPartCheck(downloadCheckStatus:DownloadCheckStatus, downloadItemPart:DownloadItemPart) {
|
||||
/** Handles the result of a download item part check. */
|
||||
private fun handleDownloadItemPartCheck(
|
||||
downloadCheckStatus: DownloadCheckStatus,
|
||||
downloadItemPart: DownloadItemPart
|
||||
) {
|
||||
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
|
||||
if (downloadItem == null) {
|
||||
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)
|
||||
} else if (downloadCheckStatus == DownloadCheckStatus.Successful) {
|
||||
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
|
||||
Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}")
|
||||
|
||||
val fcb = object : FileCallback() {
|
||||
override fun onPrepare() {
|
||||
Log.d(tag, "DOWNLOAD: PREPARING MOVE FILE")
|
||||
}
|
||||
override fun onFailed(errorCode: ErrorCode) {
|
||||
Log.e(tag, "DOWNLOAD: FAILED TO MOVE FILE $errorCode")
|
||||
downloadItemPart.failed = true
|
||||
downloadItemPart.isMoving = false
|
||||
file?.delete()
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
override fun onCompleted(result:Any) {
|
||||
Log.d(tag, "DOWNLOAD: FILE MOVE COMPLETED")
|
||||
val resultDocFile = result as DocumentFile
|
||||
Log.d(tag, "DOWNLOAD: COMPLETED FILE INFO (name=${resultDocFile.name}) ${resultDocFile.getAbsolutePath(mainActivity)}")
|
||||
|
||||
// Rename to fix appended .mp3 on m4b/m4a files
|
||||
// REF: https://github.com/anggrayudi/SimpleStorage/issues/94
|
||||
val docNameLowerCase = resultDocFile.name?.lowercase(Locale.getDefault()) ?: ""
|
||||
if (docNameLowerCase.endsWith(".m4b.mp3")|| docNameLowerCase.endsWith(".m4a.mp3")) {
|
||||
resultDocFile.renameTo(downloadItemPart.filename)
|
||||
}
|
||||
|
||||
downloadItemPart.moved = true
|
||||
downloadItemPart.isMoving = false
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
val localFolderFile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(downloadItemPart.localFolderUrl))
|
||||
if (localFolderFile == null) {
|
||||
// fAILED
|
||||
downloadItemPart.failed = true
|
||||
Log.e(tag, "Local Folder File from uri is null")
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
} else {
|
||||
downloadItemPart.isMoving = true
|
||||
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
|
||||
val fileDescription = FileDescription(downloadItemPart.filename, downloadItemPart.finalDestinationSubfolder, mimetype)
|
||||
file?.moveFileTo(mainActivity, localFolderFile, fileDescription, fcb)
|
||||
}
|
||||
|
||||
moveDownloadedFile(downloadItem, downloadItemPart)
|
||||
} else if (downloadCheckStatus != DownloadCheckStatus.InProgress) {
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkDownloadItemFinished(downloadItem:DownloadItem) {
|
||||
/** Moves the downloaded file to its final destination. */
|
||||
private fun moveDownloadedFile(downloadItem: DownloadItem, downloadItemPart: DownloadItemPart) {
|
||||
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
|
||||
Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}")
|
||||
|
||||
val fcb =
|
||||
object : FileCallback() {
|
||||
override fun onPrepare() {
|
||||
Log.d(tag, "DOWNLOAD: PREPARING MOVE FILE")
|
||||
}
|
||||
|
||||
override fun onFailed(errorCode: ErrorCode) {
|
||||
Log.e(tag, "DOWNLOAD: FAILED TO MOVE FILE $errorCode")
|
||||
downloadItemPart.failed = true
|
||||
downloadItemPart.isMoving = false
|
||||
file?.delete()
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
|
||||
override fun onCompleted(result: Any) {
|
||||
Log.d(tag, "DOWNLOAD: FILE MOVE COMPLETED")
|
||||
val resultDocFile = result as DocumentFile
|
||||
Log.d(
|
||||
tag,
|
||||
"DOWNLOAD: COMPLETED FILE INFO (name=${resultDocFile.name}) ${resultDocFile.getAbsolutePath(mainActivity)}"
|
||||
)
|
||||
|
||||
// Rename to fix appended .mp3 on m4b/m4a files
|
||||
// REF: https://github.com/anggrayudi/SimpleStorage/issues/94
|
||||
val docNameLowerCase = resultDocFile.name?.lowercase(Locale.getDefault()) ?: ""
|
||||
if (docNameLowerCase.endsWith(".m4b.mp3") || docNameLowerCase.endsWith(".m4a.mp3")
|
||||
) {
|
||||
resultDocFile.renameTo(downloadItemPart.filename)
|
||||
}
|
||||
|
||||
downloadItemPart.moved = true
|
||||
downloadItemPart.isMoving = false
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
val localFolderFile =
|
||||
DocumentFileCompat.fromUri(mainActivity, Uri.parse(downloadItemPart.localFolderUrl))
|
||||
if (localFolderFile == null) {
|
||||
// Failed
|
||||
downloadItemPart.failed = true
|
||||
Log.e(tag, "Local Folder File from uri is null")
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
} else {
|
||||
downloadItemPart.isMoving = true
|
||||
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
|
||||
val fileDescription =
|
||||
FileDescription(
|
||||
downloadItemPart.filename,
|
||||
downloadItemPart.finalDestinationSubfolder,
|
||||
mimetype
|
||||
)
|
||||
file?.moveFileTo(mainActivity, localFolderFile, fileDescription, fcb)
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks if a download item is finished and processes it. */
|
||||
private fun checkDownloadItemFinished(downloadItem: DownloadItem) {
|
||||
if (downloadItem.isDownloadFinished) {
|
||||
Log.i(tag, "Download Item finished ${downloadItem.media.metadata.title}")
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
folderScanner.scanDownloadItem(downloadItem) { downloadItemScanResult ->
|
||||
Log.d(tag, "Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id}")
|
||||
Log.d(
|
||||
tag,
|
||||
"Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id}"
|
||||
)
|
||||
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("libraryItemId", downloadItem.id)
|
||||
jsobj.put("localFolderId", downloadItem.localFolder.id)
|
||||
val jsobj =
|
||||
JSObject().apply {
|
||||
put("libraryItemId", downloadItem.id)
|
||||
put("localFolderId", downloadItem.localFolder.id)
|
||||
|
||||
downloadItemScanResult?.localLibraryItem?.let { localLibraryItem ->
|
||||
jsobj.put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
|
||||
}
|
||||
downloadItemScanResult?.localMediaProgress?.let { localMediaProgress ->
|
||||
jsobj.put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
||||
}
|
||||
downloadItemScanResult?.localLibraryItem?.let { localLibraryItem ->
|
||||
put(
|
||||
"localLibraryItem",
|
||||
JSObject(jacksonMapper.writeValueAsString(localLibraryItem))
|
||||
)
|
||||
}
|
||||
downloadItemScanResult?.localMediaProgress?.let { localMediaProgress ->
|
||||
put(
|
||||
"localMediaProgress",
|
||||
JSObject(jacksonMapper.writeValueAsString(localMediaProgress))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
clientEventEmitter.onDownloadItemComplete(jsobj)
|
||||
|
||||
+74
-34
@@ -1,60 +1,95 @@
|
||||
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.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import okhttp3.*
|
||||
|
||||
/**
|
||||
* Manages the internal download process.
|
||||
*
|
||||
* @property outputStream The output stream to write the downloaded data.
|
||||
* @property progressCallback The callback to report download progress.
|
||||
*/
|
||||
class InternalDownloadManager(
|
||||
private val outputStream: FileOutputStream,
|
||||
private val progressCallback: DownloadItemManager.InternalProgressCallback
|
||||
) : AutoCloseable {
|
||||
|
||||
class InternalDownloadManager(outputStream:FileOutputStream, private val progressCallback: DownloadItemManager.InternalProgressCallback) : AutoCloseable {
|
||||
private val tag = "InternalDownloadManager"
|
||||
|
||||
private val client: OkHttpClient = OkHttpClient()
|
||||
private val client: OkHttpClient =
|
||||
OkHttpClient.Builder().connectTimeout(30, TimeUnit.SECONDS).build()
|
||||
private val writer = BinaryFileWriter(outputStream, progressCallback)
|
||||
|
||||
/**
|
||||
* Downloads a file from the given URL.
|
||||
*
|
||||
* @param url The URL to download the file from.
|
||||
* @throws IOException If an I/O error occurs.
|
||||
*/
|
||||
@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)
|
||||
}
|
||||
fun download(url: String) {
|
||||
val request: Request = Request.Builder().url(url).addHeader("Accept-Encoding", "identity").build()
|
||||
client.newCall(request)
|
||||
.enqueue(
|
||||
object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(tag, "Download URL $url FAILED", e)
|
||||
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)
|
||||
}
|
||||
})
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.body?.let { responseBody ->
|
||||
val length: Long = response.header("Content-Length")?.toLongOrNull() ?: 0L
|
||||
writer.write(responseBody.byteStream(), length)
|
||||
}
|
||||
?: run {
|
||||
Log.e(tag, "Response doesn't contain a file")
|
||||
progressCallback.onComplete(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the download manager and releases resources.
|
||||
*
|
||||
* @throws Exception If an error occurs during closing.
|
||||
*/
|
||||
@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
|
||||
}
|
||||
/**
|
||||
* Writes binary data to an output stream.
|
||||
*
|
||||
* @property outputStream The output stream to write the data to.
|
||||
* @property progressCallback The callback to report write progress.
|
||||
*/
|
||||
class BinaryFileWriter(
|
||||
private val outputStream: OutputStream,
|
||||
private val progressCallback: DownloadItemManager.InternalProgressCallback
|
||||
) : AutoCloseable {
|
||||
|
||||
/**
|
||||
* Writes data from the input stream to the output stream.
|
||||
*
|
||||
* @param inputStream The input stream to read the data from.
|
||||
* @param length The total length of the data to be written.
|
||||
* @return The total number of bytes written.
|
||||
* @throws IOException If an I/O error occurs.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun write(inputStream: InputStream?, length: Long): Long {
|
||||
fun write(inputStream: InputStream, length: Long): Long {
|
||||
BufferedInputStream(inputStream).use { input ->
|
||||
val dataBuffer = ByteArray(CHUNK_SIZE)
|
||||
var readBytes: Int
|
||||
var totalBytes: Long = 0
|
||||
var readBytes: Int
|
||||
while (input.read(dataBuffer).also { readBytes = it } != -1) {
|
||||
totalBytes += readBytes.toLong()
|
||||
totalBytes += readBytes
|
||||
outputStream.write(dataBuffer, 0, readBytes)
|
||||
progressCallback.onProgress(totalBytes, (totalBytes * 100L) / length)
|
||||
}
|
||||
@@ -63,12 +98,17 @@ class BinaryFileWriter(outputStream: OutputStream, progressCallback: DownloadIte
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the writer and releases resources.
|
||||
*
|
||||
* @throws IOException If an error occurs during closing.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
override fun close() {
|
||||
outputStream.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHUNK_SIZE = 1024
|
||||
private const val CHUNK_SIZE = 8192 // Increased chunk size for better performance
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
class SecureStorage(private val context: Context) {
|
||||
companion object {
|
||||
private const val TAG = "SecureStorage"
|
||||
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
|
||||
private const val KEY_ALIAS = "AudiobookshelfRefreshTokens"
|
||||
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
private const val IV_LENGTH = 12
|
||||
private const val TAG_LENGTH = 128
|
||||
}
|
||||
|
||||
private val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply {
|
||||
load(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts and stores a refresh token for a specific server connection
|
||||
*/
|
||||
fun storeRefreshToken(serverConnectionId: String, refreshToken: String): Boolean {
|
||||
return try {
|
||||
val key = getOrCreateKey()
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||
|
||||
val encryptedBytes = cipher.doFinal(refreshToken.toByteArray(Charsets.UTF_8))
|
||||
val combined = cipher.iv + encryptedBytes
|
||||
|
||||
val encoded = Base64.encodeToString(combined, Base64.DEFAULT)
|
||||
|
||||
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||
sharedPrefs.edit().putString("refresh_token_$serverConnectionId", encoded).apply()
|
||||
|
||||
Log.d(TAG, "Successfully stored encrypted refresh token for server: $serverConnectionId")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to store refresh token for server: $serverConnectionId", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves and decrypts a refresh token for a specific server connection
|
||||
*/
|
||||
fun getRefreshToken(serverConnectionId: String): String? {
|
||||
return try {
|
||||
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||
val encoded = sharedPrefs.getString("refresh_token_$serverConnectionId", null) ?: return null
|
||||
|
||||
val combined = Base64.decode(encoded, Base64.DEFAULT)
|
||||
val iv = combined.copyOfRange(0, IV_LENGTH)
|
||||
val encryptedBytes = combined.copyOfRange(IV_LENGTH, combined.size)
|
||||
|
||||
val key = getOrCreateKey()
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
val spec = GCMParameterSpec(TAG_LENGTH, iv)
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, spec)
|
||||
|
||||
val decryptedBytes = cipher.doFinal(encryptedBytes)
|
||||
String(decryptedBytes, Charsets.UTF_8)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to retrieve refresh token for server: $serverConnectionId", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a refresh token for a specific server connection
|
||||
*/
|
||||
fun removeRefreshToken(serverConnectionId: String): Boolean {
|
||||
return try {
|
||||
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||
sharedPrefs.edit().remove("refresh_token_$serverConnectionId").apply()
|
||||
Log.d(TAG, "Successfully removed refresh token for server: $serverConnectionId")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to remove refresh token for server: $serverConnectionId", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a refresh token exists for a specific server connection
|
||||
*/
|
||||
fun hasRefreshToken(serverConnectionId: String): Boolean {
|
||||
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||
return sharedPrefs.contains("refresh_token_$serverConnectionId")
|
||||
}
|
||||
|
||||
private fun getOrCreateKey(): SecretKey {
|
||||
return if (keyStore.containsAlias(KEY_ALIAS)) {
|
||||
keyStore.getKey(KEY_ALIAS, null) as SecretKey
|
||||
} else {
|
||||
createKey()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createKey(): SecretKey {
|
||||
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER)
|
||||
val keyGenSpec = KeyGenParameterSpec.Builder(
|
||||
KEY_ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setUserAuthenticationRequired(false)
|
||||
.setRandomizedEncryptionRequired(true)
|
||||
.build()
|
||||
|
||||
keyGenerator.init(keyGenSpec)
|
||||
return keyGenerator.generateKey()
|
||||
}
|
||||
}
|
||||
@@ -1,133 +1,221 @@
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.content.Context
|
||||
import android.media.metrics.PlaybackSession
|
||||
import android.media.MediaPlayer
|
||||
import android.os.*
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.player.SLEEP_TIMER_WAKE_UP_EXPIRATION
|
||||
import java.text.SimpleDateFormat
|
||||
import com.audiobookshelf.app.plugins.AbsLogger
|
||||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class SleepTimerManager constructor(private val playerNotificationService: PlayerNotificationService) {
|
||||
const val SLEEP_TIMER_CHIME_SOUND_VOLUME = 0.7f
|
||||
|
||||
class SleepTimerManager
|
||||
constructor(private val playerNotificationService: PlayerNotificationService) {
|
||||
private val tag = "SleepTimerManager"
|
||||
|
||||
private var sleepTimerTask:TimerTask? = null
|
||||
private var sleepTimerRunning:Boolean = false
|
||||
private var sleepTimerEndTime:Long = 0L
|
||||
private var sleepTimerLength:Long = 0L
|
||||
private var sleepTimerElapsed:Long = 0L
|
||||
private var sleepTimerFinishedAt:Long = 0L
|
||||
private var isAutoSleepTimer:Boolean = false // When timer was auto-set
|
||||
private var isFirstAutoSleepTimer: Boolean = true
|
||||
private var sleepTimerSessionId:String = ""
|
||||
private var sleepTimerTask: TimerTask? = null
|
||||
private var sleepTimerRunning: Boolean = false
|
||||
private var sleepTimerEndTime: Long = 0L
|
||||
private var sleepTimerLength: Long = 0L
|
||||
private var sleepTimerElapsed: Long = 0L
|
||||
private var sleepTimerFinishedAt: Long = 0L
|
||||
private var isAutoSleepTimer: Boolean = false // When timer was auto-set
|
||||
private var autoTimerDisabled: Boolean = false // Disable until out of auto timer period
|
||||
private var sleepTimerSessionId: String = ""
|
||||
|
||||
private fun getCurrentTime():Long {
|
||||
/**
|
||||
* Gets the current time from the player notification service.
|
||||
* @return Long - the current time in milliseconds.
|
||||
*/
|
||||
private fun getCurrentTime(): Long {
|
||||
return playerNotificationService.getCurrentTime()
|
||||
}
|
||||
|
||||
private fun getDuration():Long {
|
||||
/**
|
||||
* Gets the duration of the current playback.
|
||||
* @return Long - the duration in milliseconds.
|
||||
*/
|
||||
private fun getDuration(): Long {
|
||||
return playerNotificationService.getDuration()
|
||||
}
|
||||
|
||||
private fun getIsPlaying():Boolean {
|
||||
/**
|
||||
* Checks if the player is currently playing.
|
||||
* @return Boolean - true if the player is playing, false otherwise.
|
||||
*/
|
||||
private fun getIsPlaying(): Boolean {
|
||||
return playerNotificationService.currentPlayer.isPlaying
|
||||
}
|
||||
|
||||
private fun setVolume(volume:Float) {
|
||||
/**
|
||||
* Gets the playback speed of the player.
|
||||
* @return Float - the playback speed.
|
||||
*/
|
||||
private fun getPlaybackSpeed(): Float {
|
||||
return playerNotificationService.currentPlayer.playbackParameters.speed
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the volume of the player.
|
||||
* @param volume Float - the volume level to set.
|
||||
*/
|
||||
private fun setVolume(volume: Float) {
|
||||
playerNotificationService.currentPlayer.volume = volume
|
||||
}
|
||||
|
||||
/** Pauses the player. */
|
||||
private fun pause() {
|
||||
playerNotificationService.currentPlayer.pause()
|
||||
}
|
||||
|
||||
/** Plays the player. */
|
||||
private fun play() {
|
||||
playerNotificationService.currentPlayer.play()
|
||||
}
|
||||
|
||||
private fun getSleepTimerTimeRemainingSeconds():Int {
|
||||
/**
|
||||
* Gets the remaining time of the sleep timer in seconds.
|
||||
* @param speed Float - the playback speed of the player, default value is 1.
|
||||
* @return Int - the remaining time in seconds.
|
||||
*/
|
||||
private fun getSleepTimerTimeRemainingSeconds(speed: Float = 1f): Int {
|
||||
if (sleepTimerEndTime == 0L && sleepTimerLength > 0) { // For regular timer
|
||||
return ((sleepTimerLength - sleepTimerElapsed) / 1000).toDouble().roundToInt()
|
||||
}
|
||||
// For chapter end timer
|
||||
if (sleepTimerEndTime <= 0) return 0
|
||||
return (((sleepTimerEndTime - getCurrentTime()) / 1000).toDouble()).roundToInt()
|
||||
return (((sleepTimerEndTime - getCurrentTime()) / 1000).toDouble() / speed).roundToInt()
|
||||
}
|
||||
|
||||
private fun setSleepTimer(time: Long, isChapterTime: Boolean) : Boolean {
|
||||
Log.d(tag, "Setting Sleep Timer for $time is chapter time $isChapterTime")
|
||||
/**
|
||||
* Sets the sleep timer.
|
||||
* @param time Long - the time to set the sleep timer for. When 0L, use end of chapter/track time.
|
||||
* @return Boolean - true if the sleep timer was set successfully, false otherwise.
|
||||
*/
|
||||
private fun setSleepTimer(time: Long): Boolean {
|
||||
Log.d(tag, "Setting Sleep Timer for $time")
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerRunning = true
|
||||
sleepTimerFinishedAt = 0L
|
||||
sleepTimerElapsed = 0L
|
||||
setVolume(1f)
|
||||
|
||||
// Register shake sensor
|
||||
playerNotificationService.registerSensor()
|
||||
if (time == 0L) {
|
||||
// Get the current chapter time and set the sleep timer to the end of the chapter
|
||||
val chapterEndTime = this.getChapterEndTime()
|
||||
|
||||
val currentTime = getCurrentTime()
|
||||
if (isChapterTime) {
|
||||
if (currentTime > time) {
|
||||
Log.d(tag, "Invalid sleep timer - current time is already passed chapter time $time")
|
||||
if (chapterEndTime == null) {
|
||||
Log.e(tag, "Setting sleep timer to end of chapter/track but there is no current session")
|
||||
return false
|
||||
}
|
||||
sleepTimerEndTime = time
|
||||
sleepTimerLength = 0
|
||||
|
||||
val currentTime = getCurrentTime()
|
||||
if (currentTime > chapterEndTime) {
|
||||
Log.d(tag, "Invalid sleep timer - time is already past chapter time $chapterEndTime")
|
||||
return false
|
||||
}
|
||||
|
||||
sleepTimerEndTime = chapterEndTime
|
||||
|
||||
if (sleepTimerEndTime > getDuration()) {
|
||||
sleepTimerEndTime = getDuration()
|
||||
}
|
||||
} else {
|
||||
sleepTimerLength = time
|
||||
sleepTimerEndTime = 0L
|
||||
}
|
||||
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
||||
// Set sleep timer length. Will be 0L if using chapter end time
|
||||
sleepTimerLength = time
|
||||
|
||||
sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
if (getIsPlaying()) {
|
||||
sleepTimerElapsed += 1000L
|
||||
// Register shake sensor
|
||||
playerNotificationService.registerSensor()
|
||||
|
||||
val sleepTimeSecondsRemaining = getSleepTimerTimeRemainingSeconds()
|
||||
Log.d(tag, "Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s")
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(
|
||||
getSleepTimerTimeRemainingSeconds(getPlaybackSpeed()),
|
||||
isAutoSleepTimer
|
||||
)
|
||||
|
||||
if (sleepTimeSecondsRemaining > 0) {
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(sleepTimeSecondsRemaining, isAutoSleepTimer)
|
||||
}
|
||||
sleepTimerTask =
|
||||
Timer("SleepTimer", false).schedule(0L, 1000L) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
if (getIsPlaying()) {
|
||||
sleepTimerElapsed += 1000L
|
||||
|
||||
if (sleepTimeSecondsRemaining <= 0) {
|
||||
Log.d(tag, "Sleep Timer Pausing Player on Chapter")
|
||||
pause()
|
||||
val sleepTimeSecondsRemaining =
|
||||
getSleepTimerTimeRemainingSeconds(getPlaybackSpeed())
|
||||
Log.d(
|
||||
tag,
|
||||
"Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s"
|
||||
)
|
||||
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime())
|
||||
clearSleepTimer()
|
||||
sleepTimerFinishedAt = System.currentTimeMillis()
|
||||
} else if (sleepTimeSecondsRemaining <= 60 && DeviceManager.deviceData.deviceSettings?.disableSleepTimerFadeOut != true) {
|
||||
// Start fading out audio down to 10% volume
|
||||
val percentToReduce = 1 - (sleepTimeSecondsRemaining / 60F)
|
||||
val volume = 1f - (percentToReduce * 0.9f)
|
||||
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
|
||||
setVolume(volume)
|
||||
} else {
|
||||
setVolume(1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sleepTimeSecondsRemaining > 0) {
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(
|
||||
sleepTimeSecondsRemaining,
|
||||
isAutoSleepTimer
|
||||
)
|
||||
}
|
||||
|
||||
if (sleepTimeSecondsRemaining == 30 && sleepTimerElapsed > 1 && DeviceManager.deviceData.deviceSettings?.enableSleepTimerAlmostDoneChime == true) {
|
||||
playChimeSound()
|
||||
}
|
||||
|
||||
if (sleepTimeSecondsRemaining <= 0) {
|
||||
Log.d(tag, "Sleep Timer Pausing Player on Chapter")
|
||||
pause()
|
||||
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(
|
||||
getCurrentTime()
|
||||
)
|
||||
clearSleepTimer()
|
||||
sleepTimerFinishedAt = System.currentTimeMillis()
|
||||
} else if (sleepTimeSecondsRemaining <= 60 &&
|
||||
DeviceManager.deviceData
|
||||
.deviceSettings
|
||||
?.disableSleepTimerFadeOut != true
|
||||
) {
|
||||
// Start fading out audio down to 10% volume
|
||||
val percentToReduce = 1 - (sleepTimeSecondsRemaining / 60F)
|
||||
val volume = 1f - (percentToReduce * 0.9f)
|
||||
Log.d(
|
||||
tag,
|
||||
"SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining"
|
||||
)
|
||||
setVolume(volume)
|
||||
} else {
|
||||
setVolume(1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun setManualSleepTimer(playbackSessionId:String, time: Long, isChapterTime:Boolean):Boolean {
|
||||
/**
|
||||
* Sets a manual sleep timer.
|
||||
* @param playbackSessionId String - the playback session ID.
|
||||
* @param time Long - the time to set the sleep timer for.
|
||||
* @param isChapterTime Boolean - true if the time is for the end of a chapter, false otherwise.
|
||||
* @return Boolean - true if the sleep timer was set successfully, false otherwise.
|
||||
*/
|
||||
fun setManualSleepTimer(playbackSessionId: String, time: Long, isChapterTime: Boolean): Boolean {
|
||||
sleepTimerSessionId = playbackSessionId
|
||||
isAutoSleepTimer = false
|
||||
return setSleepTimer(time, isChapterTime)
|
||||
if (isChapterTime) {
|
||||
Log.d(tag, "Setting manual sleep timer for end of chapter")
|
||||
return setSleepTimer(0L)
|
||||
} else {
|
||||
Log.d(tag, "Setting manual sleep timer for $time")
|
||||
return setSleepTimer(time)
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears the sleep timer. */
|
||||
private fun clearSleepTimer() {
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerTask = null
|
||||
@@ -138,32 +226,36 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
setVolume(1f)
|
||||
}
|
||||
|
||||
fun getSleepTimerTime():Long {
|
||||
/**
|
||||
* Gets the sleep timer end time.
|
||||
* @return Long - the sleep timer end time in milliseconds.
|
||||
*/
|
||||
fun getSleepTimerTime(): Long {
|
||||
return sleepTimerEndTime
|
||||
}
|
||||
|
||||
/** Cancels the sleep timer. */
|
||||
fun cancelSleepTimer() {
|
||||
Log.d(tag, "Canceling Sleep Timer")
|
||||
|
||||
if (isAutoSleepTimer) {
|
||||
Log.i(tag, "Disabling auto sleep timer")
|
||||
DeviceManager.deviceData.deviceSettings?.autoSleepTimer = false
|
||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
Log.i(tag, "Disabling auto sleep timer for this time period")
|
||||
autoTimerDisabled = true
|
||||
}
|
||||
|
||||
clearSleepTimer()
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0, false)
|
||||
}
|
||||
|
||||
// Vibrate when resetting sleep timer
|
||||
/** Provides vibration feedback when resetting the sleep timer. */
|
||||
private fun vibrateFeedback() {
|
||||
if (DeviceManager.deviceData.deviceSettings?.disableSleepTimerResetFeedback == true) return
|
||||
|
||||
val context = playerNotificationService.getContext()
|
||||
val vibrator:Vibrator
|
||||
val vibrator: Vibrator
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val vibratorManager =
|
||||
context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
||||
context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
||||
vibrator = vibratorManager.defaultVibrator
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -172,18 +264,32 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
|
||||
vibrator.let {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val vibrationEffect = VibrationEffect.createWaveform(longArrayOf(0, 150, 150, 150),-1)
|
||||
val vibrationEffect = VibrationEffect.createWaveform(longArrayOf(0, 150, 150, 150), -1)
|
||||
it.vibrate(vibrationEffect)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
it.vibrate(10)
|
||||
@Suppress("DEPRECATION") it.vibrate(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the chapter end time for use in End of Chapter timers
|
||||
// if less than 2s remain in chapter then use the next chapter
|
||||
private fun getChapterEndTime():Long? {
|
||||
/** Plays chime sound */
|
||||
private fun playChimeSound() {
|
||||
AbsLogger.info(tag, "playChimeSound: Playing sleep timer chime sound")
|
||||
val ctx = playerNotificationService.getContext()
|
||||
val mediaPlayer = MediaPlayer.create(ctx, R.raw.bell)
|
||||
mediaPlayer.setVolume(SLEEP_TIMER_CHIME_SOUND_VOLUME, SLEEP_TIMER_CHIME_SOUND_VOLUME)
|
||||
mediaPlayer.start()
|
||||
mediaPlayer.setOnCompletionListener {
|
||||
mediaPlayer.release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the chapter end time for use in End of Chapter timers. If less than 10 seconds remain in
|
||||
* the chapter, then use the next chapter.
|
||||
* @return Long? - the chapter end time in milliseconds, or null if there is no current session.
|
||||
*/
|
||||
private fun getChapterEndTime(): Long? {
|
||||
val currentChapterEndTimeMs = playerNotificationService.getEndTimeOfChapterOrTrack()
|
||||
if (currentChapterEndTimeMs == null) {
|
||||
Log.e(tag, "Getting chapter sleep timer end of chapter/track but there is no current session")
|
||||
@@ -191,11 +297,17 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
}
|
||||
|
||||
val timeLeftInChapter = currentChapterEndTimeMs - getCurrentTime()
|
||||
return if (timeLeftInChapter < 2000L) {
|
||||
Log.i(tag, "Getting chapter sleep timer time and current chapter has less than 2s remaining")
|
||||
// If less than 10 seconds remain in the chapter, set the timer to the next chapter or track
|
||||
// This handles the auto-rewind from not playing media for a little bit to select the next
|
||||
// chapter
|
||||
return if (timeLeftInChapter < 10000L) {
|
||||
Log.i(tag, "Getting chapter sleep timer time and current chapter has less than 10s remaining")
|
||||
val nextChapterEndTimeMs = playerNotificationService.getEndTimeOfNextChapterOrTrack()
|
||||
if (nextChapterEndTimeMs == null || currentChapterEndTimeMs == nextChapterEndTimeMs) {
|
||||
Log.e(tag, "Invalid next chapter time. No current session or equal to current chapter. $nextChapterEndTimeMs")
|
||||
Log.e(
|
||||
tag,
|
||||
"Invalid next chapter time. No current session or equal to current chapter. $nextChapterEndTimeMs"
|
||||
)
|
||||
null
|
||||
} else {
|
||||
nextChapterEndTimeMs
|
||||
@@ -205,17 +317,35 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetChapterTimer() {
|
||||
this.getChapterEndTime()?.let { chapterEndTime ->
|
||||
Log.d(tag, "Resetting stopped sleep timer to end of chapter $chapterEndTime")
|
||||
vibrateFeedback()
|
||||
setSleepTimer(chapterEndTime, true)
|
||||
play()
|
||||
/**
|
||||
* Rewind auto sleep timer if setting enabled. To ensure the first rewind of the time period does
|
||||
* not take place, make sure to set `isAutoSleepTimer` after calling this function.
|
||||
*/
|
||||
private fun tryRewindAutoSleepTimer() {
|
||||
DeviceManager.deviceData.deviceSettings?.let { deviceSettings ->
|
||||
if (isAutoSleepTimer && deviceSettings.autoSleepTimerAutoRewind) {
|
||||
Log.i(
|
||||
tag,
|
||||
"Auto sleep timer auto rewind seeking back ${deviceSettings.autoSleepTimerAutoRewindTime}ms"
|
||||
)
|
||||
playerNotificationService.seekBackward(deviceSettings.autoSleepTimerAutoRewindTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks if the sleep timer should be reset. */
|
||||
private fun checkShouldResetSleepTimer() {
|
||||
if (!sleepTimerRunning) {
|
||||
if (sleepTimerRunning) {
|
||||
// Reset the sleep timer if it has been running for at least 3 seconds or it is an end of
|
||||
// chapter/track timer
|
||||
if (sleepTimerLength == 0L || sleepTimerElapsed > 3000L) {
|
||||
Log.d(tag, "Resetting running sleep timer")
|
||||
vibrateFeedback()
|
||||
setSleepTimer(sleepTimerLength)
|
||||
play()
|
||||
}
|
||||
} else {
|
||||
|
||||
if (sleepTimerFinishedAt <= 0L) return
|
||||
|
||||
val finishedAtDistance = System.currentTimeMillis() - sleepTimerFinishedAt
|
||||
@@ -226,51 +356,31 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
return
|
||||
}
|
||||
|
||||
// Automatically Rewind in the book if settings is enabled
|
||||
if (isAutoSleepTimer) {
|
||||
DeviceManager.deviceData.deviceSettings?.let { deviceSettings ->
|
||||
if (deviceSettings.autoSleepTimerAutoRewind && !isFirstAutoSleepTimer) {
|
||||
Log.i(tag, "Auto sleep timer auto rewind seeking back ${deviceSettings.autoSleepTimerAutoRewindTime}ms")
|
||||
playerNotificationService.seekBackward(deviceSettings.autoSleepTimerAutoRewindTime)
|
||||
}
|
||||
isFirstAutoSleepTimer = false
|
||||
}
|
||||
// If timer was cleared by going negative on time, clear the sleep timer length so pressing
|
||||
// play allows playback to continue without the sleep timer continuously setting for 1 second.
|
||||
if (sleepTimerLength == 1000L) {
|
||||
Log.d(tag, "Sleep timer cleared by manually subtracting time, clearing sleep timer")
|
||||
sleepTimerFinishedAt = 0L
|
||||
return
|
||||
}
|
||||
|
||||
// Automatically rewind in the book if settings are enabled
|
||||
tryRewindAutoSleepTimer()
|
||||
|
||||
// Set sleep timer
|
||||
// When sleepTimerLength is 0 then use end of chapter/track time
|
||||
if (sleepTimerLength == 0L) {
|
||||
Log.d(tag, "Resetting stopped chapter sleep timer")
|
||||
resetChapterTimer()
|
||||
} else {
|
||||
Log.d(tag, "Resetting stopped sleep timer to length $sleepTimerLength")
|
||||
vibrateFeedback()
|
||||
setSleepTimer(sleepTimerLength, false)
|
||||
play()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Does not apply to chapter sleep timers and timer must be running for at least 3 seconds
|
||||
if (sleepTimerLength > 0L && sleepTimerElapsed > 3000L) {
|
||||
Log.d(tag, "Resetting running sleep timer to length $sleepTimerLength")
|
||||
Log.d(tag, "Resetting stopped sleep timer")
|
||||
vibrateFeedback()
|
||||
setSleepTimer(sleepTimerLength, false)
|
||||
} else if (sleepTimerLength == 0L) {
|
||||
// When navigating to previous chapters make sure this is still the end of the current chapter
|
||||
this.getChapterEndTime()?.let { chapterEndTime ->
|
||||
if (chapterEndTime != sleepTimerEndTime) {
|
||||
Log.d(tag, "Resetting chapter sleep timer to end of chapter $chapterEndTime from $sleepTimerEndTime")
|
||||
vibrateFeedback()
|
||||
setSleepTimer(chapterEndTime, true)
|
||||
play()
|
||||
}
|
||||
}
|
||||
setSleepTimer(sleepTimerLength)
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the shake event to reset the sleep timer. Shaking to reset only works during the 2
|
||||
* minute grace period after the timer ends or while media is playing.
|
||||
*/
|
||||
fun handleShake() {
|
||||
if (sleepTimerRunning || sleepTimerFinishedAt > 0L) {
|
||||
if ((sleepTimerRunning && getIsPlaying()) || sleepTimerFinishedAt > 0L) {
|
||||
if (DeviceManager.deviceData.deviceSettings?.disableShakeToResetSleepTimer == true) {
|
||||
Log.d(tag, "Shake to reset sleep timer is disabled")
|
||||
return
|
||||
@@ -279,48 +389,63 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increases the sleep timer time.
|
||||
* @param time Long - the time to increase the sleep timer by.
|
||||
*/
|
||||
fun increaseSleepTime(time: Long) {
|
||||
Log.d(tag, "Increase Sleep time $time")
|
||||
if (!sleepTimerRunning) return
|
||||
|
||||
// Increase the sleep timer time (if using fixed length) or end time (if using chapter end time)
|
||||
// and ensure it doesn't go over the duration of the current playback item
|
||||
if (sleepTimerEndTime == 0L) {
|
||||
// Fixed length
|
||||
sleepTimerLength += time
|
||||
if (sleepTimerLength + getCurrentTime() > getDuration()) sleepTimerLength = getDuration() - getCurrentTime()
|
||||
sleepTimerLength = minOf(sleepTimerLength, getDuration() - getCurrentTime())
|
||||
} else {
|
||||
val newSleepEndTime = sleepTimerEndTime + time
|
||||
sleepTimerEndTime = if (newSleepEndTime >= getDuration()) {
|
||||
getDuration()
|
||||
} else {
|
||||
newSleepEndTime
|
||||
}
|
||||
// Chapter end time
|
||||
sleepTimerEndTime =
|
||||
minOf(sleepTimerEndTime + (time * getPlaybackSpeed()).roundToInt(), getDuration())
|
||||
}
|
||||
|
||||
setVolume(1F)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(
|
||||
getSleepTimerTimeRemainingSeconds(getPlaybackSpeed()),
|
||||
isAutoSleepTimer
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decreases the sleep timer time.
|
||||
* @param time Long - the time to decrease the sleep timer by.
|
||||
*/
|
||||
fun decreaseSleepTime(time: Long) {
|
||||
Log.d(tag, "Decrease Sleep time $time")
|
||||
if (!sleepTimerRunning) return
|
||||
|
||||
|
||||
// Decrease the sleep timer time (if using fixed length) or end time (if using chapter end time)
|
||||
// and ensure it doesn't go below 1 second
|
||||
if (sleepTimerEndTime == 0L) {
|
||||
sleepTimerLength -= time
|
||||
if (sleepTimerLength <= 0) sleepTimerLength = 1000L
|
||||
// Fixed length
|
||||
sleepTimerLength = maxOf(sleepTimerLength - time, 1000L)
|
||||
} else {
|
||||
val newSleepEndTime = sleepTimerEndTime - time
|
||||
sleepTimerEndTime = if (newSleepEndTime <= 1000) {
|
||||
// End sleep timer in 1 second
|
||||
getCurrentTime() + 1000
|
||||
} else {
|
||||
newSleepEndTime
|
||||
}
|
||||
// Chapter end time
|
||||
sleepTimerEndTime =
|
||||
maxOf(
|
||||
sleepTimerEndTime - (time * getPlaybackSpeed()).roundToInt(),
|
||||
getCurrentTime() + 1000
|
||||
)
|
||||
}
|
||||
|
||||
setVolume(1F)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(
|
||||
getSleepTimerTimeRemainingSeconds(getPlaybackSpeed()),
|
||||
isAutoSleepTimer
|
||||
)
|
||||
}
|
||||
|
||||
/** Checks whether the auto sleep timer should be set, and set up auto sleep timer if so. */
|
||||
fun checkAutoSleepTimer() {
|
||||
if (sleepTimerRunning) { // Sleep timer already running
|
||||
return
|
||||
@@ -337,10 +462,13 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
|
||||
val currentCalendar = Calendar.getInstance()
|
||||
|
||||
// In cases where end time is before start time then we shift the time window forward or backward based on the current time.
|
||||
// In cases where end time is before start time then we shift the time window forward or
|
||||
// backward based on the current time.
|
||||
// e.g. start time 22:00 and end time 06:00.
|
||||
// If current time is less than start time (e.g. 00:30) then start time will be the previous day.
|
||||
// If current time is greater than start time (e.g. 23:00) then end time will be the next day.
|
||||
// If current time is less than start time (e.g. 00:30) then start time will be the
|
||||
// previous day.
|
||||
// If current time is greater than start time (e.g. 23:00) then end time will be the
|
||||
// next day.
|
||||
if (endCalendar.before(startCalendar)) {
|
||||
if (currentCalendar.before(startCalendar)) { // Shift start back a day
|
||||
startCalendar.add(Calendar.DAY_OF_MONTH, -1)
|
||||
@@ -349,47 +477,52 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
}
|
||||
}
|
||||
|
||||
val currentHour = SimpleDateFormat("HH:mm", Locale.getDefault()).format(currentCalendar.time)
|
||||
if (currentCalendar.after(startCalendar) && currentCalendar.before(endCalendar)) {
|
||||
Log.i(tag, "Current hour $currentHour is between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime} - starting sleep timer")
|
||||
val isDuringAutoTime =
|
||||
currentCalendar.after(startCalendar) && currentCalendar.before(endCalendar)
|
||||
|
||||
// Automatically Rewind in the book if settings is enabled
|
||||
if (deviceSettings.autoSleepTimerAutoRewind && !isFirstAutoSleepTimer) {
|
||||
Log.i(tag, "Auto sleep timer auto rewind seeking back ${deviceSettings.autoSleepTimerAutoRewindTime}ms")
|
||||
playerNotificationService.seekBackward(deviceSettings.autoSleepTimerAutoRewindTime)
|
||||
}
|
||||
isFirstAutoSleepTimer = false
|
||||
|
||||
// Set sleep timer
|
||||
// When sleepTimerLength is 0 then use end of chapter/track time
|
||||
if (deviceSettings.sleepTimerLength == 0L) {
|
||||
val chapterEndTimeMs = this.getChapterEndTime()
|
||||
if (chapterEndTimeMs == null) {
|
||||
Log.e(tag, "Setting auto sleep timer to end of chapter/track but there is no current session")
|
||||
} else {
|
||||
isAutoSleepTimer = true
|
||||
setSleepTimer(chapterEndTimeMs, true)
|
||||
}
|
||||
// Determine whether to set the auto sleep timer or not
|
||||
if (autoTimerDisabled) {
|
||||
if (!isDuringAutoTime) {
|
||||
// Check if sleep timer was disabled during the previous period and enable again
|
||||
Log.i(tag, "Leaving disabled auto sleep time period, enabling for next time period")
|
||||
autoTimerDisabled = false
|
||||
} else {
|
||||
isAutoSleepTimer = true
|
||||
setSleepTimer(deviceSettings.sleepTimerLength, false)
|
||||
// Auto time is disabled, do not set sleep timer
|
||||
Log.i(tag, "Auto sleep timer is disabled for this time period")
|
||||
}
|
||||
} else {
|
||||
isFirstAutoSleepTimer = true
|
||||
Log.d(tag, "Current hour $currentHour is NOT between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime}")
|
||||
if (isDuringAutoTime) {
|
||||
// Start an auto sleep timer
|
||||
val currentHour = currentCalendar.get(Calendar.HOUR_OF_DAY)
|
||||
val currentMin = currentCalendar.get(Calendar.MINUTE)
|
||||
Log.i(tag, "Starting auto sleep timer at $currentHour:$currentMin")
|
||||
|
||||
// Automatically rewind in the book if settings is enabled
|
||||
tryRewindAutoSleepTimer()
|
||||
|
||||
// Set `isAutoSleepTimer` to true to indicate that the timer was set automatically
|
||||
// and to not cause the timer to rewind
|
||||
isAutoSleepTimer = true
|
||||
setSleepTimer(deviceSettings.sleepTimerLength)
|
||||
} else {
|
||||
Log.d(tag, "Not in auto sleep time period")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleMediaPlayEvent(playbackSessionId:String) {
|
||||
/**
|
||||
* Handles the media play event and checks if the sleep timer should be reset or set.
|
||||
* @param playbackSessionId String - the playback session ID.
|
||||
*/
|
||||
fun handleMediaPlayEvent(playbackSessionId: String) {
|
||||
// Check if the playback session has changed
|
||||
// If it hasn't changed OR the sleep timer is running then check reset the timer
|
||||
// e.g. You set a manual sleep timer for 10 mins, then decide to change books, the sleep timer will stay on and reset to 10 mins
|
||||
// e.g. You set a manual sleep timer for 10 mins, then decide to change books, the sleep timer
|
||||
// will stay on and reset to 10 mins
|
||||
if (sleepTimerSessionId == playbackSessionId || sleepTimerRunning) {
|
||||
checkShouldResetSleepTimer()
|
||||
} else {
|
||||
isFirstAutoSleepTimer = true
|
||||
}
|
||||
} else {}
|
||||
sleepTimerSessionId = playbackSessionId
|
||||
|
||||
checkAutoSleepTimer()
|
||||
|
||||
@@ -36,76 +36,97 @@ object MediaEventManager {
|
||||
}
|
||||
|
||||
fun seekEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
|
||||
Log.i(tag, "Seek Event for media \"${playbackSession.displayTitle}\", currentTime=${playbackSession.currentTime}")
|
||||
Log.i(
|
||||
tag,
|
||||
"Seek Event for media \"${playbackSession.displayTitle}\", currentTime=${playbackSession.currentTime}"
|
||||
)
|
||||
addPlaybackEvent("Seek", playbackSession, syncResult)
|
||||
}
|
||||
|
||||
fun syncEvent(mediaProgress: MediaProgressWrapper, description: String) {
|
||||
Log.i(tag, "Sync Event for media item id \"${mediaProgress.mediaItemId}\", currentTime=${mediaProgress.currentTime}")
|
||||
Log.i(
|
||||
tag,
|
||||
"Sync Event for media item id \"${mediaProgress.mediaItemId}\", currentTime=${mediaProgress.currentTime}"
|
||||
)
|
||||
addSyncEvent("Sync", mediaProgress, description)
|
||||
}
|
||||
|
||||
private fun addSyncEvent(eventName:String, mediaProgress:MediaProgressWrapper, description: String) {
|
||||
private fun addSyncEvent(
|
||||
eventName: String,
|
||||
mediaProgress: MediaProgressWrapper,
|
||||
description: String
|
||||
) {
|
||||
val mediaItemHistory = getMediaItemHistoryMediaItem(mediaProgress.mediaItemId)
|
||||
if (mediaItemHistory == null) {
|
||||
Log.w(tag, "addSyncEvent: Media Item History not created yet for media item id ${mediaProgress.mediaItemId}")
|
||||
Log.w(
|
||||
tag,
|
||||
"addSyncEvent: Media Item History not created yet for media item id ${mediaProgress.mediaItemId}"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val mediaItemEvent = MediaItemEvent(
|
||||
name = eventName,
|
||||
type = "Sync",
|
||||
description = description,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
serverSyncAttempted = false,
|
||||
serverSyncSuccess = null,
|
||||
serverSyncMessage = null,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
val mediaItemEvent =
|
||||
MediaItemEvent(
|
||||
name = eventName,
|
||||
type = "Sync",
|
||||
description = description,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
serverSyncAttempted = false,
|
||||
serverSyncSuccess = null,
|
||||
serverSyncMessage = null,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
mediaItemHistory.events.add(mediaItemEvent)
|
||||
DeviceManager.dbManager.saveMediaItemHistory(mediaItemHistory)
|
||||
|
||||
clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory)
|
||||
}
|
||||
|
||||
private fun addPlaybackEvent(eventName:String, playbackSession:PlaybackSession, syncResult: SyncResult?) {
|
||||
val mediaItemHistory = getMediaItemHistoryMediaItem(playbackSession.mediaItemId) ?: createMediaItemHistoryForSession(playbackSession)
|
||||
private fun addPlaybackEvent(
|
||||
eventName: String,
|
||||
playbackSession: PlaybackSession,
|
||||
syncResult: SyncResult?
|
||||
) {
|
||||
val mediaItemHistory =
|
||||
getMediaItemHistoryMediaItem(playbackSession.mediaItemId)
|
||||
?: createMediaItemHistoryForSession(playbackSession)
|
||||
|
||||
val mediaItemEvent = MediaItemEvent(
|
||||
name = eventName,
|
||||
type = "Playback",
|
||||
description = "",
|
||||
currentTime = playbackSession.currentTime,
|
||||
serverSyncAttempted = syncResult?.serverSyncAttempted ?: false,
|
||||
serverSyncSuccess = syncResult?.serverSyncSuccess,
|
||||
serverSyncMessage = syncResult?.serverSyncMessage,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
val mediaItemEvent =
|
||||
MediaItemEvent(
|
||||
name = eventName,
|
||||
type = "Playback",
|
||||
description = "",
|
||||
currentTime = playbackSession.currentTime,
|
||||
serverSyncAttempted = syncResult?.serverSyncAttempted ?: false,
|
||||
serverSyncSuccess = syncResult?.serverSyncSuccess,
|
||||
serverSyncMessage = syncResult?.serverSyncMessage,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
mediaItemHistory.events.add(mediaItemEvent)
|
||||
DeviceManager.dbManager.saveMediaItemHistory(mediaItemHistory)
|
||||
|
||||
clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory)
|
||||
}
|
||||
|
||||
private fun getMediaItemHistoryMediaItem(mediaItemId: String) : MediaItemHistory? {
|
||||
private fun getMediaItemHistoryMediaItem(mediaItemId: String): MediaItemHistory? {
|
||||
return DeviceManager.dbManager.getMediaItemHistory(mediaItemId)
|
||||
}
|
||||
|
||||
private fun createMediaItemHistoryForSession(playbackSession: PlaybackSession):MediaItemHistory {
|
||||
private fun createMediaItemHistoryForSession(playbackSession: PlaybackSession): MediaItemHistory {
|
||||
Log.i(tag, "Creating new media item history for media \"${playbackSession.displayTitle}\"")
|
||||
val isLocalOnly = playbackSession.isLocalLibraryItemOnly
|
||||
val libraryItemId = if (isLocalOnly) playbackSession.localLibraryItemId else playbackSession.libraryItemId ?: ""
|
||||
val episodeId:String? = if (isLocalOnly && playbackSession.localEpisodeId != null) playbackSession.localEpisodeId else playbackSession.episodeId
|
||||
val libraryItemId = playbackSession.libraryItemId ?: ""
|
||||
val episodeId: String? = playbackSession.episodeId
|
||||
return MediaItemHistory(
|
||||
id = playbackSession.mediaItemId,
|
||||
mediaDisplayTitle = playbackSession.displayTitle ?: "Unset",
|
||||
libraryItemId,
|
||||
episodeId,
|
||||
isLocalOnly,
|
||||
playbackSession.serverConnectionConfigId,
|
||||
playbackSession.serverAddress,
|
||||
playbackSession.userId,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
events = mutableListOf())
|
||||
id = playbackSession.mediaItemId,
|
||||
mediaDisplayTitle = playbackSession.displayTitle ?: "Unset",
|
||||
libraryItemId,
|
||||
episodeId,
|
||||
false, // local-only items are not supported
|
||||
playbackSession.serverConnectionConfigId,
|
||||
playbackSession.serverAddress,
|
||||
playbackSession.userId,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
events = mutableListOf()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,6 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
|
||||
|
||||
if (!DeviceManager.isConnectedToServer || !DeviceManager.checkConnectivity(ctx) || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
|
||||
Log.d(tag, "Reset caches")
|
||||
podcastEpisodeLibraryItemMap = mutableMapOf()
|
||||
serverLibraries = listOf()
|
||||
serverLibraryItems = mutableListOf()
|
||||
|
||||
@@ -8,41 +8,49 @@ import com.audiobookshelf.app.data.MediaProgress
|
||||
import com.audiobookshelf.app.data.PlaybackSession
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.plugins.AbsLogger
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
|
||||
data class MediaProgressSyncData(
|
||||
var timeListened:Long, // seconds
|
||||
var duration:Double, // seconds
|
||||
var currentTime:Double // seconds
|
||||
var timeListened: Long, // seconds
|
||||
var duration: Double, // seconds
|
||||
var currentTime: Double // seconds
|
||||
)
|
||||
|
||||
data class SyncResult(
|
||||
var serverSyncAttempted:Boolean,
|
||||
var serverSyncSuccess:Boolean?,
|
||||
var serverSyncMessage:String?
|
||||
var serverSyncAttempted: Boolean,
|
||||
var serverSyncSuccess: Boolean?,
|
||||
var serverSyncMessage: String?
|
||||
)
|
||||
|
||||
class MediaProgressSyncer(val playerNotificationService: PlayerNotificationService, private val apiHandler: ApiHandler) {
|
||||
class MediaProgressSyncer(
|
||||
val playerNotificationService: PlayerNotificationService,
|
||||
private val apiHandler: ApiHandler
|
||||
) {
|
||||
private val tag = "MediaProgressSync"
|
||||
private val METERED_CONNECTION_SYNC_INTERVAL = 60000
|
||||
|
||||
private var listeningTimerTask: TimerTask? = null
|
||||
var listeningTimerRunning:Boolean = false
|
||||
var listeningTimerRunning: Boolean = false
|
||||
|
||||
private var lastSyncTime:Long = 0
|
||||
private var failedSyncs:Int = 0
|
||||
private var lastSyncTime: Long = 0
|
||||
private var failedSyncs: Int = 0
|
||||
|
||||
var currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing
|
||||
var currentLocalMediaProgress: LocalMediaProgress? = null
|
||||
|
||||
private val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
|
||||
val currentIsLocal get() = currentPlaybackSession?.isLocal == true
|
||||
val currentSessionId get() = currentPlaybackSession?.id ?: ""
|
||||
private val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
|
||||
private val currentDisplayTitle
|
||||
get() = currentPlaybackSession?.displayTitle ?: "Unset"
|
||||
val currentIsLocal
|
||||
get() = currentPlaybackSession?.isLocal == true
|
||||
val currentSessionId
|
||||
get() = currentPlaybackSession?.id ?: ""
|
||||
private val currentPlaybackDuration
|
||||
get() = currentPlaybackSession?.duration ?: 0.0
|
||||
|
||||
fun start(playbackSession:PlaybackSession) {
|
||||
fun start(playbackSession: PlaybackSession) {
|
||||
if (listeningTimerRunning) {
|
||||
Log.d(tag, "start: Timer already running for $currentDisplayTitle")
|
||||
if (playbackSession.id != currentSessionId) {
|
||||
@@ -62,40 +70,48 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
||||
listeningTimerRunning = true
|
||||
lastSyncTime = System.currentTimeMillis()
|
||||
currentPlaybackSession = playbackSession.clone()
|
||||
Log.d(tag, "start: init last sync time $lastSyncTime with playback session id=${currentPlaybackSession?.id}")
|
||||
Log.d(
|
||||
tag,
|
||||
"start: init last sync time $lastSyncTime with playback session id=${currentPlaybackSession?.id}"
|
||||
)
|
||||
|
||||
listeningTimerTask = Timer("ListeningTimer", false).schedule(15000L, 15000L) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
if (playerNotificationService.currentPlayer.isPlaying) {
|
||||
// Set auto sleep timer if enabled and within start/end time
|
||||
playerNotificationService.sleepTimerManager.checkAutoSleepTimer()
|
||||
listeningTimerTask =
|
||||
Timer("ListeningTimer", false).schedule(15000L, 15000L) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
if (playerNotificationService.currentPlayer.isPlaying) {
|
||||
// Set auto sleep timer if enabled and within start/end time
|
||||
playerNotificationService.sleepTimerManager.checkAutoSleepTimer()
|
||||
|
||||
// Only sync with server on unmetered connection every 15s OR sync with server if last sync time is >= 60s
|
||||
val shouldSyncServer = PlayerNotificationService.isUnmeteredNetwork || System.currentTimeMillis() - lastSyncTime >= METERED_CONNECTION_SYNC_INTERVAL
|
||||
// Only sync with server on unmetered connection every 15s OR sync with server if
|
||||
// last sync time is >= 60s
|
||||
val shouldSyncServer =
|
||||
PlayerNotificationService.isUnmeteredNetwork ||
|
||||
System.currentTimeMillis() - lastSyncTime >=
|
||||
METERED_CONNECTION_SYNC_INTERVAL
|
||||
|
||||
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||
if (currentTime > 0) {
|
||||
sync(shouldSyncServer, currentTime) { syncResult ->
|
||||
Log.d(tag, "Sync complete")
|
||||
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||
if (currentTime > 0) {
|
||||
sync(shouldSyncServer, currentTime) { syncResult ->
|
||||
Log.d(tag, "Sync complete")
|
||||
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
MediaEventManager.saveEvent(playbackSession, syncResult)
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
MediaEventManager.saveEvent(playbackSession, syncResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun play(playbackSession:PlaybackSession) {
|
||||
fun play(playbackSession: PlaybackSession) {
|
||||
Log.d(tag, "play ${playbackSession.displayTitle}")
|
||||
MediaEventManager.playEvent(playbackSession)
|
||||
|
||||
start(playbackSession)
|
||||
}
|
||||
|
||||
fun stop(shouldSync:Boolean? = true, cb: () -> Unit) {
|
||||
fun stop(shouldSync: Boolean? = true, cb: () -> Unit) {
|
||||
if (!listeningTimerRunning) {
|
||||
reset()
|
||||
return cb()
|
||||
@@ -106,7 +122,8 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
||||
listeningTimerRunning = false
|
||||
Log.d(tag, "stop: Stopping listening for $currentDisplayTitle")
|
||||
|
||||
val currentTime = if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0
|
||||
val currentTime =
|
||||
if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0
|
||||
if (currentTime > 0) { // Current time should always be > 0 on stop
|
||||
sync(true, currentTime) { syncResult ->
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
@@ -192,17 +209,21 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
||||
MediaEventManager.seekEvent(currentPlaybackSession!!, null)
|
||||
}
|
||||
|
||||
// Currently unused
|
||||
fun syncFromServerProgress(mediaProgress: MediaProgress) {
|
||||
currentPlaybackSession?.let {
|
||||
it.updatedAt = mediaProgress.lastUpdate
|
||||
it.currentTime = mediaProgress.currentTime
|
||||
|
||||
MediaEventManager.syncEvent(mediaProgress, "Received from server get media progress request while playback session open")
|
||||
MediaEventManager.syncEvent(
|
||||
mediaProgress,
|
||||
"Received from server get media progress request while playback session open"
|
||||
)
|
||||
saveLocalProgress(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun sync(shouldSyncServer:Boolean, currentTime:Double, cb: (SyncResult?) -> Unit) {
|
||||
fun sync(shouldSyncServer: Boolean, currentTime: Double, cb: (SyncResult?) -> Unit) {
|
||||
if (lastSyncTime <= 0) {
|
||||
Log.e(tag, "Last sync time is not set $lastSyncTime")
|
||||
return cb(null)
|
||||
@@ -214,11 +235,14 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
||||
}
|
||||
val listeningTimeToAdd = diffSinceLastSync / 1000L
|
||||
|
||||
val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
|
||||
val syncData = MediaProgressSyncData(listeningTimeToAdd, currentPlaybackDuration, currentTime)
|
||||
currentPlaybackSession?.syncData(syncData)
|
||||
|
||||
if (currentPlaybackSession?.progress?.isNaN() == true) {
|
||||
Log.e(tag, "Current Playback Session invalid progress ${currentPlaybackSession?.progress} | Current Time: ${currentPlaybackSession?.currentTime} | Duration: ${currentPlaybackSession?.getTotalDuration()}")
|
||||
Log.e(
|
||||
tag,
|
||||
"Current Playback Session invalid progress ${currentPlaybackSession?.progress} | Current Time: ${currentPlaybackSession?.currentTime} | Duration: ${currentPlaybackSession?.getTotalDuration()}"
|
||||
)
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
@@ -226,11 +250,7 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
||||
|
||||
// Save playback session to db (server linked sessions only)
|
||||
// Sessions are removed once successfully synced with the server
|
||||
currentPlaybackSession?.let {
|
||||
if (!it.isLocalLibraryItemOnly) {
|
||||
DeviceManager.dbManager.savePlaybackSession(it)
|
||||
}
|
||||
}
|
||||
currentPlaybackSession?.let { DeviceManager.dbManager.savePlaybackSession(it) }
|
||||
|
||||
if (currentIsLocal) {
|
||||
// Save local progress sync
|
||||
@@ -238,36 +258,50 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
||||
saveLocalProgress(it)
|
||||
lastSyncTime = System.currentTimeMillis()
|
||||
|
||||
Log.d(tag, "Sync local device current serverConnectionConfigId=${DeviceManager.serverConnectionConfig?.id}")
|
||||
Log.d(
|
||||
tag,
|
||||
"Sync local device current serverConnectionConfigId=${DeviceManager.serverConnectionConfig?.id}"
|
||||
)
|
||||
AbsLogger.info("MediaProgressSyncer", "sync: Saved local progress (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${it.id})")
|
||||
|
||||
// Local library item is linked to a server library item
|
||||
// Send sync to server also if connected to this server and local item belongs to this server
|
||||
if (hasNetworkConnection && shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
|
||||
// Send sync to server also if connected to this server and local item belongs to this
|
||||
// server
|
||||
val isConnectedToSameServer = it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId
|
||||
if (hasNetworkConnection &&
|
||||
shouldSyncServer &&
|
||||
!it.libraryItemId.isNullOrEmpty() &&
|
||||
isConnectedToSameServer
|
||||
) {
|
||||
apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg ->
|
||||
if (syncSuccess) {
|
||||
failedSyncs = 0
|
||||
playerNotificationService.alertSyncSuccess()
|
||||
DeviceManager.dbManager.removePlaybackSession(it.id) // Remove session from db
|
||||
AbsLogger.info("MediaProgressSyncer", "sync: Successfully synced local progress (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${it.id})")
|
||||
} else {
|
||||
failedSyncs++
|
||||
if (failedSyncs == 2) {
|
||||
playerNotificationService.alertSyncFailing() // Show alert in client
|
||||
failedSyncs = 0
|
||||
}
|
||||
Log.e(tag, "Local Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime with session id=${it.id}")
|
||||
AbsLogger.error("MediaProgressSyncer", "sync: Local progress sync failed (count: $failedSyncs) (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${it.id}) (${DeviceManager.serverConnectionConfigName})")
|
||||
}
|
||||
|
||||
cb(SyncResult(true, syncSuccess, errorMsg))
|
||||
}
|
||||
} else {
|
||||
AbsLogger.info("MediaProgressSyncer", "sync: Not sending local progress to server (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${it.id}) (hasNetworkConnection: $hasNetworkConnection) (isConnectedToSameServer: $isConnectedToSameServer)")
|
||||
cb(SyncResult(false, null, null))
|
||||
}
|
||||
}
|
||||
} else if (hasNetworkConnection && shouldSyncServer) {
|
||||
Log.d(tag, "sync: currentSessionId=$currentSessionId")
|
||||
AbsLogger.info("MediaProgressSyncer", "sync: Sending progress sync to server (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${currentSessionId}) (${DeviceManager.serverConnectionConfigName})")
|
||||
|
||||
apiHandler.sendProgressSync(currentSessionId, syncData) { syncSuccess, errorMsg ->
|
||||
if (syncSuccess) {
|
||||
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
|
||||
AbsLogger.info("MediaProgressSyncer", "sync: Successfully synced progress (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${currentSessionId}) (${DeviceManager.serverConnectionConfigName})")
|
||||
|
||||
failedSyncs = 0
|
||||
playerNotificationService.alertSyncSuccess()
|
||||
lastSyncTime = System.currentTimeMillis()
|
||||
@@ -278,18 +312,20 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
||||
playerNotificationService.alertSyncFailing() // Show alert in client
|
||||
failedSyncs = 0
|
||||
}
|
||||
Log.e(tag, "Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime with session id=${currentSessionId}")
|
||||
AbsLogger.error("MediaProgressSyncer", "sync: Progress sync failed (count: $failedSyncs) (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: $currentSessionId) (${DeviceManager.serverConnectionConfigName})")
|
||||
}
|
||||
cb(SyncResult(true, syncSuccess, errorMsg))
|
||||
}
|
||||
} else {
|
||||
AbsLogger.info("MediaProgressSyncer", "sync: Not sending progress to server (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: $currentSessionId) (${DeviceManager.serverConnectionConfigName}) (hasNetworkConnection: $hasNetworkConnection)")
|
||||
cb(SyncResult(false, null, null))
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveLocalProgress(playbackSession:PlaybackSession) {
|
||||
private fun saveLocalProgress(playbackSession: PlaybackSession) {
|
||||
if (currentLocalMediaProgress == null) {
|
||||
val mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(playbackSession.localMediaProgressId)
|
||||
val mediaProgress =
|
||||
DeviceManager.dbManager.getLocalMediaProgress(playbackSession.localMediaProgressId)
|
||||
if (mediaProgress == null) {
|
||||
currentLocalMediaProgress = playbackSession.getNewLocalMediaProgress()
|
||||
} else {
|
||||
@@ -306,12 +342,14 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
||||
} else {
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(it)
|
||||
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it)
|
||||
Log.d(tag, "Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${it.progressPercent}%")
|
||||
Log.d(
|
||||
tag,
|
||||
"Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${it.progressPercent}%"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun reset() {
|
||||
currentPlaybackSession = null
|
||||
currentLocalMediaProgress = null
|
||||
|
||||
@@ -41,10 +41,10 @@ data class DownloadItemPart(
|
||||
val destinationUri = Uri.fromFile(destinationFile)
|
||||
val finalDestinationUri = Uri.fromFile(finalDestinationFile)
|
||||
|
||||
var downloadUrl = "${DeviceManager.serverAddress}${serverPath}"
|
||||
|
||||
downloadUrl += if (serverPath.endsWith("/cover")) "?raw=1" // Download raw cover image
|
||||
else "?token=${DeviceManager.token}"
|
||||
var downloadUrl = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
|
||||
if (serverPath.endsWith("/cover")) {
|
||||
downloadUrl += "&raw=1" // Download raw cover image
|
||||
}
|
||||
|
||||
val downloadUri = Uri.parse(downloadUrl)
|
||||
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Download URI $downloadUri")
|
||||
|
||||
+7
-4
@@ -7,7 +7,6 @@ import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.session.MediaControllerCompat
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.R
|
||||
import com.bumptech.glide.Glide
|
||||
@@ -15,7 +14,7 @@ import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, private val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
|
||||
class AbMediaDescriptionAdapter (private val controller: MediaControllerCompat, private val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
|
||||
private val tag = "MediaDescriptionAdapter"
|
||||
|
||||
private var currentIconUri: Uri? = null
|
||||
@@ -36,12 +35,17 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
||||
callback: PlayerNotificationManager.BitmapCallback
|
||||
): Bitmap? {
|
||||
val albumArtUri = controller.metadata.description.iconUri
|
||||
val albumBitmap = controller.metadata.description.iconBitmap
|
||||
|
||||
// For local cover images, bitmap is set in PlayerNotificationService TimelineQueueNavigator.getMediaDescription
|
||||
if (albumBitmap != null) {
|
||||
return albumBitmap
|
||||
}
|
||||
|
||||
return if (currentIconUri != albumArtUri || currentBitmap == null) {
|
||||
// Cache the bitmap for the current audiobook so that successive calls to
|
||||
// `getCurrentLargeIcon` don't cause the bitmap to be recreated.
|
||||
currentIconUri = albumArtUri
|
||||
Log.d(tag, "ART $currentIconUri")
|
||||
|
||||
if (currentIconUri.toString().startsWith("content://")) {
|
||||
currentBitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||
@@ -61,7 +65,6 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
} else {
|
||||
currentBitmap
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import com.audiobookshelf.app.data.LibraryItemWrapper
|
||||
import com.audiobookshelf.app.data.PodcastEpisode
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
|
||||
|
||||
+957
-618
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.plugins.AbsLogger
|
||||
import kotlin.math.sqrt
|
||||
|
||||
class ShakeDetector : SensorEventListener {
|
||||
@@ -46,6 +47,7 @@ class ShakeDetector : SensorEventListener {
|
||||
if (mShakeTimestamp + SHAKE_COUNT_RESET_TIME_MS < now) {
|
||||
mShakeCount = 0
|
||||
}
|
||||
AbsLogger.info("ShakeDetector", "Device shake above threshold ($gForce > $shakeThreshold)")
|
||||
mShakeTimestamp = now
|
||||
mShakeCount++
|
||||
mListener!!.onShake(mShakeCount)
|
||||
|
||||
@@ -180,7 +180,8 @@ class AbsAudioPlayer : Plugin() {
|
||||
val playWhenReady = call.getBoolean("playWhenReady") == true
|
||||
val playbackRate = call.getFloat("playbackRate",1f) ?: 1f
|
||||
val startTimeOverride = call.getDouble("startTime")
|
||||
Log.d(tag, "prepareLibraryItem lid=$libraryItemId, startTimeOverride=$startTimeOverride, playbackRate=$playbackRate")
|
||||
|
||||
AbsLogger.info("AbsAudioPlayer", "prepareLibraryItem: lid=$libraryItemId, startTimeOverride=$startTimeOverride, playbackRate=$playbackRate")
|
||||
|
||||
if (libraryItemId.isEmpty()) {
|
||||
Log.e(tag, "Invalid call to play library item no library item id")
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.media.MediaEventManager
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import com.audiobookshelf.app.managers.SecureStorage
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
@@ -22,20 +23,25 @@ class AbsDatabase : Plugin() {
|
||||
val tag = "AbsDatabase"
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
lateinit var mainActivity: MainActivity
|
||||
lateinit var apiHandler: ApiHandler
|
||||
private lateinit var mainActivity: MainActivity
|
||||
private lateinit var apiHandler: ApiHandler
|
||||
private lateinit var secureStorage: SecureStorage
|
||||
|
||||
data class LocalMediaProgressPayload(val value:List<LocalMediaProgress>)
|
||||
data class LocalLibraryItemsPayload(val value:List<LocalLibraryItem>)
|
||||
data class LocalFoldersPayload(val value:List<LocalFolder>)
|
||||
data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, val token:String, val address:String?, val customHeaders:Map<String,String>?)
|
||||
data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, var version:String, val token:String, val refreshToken:String?, val address:String?, val customHeaders:Map<String,String>?)
|
||||
|
||||
override fun load() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
apiHandler = ApiHandler(mainActivity)
|
||||
ApiHandler.absDatabaseNotifyListeners = ::notifyListeners
|
||||
|
||||
secureStorage = SecureStorage(mainActivity)
|
||||
|
||||
DeviceManager.dbManager.cleanLocalMediaProgress()
|
||||
DeviceManager.dbManager.cleanLocalLibraryItems()
|
||||
DeviceManager.dbManager.cleanLogs()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
@@ -119,7 +125,9 @@ class AbsDatabase : Plugin() {
|
||||
|
||||
val userId = serverConfigPayload.userId
|
||||
val username = serverConfigPayload.username
|
||||
val token = serverConfigPayload.token
|
||||
val serverVersion = serverConfigPayload.version
|
||||
val accessToken = serverConfigPayload.token
|
||||
val refreshToken = serverConfigPayload.refreshToken // Refresh only sent after login or refresh
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
if (serverConnectionConfig == null) { // New Server Connection
|
||||
@@ -128,7 +136,16 @@ class AbsDatabase : Plugin() {
|
||||
// Create new server connection config
|
||||
val sscId = DeviceManager.getBase64Id("$serverAddress@$username")
|
||||
val sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size
|
||||
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, token, serverConfigPayload.customHeaders)
|
||||
|
||||
// Store refresh token securely if provided
|
||||
val hasRefreshToken = if (!refreshToken.isNullOrEmpty()) {
|
||||
secureStorage.storeRefreshToken(sscId, refreshToken)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
Log.d(tag, "Refresh token secured = $hasRefreshToken")
|
||||
|
||||
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, serverVersion, userId, username, accessToken, serverConfigPayload.customHeaders)
|
||||
|
||||
// Add and save
|
||||
DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!)
|
||||
@@ -136,14 +153,21 @@ class AbsDatabase : Plugin() {
|
||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
} else {
|
||||
var shouldSave = false
|
||||
if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != token) {
|
||||
if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != accessToken || serverConnectionConfig?.version != serverVersion) {
|
||||
serverConnectionConfig?.userId = userId
|
||||
serverConnectionConfig?.username = username
|
||||
serverConnectionConfig?.name = "${serverConnectionConfig?.address} (${serverConnectionConfig?.username})"
|
||||
serverConnectionConfig?.token = token
|
||||
serverConnectionConfig?.version = serverVersion
|
||||
serverConnectionConfig?.token = accessToken
|
||||
shouldSave = true
|
||||
}
|
||||
|
||||
// Update refresh token if provided
|
||||
if (!refreshToken.isNullOrEmpty()) {
|
||||
val stored = secureStorage.storeRefreshToken(serverConnectionConfig!!.id, refreshToken)
|
||||
Log.d(tag, "Refresh token secured = $stored")
|
||||
}
|
||||
|
||||
// Set last connection config
|
||||
if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConfigPayload.id) {
|
||||
DeviceManager.deviceData.lastServerConnectionConfigId = serverConfigPayload.id
|
||||
@@ -162,6 +186,10 @@ class AbsDatabase : Plugin() {
|
||||
fun removeServerConnectionConfig(call:PluginCall) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
||||
|
||||
// Remove refresh token if it exists
|
||||
secureStorage.removeRefreshToken(serverConnectionConfigId)
|
||||
|
||||
DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList<ServerConnectionConfig>
|
||||
if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) {
|
||||
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
||||
@@ -174,6 +202,42 @@ class AbsDatabase : Plugin() {
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getRefreshToken(call:PluginCall) {
|
||||
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId)
|
||||
if (refreshToken != null) {
|
||||
val result = JSObject()
|
||||
result.put("refreshToken", refreshToken)
|
||||
call.resolve(result)
|
||||
} else {
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun clearRefreshToken(call:PluginCall) {
|
||||
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
||||
|
||||
val refreshToken = secureStorage.removeRefreshToken(serverConnectionConfigId)
|
||||
val result = JSObject()
|
||||
result.put("success", refreshToken)
|
||||
call.resolve(result)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getAccessToken(call:PluginCall) {
|
||||
val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
||||
val serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConnectionConfigId }
|
||||
val token = serverConnectionConfig?.token ?: ""
|
||||
val ret = JSObject()
|
||||
ret.put("token", token)
|
||||
call.resolve(ret)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun logout(call:PluginCall) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
@@ -220,12 +284,11 @@ class AbsDatabase : Plugin() {
|
||||
@PluginMethod
|
||||
fun syncLocalSessionsWithServer(call:PluginCall) {
|
||||
if (DeviceManager.serverConnectionConfig == null) {
|
||||
Log.e(tag, "syncLocalSessionsWithServer not connected to server")
|
||||
AbsLogger.error("AbsDatabase", "syncLocalSessionsWithServer: not connected to server")
|
||||
return call.resolve()
|
||||
}
|
||||
|
||||
apiHandler.syncLocalMediaProgressForUser {
|
||||
Log.d(tag, "Finished syncing local media progress for user")
|
||||
val savedSessions = DeviceManager.dbManager.getPlaybackSessions().filter { it.serverConnectionConfigId == DeviceManager.serverConnectionConfigId }
|
||||
|
||||
if (savedSessions.isNotEmpty()) {
|
||||
@@ -233,6 +296,7 @@ class AbsDatabase : Plugin() {
|
||||
if (!success) {
|
||||
call.resolve(JSObject("{\"error\":\"$errorMsg\"}"))
|
||||
} else {
|
||||
AbsLogger.info("AbsDatabase", "syncLocalSessionsWithServer: Finished sending local playback sessions to server. Removing ${savedSessions.size} saved sessions.")
|
||||
// Remove all local sessions
|
||||
savedSessions.forEach {
|
||||
DeviceManager.dbManager.removePlaybackSession(it.id)
|
||||
@@ -241,6 +305,7 @@ class AbsDatabase : Plugin() {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
AbsLogger.info("AbsDatabase", "syncLocalSessionsWithServer: No saved local playback sessions to send to server.")
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.audiobookshelf.app.plugins
|
||||
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import java.util.UUID
|
||||
|
||||
data class AbsLog(
|
||||
var id:String,
|
||||
var tag:String,
|
||||
var level:String,
|
||||
var message:String,
|
||||
var timestamp:Long
|
||||
)
|
||||
|
||||
data class AbsLogList(val value:List<AbsLog>)
|
||||
|
||||
@CapacitorPlugin(name = "AbsLogger")
|
||||
class AbsLogger : Plugin() {
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
override fun load() {
|
||||
onLogEmitter = { log:AbsLog ->
|
||||
notifyListeners("onLog", JSObject(jacksonMapper.writeValueAsString(log)))
|
||||
}
|
||||
info("AbsLogger", "load: AbsLogger plugin initialized")
|
||||
}
|
||||
|
||||
companion object {
|
||||
var onLogEmitter:((log:AbsLog) -> Unit)? = null
|
||||
|
||||
fun log(level:String, tag:String, message:String) {
|
||||
val absLog = AbsLog(id = UUID.randomUUID().toString(), tag, level, message, timestamp = System.currentTimeMillis())
|
||||
DeviceManager.dbManager.saveLog(absLog)
|
||||
onLogEmitter?.let { it(absLog) }
|
||||
}
|
||||
fun info(tag:String, message:String) {
|
||||
Log.i("AbsLogger", message)
|
||||
log("info", tag, message)
|
||||
}
|
||||
fun error(tag:String, message:String) {
|
||||
Log.e("AbsLogger", message)
|
||||
log("error", tag, message)
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun info(call: PluginCall) {
|
||||
val msg = call.getString("message") ?: return call.reject("No message")
|
||||
val tag = call.getString("tag") ?: ""
|
||||
info(tag, msg)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun error(call: PluginCall) {
|
||||
val msg = call.getString("message") ?: return call.reject("No message")
|
||||
val tag = call.getString("tag") ?: ""
|
||||
error(tag, msg)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getAllLogs(call: PluginCall) {
|
||||
val absLogs = DeviceManager.dbManager.getAllLogs()
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(AbsLogList(absLogs))))
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun clearLogs(call: PluginCall) {
|
||||
DeviceManager.dbManager.removeAllLogs()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import com.audiobookshelf.app.media.MediaProgressSyncData
|
||||
import com.audiobookshelf.app.media.SyncResult
|
||||
import com.audiobookshelf.app.models.User
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.plugins.AbsLogger
|
||||
import com.audiobookshelf.app.managers.SecureStorage
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
@@ -32,9 +34,19 @@ import java.util.concurrent.TimeUnit
|
||||
class ApiHandler(var ctx:Context) {
|
||||
val tag = "ApiHandler"
|
||||
|
||||
companion object {
|
||||
// For sending data back to the Webview frontend
|
||||
lateinit var absDatabaseNotifyListeners:(String, JSObject) -> Unit
|
||||
|
||||
fun checkAbsDatabaseNotifyListenersInitted():Boolean {
|
||||
return ::absDatabaseNotifyListeners.isInitialized
|
||||
}
|
||||
}
|
||||
|
||||
private var defaultClient = OkHttpClient()
|
||||
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
private var secureStorage = SecureStorage(ctx)
|
||||
|
||||
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>, val deviceInfo:DeviceInfo)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@@ -109,6 +121,13 @@ class ApiHandler(var ctx:Context) {
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (it.code == 401) {
|
||||
// Handle 401 Unauthorized by attempting token refresh
|
||||
AbsLogger.info(tag, "makeRequest: 401 Unauthorized for request to \"${request.url}\" - attempt token refresh")
|
||||
handleTokenRefresh(request, httpClient, cb)
|
||||
return
|
||||
}
|
||||
|
||||
if (!it.isSuccessful) {
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("error", "Unexpected code $response")
|
||||
@@ -141,6 +160,266 @@ class ApiHandler(var ctx:Context) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles token refresh when a 401 Unauthorized response is received
|
||||
* This function will:
|
||||
* 1. Get the refresh token from secure storage for the current server connection
|
||||
* 2. Make a request to /auth/refresh endpoint with the refresh token
|
||||
* 3. Update the stored tokens with the new access token
|
||||
* 4. Retry the original request with the new access token
|
||||
* 5. If refresh fails, handle logout
|
||||
*
|
||||
* @param originalRequest The original request that failed with 401
|
||||
* @param httpClient The HTTP client to use for the request
|
||||
* @param callback The callback to return the response
|
||||
*/
|
||||
private fun handleTokenRefresh(originalRequest: Request, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) {
|
||||
try {
|
||||
AbsLogger.info(tag, "handleTokenRefresh: Attempting to refresh auth tokens for server ${DeviceManager.serverConnectionConfigString}")
|
||||
|
||||
// Get current server connection config ID
|
||||
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
|
||||
if (serverConnectionConfigId.isEmpty()) {
|
||||
AbsLogger.error(tag, "handleTokenRefresh: Unable to refresh auth tokens. No server connection config ID")
|
||||
val errorObj = JSObject()
|
||||
errorObj.put("error", "No server connection available")
|
||||
callback(errorObj)
|
||||
return
|
||||
}
|
||||
|
||||
// Get refresh token from secure storage
|
||||
val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId)
|
||||
if (refreshToken.isNullOrEmpty()) {
|
||||
AbsLogger.error(tag, "handleTokenRefresh: Unable to refresh auth tokens. No refresh token available for server ${DeviceManager.serverConnectionConfigString}")
|
||||
val errorObj = JSObject()
|
||||
errorObj.put("error", "No refresh token available")
|
||||
callback(errorObj)
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(tag, "handleTokenRefresh: Retrieved refresh token, attempting to refresh access token")
|
||||
|
||||
// Create refresh token request
|
||||
val refreshEndpoint = "${DeviceManager.serverAddress}/auth/refresh"
|
||||
val refreshRequest = Request.Builder()
|
||||
.url(refreshEndpoint)
|
||||
.addHeader("x-refresh-token", refreshToken)
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(EMPTY_REQUEST)
|
||||
.build()
|
||||
|
||||
// Make the refresh request
|
||||
val client = httpClient ?: defaultClient
|
||||
client.newCall(refreshRequest).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(tag, "handleTokenRefresh: Failed to connect to refresh endpoint", e)
|
||||
AbsLogger.error(tag, "handleTokenRefresh: Failed to connect to refresh endpoint for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
|
||||
handleRefreshFailure(callback)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (!it.isSuccessful) {
|
||||
AbsLogger.error(tag, "handleTokenRefresh: Refresh request failed with status ${it.code} for server ${DeviceManager.serverConnectionConfigString}")
|
||||
handleRefreshFailure(callback)
|
||||
return
|
||||
}
|
||||
|
||||
val bodyString = it.body!!.string()
|
||||
try {
|
||||
val responseJson = JSONObject(bodyString)
|
||||
val userObj = responseJson.optJSONObject("user")
|
||||
|
||||
if (userObj == null) {
|
||||
AbsLogger.error(tag, "handleTokenRefresh: No user object in refresh response for server ${DeviceManager.serverConnectionConfigString}")
|
||||
handleRefreshFailure(callback)
|
||||
return
|
||||
}
|
||||
|
||||
val newAccessToken = userObj.optString("accessToken")
|
||||
val newRefreshToken = userObj.optString("refreshToken")
|
||||
|
||||
if (newAccessToken.isEmpty()) {
|
||||
AbsLogger.error(tag, "handleTokenRefresh: No access token in refresh response for server ${DeviceManager.serverConnectionConfigString}")
|
||||
handleRefreshFailure(callback)
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(tag, "handleTokenRefresh: Successfully obtained new access token")
|
||||
|
||||
// Update tokens in secure storage and device manager
|
||||
updateTokens(newAccessToken, newRefreshToken.ifEmpty { refreshToken }, serverConnectionConfigId)
|
||||
|
||||
// Retry the original request with the new access token
|
||||
Log.d(tag, "handleTokenRefresh: Retrying original request with new token")
|
||||
retryOriginalRequest(originalRequest, newAccessToken, httpClient, callback)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "handleTokenRefresh: Failed to parse refresh response", e)
|
||||
AbsLogger.error(tag, "handleTokenRefresh: Failed to parse refresh response for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
|
||||
handleRefreshFailure(callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "handleTokenRefresh: Unexpected error during token refresh", e)
|
||||
handleRefreshFailure(callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the stored tokens with new access and refresh tokens
|
||||
*
|
||||
* @param newAccessToken The new access token
|
||||
* @param newRefreshToken The new refresh token (or existing one if not provided)
|
||||
*/
|
||||
private fun updateTokens(newAccessToken: String, newRefreshToken: String, serverConnectionConfigId: String) {
|
||||
try {
|
||||
// Update the refresh token in secure storage if it's new
|
||||
if (newRefreshToken != secureStorage.getRefreshToken(serverConnectionConfigId)) {
|
||||
secureStorage.storeRefreshToken(serverConnectionConfigId, newRefreshToken)
|
||||
Log.d(tag, "updateTokens: Updated refresh token in secure storage")
|
||||
}
|
||||
|
||||
// Update the access token in the current server connection config
|
||||
DeviceManager.serverConnectionConfig?.let { config ->
|
||||
config.token = newAccessToken
|
||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
Log.d(tag, "updateTokens: Updated access token in server connection config")
|
||||
}
|
||||
|
||||
// Send access token to Webview frontend
|
||||
if (checkAbsDatabaseNotifyListenersInitted()) {
|
||||
val tokenJsObject = JSObject()
|
||||
tokenJsObject.put("accessToken", newAccessToken)
|
||||
absDatabaseNotifyListeners("onTokenRefresh", tokenJsObject)
|
||||
} else {
|
||||
// Can happen if Webview is never run
|
||||
Log.i(tag, "AbsDatabaseNotifyListeners is not initialized so cannot send new access token")
|
||||
}
|
||||
AbsLogger.info(tag, "updateTokens: Successfully refreshed auth tokens for server ${DeviceManager.serverConnectionConfigString}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "updateTokens: Failed to update tokens", e)
|
||||
AbsLogger.error(tag, "updateTokens: Failed to refresh auth tokens for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retries the original request with the new access token
|
||||
*
|
||||
* @param originalRequest The original request to retry
|
||||
* @param newAccessToken The new access token to use
|
||||
* @param httpClient The HTTP client to use
|
||||
* @param callback The callback to return the response
|
||||
*/
|
||||
private fun retryOriginalRequest(originalRequest: Request, newAccessToken: String, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) {
|
||||
try {
|
||||
// Create a new request with the updated authorization header
|
||||
val newRequest = originalRequest.newBuilder()
|
||||
.removeHeader("Authorization")
|
||||
.addHeader("Authorization", "Bearer $newAccessToken")
|
||||
.build()
|
||||
|
||||
Log.d(tag, "retryOriginalRequest: Retrying request to ${newRequest.url}")
|
||||
|
||||
// Make the retry request
|
||||
val client = httpClient ?: defaultClient
|
||||
client.newCall(newRequest).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(tag, "retryOriginalRequest: Failed to retry request", e)
|
||||
AbsLogger.error(tag, "retryOriginalRequest: Failed to retry request after token refresh for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
|
||||
val errorObj = JSObject()
|
||||
errorObj.put("error", "Failed to retry request after token refresh")
|
||||
callback(errorObj)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (!it.isSuccessful) {
|
||||
Log.e(tag, "retryOriginalRequest: Retry request failed with status ${it.code}")
|
||||
AbsLogger.error(tag, "retryOriginalRequest: Retry request failed with status ${it.code} for server ${DeviceManager.serverConnectionConfigString}")
|
||||
val errorObj = JSObject()
|
||||
errorObj.put("error", "Retry request failed with status ${it.code}")
|
||||
callback(errorObj)
|
||||
return
|
||||
}
|
||||
|
||||
val bodyString = it.body!!.string()
|
||||
if (bodyString == "OK") {
|
||||
callback(JSObject())
|
||||
} else {
|
||||
try {
|
||||
var jsonObj = JSObject()
|
||||
if (bodyString.startsWith("[")) {
|
||||
val array = JSArray(bodyString)
|
||||
jsonObj.put("value", array)
|
||||
} else {
|
||||
jsonObj = JSObject(bodyString)
|
||||
}
|
||||
callback(jsonObj)
|
||||
} catch(je:JSONException) {
|
||||
Log.e(tag, "retryOriginalRequest: Invalid JSON response ${je.localizedMessage} from body $bodyString")
|
||||
val errorObj = JSObject()
|
||||
errorObj.put("error", "Invalid response body")
|
||||
callback(errorObj)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "retryOriginalRequest: Unexpected error during retry", e)
|
||||
AbsLogger.error(tag, "retryOriginalRequest: Unexpected error during retry for server ${DeviceManager.serverConnectionConfigString}")
|
||||
val errorObj = JSObject()
|
||||
errorObj.put("error", "Failed to retry request")
|
||||
callback(errorObj)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the case when token refresh fails
|
||||
* This will clear the current session and notify the callback
|
||||
*
|
||||
* @param callback The callback to return the error
|
||||
*/
|
||||
private fun handleRefreshFailure(callback: (JSObject) -> Unit) {
|
||||
try {
|
||||
Log.d(tag, "handleRefreshFailure: Token refresh failed, clearing session")
|
||||
|
||||
// Clear the current server connection
|
||||
DeviceManager.serverConnectionConfig = null
|
||||
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
|
||||
// Remove refresh token from secure storage
|
||||
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
|
||||
if (serverConnectionConfigId.isNotEmpty()) {
|
||||
secureStorage.removeRefreshToken(serverConnectionConfigId)
|
||||
}
|
||||
|
||||
val errorObj = JSObject()
|
||||
errorObj.put("error", "Authentication failed - please login again")
|
||||
callback(errorObj)
|
||||
|
||||
if (checkAbsDatabaseNotifyListenersInitted()) {
|
||||
val tokenJsObject = JSObject()
|
||||
tokenJsObject.put("error", "Token refresh failed")
|
||||
absDatabaseNotifyListeners("onTokenRefreshFailure", tokenJsObject)
|
||||
} else {
|
||||
// Can happen if Webview is never run
|
||||
Log.i(tag, "AbsDatabaseNotifyListeners is not initialized so cannot send token refresh failure notification")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "handleRefreshFailure: Error during failure handling", e)
|
||||
val errorObj = JSObject()
|
||||
errorObj.put("error", "Authentication failed")
|
||||
callback(errorObj)
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentUser(cb: (User?) -> Unit) {
|
||||
getRequest("/api/me", null, null) {
|
||||
if (it.has("error")) {
|
||||
@@ -333,8 +612,7 @@ class ApiHandler(var ctx:Context) {
|
||||
val array = it.getJSONArray("libraryItems")
|
||||
for (i in 0 until array.length()) {
|
||||
val jsobj = array.get(i) as JSONObject
|
||||
|
||||
val itemInProgress = ItemInProgress.makeFromServerObject(jsobj)
|
||||
val itemInProgress = ItemInProgress.makeFromServerObject(jsobj, jacksonMapper)
|
||||
items.add(itemInProgress)
|
||||
}
|
||||
}
|
||||
@@ -468,22 +746,27 @@ class ApiHandler(var ctx:Context) {
|
||||
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")
|
||||
AbsLogger.info("ApiHandler", "sendSyncLocalSessions: Sending ${playbackSessions.size} saved local playback sessions to server (${DeviceManager.serverConnectionConfigName})")
|
||||
|
||||
postRequest("/api/session/local-all", payload, null) {
|
||||
if (!it.getString("error").isNullOrEmpty()) {
|
||||
Log.e(tag, "Failed to sync local sessions")
|
||||
AbsLogger.error("ApiHandler", "sendSyncLocalSessions: Failed to sync local sessions. (${it.getString("error")})")
|
||||
cb(false, it.getString("error"))
|
||||
} else {
|
||||
val response = jacksonMapper.readValue<LocalSessionsSyncResponsePayload>(it.toString())
|
||||
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 ->
|
||||
if (localSessionSyncResult.progressSynced == true) {
|
||||
val syncResult = SyncResult(true, true, "Progress synced on server")
|
||||
MediaEventManager.saveEvent(session, syncResult)
|
||||
Log.i(tag, "Successfully synced session ${session.displayTitle} with server")
|
||||
|
||||
AbsLogger.info("ApiHandler", "sendSyncLocalSessions: Synced session \"${session.displayTitle}\" with server, server progress was updated for item ${session.mediaItemId}")
|
||||
} else if (!localSessionSyncResult.success) {
|
||||
Log.e(tag, "Failed to sync session ${session.displayTitle} with server. Error: ${localSessionSyncResult.error}")
|
||||
AbsLogger.error("ApiHandler", "sendSyncLocalSessions: Failed to sync session \"${session.displayTitle}\" with server. Error: ${localSessionSyncResult.error}")
|
||||
} else {
|
||||
AbsLogger.info("ApiHandler", "sendSyncLocalSessions: Synced session \"${session.displayTitle}\" with server. Server progress was up-to-date for item ${session.mediaItemId}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -493,37 +776,72 @@ class ApiHandler(var ctx:Context) {
|
||||
}
|
||||
|
||||
fun syncLocalMediaProgressForUser(cb: () -> Unit) {
|
||||
AbsLogger.info("ApiHandler", "[ApiHandler] syncLocalMediaProgressForUser: Server connection ${DeviceManager.serverConnectionConfigName}")
|
||||
|
||||
// Get all local media progress for this server
|
||||
val allLocalMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress().filter { it.serverConnectionConfigId == DeviceManager.serverConnectionConfigId }
|
||||
if (allLocalMediaProgress.isEmpty()) {
|
||||
Log.d(tag, "No local media progress to sync")
|
||||
AbsLogger.info("ApiHandler", "[ApiHandler] syncLocalMediaProgressForUser: No local media progress to sync")
|
||||
return cb()
|
||||
}
|
||||
|
||||
getCurrentUser { _user ->
|
||||
_user?.let { user->
|
||||
AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Found ${allLocalMediaProgress.size} local media progress")
|
||||
|
||||
getCurrentUser { user ->
|
||||
if (user == null) {
|
||||
AbsLogger.error("ApiHandler", "syncLocalMediaProgressForUser: Failed to load user from server (${DeviceManager.serverConnectionConfigName})")
|
||||
} else {
|
||||
var numLocalMediaProgressUptToDate = 0
|
||||
var numLocalMediaProgressUpdated = 0
|
||||
|
||||
// Compare server user progress with local progress
|
||||
user.mediaProgress.forEach { mediaProgress ->
|
||||
// Get matching local media progress
|
||||
allLocalMediaProgress.find { it.isMatch(mediaProgress) }?.let { localMediaProgress ->
|
||||
if (mediaProgress.lastUpdate > localMediaProgress.lastUpdate) {
|
||||
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}")
|
||||
val updateLogs = mutableListOf<String>()
|
||||
if (mediaProgress.progress != localMediaProgress.progress) {
|
||||
updateLogs.add("Updated progress from ${localMediaProgress.progress} to ${mediaProgress.progress}")
|
||||
}
|
||||
if (mediaProgress.currentTime != localMediaProgress.currentTime) {
|
||||
updateLogs.add("Updated currentTime from ${localMediaProgress.currentTime} to ${mediaProgress.currentTime}")
|
||||
}
|
||||
if (mediaProgress.isFinished != localMediaProgress.isFinished) {
|
||||
updateLogs.add("Updated isFinished from ${localMediaProgress.isFinished} to ${mediaProgress.isFinished}")
|
||||
}
|
||||
if (mediaProgress.ebookProgress != localMediaProgress.ebookProgress) {
|
||||
updateLogs.add("Updated ebookProgress from ${localMediaProgress.isFinished} to ${mediaProgress.isFinished}")
|
||||
}
|
||||
if (updateLogs.isNotEmpty()) {
|
||||
AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Server progress for item \"${mediaProgress.mediaItemId}\" is more recent than local (server lastUpdate=${mediaProgress.lastUpdate}, local lastUpdate=${localMediaProgress.lastUpdate}). ${updateLogs.joinToString()}")
|
||||
}
|
||||
|
||||
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
|
||||
MediaEventManager.syncEvent(mediaProgress, "Sync on server connection")
|
||||
|
||||
// Only report sync if progress changed
|
||||
if (updateLogs.isNotEmpty()) {
|
||||
MediaEventManager.syncEvent(mediaProgress, "Sync on server connection")
|
||||
}
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||
numLocalMediaProgressUpdated++
|
||||
} else if (localMediaProgress.lastUpdate > mediaProgress.lastUpdate && localMediaProgress.ebookLocation != null && localMediaProgress.ebookLocation != mediaProgress.ebookLocation) {
|
||||
// Patch ebook progress to server
|
||||
AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Local progress for ebook item \"${mediaProgress.mediaItemId}\" is more recent than server progress. Local progress last updated ${localMediaProgress.lastUpdate}, server progress last updated ${mediaProgress.lastUpdate}. Sending server request to update ebook progress from ${mediaProgress.ebookProgress} to ${localMediaProgress.ebookProgress}")
|
||||
val endpoint = "/api/me/progress/${localMediaProgress.libraryItemId}"
|
||||
val updatePayload = JSObject()
|
||||
updatePayload.put("ebookLocation", localMediaProgress.ebookLocation)
|
||||
updatePayload.put("ebookProgress", localMediaProgress.ebookProgress)
|
||||
updatePayload.put("lastUpdate", localMediaProgress.lastUpdate)
|
||||
patchRequest(endpoint,updatePayload) {
|
||||
Log.d(tag, "syncLocalMediaProgressForUser patched ebook progress")
|
||||
AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Successfully updated server ebook progress for item item \"${mediaProgress.mediaItemId}\"")
|
||||
}
|
||||
} else {
|
||||
numLocalMediaProgressUptToDate++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Finishing syncing local media progress with server. $numLocalMediaProgressUptToDate up-to-date, $numLocalMediaProgressUpdated updated")
|
||||
}
|
||||
cb()
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<WebView
|
||||
android:id="@+id/webview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
||||
Binary file not shown.
@@ -10,7 +10,7 @@
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
<item name="android:background">@color/background_dark</item>
|
||||
<item name="android:statusBarColor">@color/background_dark</item>
|
||||
<item name="android:navigationBarColor">@color/background_dark</item>
|
||||
</style>
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
<color name="light_blue_200">#FF81D4FA</color>
|
||||
<color name="light_blue_600">#FF039BE5</color>
|
||||
<color name="light_blue_900">#FF01579B</color>
|
||||
<color name="background_dark">#262626</color>
|
||||
</resources>
|
||||
<color name="background_dark">#232323</color>
|
||||
</resources>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
<item name="android:background">@color/background_dark</item>
|
||||
<item name="android:statusBarColor">@color/background_dark</item>
|
||||
<item name="android:navigationBarColor">@color/background_dark</item>
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,7 @@ buildscript {
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.google.gms:google-services:4.4.2'
|
||||
classpath 'com.android.tools.build:gradle:8.8.0'
|
||||
classpath 'com.android.tools.build:gradle:8.8.2'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':byteowls-capacitor-filesharer'
|
||||
project(':byteowls-capacitor-filesharer').projectDir = new File('../node_modules/@byteowls/capacitor-filesharer/android')
|
||||
include ':webnativellc-capacitor-filesharer'
|
||||
project(':webnativellc-capacitor-filesharer').projectDir = new File('../node_modules/@webnativellc/capacitor-filesharer/android')
|
||||
|
||||
include ':capacitor-community-keep-awake'
|
||||
project(':capacitor-community-keep-awake').projectDir = new File('../node_modules/@capacitor-community/keep-awake/android')
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||
networkTimeout=10000
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
+10
-10
@@ -1,24 +1,24 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 35
|
||||
targetSdkVersion = 34
|
||||
androidxActivityVersion = '1.8.0'
|
||||
targetSdkVersion = 35
|
||||
androidxActivityVersion = '1.9.2'
|
||||
androidxAppCompatVersion = '1.7.0'
|
||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||
androidxCoreVersion = '1.12.0'
|
||||
androidxFragmentVersion = '1.6.2'
|
||||
androidxCoordinatorLayoutVersion = '1.3.0'
|
||||
androidxCoreVersion = '1.15.0'
|
||||
androidxFragmentVersion = '1.8.4'
|
||||
junitVersion = '4.13.2'
|
||||
androidxJunitVersion = '1.1.5'
|
||||
androidxEspressoCoreVersion = '3.5.1'
|
||||
androidxJunitVersion = '1.2.1'
|
||||
androidxEspressoCoreVersion = '3.6.1'
|
||||
cordovaAndroidVersion = '10.1.1'
|
||||
androidx_core_ktx_version = '1.15.0'
|
||||
androidx_core_ktx_version = '1.16.0'
|
||||
androidx_media_version = '1.7.0'
|
||||
exoplayer_version = '2.18.7'
|
||||
glide_version = '4.11.0'
|
||||
glide_version = '4.16.0'
|
||||
junit_version = '4.13.2'
|
||||
kotlin_version = '2.1.0'
|
||||
kotlin_coroutines_version = '1.10.1'
|
||||
test_runner_version = '1.1.0'
|
||||
coreSplashScreenVersion = '1.0.1'
|
||||
androidxWebkitVersion = '1.9.0'
|
||||
androidxWebkitVersion = '1.12.1'
|
||||
}
|
||||
|
||||
+7
-3
@@ -1,5 +1,5 @@
|
||||
@import "./tailwind.css";
|
||||
@import "./fonts.css";
|
||||
@import './tailwind.css';
|
||||
@import './fonts.css';
|
||||
@import './defaultStyles.css';
|
||||
@import './absicons.css';
|
||||
@import './transitions.css';
|
||||
@@ -61,6 +61,10 @@ textarea {
|
||||
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
||||
}
|
||||
|
||||
.box-shadow-progressbar {
|
||||
box-shadow: 0px -1px 4px rgb(62, 50, 2, 0.5);
|
||||
}
|
||||
|
||||
.shadow-height {
|
||||
height: calc(100% - 4px);
|
||||
}
|
||||
@@ -164,4 +168,4 @@ Bookshelf Label
|
||||
.tracksTable th {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,4 +52,16 @@
|
||||
text-indent: 0px !important;
|
||||
text-align: start !important;
|
||||
text-align-last: start !important;
|
||||
}
|
||||
}
|
||||
|
||||
.default-style.less-spacing p {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
.default-style.less-spacing ul {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
.default-style.less-spacing ol {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
+9
-33
@@ -1,19 +1,12 @@
|
||||
@font-face {
|
||||
font-family: 'Material Icons';
|
||||
font-family: 'Material Symbols Rounded';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(/fonts/MaterialIcons-Regular.ttf) format('truetype');
|
||||
src: url(/fonts/MaterialSymbolsRounded.woff2) format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Material Icons Outlined';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(/fonts/MaterialIconsOutlined-Regular.otf) format('opentype');
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-family: 'Material Icons';
|
||||
.material-symbols {
|
||||
font-family: 'Material Symbols Rounded';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
@@ -24,31 +17,14 @@
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.material-icons:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
|
||||
font-size: 1.5rem;
|
||||
.material-symbols.fill {
|
||||
font-variation-settings:
|
||||
'FILL' 1
|
||||
}
|
||||
|
||||
.material-icons-outlined {
|
||||
font-family: 'Material Icons Outlined';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.material-icons-outlined:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
@@ -317,4 +293,4 @@
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,25 @@
|
||||
--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='black'] {
|
||||
color: white;
|
||||
--color-bg: 0 0 0;
|
||||
--color-bg-hover: 0 0 0;
|
||||
--color-fg: 230 237 243;
|
||||
--color-fg-muted: 120 126 132;
|
||||
--color-primary: 0 0 0;
|
||||
--color-secondary: 0 0 0;
|
||||
--color-border: 55 62 65;
|
||||
--color-bg-toggle: 0 0 0;
|
||||
--color-bg-toggle-selected: 35 35 35;
|
||||
--color-track-cursor: 229 231 235;
|
||||
--color-track: 107 114 128;
|
||||
--color-track-buffered: 75 85 99;
|
||||
--gradient-item-page: rgb(0, 0, 0);
|
||||
--gradient-audio-player: rgb(0, 0, 0);
|
||||
--gradient-minimized-audio-player: rgb(0, 0, 0);
|
||||
}
|
||||
|
||||
html[data-theme='light'] {
|
||||
color: black;
|
||||
--color-bg: 255 255 255;
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
"plugins": {
|
||||
"CapacitorHttp": {
|
||||
"enabled": false
|
||||
},
|
||||
"StatusBar": {
|
||||
"backgroundColor": "#232323",
|
||||
"style": "DARK"
|
||||
}
|
||||
},
|
||||
"server": {
|
||||
|
||||
+12
-13
@@ -5,9 +5,9 @@
|
||||
<img src="/Logo.png" class="h-10 w-10" />
|
||||
</nuxt-link>
|
||||
<a v-if="showBack" @click="back" class="rounded-full h-10 w-10 flex items-center justify-center mr-2 cursor-pointer">
|
||||
<span class="material-icons text-3xl text-fg">arrow_back</span>
|
||||
<span class="material-symbols text-3xl text-fg">arrow_back</span>
|
||||
</a>
|
||||
<div v-if="user && currentLibrary && socketConnected">
|
||||
<div v-if="user && currentLibrary">
|
||||
<div class="pl-1.5 pr-2.5 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
|
||||
<ui-library-icon :icon="currentLibraryIcon" :size="4" font-size="base" />
|
||||
<p class="text-sm leading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
|
||||
@@ -21,16 +21,18 @@
|
||||
<widgets-download-progress-indicator />
|
||||
|
||||
<!-- Must be connected to a server to cast, only supports media items on server -->
|
||||
<div v-show="isCastAvailable && user" class="mx-2 cursor-pointer flex items-center pt-0.5" @click="castClick">
|
||||
<span class="material-icons" :class="isCasting ? 'text-success' : ''">cast</span>
|
||||
<div v-show="isCastAvailable && user" class="mx-2 cursor-pointer flex items-center" @click="castClick">
|
||||
<span class="material-symbols text-2xl leading-none">
|
||||
{{ isCasting ? 'cast_connected' : 'cast' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nuxt-link v-if="user" class="h-7 mx-1.5" style="padding-top: 3px" to="/search">
|
||||
<span class="material-icons">search</span>
|
||||
<nuxt-link v-if="user" class="mx-1.5 flex items-center h-10" to="/search">
|
||||
<span class="material-symbols text-2xl leading-none">search</span>
|
||||
</nuxt-link>
|
||||
|
||||
<div class="h-7 mx-1.5">
|
||||
<span class="material-icons" style="font-size: 1.75rem" @click="clickShowSideDrawer">menu</span>
|
||||
<span class="material-symbols" style="font-size: 1.75rem" @click="clickShowSideDrawer">menu</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,9 +56,6 @@ export default {
|
||||
this.$store.commit('setCastAvailable', val)
|
||||
}
|
||||
},
|
||||
socketConnected() {
|
||||
return this.$store.state.socketConnected
|
||||
},
|
||||
currentLibrary() {
|
||||
return this.$store.getters['libraries/getCurrentLibrary']
|
||||
},
|
||||
@@ -101,14 +100,14 @@ export default {
|
||||
this.isCastAvailable = data && data.value
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
async mounted() {
|
||||
AbsAudioPlayer.getIsCastAvailable().then((data) => {
|
||||
this.isCastAvailable = data && data.value
|
||||
})
|
||||
this.onCastAvailableUpdateListener = AbsAudioPlayer.addListener('onCastAvailableUpdate', this.onCastAvailableUpdate)
|
||||
this.onCastAvailableUpdateListener = await AbsAudioPlayer.addListener('onCastAvailableUpdate', this.onCastAvailableUpdate)
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.onCastAvailableUpdateListener) this.onCastAvailableUpdateListener.remove()
|
||||
this.onCastAvailableUpdateListener?.remove()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
<div class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-audio-player)" />
|
||||
|
||||
<div class="top-4 left-4 absolute cursor-pointer">
|
||||
<span class="material-icons text-5xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="collapseFullscreen">expand_more</span>
|
||||
<span class="material-symbols text-5xl" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" @click="collapseFullscreen">keyboard_arrow_down</span>
|
||||
</div>
|
||||
<div v-show="showCastBtn" class="top-6 right-16 absolute cursor-pointer">
|
||||
<span class="material-icons text-3xl" :class="isCasting ? (coverBgIsLight ? 'text-successDark' : 'text-success') : coverBgIsLight ? 'text-black' : ''" @click="castClick">cast</span>
|
||||
<span class="material-symbols text-3xl" :class="coverBgIsLight && theme !== 'black' ? 'text-black' : ''" @click="castClick">{{ isCasting ? 'cast_connected' : 'cast' }}</span>
|
||||
</div>
|
||||
<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-symbols text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" @click="showMoreMenuDialog = true">more_vert</span>
|
||||
</div>
|
||||
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" :class="{ 'text-black text-opacity-75': coverBgIsLight }" style="font-size: 10px">{{ isDirectPlayMethod ? $strings.LabelPlaybackDirect : isLocalPlayMethod ? $strings.LabelPlaybackLocal : $strings.LabelPlaybackTranscode }}</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 && theme !== 'black' }" style="font-size: 10px">{{ isDirectPlayMethod ? $strings.LabelPlaybackDirect : isLocalPlayMethod ? $strings.LabelPlaybackLocal : $strings.LabelPlaybackTranscode }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="playerSettings.useChapterTrack && playerSettings.useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
|
||||
<div v-if="syncStatus === $constants.SyncStatus.FAILED" class="absolute top-0 left-0 w-full h-full flex items-center justify-center z-30" @click.stop="showSyncsFailedDialog">
|
||||
<span class="material-icons text-error text-3xl">error</span>
|
||||
<span class="material-symbols text-error text-3xl">error</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,9 +50,9 @@
|
||||
<div id="playerContent" class="playerContainer w-full z-20 absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" :style="{ backgroundColor: showFullscreen ? '' : coverRgb }" @click="clickContainer">
|
||||
<div v-if="showFullscreen" class="absolute bottom-4 left-0 right-0 w-full pb-4 pt-2 mx-auto px-6" style="max-width: 414px">
|
||||
<div class="flex items-center justify-between pointer-events-auto">
|
||||
<span v-if="!isPodcast && serverLibraryItemId && socketConnected" class="material-icons text-3xl text-fg-muted cursor-pointer" @click="$emit('showBookmarks')">{{ bookmarks.length ? 'bookmark' : 'bookmark_border' }}</span>
|
||||
<span v-if="!isPodcast && serverLibraryItemId && socketConnected" class="material-symbols text-3xl text-fg-muted cursor-pointer" :class="{ fill: bookmarks.length }" @click="$emit('showBookmarks')">bookmark</span>
|
||||
<!-- hidden for podcasts but still using this as a placeholder -->
|
||||
<span v-else class="material-icons text-3xl text-white text-opacity-0">bookmark</span>
|
||||
<span v-else class="material-symbols text-3xl text-white text-opacity-0">bookmark</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-fg-muted cursor-pointer" fill="none" viewBox="0 0 24 24" stroke="currentColor" @click.stop="$emit('showSleepTimer')">
|
||||
@@ -62,23 +62,23 @@
|
||||
<p class="text-xl font-mono text-success">{{ sleepTimeRemainingPretty }}</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<span class="material-symbols text-3xl text-fg cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="clickChaptersBtn">format_list_bulleted</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-minimized-audio-player)" />
|
||||
|
||||
<div id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px">
|
||||
<div class="flex items-center max-w-full" :class="playerSettings.lockUi ? 'justify-center' : 'justify-between'">
|
||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-icons next-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||
<span v-show="!playerSettings.lockUi" class="material-icons jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||
<span v-show="!playerSettings.lockUi" class="material-symbols jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
||||
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4 relative overflow-hidden" :style="{ backgroundColor: coverRgb }" :class="{ 'animate-spin': seekLoading }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
|
||||
<div v-if="!coverBgIsLight" class="absolute top-0 left-0 w-full h-full bg-white bg-opacity-20 pointer-events-none" />
|
||||
|
||||
<span v-if="!isLoading" class="material-icons" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
||||
<span v-if="!isLoading" class="material-symbols fill" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
||||
<widgets-spinner-icon v-else class="h-8 w-8" />
|
||||
</div>
|
||||
<span v-show="!playerSettings.lockUi" class="material-icons jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
|
||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-icons next-icon text-fg cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||
<span v-show="!playerSettings.lockUi" class="material-symbols jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
|
||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -140,13 +140,6 @@ export default {
|
||||
readyTrackWidth: 0,
|
||||
seekedTime: 0,
|
||||
seekLoading: false,
|
||||
onPlaybackSessionListener: null,
|
||||
onPlaybackClosedListener: null,
|
||||
onPlayingUpdateListener: null,
|
||||
onMetadataListener: null,
|
||||
onProgressSyncFailing: null,
|
||||
onProgressSyncSuccess: null,
|
||||
onPlaybackSpeedChangedListener: null,
|
||||
touchStartY: 0,
|
||||
touchStartTime: 0,
|
||||
playerSettings: {
|
||||
@@ -182,6 +175,9 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
theme() {
|
||||
return document.documentElement.dataset.theme || 'dark'
|
||||
},
|
||||
menuItems() {
|
||||
const items = []
|
||||
// TODO: Implement on iOS
|
||||
@@ -880,14 +876,14 @@ export default {
|
||||
async init() {
|
||||
await this.loadPlayerSettings()
|
||||
|
||||
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
|
||||
this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
|
||||
this.onPlaybackFailedListener = AbsAudioPlayer.addListener('onPlaybackFailed', this.onPlaybackFailed)
|
||||
this.onPlayingUpdateListener = AbsAudioPlayer.addListener('onPlayingUpdate', this.onPlayingUpdate)
|
||||
this.onMetadataListener = AbsAudioPlayer.addListener('onMetadata', this.onMetadata)
|
||||
this.onProgressSyncFailing = AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing)
|
||||
this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
|
||||
this.onPlaybackSpeedChangedListener = AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged)
|
||||
AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
|
||||
AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
|
||||
AbsAudioPlayer.addListener('onPlaybackFailed', this.onPlaybackFailed)
|
||||
AbsAudioPlayer.addListener('onPlayingUpdate', this.onPlayingUpdate)
|
||||
AbsAudioPlayer.addListener('onMetadata', this.onMetadata)
|
||||
AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing)
|
||||
AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
|
||||
AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged)
|
||||
},
|
||||
async screenOrientationChange() {
|
||||
if (this.isRefreshingUI) return
|
||||
@@ -981,14 +977,9 @@ export default {
|
||||
document.body.removeEventListener('touchend', this.touchend)
|
||||
document.body.removeEventListener('touchmove', this.touchmove)
|
||||
|
||||
if (this.onPlayingUpdateListener) this.onPlayingUpdateListener.remove()
|
||||
if (this.onMetadataListener) this.onMetadataListener.remove()
|
||||
if (this.onPlaybackSessionListener) this.onPlaybackSessionListener.remove()
|
||||
if (this.onPlaybackClosedListener) this.onPlaybackClosedListener.remove()
|
||||
if (this.onPlaybackFailedListener) this.onPlaybackFailedListener.remove()
|
||||
if (this.onProgressSyncFailing) this.onProgressSyncFailing.remove()
|
||||
if (this.onProgressSyncSuccess) this.onProgressSyncSuccess.remove()
|
||||
if (this.onPlaybackSpeedChangedListener) this.onPlaybackSpeedChangedListener.remove()
|
||||
if (AbsAudioPlayer.removeAllListeners) {
|
||||
AbsAudioPlayer.removeAllListeners()
|
||||
}
|
||||
clearInterval(this.playInterval)
|
||||
}
|
||||
}
|
||||
@@ -1106,7 +1097,7 @@ export default {
|
||||
min-height: 40px;
|
||||
margin: 0px 7px;
|
||||
}
|
||||
#playerControls .play-btn .material-icons {
|
||||
#playerControls .play-btn .material-symbols {
|
||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
transition-property: font-size;
|
||||
|
||||
@@ -1142,7 +1133,7 @@ export default {
|
||||
min-width: 65px;
|
||||
min-height: 65px;
|
||||
}
|
||||
.fullscreen #playerControls .play-btn .material-icons {
|
||||
.fullscreen #playerControls .play-btn .material-symbols {
|
||||
font-size: 2.1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
||||
import { AbsAudioPlayer, AbsLogger } from '@/plugins/capacitor'
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
import CellularPermissionHelpers from '@/mixins/cellularPermissionHelpers'
|
||||
|
||||
@@ -190,6 +190,7 @@ export default {
|
||||
})
|
||||
},
|
||||
async playLibraryItem(payload) {
|
||||
await AbsLogger.info({ tag: 'AudioPlayerContainer', message: `playLibraryItem: Received play request for library item ${payload.libraryItemId} ${payload.episodeId ? `episode ${payload.episodeId}` : ''}` })
|
||||
const libraryItemId = payload.libraryItemId
|
||||
const episodeId = payload.episodeId
|
||||
const startTime = payload.startTime
|
||||
@@ -350,11 +351,11 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.onLocalMediaProgressUpdateListener = AbsAudioPlayer.addListener('onLocalMediaProgressUpdate', this.onLocalMediaProgressUpdate)
|
||||
this.onSleepTimerEndedListener = AbsAudioPlayer.addListener('onSleepTimerEnded', this.onSleepTimerEnded)
|
||||
this.onSleepTimerSetListener = AbsAudioPlayer.addListener('onSleepTimerSet', this.onSleepTimerSet)
|
||||
this.onMediaPlayerChangedListener = AbsAudioPlayer.addListener('onMediaPlayerChanged', this.onMediaPlayerChanged)
|
||||
async mounted() {
|
||||
this.onLocalMediaProgressUpdateListener = await AbsAudioPlayer.addListener('onLocalMediaProgressUpdate', this.onLocalMediaProgressUpdate)
|
||||
this.onSleepTimerEndedListener = await AbsAudioPlayer.addListener('onSleepTimerEnded', this.onSleepTimerEnded)
|
||||
this.onSleepTimerSetListener = await AbsAudioPlayer.addListener('onSleepTimerSet', this.onSleepTimerSet)
|
||||
this.onMediaPlayerChangedListener = await AbsAudioPlayer.addListener('onMediaPlayerChanged', this.onMediaPlayerChanged)
|
||||
|
||||
this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate')
|
||||
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
|
||||
@@ -369,10 +370,10 @@ export default {
|
||||
this.$eventBus.$on('device-focus-update', this.deviceFocused)
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove()
|
||||
if (this.onSleepTimerEndedListener) this.onSleepTimerEndedListener.remove()
|
||||
if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove()
|
||||
if (this.onMediaPlayerChangedListener) this.onMediaPlayerChangedListener.remove()
|
||||
this.onLocalMediaProgressUpdateListener?.remove()
|
||||
this.onSleepTimerEndedListener?.remove()
|
||||
this.onSleepTimerSetListener?.remove()
|
||||
this.onMediaPlayerChangedListener?.remove()
|
||||
|
||||
this.$eventBus.$off('abs-ui-ready', this.onReady)
|
||||
this.$eventBus.$off('play-item', this.playLibraryItem)
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
<div class="w-full overflow-y-auto">
|
||||
<template v-for="item in navItems">
|
||||
<button v-if="item.action" :key="item.text" class="w-full hover:bg-bg/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>
|
||||
<span class="material-symbols fill text-lg">{{ 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="material-symbols fill text-lg">{{ item.icon }}</span>
|
||||
<p class="pl-4">{{ item.text }}</p>
|
||||
</nuxt-link>
|
||||
</template>
|
||||
@@ -30,7 +30,7 @@
|
||||
<div class="flex-grow" />
|
||||
<div v-if="user" class="flex items-center" @click="disconnect">
|
||||
<p class="text-xs pr-2">{{ $strings.ButtonDisconnect }}</p>
|
||||
<i class="material-icons text-sm -mb-0.5">cloud_off</i>
|
||||
<i class="material-symbols text-sm -mb-0.5">cloud_off</i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,6 +134,15 @@ export default {
|
||||
to: '/settings'
|
||||
})
|
||||
|
||||
if (this.$platform !== 'ios') {
|
||||
items.push({
|
||||
icon: 'bug_report',
|
||||
iconOutlined: true,
|
||||
text: this.$strings.ButtonLogs,
|
||||
to: '/logs'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.serverConnectionConfig) {
|
||||
items.push({
|
||||
icon: 'language',
|
||||
@@ -170,21 +179,7 @@ export default {
|
||||
this.show = false
|
||||
},
|
||||
async logout() {
|
||||
if (this.user) {
|
||||
if (this.$store.getters['getIsPlayerOpen']) {
|
||||
this.$eventBus.$emit('close-stream')
|
||||
}
|
||||
|
||||
await this.$nativeHttp.post('/logout').catch((error) => {
|
||||
console.error('Failed to logout', error)
|
||||
})
|
||||
}
|
||||
|
||||
this.$socket.logout()
|
||||
await this.$db.logout()
|
||||
this.$localStore.removeLastLibraryId()
|
||||
this.$store.commit('user/logout')
|
||||
this.$store.commit('libraries/setCurrentLibrary', null)
|
||||
await this.$store.dispatch('user/logout')
|
||||
},
|
||||
async disconnect() {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
@@ -105,8 +105,18 @@ export default {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
bookWidth() {
|
||||
var coverSize = 100
|
||||
if (window.innerWidth <= 375) coverSize = 90
|
||||
const availableWidth = window.innerWidth - 16
|
||||
let coverSize = 100
|
||||
|
||||
// Smaller screens fill width with 2 items per row
|
||||
if (availableWidth <= 400) {
|
||||
coverSize = Math.floor(availableWidth / 2 - 24)
|
||||
if (coverSize < 120) {
|
||||
// Fallback to 1 item per row
|
||||
coverSize = Math.min(availableWidth - 24, 200)
|
||||
}
|
||||
if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') coverSize /= 1.6
|
||||
}
|
||||
|
||||
if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return coverSize * 1.6
|
||||
return coverSize
|
||||
|
||||
@@ -43,14 +43,14 @@
|
||||
|
||||
<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>
|
||||
<span class="material-symbols fill">{{ streamIsPlaying ? 'pause_circle' : 'play_circle' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Play/pause button for podcast episode -->
|
||||
<div v-if="recentEpisode" class="absolute z-10 top-0 left-0 bottom-0 right-0 m-auto flex items-center justify-center w-12 h-12 rounded-full" :class="{ 'bg-white/70': !playerIsStartingForThisMedia }" @click.stop="playEpisode">
|
||||
<span v-if="!playerIsStartingForThisMedia" class="material-icons text-6xl text-black/80">{{ streamIsPlaying ? 'pause_circle' : 'play_circle_filled' }}</span>
|
||||
<span v-if="!playerIsStartingForThisMedia" class="material-symbols fill text-6xl text-black/80">{{ streamIsPlaying ? 'pause_circle' : 'play_circle' }}</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" />
|
||||
@@ -59,30 +59,30 @@
|
||||
</div>
|
||||
|
||||
<!-- No progress shown for collapsed series in library -->
|
||||
<div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
<div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 max-w-full z-10 rounded-b box-shadow-progressbar" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
|
||||
<!-- Downloaded icon -->
|
||||
<div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast || (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-symbols text-2xl text-success">download_done</span>
|
||||
</div>
|
||||
|
||||
<!-- Error widget -->
|
||||
<div v-if="showError" :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
|
||||
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
|
||||
<div v-if="showError" :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300 absolute bottom-4 left-0 z-10">
|
||||
<span class="material-symbols text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
|
||||
</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>
|
||||
<span class="material-symbols" :style="{ fontSize: sizeMultiplier * 1.5 + 'rem' }">rss_feed</span>
|
||||
</div>
|
||||
|
||||
<!-- 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/90 text-white box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Episode # -->
|
||||
<div v-if="recentEpisodeNumber !== null && !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/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 class="text-white" :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">
|
||||
Episode<span v-if="recentEpisodeNumber"> #{{ recentEpisodeNumber }}</span>
|
||||
</p>
|
||||
@@ -153,10 +153,6 @@ export default {
|
||||
isLocal() {
|
||||
return !!this._libraryItem.isLocal
|
||||
},
|
||||
isLocalOnly() {
|
||||
// Local item with no server match
|
||||
return this.isLocal && !this._libraryItem.libraryItemId
|
||||
},
|
||||
media() {
|
||||
return this._libraryItem.media || {}
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<!-- No progress shown for collapsed series or podcasts in library -->
|
||||
<div v-if="!isPodcast && !collapsedSeries" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * userProgressPercent + 'px' }"></div>
|
||||
</div>
|
||||
<div class="flex-grow px-2">
|
||||
<div class="flex-grow pl-2" :class="showPlayButton ? 'pr-12' : 'pr-2'">
|
||||
<p class="whitespace-normal line-clamp-2" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
|
||||
<span v-if="seriesSequence">#{{ seriesSequence }} </span>{{ displayTitle }}
|
||||
</p>
|
||||
@@ -29,9 +29,19 @@
|
||||
{{ $getString('LabelNumEpisodes', [numEpisodes]) }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="showPlayButton" class="absolute top-0 bottom-0 right-0 h-full flex items-center justify-center z-20 pr-1">
|
||||
<button type="button" class="relative rounded-full bg-fg-muted/50" :class="{ 'p-2': !playerIsStartingForThisMedia }" @click.stop.prevent="play">
|
||||
<span v-if="!playerIsStartingForThisMedia" class="material-symbols text-2xl fill text-white">{{ playerIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||
<div v-else class="p-2 text-fg w-10 h-10 flex items-center justify-center bg-fg-muted/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>
|
||||
</button>
|
||||
</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` }">
|
||||
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
|
||||
<span class="material-symbols text-2xl text-success">download_done</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -93,10 +103,6 @@ export default {
|
||||
isLocal() {
|
||||
return !!this._libraryItem.isLocal
|
||||
},
|
||||
isLocalOnly() {
|
||||
// Local item with no server match
|
||||
return this.isLocal && !this._libraryItem.libraryItemId
|
||||
},
|
||||
media() {
|
||||
return this._libraryItem.media || {}
|
||||
},
|
||||
@@ -133,6 +139,9 @@ export default {
|
||||
libraryItemId() {
|
||||
return this._libraryItem.id
|
||||
},
|
||||
localLibraryItemId() {
|
||||
return this.localLibraryItem?.id
|
||||
},
|
||||
series() {
|
||||
return this.mediaMetadata.series
|
||||
},
|
||||
@@ -221,13 +230,31 @@ export default {
|
||||
return this.isMissing || this.isInvalid
|
||||
},
|
||||
isStreaming() {
|
||||
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
|
||||
return this.isPlaying && !this.store.getters['getIsCurrentSessionLocal']
|
||||
},
|
||||
isPlaying() {
|
||||
if (this.localLibraryItemId && this.store.getters['getIsMediaStreaming'](this.localLibraryItemId)) return true
|
||||
return this.store.getters['getIsMediaStreaming'](this.libraryItemId)
|
||||
},
|
||||
playerIsPlaying() {
|
||||
return this.store.state.playerIsPlaying && (this.isStreaming || this.isPlaying)
|
||||
},
|
||||
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.libraryItemId
|
||||
},
|
||||
isCasting() {
|
||||
return this.store.state.isCasting
|
||||
},
|
||||
showReadButton() {
|
||||
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook
|
||||
},
|
||||
showPlayButton() {
|
||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
|
||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isPodcast
|
||||
},
|
||||
showSmallEBookIcon() {
|
||||
return !this.isSelectionMode && this.hasEbook
|
||||
@@ -297,9 +324,32 @@ export default {
|
||||
this.selected = !this.selected
|
||||
this.$emit('select', this.libraryItem)
|
||||
},
|
||||
play() {
|
||||
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId })
|
||||
async play() {
|
||||
if (this.playerIsStartingPlayback) return
|
||||
|
||||
const hapticsImpact = this.$hapticsImpact || this.$nuxt.$hapticsImpact
|
||||
if (hapticsImpact) {
|
||||
await hapticsImpact()
|
||||
}
|
||||
|
||||
const eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||
|
||||
if (this.playerIsPlaying) {
|
||||
eventBus.$emit('pause-item')
|
||||
} else {
|
||||
// Audiobook
|
||||
let libraryItemId = this.libraryItemId
|
||||
|
||||
// When casting use server library item
|
||||
if (this.localLibraryItem && !this.isCasting) {
|
||||
libraryItemId = this.localLibraryItem.id
|
||||
} else if (this.hasLocal) {
|
||||
libraryItemId = this.localLibraryItem.id
|
||||
}
|
||||
|
||||
this.store.commit('setPlayerIsStartingPlayback', this.libraryItemId)
|
||||
eventBus.$emit('play-item', { libraryItemId, serverLibraryItemId: this.libraryItemId })
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
// destroy the vue listeners, etc
|
||||
@@ -363,4 +413,4 @@ export default {
|
||||
min-width: var(--list-card-cover-width);
|
||||
max-width: var(--list-card-cover-width);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="title" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
|
||||
<div v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b w-full" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
|
||||
<div v-if="seriesPercentInProgress > 0" class="absolute bottom-0 left-0 h-1 max-w-full z-10 rounded-b w-full box-shadow-progressbar" :class="isSeriesFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: seriesPercentInProgress * 100 + '%' }" />
|
||||
|
||||
<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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<div class="w-10 h-10 flex items-center justify-center">
|
||||
<span class="material-icons text-2xl text-gray-200">record_voice_over</span>
|
||||
<span class="material-symbols text-2xl text-gray-200">record_voice_over</span>
|
||||
</div>
|
||||
<div class="flex-grow px-2 narratorSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ narrator }}</p>
|
||||
@@ -31,4 +31,4 @@ export default {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<div class="w-10 h-10 flex items-center justify-center">
|
||||
<span class="material-icons text-2xl text-fg">local_offer</span>
|
||||
<span class="material-symbols fill text-2xl text-fg">sell</span>
|
||||
</div>
|
||||
<div class="flex-grow px-2 tagSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ tag }}</p>
|
||||
@@ -31,4 +31,4 @@ export default {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
<template v-if="!showForm">
|
||||
<div v-for="config in serverConnectionConfigs" :key="config.id" class="border-b border-fg/10 py-4">
|
||||
<div class="flex items-center my-1 relative" @click="connectToServer(config)">
|
||||
<span class="material-icons-outlined text-xl text-fg-muted">dns</span>
|
||||
<span class="material-symbols text-xl text-fg-muted">dns</span>
|
||||
<p class="pl-3 pr-6 text-base text-fg">{{ config.name }}</p>
|
||||
|
||||
<div class="absolute top-0 right-0 h-full px-4 flex items-center" @click.stop="editServerConfig(config)">
|
||||
<span class="material-icons text-lg text-fg-muted">more_vert</span>
|
||||
<span class="material-symbols text-2xl text-fg-muted">more_vert</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- warning message if server connection config is using an old user id -->
|
||||
@@ -17,6 +17,11 @@
|
||||
<p class="text-xs text-warning">{{ $strings.MessageOldServerConnectionWarning }}</p>
|
||||
<ui-btn class="text-xs whitespace-nowrap" :padding-x="2" :padding-y="1" @click="showOldUserIdWarningDialog">{{ $strings.LabelMoreInfo }}</ui-btn>
|
||||
</div>
|
||||
<!-- warning message if server connection config is using an old auth method -->
|
||||
<div v-if="config.version && checkIsUsingOldAuth(config)" class="flex flex-nowrap justify-between items-center space-x-4 pt-4">
|
||||
<p class="text-xs text-warning">{{ $strings.MessageOldServerAuthWarning }}</p>
|
||||
<ui-btn class="text-xs whitespace-nowrap" :padding-x="2" :padding-y="1" @click="showOldAuthWarningDialog">{{ $strings.LabelMoreInfo }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-1 py-4 w-full">
|
||||
<ui-btn class="w-full" @click="newServerConfigClick">{{ $strings.ButtonAddNewServer }}</ui-btn>
|
||||
@@ -27,7 +32,7 @@
|
||||
<!-- server address input -->
|
||||
<form v-if="!showAuth" @submit.prevent="submit" novalidate class="w-full">
|
||||
<div v-if="serverConnectionConfigs.length" class="flex items-center mb-4" @click="showServerList">
|
||||
<span class="material-icons text-fg-muted">arrow_back</span>
|
||||
<span class="material-symbols text-fg-muted">arrow_back</span>
|
||||
</div>
|
||||
<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" />
|
||||
@@ -38,13 +43,13 @@
|
||||
<!-- username/password and auth methods -->
|
||||
<template v-else>
|
||||
<div v-if="serverConfig.id" class="flex items-center mb-4" @click="showServerList">
|
||||
<span class="material-icons text-fg-muted">arrow_back</span>
|
||||
<span class="material-symbols text-fg-muted">arrow_back</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<p class="text-fg-muted">{{ serverConfig.address }}</p>
|
||||
<div class="flex-grow" />
|
||||
<span v-if="!serverConfig.id" class="material-icons" style="font-size: 1.1rem" @click="editServerAddress">edit</span>
|
||||
<span v-if="!serverConfig.id" class="material-symbols" style="font-size: 1.1rem" @click="editServerAddress">edit</span>
|
||||
</div>
|
||||
<div class="w-full h-px bg-fg/10 my-2" />
|
||||
<form v-if="isLocalAuthEnabled" @submit.prevent="submitAuth" class="pt-3">
|
||||
@@ -64,7 +69,7 @@
|
||||
|
||||
<!-- 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">
|
||||
<span class="material-icons mr-2 text-error" style="font-size: 1.1rem">warning</span>
|
||||
<span class="material-symbols mr-2 text-error" style="font-size: 1.1rem">warning</span>
|
||||
<p class="text-error">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -155,9 +160,23 @@ export default {
|
||||
cancelText: this.$strings.ButtonOk
|
||||
})
|
||||
},
|
||||
async showOldAuthWarningDialog() {
|
||||
const confirmResult = await Dialog.confirm({
|
||||
title: 'Old Server Auth Warning',
|
||||
message: this.$strings.MessageOldServerAuthWarningHelp,
|
||||
cancelButtonTitle: this.$strings.ButtonReadMore
|
||||
})
|
||||
if (!confirmResult.value) {
|
||||
window.open('https://github.com/advplyr/audiobookshelf/discussions/4460', '_blank')
|
||||
}
|
||||
},
|
||||
checkIdUuid(userId) {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(userId)
|
||||
},
|
||||
checkIsUsingOldAuth(config) {
|
||||
if (!config.version) return true
|
||||
return !this.$isValidVersion(config.version, '2.26.0')
|
||||
},
|
||||
/**
|
||||
* Initiates the login process using OpenID via OAuth2.0.
|
||||
* 1. Verifying the server's address
|
||||
@@ -374,15 +393,19 @@ export default {
|
||||
} catch (error) {} // No Error handling needed
|
||||
|
||||
try {
|
||||
// Returns the same user response payload as /login
|
||||
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.')
|
||||
// v2.26.0+ returns accessToken and refreshToken on user object
|
||||
if (!response.data?.user?.token && !response.data?.user?.accessToken) {
|
||||
throw new Error('Token is missing in response.')
|
||||
}
|
||||
|
||||
this.serverConfig.token = response.data.user.token
|
||||
const user = response.data.user
|
||||
this.serverConfig.token = user.accessToken || user.token
|
||||
|
||||
// TODO: Is it necessary to authenticate again?
|
||||
const payload = await this.authenticateToken()
|
||||
|
||||
if (!payload) {
|
||||
@@ -394,6 +417,12 @@ export default {
|
||||
throw new Error('Config already exists for this address and username.')
|
||||
}
|
||||
|
||||
// For v2.26.0+ re-attach accessToken and refreshToken to user object because /authorize does not return them
|
||||
if (user.accessToken) {
|
||||
payload.user.accessToken = user.accessToken
|
||||
payload.user.refreshToken = user.refreshToken
|
||||
}
|
||||
|
||||
this.setUserAndConnection(payload)
|
||||
} catch (error) {
|
||||
console.error('[SSO] Error in exchangeCodeForToken: ', error)
|
||||
@@ -436,9 +465,10 @@ export default {
|
||||
}
|
||||
|
||||
this.error = null
|
||||
var payload = await this.authenticateToken()
|
||||
const payload = await this.authenticateToken()
|
||||
|
||||
if (payload) {
|
||||
// Will NOT include access token and refresh token
|
||||
this.setUserAndConnection(payload)
|
||||
} else {
|
||||
this.showAuth = true
|
||||
@@ -504,7 +534,7 @@ export default {
|
||||
try {
|
||||
var urlObject = new URL(url)
|
||||
if (protocolOverride) urlObject.protocol = protocolOverride
|
||||
return urlObject.href
|
||||
return urlObject.href.replace(/\/$/, '') // Remove trailing slash
|
||||
} catch (error) {
|
||||
console.error('Invalid URL', error)
|
||||
return null
|
||||
@@ -597,7 +627,12 @@ export default {
|
||||
})
|
||||
},
|
||||
requestServerLogin() {
|
||||
return this.postRequest(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password || '' }, this.serverConfig.customHeaders, 20000)
|
||||
const headers = {
|
||||
// Tells the Abs server to return the refresh token
|
||||
'x-return-tokens': 'true',
|
||||
...(this.serverConfig.customHeaders || {})
|
||||
}
|
||||
return this.postRequest(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password || '' }, headers, 20000)
|
||||
.then((data) => {
|
||||
if (!data.user) {
|
||||
console.error(data.error)
|
||||
@@ -642,9 +677,13 @@ export default {
|
||||
if (statusData.data.authFormData?.authOpenIDAutoLaunch) {
|
||||
this.clickLoginWithOpenId()
|
||||
}
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleLoginFormError(error)
|
||||
return false
|
||||
} finally {
|
||||
this.processing = false
|
||||
}
|
||||
@@ -770,26 +809,6 @@ export default {
|
||||
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() {
|
||||
if (!this.networkConnected) return
|
||||
if (!this.serverConfig.username) {
|
||||
@@ -806,9 +825,10 @@ export default {
|
||||
this.error = null
|
||||
this.processing = true
|
||||
|
||||
var payload = await this.requestServerLogin()
|
||||
const payload = await this.requestServerLogin()
|
||||
this.processing = false
|
||||
if (payload) {
|
||||
// Will include access token and refresh token
|
||||
this.setUserAndConnection(payload)
|
||||
}
|
||||
},
|
||||
@@ -821,18 +841,30 @@ export default {
|
||||
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
|
||||
this.$setServerLanguageCode(serverSettings.language)
|
||||
|
||||
// Set library - Use last library if set and available fallback to default user library
|
||||
var lastLibraryId = await this.$localStore.getLastLibraryId()
|
||||
if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) {
|
||||
this.$store.commit('libraries/setCurrentLibrary', lastLibraryId)
|
||||
} else if (userDefaultLibraryId) {
|
||||
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
|
||||
this.serverConfig.userId = user.id
|
||||
this.serverConfig.username = user.username
|
||||
|
||||
if (this.$isValidVersion(serverSettings.version, '2.26.0')) {
|
||||
// Tokens only returned from /login endpoint
|
||||
if (user.accessToken) {
|
||||
this.serverConfig.token = user.accessToken
|
||||
this.serverConfig.refreshToken = user.refreshToken
|
||||
} else {
|
||||
// Detect if the connection config is using the old token. If so, force re-login
|
||||
if (this.serverConfig.token === user.token || user.isOldToken) {
|
||||
this.setForceReloginForNewAuth()
|
||||
return
|
||||
}
|
||||
|
||||
// If the token was updated during a refresh (in nativeHttp.js) it gets updated in the store, so refetch
|
||||
this.serverConfig.token = this.$store.getters['user/getToken'] || this.serverConfig.token
|
||||
}
|
||||
} else {
|
||||
// Server version before new JWT auth, use old user.token
|
||||
this.serverConfig.token = user.token
|
||||
}
|
||||
|
||||
this.serverConfig.userId = user.id
|
||||
this.serverConfig.token = user.token
|
||||
this.serverConfig.username = user.username
|
||||
delete this.serverConfig.version
|
||||
this.serverConfig.version = serverSettings.version
|
||||
|
||||
var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig)
|
||||
|
||||
@@ -849,7 +881,16 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// Set library - Use last library if set and available fallback to default user library
|
||||
const lastLibraryId = await this.$localStore.getLastLibraryId()
|
||||
if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) {
|
||||
this.$store.commit('libraries/setCurrentLibrary', lastLibraryId)
|
||||
} else if (userDefaultLibraryId) {
|
||||
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
|
||||
}
|
||||
|
||||
this.$store.commit('user/setUser', user)
|
||||
this.$store.commit('user/setAccessToken', serverConnectionConfig.token)
|
||||
this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig)
|
||||
|
||||
this.$socket.connect(this.serverConfig.address, this.serverConfig.token)
|
||||
@@ -865,7 +906,13 @@ export default {
|
||||
this.error = null
|
||||
this.processing = true
|
||||
|
||||
const authRes = await this.postRequest(`${this.serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${this.serverConfig.token}` }).catch((error) => {
|
||||
const nativeHttpOptions = {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.serverConfig.token}`
|
||||
},
|
||||
serverConnectionConfig: this.serverConfig
|
||||
}
|
||||
const authRes = await this.$nativeHttp.post(`${this.serverConfig.address}/api/authorize`, null, nativeHttpOptions).catch((error) => {
|
||||
console.error('[ServerConnectForm] Server auth failed', error)
|
||||
const errorMsg = error.message || error
|
||||
this.error = 'Failed to authorize'
|
||||
@@ -874,14 +921,30 @@ export default {
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
console.log('[ServerConnectForm] authRes=', authRes)
|
||||
|
||||
this.processing = false
|
||||
return authRes
|
||||
},
|
||||
async setForceReloginForNewAuth() {
|
||||
// This calls /status on the server and sets the auth methods
|
||||
const result = await this.submit()
|
||||
if (result) {
|
||||
this.error = this.$strings.MessageOldServerAuthReLoginRequired
|
||||
}
|
||||
},
|
||||
init() {
|
||||
// Handle force re-login for servers using new JWT auth but still using an old token in the server config
|
||||
if (this.$route.query.error === 'oldAuthToken' && this.$route.query.serverConnectionConfigId) {
|
||||
this.serverConfig = this.serverConnectionConfigs.find((scc) => scc.id === this.$route.query.serverConnectionConfigId)
|
||||
if (this.serverConfig) {
|
||||
this.setForceReloginForNewAuth()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (this.lastServerConnectionConfig) {
|
||||
console.log('[ServerConnectForm] init with lastServerConnectionConfig', this.lastServerConnectionConfig)
|
||||
this.connectToServer(this.lastServerConnectionConfig)
|
||||
} else {
|
||||
this.showForm = !this.serverConnectionConfigs.length
|
||||
|
||||
@@ -56,11 +56,15 @@ export default {
|
||||
},
|
||||
imgSrc() {
|
||||
if (!this.imagePath || !this.serverAddress) return null
|
||||
const urlQuery = new URLSearchParams({ ts: this.updatedAt })
|
||||
if (this.$store.getters.getDoesServerImagesRequireToken) {
|
||||
urlQuery.append('token', this.$store.getters['user/getToken'])
|
||||
}
|
||||
if (process.env.NODE_ENV !== 'production' && this.serverAddress.startsWith('http://192.168')) {
|
||||
// Testing
|
||||
return `http://localhost:3333/api/authors/${this.authorId}/image?ts=${this.updatedAt}`
|
||||
return `http://localhost:3333/api/authors/${this.authorId}/image?${urlQuery.toString()}`
|
||||
}
|
||||
return `${this.serverAddress}/api/authors/${this.authorId}/image?ts=${this.updatedAt}`
|
||||
return `${this.serverAddress}/api/authors/${this.authorId}/image?${urlQuery.toString()}`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
|
||||
|
||||
<a v-if="!imageFailed && showOpenNewTab && isHovering" :href="cover" @click.stop target="_blank" class="absolute bg-primary flex items-center justify-center shadow-sm rounded-full hover:scale-110 transform duration-100" :style="{ top: sizeMultiplier * 0.5 + 'rem', right: sizeMultiplier * 0.5 + 'rem', width: 2.5 * sizeMultiplier + 'rem', height: 2.5 * sizeMultiplier + 'rem' }">
|
||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.75 + 'rem' }">open_in_new</span>
|
||||
<span class="material-symbols" :style="{ fontSize: sizeMultiplier * 1.75 + 'rem' }">open_in_new</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -100,4 +100,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -62,7 +62,7 @@ export default {
|
||||
items.push({
|
||||
to: '/bookshelf/add-podcast',
|
||||
routeName: 'bookshelf-add-podcast',
|
||||
iconPack: 'material-icons',
|
||||
iconPack: 'material-symbols',
|
||||
icon: 'podcasts',
|
||||
iconClass: 'text-xl',
|
||||
text: this.$strings.ButtonAdd
|
||||
@@ -97,7 +97,7 @@ export default {
|
||||
{
|
||||
to: '/bookshelf/collections',
|
||||
routeName: 'bookshelf-collections',
|
||||
iconPack: 'material-icons-outlined',
|
||||
iconPack: 'material-symbols',
|
||||
icon: 'collections_bookmark',
|
||||
iconClass: 'text-xl',
|
||||
text: this.$strings.ButtonCollections
|
||||
@@ -117,8 +117,9 @@ export default {
|
||||
items.push({
|
||||
to: '/bookshelf/playlists',
|
||||
routeName: 'bookshelf-playlists',
|
||||
iconPack: 'material-icons',
|
||||
iconPack: 'material-symbols',
|
||||
icon: 'queue_music',
|
||||
iconClass: 'text-2xl',
|
||||
text: this.$strings.ButtonPlaylists
|
||||
})
|
||||
}
|
||||
@@ -149,4 +150,4 @@ export default {
|
||||
#bookshelf-navbar a {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
<p v-show="!selectedSeriesName" class="pt-1">{{ $formatNumber(totalEntities) }} {{ entityTitle }}</p>
|
||||
<p v-show="selectedSeriesName" class="ml-2 pt-1">{{ selectedSeriesName }} ({{ $formatNumber(totalEntities) }})</p>
|
||||
<div class="flex-grow" />
|
||||
<span v-if="page == 'library' || seriesBookPage" class="material-icons px-2" @click="changeView">{{ !bookshelfListView ? 'view_list' : 'grid_view' }}</span>
|
||||
<span v-if="page == 'library' || seriesBookPage" class="material-symbols text-2xl px-2" @click="changeView">{{ !bookshelfListView ? 'view_list' : 'grid_view' }}</span>
|
||||
<template v-if="page === 'library'">
|
||||
<div class="relative flex items-center px-2">
|
||||
<span class="material-icons" @click="showFilterModal = true">filter_alt</span>
|
||||
<span class="material-symbols text-2xl" @click="showFilterModal = true">filter_alt</span>
|
||||
<div v-show="hasFilters" class="absolute top-0 right-2 w-2 h-2 rounded-full bg-success border border-green-300 shadow-sm z-10 pointer-events-none" />
|
||||
</div>
|
||||
<span class="material-icons px-2" @click="showSortModal = true">sort</span>
|
||||
<span class="material-symbols text-2xl px-2" @click="showSortModal = true">sort</span>
|
||||
</template>
|
||||
<span v-if="seriesBookPage" class="material-icons px-2" @click="downloadSeries">download</span>
|
||||
<span v-if="(page == 'library' && isBookLibrary) || seriesBookPage" class="material-icons px-2" @click="showMoreMenuDialog = true">more_vert</span>
|
||||
<span v-if="seriesBookPage" class="material-symbols text-2xl px-2" @click="downloadSeries">download</span>
|
||||
<span v-if="(page == 'library' && isBookLibrary) || seriesBookPage" class="material-symbols text-2xl px-2" @click="showMoreMenuDialog = true">more_vert</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,22 +6,25 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="
|
||||
<div
|
||||
class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center"
|
||||
@click="
|
||||
show = false
|
||||
manualTimerModal = false
|
||||
">
|
||||
"
|
||||
>
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<div v-if="manualTimerModal" class="p-4">
|
||||
<div class="flex mb-4" @click="manualTimerModal = false">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
<span class="material-symbols text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<div class="flex my-2 justify-between">
|
||||
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px">
|
||||
<span class="material-icons">remove</span>
|
||||
<span class="material-symbols">remove</span>
|
||||
</ui-btn>
|
||||
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
|
||||
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px">
|
||||
<span class="material-icons">add</span>
|
||||
<span class="material-symbols">add</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">{{ $strings.ButtonSetTimer }}</ui-btn>
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
<div class="w-full h-full p-4" v-if="showBookmarkTitleInput">
|
||||
<div class="flex mb-4 items-center">
|
||||
<div class="w-9 h-9 flex items-center justify-center rounded-full hover:bg-white hover:bg-opacity-10 cursor-pointer" @click.stop="showBookmarkTitleInput = false">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
<span class="material-symbols text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<p class="text-xl pl-2">{{ selectedBookmark ? 'Edit Bookmark' : 'New Bookmark' }}</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="text-xl font-mono">{{ this.$secondsToTimestamp(currentTime / _playbackRate) }}</p>
|
||||
</div>
|
||||
|
||||
<ui-text-input-with-label v-model="newBookmarkTitle" ref="noteInput" label="Note" />
|
||||
<ui-text-input-with-label v-model="newBookmarkTitle" :placeholder="bookmarkPlaceholder()" :autofocus="false" ref="noteInput" label="Note" />
|
||||
<div class="flex justify-end mt-6">
|
||||
<ui-btn color="success" class="w-full" @click.stop="submitBookmark">{{ selectedBookmark ? 'Update' : 'Create' }}</ui-btn>
|
||||
</div>
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="canCreateBookmark && !showBookmarkTitleInput" 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 sticky bottom-0 left-0 w-full" @click.stop="createBookmark">
|
||||
<span class="material-icons">add</span>
|
||||
<span class="material-symbols">add</span>
|
||||
<p class="text-base pl-2">{{ $strings.ButtonCreateBookmark }}</p>
|
||||
<p class="text-sm font-mono">{{ this.$secondsToTimestamp(currentTime / _playbackRate) }}</p>
|
||||
</div>
|
||||
@@ -93,6 +93,10 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
bookmarkPlaceholder() {
|
||||
// using a method prevents caching the date
|
||||
return this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
||||
},
|
||||
editBookmark(bm) {
|
||||
this.selectedBookmark = bm
|
||||
this.newBookmarkTitle = bm.title
|
||||
@@ -157,18 +161,8 @@ export default {
|
||||
},
|
||||
createBookmark() {
|
||||
this.selectedBookmark = null
|
||||
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
||||
this.newBookmarkTitle = ''
|
||||
this.showBookmarkTitleInput = true
|
||||
|
||||
// Auto focus the input and select the text
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.noteInput?.$refs.input?.$refs.input) {
|
||||
this.$refs.noteInput.$refs.input.$refs.input.focus()
|
||||
setTimeout(() => {
|
||||
this.$refs.noteInput?.$refs.input?.$refs.input?.select()
|
||||
}, 10)
|
||||
}
|
||||
})
|
||||
},
|
||||
async submitBookmark() {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<p class="text-sm text-gray-400">{{ value }}</p>
|
||||
|
||||
<div class="absolute top-0 bottom-0 right-0 h-full p-4 flex items-center justify-center text-error">
|
||||
<button @click="removeHeader(key)"><span class="material-icons text-lg">delete</span></button>
|
||||
<button @click="removeHeader(key)"><span class="material-symbols text-lg">delete</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<slot :name="item.value" :item="item" :selected="item.value === selected">
|
||||
<li :key="item.value" :ref="`item-${item.value}`" class="text-fg select-none relative cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" :style="{ paddingTop: itemPaddingY, paddingBottom: itemPaddingY }" role="option" @click="clickedOption(item.value)">
|
||||
<div class="relative flex items-center px-3">
|
||||
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-fg text-opacity-80">{{ item.icon }}</span>
|
||||
<span v-if="item.icon" class="material-symbols text-xl mr-2 text-fg text-opacity-80">{{ item.icon }}</span>
|
||||
<p class="font-normal block truncate text-base text-fg text-opacity-80">{{ item.text }}</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<span class="font-normal ml-3 block truncate text-lg">{{ item.text }}</span>
|
||||
</div>
|
||||
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
|
||||
<span class="material-icons text-2xl">arrow_right</span>
|
||||
<span class="material-symbols text-2xl">arrow_right</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
@@ -22,7 +22,7 @@
|
||||
<ul v-show="sublist" class="h-full w-full rounded-lg" role="listbox" aria-labelledby="listbox-label">
|
||||
<li class="text-fg select-none relative py-3 pl-9 cursor-pointer" role="option" @click="sublist = null">
|
||||
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
|
||||
<span class="material-icons text-2xl">arrow_left</span>
|
||||
<span class="material-symbols text-2xl">arrow_left</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-normal ml-3 block truncate text-lg">{{ $strings.ButtonBack }}</span>
|
||||
@@ -54,8 +54,39 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
sublist: null,
|
||||
bookItems: [
|
||||
sublist: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
if (!newVal) {
|
||||
if (this.sublist && !this.selectedItemSublist) this.sublist = null
|
||||
if (!this.sublist && this.selectedItemSublist) this.sublist = this.selectedItemSublist
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
selected: {
|
||||
get() {
|
||||
return this.filterBy
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:filterBy', val)
|
||||
}
|
||||
},
|
||||
userCanAccessExplicitContent() {
|
||||
return this.$store.getters['user/getUserCanAccessExplicitContent']
|
||||
},
|
||||
bookItems() {
|
||||
const items = [
|
||||
{
|
||||
text: this.$strings.LabelAll,
|
||||
value: 'all'
|
||||
@@ -104,9 +135,26 @@ export default {
|
||||
text: this.$strings.ButtonIssues,
|
||||
value: 'issues',
|
||||
sublist: false
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelRSSFeedOpen,
|
||||
value: 'feed-open',
|
||||
sublist: false
|
||||
}
|
||||
],
|
||||
podcastItems: [
|
||||
]
|
||||
|
||||
if (this.userCanAccessExplicitContent) {
|
||||
items.push({
|
||||
text: this.$strings.LabelExplicit,
|
||||
value: 'explicit',
|
||||
sublist: false
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
},
|
||||
podcastItems() {
|
||||
const items = [
|
||||
{
|
||||
text: this.$strings.LabelAll,
|
||||
value: 'all'
|
||||
@@ -120,34 +168,23 @@ export default {
|
||||
text: this.$strings.LabelTag,
|
||||
value: 'tags',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelRSSFeedOpen,
|
||||
value: 'feed-open',
|
||||
sublist: false
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
if (!newVal) {
|
||||
if (this.sublist && !this.selectedItemSublist) this.sublist = null
|
||||
if (!this.sublist && this.selectedItemSublist) this.sublist = this.selectedItemSublist
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
selected: {
|
||||
get() {
|
||||
return this.filterBy
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:filterBy', val)
|
||||
|
||||
if (this.userCanAccessExplicitContent) {
|
||||
items.push({
|
||||
text: this.$strings.LabelExplicit,
|
||||
value: 'explicit',
|
||||
sublist: false
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
},
|
||||
isPodcast() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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-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-symbols text-4xl">close</span>
|
||||
</div>
|
||||
<slot name="outer" />
|
||||
<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">
|
||||
@@ -110,4 +110,4 @@ export default {
|
||||
this.$eventBus.$off('close-modal', this.closeModalEvt)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<span class="font-normal ml-3 block truncate text-lg">{{ item.text }}</span>
|
||||
</div>
|
||||
<span v-if="item.value === selected" class="text-yellow-300 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-icons text-3xl">{{ descending ? 'south' : 'north' }}</span>
|
||||
<span class="material-symbols text-3xl">{{ descending ? 'south' : 'north' }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
@@ -119,6 +119,10 @@ export default {
|
||||
{
|
||||
text: this.$strings.LabelEpisode,
|
||||
value: 'episode'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelFilename,
|
||||
value: 'audioFile.metadata.filename'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -19,13 +19,13 @@
|
||||
</ul>
|
||||
<div class="flex items-center justify-center py-3 border-t border-fg/10">
|
||||
<button :disabled="!canDecrement" @click="decrement" class="icon-num-btn w-8 h-8 text-fg-muted rounded border border-border flex items-center justify-center">
|
||||
<span class="material-icons">remove</span>
|
||||
<span class="material-symbols">remove</span>
|
||||
</button>
|
||||
<div class="w-24 text-center">
|
||||
<p class="text-xl">{{ playbackRate }}<span class="text-lg">⨯</span></p>
|
||||
</div>
|
||||
<button :disabled="!canIncrement" @click="increment" class="icon-num-btn w-8 h-8 text-fg-muted rounded border border-border flex items-center justify-center">
|
||||
<span class="material-icons">add</span>
|
||||
<span class="material-symbols">add</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,4 +118,4 @@ button.icon-num-btn:disabled::before {
|
||||
button.icon-num-btn:disabled span {
|
||||
color: #777;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<template v-for="(episode, index) in episodes">
|
||||
<div :key="index" class="relative" :class="itemEpisodeMap[episode.enclosure.url] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'bg-success bg-opacity-10' : index % 2 == 0 ? 'bg-primary bg-opacity-25' : 'bg-primary bg-opacity-5'" @click="selectEpisode(episode, index)">
|
||||
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
||||
<span v-if="itemEpisodeMap[episode.enclosure.url]" class="material-icons text-success text-xl">download_done</span>
|
||||
<span v-if="itemEpisodeMap[episode.enclosure.url]" class="material-symbols text-success text-xl">download_done</span>
|
||||
<ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
|
||||
</div>
|
||||
<div class="pl-9 pr-2 py-2 border-b border-white border-opacity-10">
|
||||
@@ -134,4 +134,4 @@ export default {
|
||||
max-height: calc(100vh - 150px);
|
||||
margin-top: 5px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
</template>
|
||||
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" 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" style="max-height: 75%" @click.stop>
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="folder in localFolders">
|
||||
<li :key="folder.id" :id="`folder-${folder.id}`" class="text-gray-50 select-none relative py-5" role="option" @click="clickedOption(folder)">
|
||||
<li :key="folder.id" :id="`folder-${folder.id}`" class="text-fg select-none relative py-5" role="option" @click="clickedOption(folder)">
|
||||
<div class="relative flex items-center pl-3" style="padding-right: 4.5rem">
|
||||
<span class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">folder</span>
|
||||
<p class="font-normal block truncate text-sm text-white text-opacity-80">{{ folder.name }}</p>
|
||||
<span class="material-symbols text-xl mr-2 text-fg text-opacity-80">folder</span>
|
||||
<p class="font-normal block truncate text-sm text-fg text-opacity-80">{{ folder.name }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@@ -16,12 +16,12 @@
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<div v-if="manualTimerModal" class="p-4">
|
||||
<div class="flex mb-4" @click="manualTimerModal = false">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
<span class="material-symbols text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<div class="flex my-2 justify-between">
|
||||
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
|
||||
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-symbols">remove</span></ui-btn>
|
||||
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
|
||||
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
|
||||
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-symbols">add</span></ui-btn>
|
||||
</div>
|
||||
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">{{ $strings.ButtonSetTimer }}</ui-btn>
|
||||
</div>
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-border" style="max-height: 75%" @click.stop>
|
||||
<div v-if="manualTimerModal" class="p-4">
|
||||
<div class="flex mb-4" @click="manualTimerModal = false">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
<span class="material-symbols text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<div class="flex my-2 justify-between">
|
||||
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
|
||||
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-symbols text-lg">remove</span></ui-btn>
|
||||
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
|
||||
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
|
||||
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-symbols text-lg">add</span></ui-btn>
|
||||
</div>
|
||||
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">{{ $strings.ButtonSetTimer }}</ui-btn>
|
||||
</div>
|
||||
@@ -40,9 +40,9 @@
|
||||
</ul>
|
||||
<div v-else class="p-4">
|
||||
<div class="flex my-2 justify-between">
|
||||
<ui-btn @click="decreaseSleepTime" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
|
||||
<ui-btn @click="decreaseSleepTime" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-symbols text-lg">remove</span></ui-btn>
|
||||
<p class="text-2xl font-mono text-center">{{ timeRemainingPretty }}</p>
|
||||
<ui-btn @click="increaseSleepTime" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
|
||||
<ui-btn @click="increaseSleepTime" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-symbols text-lg">add</span></ui-btn>
|
||||
</div>
|
||||
|
||||
<ui-btn @click="cancelSleepTimer" class="w-full">{{ isAuto ? $strings.ButtonDisableAutoTimer : $strings.ButtonCancelTimer }}</ui-btn>
|
||||
@@ -85,7 +85,11 @@ export default {
|
||||
return [5, 10, 15, 30, 45, 60, 90]
|
||||
},
|
||||
timeRemainingPretty() {
|
||||
if (this.currentTime <= 0) return '0:00'
|
||||
return this.$secondsToTimestamp(this.currentTime)
|
||||
},
|
||||
isIos() {
|
||||
return this.$platform === 'ios'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -105,7 +109,7 @@ export default {
|
||||
if (this.isAuto) {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: 'Are you sure you want to disable the auto sleep timer? You will need to enable this again in settings.'
|
||||
message: this.$strings.MessageConfirmDisableAutoTimer
|
||||
})
|
||||
if (!value) return
|
||||
}
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
<div :key="bookmark.time" :id="`bookmark-row-${bookmark.time}`" class="flex items-center px-1 py-4 justify-start relative" :class="highlight ? 'bg-bg bg-opacity-60' : ' bg-opacity-20'" @click="click">
|
||||
<div class="flex-grow overflow-hidden px-2">
|
||||
<div class="flex items-center mb-0.5">
|
||||
<i class="material-icons text-lg pr-1 -mb-1" :class="highlight ? 'text-success' : 'text-fg-muted'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</i>
|
||||
<i class="material-symbols text-lg pr-1 -mb-1" :class="{ 'text-success fill': highlight, 'text-fg-muted': !highlight }">bookmark</i>
|
||||
<p class="truncate text-sm">
|
||||
{{ bookmark.title }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm font-mono text-fg-muted flex items-center"><span class="material-icons text-base pl-px pr-1">schedule</span>{{ $secondsToTimestamp(bookmark.time / playbackRate) }}</p>
|
||||
<p class="text-sm font-mono text-fg-muted flex items-center"><span class="material-symbols text-base pl-px pr-1">schedule</span>{{ $secondsToTimestamp(bookmark.time / playbackRate) }}</p>
|
||||
</div>
|
||||
<div class="h-full flex items-center justify-end transform w-16 pr-2" @click.stop>
|
||||
<span class="material-icons text-2xl mr-2 text-fg hover:text-yellow-400" @click.stop="editClick">edit</span>
|
||||
<span class="material-icons text-2xl text-fg hover:text-error" @click.stop="deleteClick">delete</span>
|
||||
<span class="material-symbols text-2xl mr-2 text-fg hover:text-yellow-400" @click.stop="editClick">edit</span>
|
||||
<span class="material-symbols text-2xl text-fg hover:text-error" @click.stop="deleteClick">delete</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -42,4 +42,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div v-if="download.isIncomplete || download.isMissing" class="shadow-sm text-warning flex items-center justify-center rounded-full mr-4">
|
||||
<span class="material-icons">error_outline</span>
|
||||
<span class="material-symbols">error_outline</span>
|
||||
</div>
|
||||
<button v-if="!isMissing" class="shadow-sm text-accent flex items-center justify-center rounded-full" @click.stop="playDownload">
|
||||
<span class="material-icons" style="font-size: 2rem">play_arrow</span>
|
||||
<span class="material-symbols fill" style="font-size: 2rem">play_arrow</span>
|
||||
</button>
|
||||
<div class="shadow-sm text-error flex items-center justify-center rounded-ful ml-4" @click.stop="clickDelete">
|
||||
<span class="material-icons" style="font-size: 1.2rem">delete</span>
|
||||
<span class="material-symbols" style="font-size: 1.2rem">delete</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -49,4 +49,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="flex items-end justify-between h-24 px-4 pb-2">
|
||||
<h1 class="text-lg">{{ $strings.LabelAddToPlaylist }}</h1>
|
||||
<button class="flex" @click="show = false">
|
||||
<span class="material-icons">close</span>
|
||||
<span class="material-symbols">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div class="w-full px-4">
|
||||
<div class="flex mb-4 items-center">
|
||||
<div class="w-9 h-9 flex items-center justify-center rounded-full cursor-pointer" @click.stop="showPlaylistNameInput = false">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
<span class="material-symbols text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<p class="text-xl pl-2 leading-none">{{ $strings.HeaderNewPlaylist }}</p>
|
||||
<div class="flex-grow" />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="flex items-end justify-end h-24 pr-4 pb-2">
|
||||
<!-- <h1 class="text-lg">RSS Feed</h1> -->
|
||||
<button class="flex" @click="show = false">
|
||||
<span class="material-icons">close</span>
|
||||
<span class="material-symbols">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<ui-text-input v-model="currentFeed.feedUrl" class="text-sm" readonly />
|
||||
|
||||
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base" :class="linkCopied ? 'text-success' : 'text-fg-muted'" @click="copyToClipboard(currentFeed.feedUrl)">{{ linkCopied ? 'done' : 'content_copy' }}</span>
|
||||
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base" :class="linkCopied ? 'text-success' : 'text-fg-muted'" @click="copyToClipboard(currentFeed.feedUrl)">{{ linkCopied ? 'check' : 'content_copy' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="currentFeed.meta" class="mt-5">
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fixed left-0 h-8 w-full bg-bg px-4 flex items-center text-fg-muted" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
|
||||
<div class="fixed left-0 h-8 w-full bg-bg px-4 flex items-center text-fg-muted transition-transform" :class="showingToolbar ? 'translate-y-0' : isPlayerOpen ? 'translate-y-[120px]' : 'translate-y-32'" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
|
||||
<div class="flex-grow" />
|
||||
<p class="text-xs">{{ page }} / {{ numPages }}</p>
|
||||
</div>
|
||||
@@ -49,7 +49,8 @@ export default {
|
||||
default: () => {}
|
||||
},
|
||||
isLocal: Boolean,
|
||||
keepProgress: Boolean
|
||||
keepProgress: Boolean,
|
||||
showingToolbar: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -76,9 +77,6 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem?.id
|
||||
},
|
||||
@@ -244,11 +242,9 @@ export default {
|
||||
async extract() {
|
||||
this.loading = true
|
||||
|
||||
// TODO: Handle JWT auth refresh
|
||||
const buff = await this.$axios.$get(this.url, {
|
||||
responseType: 'blob',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.userToken}`
|
||||
}
|
||||
responseType: 'blob'
|
||||
})
|
||||
|
||||
const archive = await Archive.open(buff)
|
||||
@@ -372,14 +368,12 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
#comic-reader {
|
||||
height: calc(100% - 36px);
|
||||
max-height: calc(100% - 36px);
|
||||
padding-top: 36px;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
.reader-player-open #comic-reader {
|
||||
height: calc(100% - 156px);
|
||||
max-height: calc(100% - 156px);
|
||||
padding-top: 36px;
|
||||
height: calc(100% - 120px);
|
||||
max-height: calc(100% - 120px);
|
||||
}
|
||||
|
||||
.comicimg {
|
||||
@@ -390,4 +384,4 @@ export default {
|
||||
width: 100vw;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div id="epub-frame" class="w-full">
|
||||
<div id="viewer" class="h-full w-full"></div>
|
||||
|
||||
<div class="fixed left-0 h-8 w-full px-4 flex items-center" :class="isLightTheme ? 'bg-white text-black' : 'bg-[#232323] text-white/80'" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
|
||||
<div class="fixed left-0 h-8 w-full px-4 flex items-center" :class="isLightTheme ? 'bg-white text-black' : isDarkTheme ? 'bg-[#232323] text-white/80' : 'bg-black text-white/80'" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
|
||||
<p v-if="totalLocations" class="text-xs text-slate-600">Location {{ currentLocationNum }} of {{ totalLocations }}</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="text-xs">{{ progress }}%</p>
|
||||
@@ -49,9 +49,6 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
/** @returns {string} */
|
||||
libraryItemId() {
|
||||
return this.libraryItem?.id
|
||||
@@ -105,17 +102,21 @@ export default {
|
||||
isLightTheme() {
|
||||
return this.ereaderSettings.theme === 'light'
|
||||
},
|
||||
isDarkTheme() {
|
||||
return this.ereaderSettings.theme === 'dark'
|
||||
},
|
||||
themeRules() {
|
||||
const isDark = this.ereaderSettings.theme === 'dark'
|
||||
const fontColor = isDark ? '#fff' : '#000'
|
||||
const backgroundColor = isDark ? 'rgb(35 35 35)' : 'rgb(255, 255, 255)'
|
||||
const isBlack = this.ereaderSettings.theme === 'black'
|
||||
const fontColor = isDark ? '#fff' : isBlack ? '#fff' : '#000'
|
||||
const backgroundColor = isDark ? 'rgb(35 35 35)' : isBlack ? 'rgb(0 0 0)' : 'rgb(255, 255, 255)'
|
||||
|
||||
return {
|
||||
'*': {
|
||||
color: `${fontColor}!important`,
|
||||
'background-color': `${backgroundColor}!important`,
|
||||
'line-height': this.ereaderSettings.lineSpacing + '%!important',
|
||||
'-webkit-text-stroke': this.ereaderSettings.textStroke/100 + 'px ' + fontColor + '!important'
|
||||
'-webkit-text-stroke': this.ereaderSettings.textStroke / 100 + 'px ' + fontColor + '!important'
|
||||
},
|
||||
a: {
|
||||
color: `${fontColor}!important`
|
||||
@@ -294,15 +295,26 @@ export default {
|
||||
|
||||
/** @type {EpubReader} */
|
||||
const reader = this
|
||||
|
||||
// Use axios to make request because we have token refresh logic in interceptor
|
||||
const customRequest = async (url) => {
|
||||
try {
|
||||
return this.$axios.$get(url, {
|
||||
responseType: 'arraybuffer'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('EpubReader.initEpub customRequest failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[EpubReader] initEpub', reader.url)
|
||||
/** @type {ePub.Book} */
|
||||
reader.book = new ePub(reader.url, {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight - this.readerHeightOffset,
|
||||
openAs: 'epub',
|
||||
requestHeaders: {
|
||||
Authorization: `Bearer ${this.userToken}`
|
||||
}
|
||||
requestMethod: this.isLocal ? null : customRequest
|
||||
})
|
||||
|
||||
/** @type {ePub.Rendition} */
|
||||
@@ -427,7 +439,7 @@ export default {
|
||||
document.removeEventListener('orientationchange', this.screenOrientationChange)
|
||||
}
|
||||
window.removeEventListener('resize', this.screenOrientationChange)
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -22,11 +22,7 @@ export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
addHtmlCss() {
|
||||
let iframe = document.getElementsByTagName('iframe')[0]
|
||||
@@ -84,11 +80,9 @@ export default {
|
||||
},
|
||||
async initMobi() {
|
||||
// Fetch mobi file as blob
|
||||
// TODO: Handle JWT auth refresh
|
||||
var buff = await this.$axios.$get(this.url, {
|
||||
responseType: 'blob',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.userToken}`
|
||||
}
|
||||
responseType: 'blob'
|
||||
})
|
||||
var reader = new FileReader()
|
||||
reader.onload = async (event) => {
|
||||
@@ -133,4 +127,4 @@ export default {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
<div class="w-full h-full relative">
|
||||
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
|
||||
<div class="flex items-center justify-center h-full w-1/2">
|
||||
<span class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
|
||||
<span class="material-symbols text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
|
||||
<div class="flex items-center justify-center h-full w-1/2 ml-auto">
|
||||
<span class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
|
||||
<span class="material-symbols text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,7 +49,8 @@ export default {
|
||||
numPages: 0,
|
||||
windowWidth: 0,
|
||||
windowHeight: 0,
|
||||
pdfDocInitParams: null
|
||||
pdfDocInitParams: null,
|
||||
isRefreshing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -109,6 +110,10 @@ export default {
|
||||
},
|
||||
isPlayerOpen() {
|
||||
return this.$store.getters['getIsPlayerOpen']
|
||||
},
|
||||
ebookUrl() {
|
||||
const serverAddress = this.$store.getters['user/getServerAddress']
|
||||
return this.isLocal ? this.url : `${serverAddress}${this.url}`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -164,7 +169,54 @@ export default {
|
||||
this.page++
|
||||
this.updateProgress()
|
||||
},
|
||||
error(err) {
|
||||
async handleRefreshFailure() {
|
||||
try {
|
||||
console.log('[PdfReader] Handling refresh failure - logging out user')
|
||||
|
||||
// Clear store
|
||||
await this.$store.dispatch('user/logout')
|
||||
|
||||
if (this.$store.getters['user/getServerConnectionConfigId']) {
|
||||
// Clear refresh token for server connection config
|
||||
await this.$db.clearRefreshToken(this.$store.getters['user/getServerConnectionConfigId'])
|
||||
}
|
||||
|
||||
if (window.location.pathname !== '/connect') {
|
||||
window.location.href = '/connect'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[PdfReader] Failed to handle refresh failure:', error)
|
||||
}
|
||||
},
|
||||
async refreshToken() {
|
||||
if (this.isRefreshing) return
|
||||
this.isRefreshing = true
|
||||
// Cannot use axios with this pdf reader so we need to handle the refresh separately
|
||||
// Should work on migrating to a different pdf reader in the future
|
||||
const newAccessToken = await this.$store.dispatch('user/refreshToken').catch((error) => {
|
||||
console.error('Failed to refresh token', error)
|
||||
return null
|
||||
})
|
||||
if (!newAccessToken) {
|
||||
this.handleRefreshFailure()
|
||||
return
|
||||
}
|
||||
|
||||
// Force Vue to re-render the PDF component by creating a new object
|
||||
this.pdfDocInitParams = {
|
||||
url: this.ebookUrl,
|
||||
httpHeaders: {
|
||||
Authorization: `Bearer ${newAccessToken}`
|
||||
}
|
||||
}
|
||||
this.isRefreshing = false
|
||||
},
|
||||
async error(err) {
|
||||
if (err && err.status === 401) {
|
||||
console.log('Received 401 error, refreshing token')
|
||||
await this.refreshToken()
|
||||
return
|
||||
}
|
||||
console.error(err)
|
||||
},
|
||||
screenOrientationChange() {
|
||||
@@ -173,7 +225,7 @@ export default {
|
||||
},
|
||||
init() {
|
||||
this.pdfDocInitParams = {
|
||||
url: this.url,
|
||||
url: this.ebookUrl,
|
||||
httpHeaders: {
|
||||
Authorization: `Bearer ${this.userToken}`
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<div v-if="show" :data-theme="ereaderTheme" class="group fixed top-0 left-0 right-0 layout-wrapper w-full z-40 pt-8 data-[theme=dark]:bg-[#232323] data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': isPlayerOpen }">
|
||||
<div v-if="show" :data-theme="ereaderTheme" class="group fixed top-0 left-0 right-0 layout-wrapper w-full z-40 pt-8 data-[theme=black]:bg-black data-[theme=black]:text-white data-[theme=dark]:bg-[#232323] data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': isPlayerOpen }">
|
||||
<!-- toolbar -->
|
||||
<div class="h-32 pt-10 w-full px-2 fixed top-0 left-0 z-30 transition-transform bg-bg text-fg" :class="showingToolbar ? 'translate-y-0' : '-translate-y-32'" :style="{ boxShadow: showingToolbar ? '0px 8px 8px #11111155' : '' }" @touchstart.stop @mousedown.stop @touchend.stop @mouseup.stop>
|
||||
<div class="flex items-center mb-2">
|
||||
<button type="button" class="inline-flex mx-2" @click.stop="show = false">
|
||||
<span class="material-icons-outlined text-3xl text-fg">chevron_left</span>
|
||||
<span class="material-symbols text-3xl text-fg">chevron_left</span>
|
||||
</button>
|
||||
<div class="flex-grow" />
|
||||
<button v-if="isComic || isEpub" type="button" class="inline-flex mx-2" @click.stop="clickTOCBtn">
|
||||
<span class="material-icons-outlined text-2xl text-fg">format_list_bulleted</span>
|
||||
<span class="material-symbols text-2xl text-fg">format_list_bulleted</span>
|
||||
</button>
|
||||
<button v-if="isEpub" type="button" class="inline-flex mx-2" @click.stop="clickSettingsBtn">
|
||||
<span class="material-icons text-2xl text-fg">settings</span>
|
||||
<span class="material-symbols text-2xl text-fg">settings</span>
|
||||
</button>
|
||||
<button v-if="comicHasMetadata" type="button" class="inline-flex mx-2" @click.stop="clickMetadataBtn">
|
||||
<span class="material-icons text-2xl text-fg">more</span>
|
||||
<span class="material-symbols text-2xl text-fg">more</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -22,14 +22,14 @@
|
||||
</div>
|
||||
|
||||
<!-- ereader -->
|
||||
<component v-if="readerComponentName" ref="readerComponent" :is="readerComponentName" :url="ebookUrl" :library-item="selectedLibraryItem" :is-local="isLocal" :keep-progress="keepProgress" @touchstart="touchstart" @touchend="touchend" @loaded="readerLoaded" @hook:mounted="readerMounted" />
|
||||
<component v-if="readerComponentName" ref="readerComponent" :is="readerComponentName" :url="ebookUrl" :library-item="selectedLibraryItem" :is-local="isLocal" :keep-progress="keepProgress" :showing-toolbar="showingToolbar" @touchstart="touchstart" @touchend="touchend" @loaded="readerLoaded" @hook:mounted="readerMounted" />
|
||||
|
||||
<!-- table of contents modal -->
|
||||
<modals-fullscreen-modal v-model="showTOCModal" :theme="ereaderTheme">
|
||||
<div class="flex items-end justify-between h-20 px-4 pb-2">
|
||||
<h1 class="text-lg">{{ $strings.HeaderTableOfContents }}</h1>
|
||||
<button class="flex" @click.stop="showTOCModal = false">
|
||||
<span class="material-icons">close</span>
|
||||
<span class="material-symbols">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<div class="flex items-end justify-between h-14 px-4 pb-2 mb-6">
|
||||
<h1 class="text-lg">{{ $strings.HeaderEreaderSettings }}</h1>
|
||||
<button class="flex" @click="showSettingsModal = false">
|
||||
<span class="material-icons">close</span>
|
||||
<span class="material-symbols">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(75vh-85px)]">
|
||||
@@ -238,6 +238,10 @@ export default {
|
||||
},
|
||||
themeItems() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelThemeBlack,
|
||||
value: 'black'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelThemeDark,
|
||||
value: 'dark'
|
||||
@@ -256,6 +260,7 @@ export default {
|
||||
return null
|
||||
},
|
||||
ebookFile() {
|
||||
if (!this.media) return null
|
||||
// ebook file id is passed when reading a supplementary ebook
|
||||
if (this.ebookFileId) {
|
||||
return this.selectedLibraryItem.libraryFiles.find((lf) => lf.ino === this.ebookFileId)
|
||||
@@ -300,11 +305,11 @@ export default {
|
||||
if (this.localContentUrl) {
|
||||
return Capacitor.convertFileSrc(this.localContentUrl)
|
||||
}
|
||||
const serverAddress = this.$store.getters['user/getServerAddress']
|
||||
|
||||
if (this.ebookFileId) {
|
||||
return `${serverAddress}/api/items/${this.selectedLibraryItem.id}/ebook/${this.ebookFileId}`
|
||||
return `/api/items/${this.selectedLibraryItem.id}/ebook/${this.ebookFileId}`
|
||||
}
|
||||
return `${serverAddress}/api/items/${this.selectedLibraryItem.id}/ebook`
|
||||
return `/api/items/${this.selectedLibraryItem.id}/ebook`
|
||||
},
|
||||
isPlayerOpen() {
|
||||
return this.$store.getters['getIsPlayerOpen']
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { FileSharer } from '@byteowls/capacitor-filesharer'
|
||||
import { FileSharer } from '@webnativellc/capacitor-filesharer'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@@ -75,7 +75,7 @@ export default {
|
||||
|
||||
const addIcon = (icon, color, fontSize, x, y) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `${fontSize} Material Icons Outlined`
|
||||
ctx.font = `${fontSize} Material Symbols Rounded`
|
||||
ctx.fillText(icon, x, y)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-2 sm:p-4 mb-4">
|
||||
<!-- hack to get icon fonts loaded on init -->
|
||||
<div class="h-0 w-0 overflow-hidden opacity-0">
|
||||
<span class="material-icons-outlined">close</span>
|
||||
<span class="material-symbols">close</span>
|
||||
<span class="abs-icons icon-audiobookshelf" />
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<div class="flex items-center justify-center mb-2 max-w-[800px] mx-auto">
|
||||
<!-- previous button -->
|
||||
<ui-btn small :disabled="!yearInReviewVariant || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant--">
|
||||
<span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||
<span class="material-symbols text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||
<span class="hidden sm:inline-block pr-2">Previous</span>
|
||||
</ui-btn>
|
||||
<!-- share button -->
|
||||
@@ -33,12 +33,12 @@
|
||||
<!-- refresh button -->
|
||||
<ui-btn small :disabled="processingYearInReview" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReview">
|
||||
<span class="hidden sm:inline-block">Refresh</span>
|
||||
<span class="material-icons sm:!hidden text-lg py-px">refresh</span>
|
||||
<span class="material-symbols sm:!hidden text-lg py-px">refresh</span>
|
||||
</ui-btn>
|
||||
<!-- next button -->
|
||||
<ui-btn small :disabled="yearInReviewVariant >= 2 || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant++">
|
||||
<span class="hidden sm:inline-block pl-2">Next</span>
|
||||
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||
<span class="material-symbols text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
<stats-year-in-review ref="yearInReview" :variant="yearInReviewVariant" :year="yearInReviewYear" :processing.sync="processingYearInReview" />
|
||||
@@ -55,7 +55,7 @@
|
||||
<div class="flex items-center justify-center mb-2">
|
||||
<!-- previous button -->
|
||||
<ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant--">
|
||||
<span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||
<span class="material-symbols text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||
<span class="hidden sm:inline-block pr-2">Previous</span>
|
||||
</ui-btn>
|
||||
<!-- share button -->
|
||||
@@ -69,12 +69,12 @@
|
||||
<!-- refresh button -->
|
||||
<ui-btn small :disabled="processingYearInReviewServer" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReviewServer">
|
||||
<span class="hidden sm:inline-block">Refresh</span>
|
||||
<span class="material-icons sm:!hidden text-lg py-px">refresh</span>
|
||||
<span class="material-symbols sm:!hidden text-lg py-px">refresh</span>
|
||||
</ui-btn>
|
||||
<!-- next button -->
|
||||
<ui-btn small :disabled="yearInReviewServerVariant >= 2 || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant++">
|
||||
<span class="hidden sm:inline-block pl-2">Next</span>
|
||||
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||
<span class="material-symbols text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,4 +131,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { FileSharer } from '@byteowls/capacitor-filesharer'
|
||||
import { FileSharer } from '@webnativellc/capacitor-filesharer'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { FileSharer } from '@byteowls/capacitor-filesharer'
|
||||
import { FileSharer } from '@webnativellc/capacitor-filesharer'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@@ -66,7 +66,7 @@ export default {
|
||||
|
||||
const addIcon = (icon, color, fontSize, x, y) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `${fontSize} Material Icons Outlined`
|
||||
ctx.font = `${fontSize} Material Symbols Rounded`
|
||||
ctx.fillText(icon, x, y)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div class="h-10 w-10 rounded-full flex justify-center items-center duration-500" :class="expanded ? 'transform rotate-180' : ''">
|
||||
<span class="material-icons text-3xl">expand_more</span>
|
||||
<span class="material-symbols text-3xl">arrow_drop_down</span>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="slide">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div class="h-10 w-10 rounded-full flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''">
|
||||
<span class="material-icons text-3xl">expand_more</span>
|
||||
<span class="material-symbols text-3xl">arrow_drop_down</span>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="slide">
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
</div>
|
||||
<div class="book-table-content h-full px-2 flex items-center">
|
||||
<div class="max-w-full">
|
||||
<p class="truncate block text-sm">{{ bookTitle }}</p>
|
||||
<p class="truncate block text-sm">{{ bookTitle }} <span v-if="localLibraryItem" class="material-symbols text-success text-base align-text-bottom">download_done</span></p>
|
||||
<p class="truncate block text-fg-muted text-xs">{{ bookAuthor }}</p>
|
||||
<p v-if="media.duration" class="text-xxs text-fg-muted">{{ bookDuration }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-8 min-w-8 flex justify-center">
|
||||
<button v-if="showPlayBtn" class="w-8 h-8 rounded-full border border-white border-opacity-20 flex items-center justify-center" @click.stop.prevent="playClick">
|
||||
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||
<span class="material-symbols text-2xl fill" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
@@ -39,6 +39,9 @@ export default {
|
||||
libraryItemId() {
|
||||
return this.book.id
|
||||
},
|
||||
localLibraryItem() {
|
||||
return this.book.localLibraryItem
|
||||
},
|
||||
media() {
|
||||
return this.book.media || {}
|
||||
},
|
||||
@@ -73,18 +76,34 @@ export default {
|
||||
showPlayBtn() {
|
||||
return !this.isMissing && !this.isInvalid && this.tracks.length
|
||||
},
|
||||
isStreaming() {
|
||||
playerIsStartingPlayback() {
|
||||
// Play has been pressed and waiting for native play response
|
||||
return this.$store.state.playerIsStartingPlayback
|
||||
},
|
||||
isOpenInPlayer() {
|
||||
if (this.localLibraryItem && this.$store.getters['getIsMediaStreaming'](this.localLibraryItem.id)) return true
|
||||
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId)
|
||||
},
|
||||
streamIsPlaying() {
|
||||
return this.$store.state.playerIsPlaying && this.isStreaming
|
||||
return this.$store.state.playerIsPlaying && this.isOpenInPlayer
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async playClick() {
|
||||
if (this.playerIsStartingPlayback) return
|
||||
await this.$hapticsImpact()
|
||||
|
||||
if (this.streamIsPlaying) {
|
||||
this.$eventBus.$emit('pause-item')
|
||||
return
|
||||
}
|
||||
|
||||
this.$store.commit('setPlayerIsStartingPlayback', this.libraryItemId)
|
||||
if (this.localLibraryItem) {
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.localLibraryItem.id,
|
||||
serverLibraryItemId: this.libraryItemId
|
||||
})
|
||||
} else {
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.libraryItemId
|
||||
@@ -101,4 +120,4 @@ export default {
|
||||
width: calc(100% - 82px);
|
||||
max-width: calc(100% - 82px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div class="h-10 w-10 rounded-full flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
|
||||
<span class="material-icons text-3xl">expand_more</span>
|
||||
<span class="material-symbols text-3xl">arrow_drop_down</span>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="slide">
|
||||
@@ -96,4 +96,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<tr>
|
||||
<td class="px-4">{{ file.metadata.filename }} <span v-if="isPrimary" class="material-icons-outlined text-success align-text-bottom text-base">check_circle</span></td>
|
||||
<td class="px-4">{{ file.metadata.filename }} <span v-if="isPrimary" class="material-symbols text-success align-text-bottom text-base">check_circle</span></td>
|
||||
<td class="text-xs w-16">
|
||||
<ui-icon-btn icon="auto_stories" outlined borderless icon-font-size="1.125rem" :size="8" @click="readEbook" />
|
||||
</td>
|
||||
@@ -57,4 +57,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user