mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-08-08 12:58:38 +02:00
Compare commits
335
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bc5932889 | ||
|
|
3cd597d71e | ||
|
|
48d5ad462e | ||
|
|
40cab87640 | ||
|
|
f405947ad8 | ||
|
|
fd550c15a5 | ||
|
|
d18537cf04 | ||
|
|
f01f0229a8 | ||
|
|
b369eb2d40 | ||
|
|
f08c65c5bf | ||
|
|
1043f4ecd7 | ||
|
|
4f17a32373 | ||
|
|
2203aae6e6 | ||
|
|
7b60c7b6c5 | ||
|
|
be98e06efc | ||
|
|
45239bca34 | ||
|
|
2493ce7ed7 | ||
|
|
2b1e53b371 | ||
|
|
d643a1769f | ||
|
|
a63b7a188c | ||
|
|
f9c0162b8a | ||
|
|
cda3cb3512 | ||
|
|
766f6938a7 | ||
|
|
8e1ccb517b | ||
|
|
540aab6f83 | ||
|
|
d1969d7897 | ||
|
|
b69e8fd649 | ||
|
|
2e95cf908d | ||
|
|
b589bae2b0 | ||
|
|
e7e4891d82 | ||
|
|
03a46decba | ||
|
|
d80482f443 | ||
|
|
735cce7614 | ||
|
|
8ea35a0b36 | ||
|
|
ad90bd3d05 | ||
|
|
fa3856e845 | ||
|
|
752d4ddc8c | ||
|
|
fd2d7a05d1 | ||
|
|
ccf4675867 | ||
|
|
bc043c73ca | ||
|
|
291ab247bc | ||
|
|
d901f470a1 | ||
|
|
0ebb01e39d | ||
|
|
20ac99bf8d | ||
|
|
90e86e1575 | ||
|
|
28652031e7 | ||
|
|
cd8be40cc8 | ||
|
|
52fc937f96 | ||
|
|
68e4722387 | ||
|
|
5f5b38b2f9 | ||
|
|
65f8d3ce81 | ||
|
|
ac571fd63d | ||
|
|
ea2d6e9f31 | ||
|
|
80b999d648 | ||
|
|
54720039e4 | ||
|
|
8c6717c201 | ||
|
|
6610754e25 | ||
|
|
d38622d8a2 | ||
|
|
cd2750a489 | ||
|
|
30140629ae | ||
|
|
0ce29337ef | ||
|
|
6715f29ebb | ||
|
|
b4a8dc9e26 | ||
|
|
c349a0bf00 | ||
|
|
0dc6c142be | ||
|
|
e46c1d9120 | ||
|
|
27ad554ea1 | ||
|
|
5660772b46 | ||
|
|
dab80642ab | ||
|
|
da46191208 | ||
|
|
6484c4de79 | ||
|
|
6716b6042a | ||
|
|
0686859fa7 | ||
|
|
4180862c3a | ||
|
|
7aa20d3211 | ||
|
|
b694b55710 | ||
|
|
024b613063 | ||
|
|
8768d11a20 | ||
|
|
5898303ba4 | ||
|
|
e79d09941c | ||
|
|
c52a60fe77 | ||
|
|
e64f9aa427 | ||
|
|
ee343f78ef | ||
|
|
1f20d5821f | ||
|
|
b0c512e946 | ||
|
|
0d13b7abd1 | ||
|
|
43fd8285c9 | ||
|
|
2f7e8d618a | ||
|
|
a0b910deeb | ||
|
|
e9dc45613c | ||
|
|
b5e90ec3d2 | ||
|
|
3d9f42c1e1 | ||
|
|
e62f430f37 | ||
|
|
9c53564c46 | ||
|
|
f7add6f6cd | ||
|
|
7786e6a827 | ||
|
|
b213e2aabf | ||
|
|
ebca0fafca | ||
|
|
ac8a2ca985 | ||
|
|
6380d7cc02 | ||
|
|
9056ba6b8d | ||
|
|
23801363c4 | ||
|
|
1cdcd7327f | ||
|
|
50ec50003e | ||
|
|
5ff4db1a3f | ||
|
|
394b44c343 | ||
|
|
eb2aa6f773 | ||
|
|
81130e04e5 | ||
|
|
c0ec0758f4 | ||
|
|
e21f7c545e | ||
|
|
c16dd2fcf6 | ||
|
|
90974e6d73 | ||
|
|
8e4f75d85b | ||
|
|
792ee2adb8 | ||
|
|
2c57489c07 | ||
|
|
e3321770fd | ||
|
|
848673f028 | ||
|
|
e6d3003ecf | ||
|
|
90551b7ce7 | ||
|
|
aabac5da13 | ||
|
|
4fad77f9c1 | ||
|
|
0fb6d746eb | ||
|
|
7fc712d19b | ||
|
|
d6018cfe10 | ||
|
|
5b8f231a98 | ||
|
|
cb503f84e1 | ||
|
|
a34f9b80ac | ||
|
|
3e6696b516 | ||
|
|
9cd8bd439a | ||
|
|
ca5b99e23d | ||
|
|
c44fd74f5b | ||
|
|
f82c4834c3 | ||
|
|
dda95bd69c | ||
|
|
a623cb57a7 | ||
|
|
e798d930fb | ||
|
|
f91b0c9e2f | ||
|
|
b6824cf242 | ||
|
|
41477fdac1 | ||
|
|
d4e8afc2f1 | ||
|
|
d42cbfe3e4 | ||
|
|
c7c3ef52e6 | ||
|
|
92344325b6 | ||
|
|
a8172fb3f9 | ||
|
|
a73ec783d5 | ||
|
|
5c9fc4c9e4 | ||
|
|
494ff38ef3 | ||
|
|
b7e6cbf1ab | ||
|
|
b33623b74f | ||
|
|
4f939ce6fc | ||
|
|
4f45f360df | ||
|
|
4ad54f34fe | ||
|
|
574dda5286 | ||
|
|
d788623509 | ||
|
|
f047f6a23b | ||
|
|
48b30f6b03 | ||
|
|
67d32164d9 | ||
|
|
6672a21049 | ||
|
|
b833019a14 | ||
|
|
00eeeb2a5a | ||
|
|
06a0a68143 | ||
|
|
1a7ae468bd | ||
|
|
e8f74d7358 | ||
|
|
984f01da23 | ||
|
|
d6e75410a5 | ||
|
|
7627e1b58c | ||
|
|
5fb31b18ae | ||
|
|
20903e8217 | ||
|
|
8fe348d4eb | ||
|
|
24cda8a179 | ||
|
|
fc954a968b | ||
|
|
aae53a54cd | ||
|
|
cfedcffc98 | ||
|
|
0e74c74a6c | ||
|
|
fb86c45e78 | ||
|
|
124300b215 | ||
|
|
6746c0627b | ||
|
|
0f2cd3b132 | ||
|
|
e94d6fdc1e | ||
|
|
e301b3071c | ||
|
|
3d3dad5e4a | ||
|
|
f8a9033738 | ||
|
|
edb25f5bcd | ||
|
|
bcb239efd1 | ||
|
|
d208211c6f | ||
|
|
0b20b72241 | ||
|
|
5c29459af2 | ||
|
|
e54fd38784 | ||
|
|
94774f13df | ||
|
|
43ec35fe67 | ||
|
|
b2645dc444 | ||
|
|
c79624a82c | ||
|
|
8201f3c9a6 | ||
|
|
e0ac6a1c38 | ||
|
|
400da5566d | ||
|
|
9b67037774 | ||
|
|
48596542c7 | ||
|
|
9715d65d88 | ||
|
|
2e14b31a69 | ||
|
|
1c8ce6c3db | ||
|
|
dc74a6213d | ||
|
|
e9a66bb7a7 | ||
|
|
3c6d4b5f19 | ||
|
|
f04eebe0d8 | ||
|
|
63b5227ccc | ||
|
|
934d9b1df4 | ||
|
|
8994cb3a6e | ||
|
|
4a32de482b | ||
|
|
95f47793d2 | ||
|
|
f27048618d | ||
|
|
c90ad419e8 | ||
|
|
2cdc3ecb9d | ||
|
|
9f89fcd968 | ||
|
|
7c2d51f811 | ||
|
|
41fe7a29cd | ||
|
|
54f3eec303 | ||
|
|
cd91013f69 | ||
|
|
0af16ef05f | ||
|
|
5ba830d56e | ||
|
|
81919e963a | ||
|
|
8a1d4bb958 | ||
|
|
eabac0e709 | ||
|
|
d90783d7c8 | ||
|
|
faf8211e5c | ||
|
|
65d90947c5 | ||
|
|
752e918d26 | ||
|
|
ad08cc69f4 | ||
|
|
da3882fd5e | ||
|
|
c04c7a55d9 | ||
|
|
3318846b43 | ||
|
|
e0860feeb9 | ||
|
|
c0253a69b9 | ||
|
|
9242c7e6e0 | ||
|
|
0bfc579ae4 | ||
|
|
5fad06756b | ||
|
|
5be6afa6c1 | ||
|
|
5ba3b8be7a | ||
|
|
0eb62f541d | ||
|
|
6baff881c7 | ||
|
|
8c23c1e795 | ||
|
|
9706c66a27 | ||
|
|
f4f7252537 | ||
|
|
430d200151 | ||
|
|
7be82d03f3 | ||
|
|
15aaef742d | ||
|
|
e443ba09da | ||
|
|
8235d1f1f7 | ||
|
|
9a39dcceee | ||
|
|
a707b7fc86 | ||
|
|
b6fea85ef4 | ||
|
|
7e3b3f55ae | ||
|
|
d2d3bbe8a1 | ||
|
|
41a65c0f9d | ||
|
|
165ddd58a6 | ||
|
|
09dc04c0de | ||
|
|
57d5dbc0d0 | ||
|
|
11af75e127 | ||
|
|
f8d82a3020 | ||
|
|
81d7b1cfd7 | ||
|
|
e15c1bcb32 | ||
|
|
826d414d9f | ||
|
|
0da6fca727 | ||
|
|
2327bc1705 | ||
|
|
3bb76cecac | ||
|
|
e3f82bb237 | ||
|
|
fcd6270f2d | ||
|
|
d5411703ca | ||
|
|
8fc2058db8 | ||
|
|
d03949f5d1 | ||
|
|
ff01786063 | ||
|
|
bb117ab4da | ||
|
|
f5ec247b47 | ||
|
|
6e6dd00523 | ||
|
|
c9fb98f033 | ||
|
|
75ecff5e6d | ||
|
|
91a9f918fb | ||
|
|
03305f90ca | ||
|
|
9428bc07c5 | ||
|
|
f38f9c3502 | ||
|
|
64a796aece | ||
|
|
63039fe8c9 | ||
|
|
3f2b723d0f | ||
|
|
ffd7fb1cf9 | ||
|
|
b049b206cf | ||
|
|
35f56dc8a9 | ||
|
|
a71cd326cc | ||
|
|
cc841d1c7c | ||
|
|
16ecdcf96e | ||
|
|
96ec56d1ad | ||
|
|
9b15bafc9f | ||
|
|
aa02f2e465 | ||
|
|
15464617ef | ||
|
|
60ecceb59d | ||
|
|
3fe943e989 | ||
|
|
5e7d039454 | ||
|
|
f928df146a | ||
|
|
6e57cb49c4 | ||
|
|
2f8494b013 | ||
|
|
6f1b93bd9f | ||
|
|
19b928d7f6 | ||
|
|
b849be6f5e | ||
|
|
86096df1a4 | ||
|
|
98a465c7b4 | ||
|
|
3315f61ae4 | ||
|
|
f977445d7a | ||
|
|
095d5e1cec | ||
|
|
69fbfa4b33 | ||
|
|
1c89d33726 | ||
|
|
acee907932 | ||
|
|
08ad216fe2 | ||
|
|
3cc7a0937c | ||
|
|
322cad5548 | ||
|
|
73d70dd480 | ||
|
|
56bc569d4a | ||
|
|
981ae58f6a | ||
|
|
b2bfa4c002 | ||
|
|
8b6003c5e3 | ||
|
|
017b6237ca | ||
|
|
d5496c2e9a | ||
|
|
5ef8961c33 | ||
|
|
e8bea48577 | ||
|
|
6cae78f5c2 | ||
|
|
d9d31a0620 | ||
|
|
3682c5fca2 | ||
|
|
14b04a14b6 | ||
|
|
f218d90bfb | ||
|
|
020f936ad4 | ||
|
|
224e5efe48 | ||
|
|
439ab2c8c0 | ||
|
|
26d0209763 | ||
|
|
29086fc783 | ||
|
|
bb656a265f | ||
|
|
b11ad9dc5b | ||
|
|
2b1667e532 | ||
|
|
228a198598 | ||
|
|
2843308fb8 |
+1
-6
@@ -1,13 +1,8 @@
|
|||||||
# editorconfig.org
|
|
||||||
root = true
|
root = true
|
||||||
|
|
||||||
[*]
|
[*]
|
||||||
indent_style = space
|
indent_style = space
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
end_of_line = lf
|
|
||||||
charset = utf-8
|
charset = utf-8
|
||||||
trim_trailing_whitespace = true
|
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
[*.md]
|
|
||||||
trim_trailing_whitespace = false
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Create a report to help us improve
|
|
||||||
labels: bug
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Steps to reproduce
|
|
||||||
|
|
||||||
1.
|
|
||||||
2.
|
|
||||||
3.
|
|
||||||
|
|
||||||
### Expected behaviour
|
|
||||||
- Tell us what should happen
|
|
||||||
|
|
||||||
### Actual behaviour
|
|
||||||
- Tell us what happens
|
|
||||||
|
|
||||||
### Environment data
|
|
||||||
|
|
||||||
Audiobookshelf Version:
|
|
||||||
|
|
||||||
- [ ] Android App?
|
|
||||||
- [ ] iOS App?
|
|
||||||
|
|
||||||
#### Android Issue
|
|
||||||
Android version:
|
|
||||||
|
|
||||||
Device model:
|
|
||||||
|
|
||||||
Stock or customized system:
|
|
||||||
|
|
||||||
#### iOS Issue
|
|
||||||
iOS Version:
|
|
||||||
|
|
||||||
iPhone model:
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
name: 🐞 ABS App Bug Report
|
||||||
|
description: File a bug/issue and help us improve the Audiobookshelf mobile apps.
|
||||||
|
title: "[Bug]: "
|
||||||
|
labels: ["bug", "triage"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## App Bug Description"
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "Thank you for filing a bug report! 🐛"
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "Join the [discord server](https://discord.gg/HQgCbd6E75) for questions or if you are not sure about a bug."
|
||||||
|
- type: textarea
|
||||||
|
id: what-happened
|
||||||
|
attributes:
|
||||||
|
label: What was the Problem?
|
||||||
|
description: Please help us understand the problem.
|
||||||
|
placeholder: Describe the problem you saw or experienced in detail! Include a screenshot or video, if applicable.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: steps-to-reproduce
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce the Issue
|
||||||
|
description: Please help us understand how we can reliably reproduce the issue.
|
||||||
|
placeholder: "1. Go to the library page of a Podcast library and..."
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: what-was-expected
|
||||||
|
attributes:
|
||||||
|
label: What was Expected?
|
||||||
|
description: Please help us understand what should have happened.
|
||||||
|
placeholder: Please help us visualize by including a doodle or screenshot.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## Mobile Environment"
|
||||||
|
- type: input
|
||||||
|
id: phone-model
|
||||||
|
attributes:
|
||||||
|
label: Phone Model
|
||||||
|
description: What kind of phone are you using?
|
||||||
|
placeholder: e.g. Pixel 6, iPhone 14, Samusung Galaxy s23, etc
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: phone-os
|
||||||
|
attributes:
|
||||||
|
label: Phone OS
|
||||||
|
description: What operating system? If Android, is it stock or a custom system?
|
||||||
|
placeholder: e.g. Android 14, iOS 17.4, etc
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: app-version
|
||||||
|
attributes:
|
||||||
|
label: Audiobookshelf App Version
|
||||||
|
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- Android App - 0.9.74
|
||||||
|
- iOS App - 0.9.74
|
||||||
|
- Android App - 0.9.73
|
||||||
|
- iOS App - 0.9.73
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: install-source
|
||||||
|
attributes:
|
||||||
|
label: Installation Source
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- Google Play Store
|
||||||
|
- Testflight
|
||||||
|
- SideStore
|
||||||
|
- Other (List in "Additional Notes")
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: additional-notes
|
||||||
|
attributes:
|
||||||
|
label: Additional Notes
|
||||||
|
description: Anything else you want to add?
|
||||||
|
placeholder: "e.g. I have tried X, Y, and Z."
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
---
|
|
||||||
name: Feature request
|
|
||||||
about: Suggest an idea for this project
|
|
||||||
labels: enhancement
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Is your feature request related to a problem? Please describe.
|
|
||||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
|
||||||
|
|
||||||
### Describe the solution you'd like
|
|
||||||
A clear and concise description of what you want to happen.
|
|
||||||
|
|
||||||
### Describe alternatives you've considered
|
|
||||||
A clear and concise description of any alternative solutions or features you've considered.
|
|
||||||
|
|
||||||
### Additional context
|
|
||||||
Add any other context or screenshots about the feature request here.
|
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
name: 🚀 App Feature Request
|
||||||
|
description: Request a feature/enhancement
|
||||||
|
title: "[Enhancement]: "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## App Feature Request Description"
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "Please first search in both issues & discussions for your enhancement and make sure your app is up to date."
|
||||||
|
- type: textarea
|
||||||
|
id: describe
|
||||||
|
attributes:
|
||||||
|
label: Describe the Feature/Enhancement
|
||||||
|
description: Please help us understand what you want.
|
||||||
|
placeholder: What is your vision?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: the-why
|
||||||
|
attributes:
|
||||||
|
label: Why would this be helpful?
|
||||||
|
description: Please help us understand why this would enhance your experience.
|
||||||
|
placeholder: Explain the "why" or "use case".
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: image
|
||||||
|
attributes:
|
||||||
|
label: Future Implementation (Screenshot)
|
||||||
|
description: Please help us visualize by including a doodle or screenshot.
|
||||||
|
placeholder: How could this look or where could it go?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: "## App Current Implementation"
|
||||||
|
- type: dropdown
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Audiobookshelf App Version
|
||||||
|
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- Android App - 0.9.74
|
||||||
|
- iOS App - 0.9.74
|
||||||
|
- Android App - 0.9.73
|
||||||
|
- iOS App - 0.9.73
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: current-image
|
||||||
|
attributes:
|
||||||
|
label: Current Implementation (Screenshot)
|
||||||
|
description: What screen were you looking at when you thought of this enhancement?
|
||||||
|
placeholder: If an image is not applicable, please explain why.
|
||||||
@@ -16,10 +16,10 @@ jobs:
|
|||||||
- name: checkout sources
|
- name: checkout sources
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: use Node.js 16.x
|
- name: use Node.js 20.x
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 20
|
||||||
|
|
||||||
- name: Set up Java
|
- name: Set up Java
|
||||||
uses: actions/setup-java@v2
|
uses: actions/setup-java@v2
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
name: Close fixed issues on release.
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
comment:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Close issues marked as fixed upon a release.
|
||||||
|
uses: gcampbell-msft/fixed-pending-release@7fa1b75a0c04bcd4b375110522878e5f6100cff5
|
||||||
|
with:
|
||||||
|
label: 'awaiting release'
|
||||||
|
removeLabel: true
|
||||||
|
applyToAll: true
|
||||||
|
message: Fixed in [${releaseTag}](${releaseUrl}).
|
||||||
@@ -15,10 +15,10 @@ jobs:
|
|||||||
- name: checkout sources
|
- name: checkout sources
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: use Node.js 16.x
|
- name: use Node.js 20.x
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 20
|
||||||
|
|
||||||
- name: Set up Java
|
- name: Set up Java
|
||||||
uses: actions/setup-java@v2
|
uses: actions/setup-java@v2
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
name: Verify all i18n files are alphabetized
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- strings/** # Should only check if any strings changed
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update_translations:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# Check out the repository
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Set up node to run the javascript
|
||||||
|
- name: Set up node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
# The only argument is the `directory`, which is where the i18n files are
|
||||||
|
# stored.
|
||||||
|
- name: Run Update JSON Files action
|
||||||
|
uses: audiobookshelf/audiobookshelf-i18n-updater@v1.3.0
|
||||||
|
with:
|
||||||
|
directory: 'strings/' # Adjust the directory path as needed
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 400,
|
||||||
|
"proseWrap": "never",
|
||||||
|
"trailingComma": "none",
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["*.html"],
|
||||||
|
"options": {
|
||||||
|
"singleQuote": false,
|
||||||
|
"wrapAttributes": false,
|
||||||
|
"sortAttributes": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"EditorConfig.EditorConfig",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"octref.vetur"
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+27
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"vetur.format.defaultFormatterOptions": {
|
||||||
|
"prettier": {
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 400,
|
||||||
|
"proseWrap": "never",
|
||||||
|
"trailingComma": "none"
|
||||||
|
},
|
||||||
|
"prettyhtml": {
|
||||||
|
"printWidth": 400,
|
||||||
|
"singleQuote": false,
|
||||||
|
"wrapAttributes": false,
|
||||||
|
"sortAttributes": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.detectIndentation": true,
|
||||||
|
"editor.tabSize": 2,
|
||||||
|
"javascript.format.semicolons": "remove",
|
||||||
|
"[javascript][json][jsonc]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[vue]": {
|
||||||
|
"editor.defaultFormatter": "octref.vetur"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,8 +33,8 @@ android {
|
|||||||
applicationId "com.audiobookshelf.app"
|
applicationId "com.audiobookshelf.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 101
|
versionCode 107
|
||||||
versionName "0.9.70-beta"
|
versionName "0.9.76-beta"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
manifestPlaceholders = [
|
manifestPlaceholders = [
|
||||||
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
||||||
@@ -64,6 +64,8 @@ repositories {
|
|||||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||||
}
|
}
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
// TODO: Temporarily using SNAPSHOT version of Simple Storage that resolves https://github.com/anggrayudi/SimpleStorage/issues/133
|
||||||
|
maven { url "https://oss.sonatype.org/content/repositories/snapshots" }
|
||||||
}
|
}
|
||||||
|
|
||||||
configurations.configureEach {
|
configurations.configureEach {
|
||||||
@@ -118,7 +120,7 @@ dependencies {
|
|||||||
implementation 'io.github.pilgr:paperdb:2.7.2'
|
implementation 'io.github.pilgr:paperdb:2.7.2'
|
||||||
|
|
||||||
// Simple Storage
|
// Simple Storage
|
||||||
implementation "com.anggrayudi:storage:1.5.4"
|
implementation "com.anggrayudi:storage:1.5.5-SNAPSHOT"
|
||||||
|
|
||||||
// OK HTTP
|
// OK HTTP
|
||||||
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
|
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ android {
|
|||||||
|
|
||||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation project(':byteowls-capacitor-filesharer')
|
||||||
implementation project(':capacitor-app')
|
implementation project(':capacitor-app')
|
||||||
implementation project(':capacitor-browser')
|
implementation project(':capacitor-browser')
|
||||||
implementation project(':capacitor-clipboard')
|
implementation project(':capacitor-clipboard')
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"pkg": "@byteowls/capacitor-filesharer",
|
||||||
|
"classpath": "com.byteowls.capacitor.filesharer.FileSharerPlugin"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"pkg": "@capacitor/app",
|
"pkg": "@capacitor/app",
|
||||||
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
||||||
|
|||||||
@@ -103,6 +103,9 @@ class MainActivity : BridgeActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun isPlayerNotificationServiceInitialized():Boolean {
|
||||||
|
return ::foregroundService.isInitialized
|
||||||
|
}
|
||||||
|
|
||||||
fun stopMyService() {
|
fun stopMyService() {
|
||||||
if (mBounded) {
|
if (mBounded) {
|
||||||
@@ -116,6 +119,7 @@ class MainActivity : BridgeActivity() {
|
|||||||
override fun onSaveInstanceState(outState: Bundle) {
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
storageHelper.onSaveInstanceState(outState)
|
storageHelper.onSaveInstanceState(outState)
|
||||||
super.onSaveInstanceState(outState)
|
super.onSaveInstanceState(outState)
|
||||||
|
outState.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ class Podcast(
|
|||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
|
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
|
||||||
val localEpisodeId = "local_ep_" + episode.id
|
val localEpisodeId = "local_ep_" + episode.id
|
||||||
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,null,null,audioTrack,episode.chapters,audioTrack.duration,0, episode.id, localEpisodeId)
|
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,null,null,audioTrack,episode.chapters,audioTrack.duration,episode.size, episode.id, localEpisodeId)
|
||||||
episodes?.add(newEpisode)
|
episodes?.add(newEpisode)
|
||||||
|
|
||||||
var index = 1
|
var index = 1
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ enum class ShakeSensitivitySetting {
|
|||||||
VERY_LOW, LOW, MEDIUM, HIGH, VERY_HIGH
|
VERY_LOW, LOW, MEDIUM, HIGH, VERY_HIGH
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class DownloadUsingCellularSetting {
|
||||||
|
ASK, ALWAYS, NEVER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class StreamingUsingCellularSetting {
|
||||||
|
ASK, ALWAYS, NEVER
|
||||||
|
}
|
||||||
|
|
||||||
data class ServerConnectionConfig(
|
data class ServerConnectionConfig(
|
||||||
var id:String,
|
var id:String,
|
||||||
var index:Int,
|
var index:Int,
|
||||||
@@ -107,6 +115,7 @@ data class PlayItemRequestPayload(
|
|||||||
data class DeviceSettings(
|
data class DeviceSettings(
|
||||||
var disableAutoRewind:Boolean,
|
var disableAutoRewind:Boolean,
|
||||||
var enableAltView:Boolean,
|
var enableAltView:Boolean,
|
||||||
|
var allowSeekingOnMediaControls:Boolean,
|
||||||
var jumpBackwardsTime:Int,
|
var jumpBackwardsTime:Int,
|
||||||
var jumpForwardTime:Int,
|
var jumpForwardTime:Int,
|
||||||
var enableMp3IndexSeeking:Boolean,
|
var enableMp3IndexSeeking:Boolean,
|
||||||
@@ -122,7 +131,9 @@ data class DeviceSettings(
|
|||||||
var sleepTimerLength: Long, // Time in milliseconds
|
var sleepTimerLength: Long, // Time in milliseconds
|
||||||
var disableSleepTimerFadeOut: Boolean,
|
var disableSleepTimerFadeOut: Boolean,
|
||||||
var disableSleepTimerResetFeedback: Boolean,
|
var disableSleepTimerResetFeedback: Boolean,
|
||||||
var languageCode: String
|
var languageCode: String,
|
||||||
|
var downloadUsingCellular: DownloadUsingCellularSetting,
|
||||||
|
var streamingUsingCellular: StreamingUsingCellularSetting
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
// Static method to get default device settings
|
// Static method to get default device settings
|
||||||
@@ -130,6 +141,7 @@ data class DeviceSettings(
|
|||||||
return DeviceSettings(
|
return DeviceSettings(
|
||||||
disableAutoRewind = false,
|
disableAutoRewind = false,
|
||||||
enableAltView = true,
|
enableAltView = true,
|
||||||
|
allowSeekingOnMediaControls = false,
|
||||||
jumpBackwardsTime = 10,
|
jumpBackwardsTime = 10,
|
||||||
jumpForwardTime = 10,
|
jumpForwardTime = 10,
|
||||||
enableMp3IndexSeeking = false,
|
enableMp3IndexSeeking = false,
|
||||||
@@ -145,7 +157,9 @@ data class DeviceSettings(
|
|||||||
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
|
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
|
||||||
disableSleepTimerFadeOut = false,
|
disableSleepTimerFadeOut = false,
|
||||||
disableSleepTimerResetFeedback = false,
|
disableSleepTimerResetFeedback = false,
|
||||||
languageCode = "en-us"
|
languageCode = "en-us",
|
||||||
|
downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS,
|
||||||
|
streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,14 @@ object DeviceManager {
|
|||||||
if (deviceData.deviceSettings?.languageCode == null) {
|
if (deviceData.deviceSettings?.languageCode == null) {
|
||||||
deviceData.deviceSettings?.languageCode = "en-us"
|
deviceData.deviceSettings?.languageCode = "en-us"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (deviceData.deviceSettings?.downloadUsingCellular == null) {
|
||||||
|
deviceData.deviceSettings?.downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceData.deviceSettings?.streamingUsingCellular == null) {
|
||||||
|
deviceData.deviceSettings?.streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getBase64Id(id:String):String {
|
fun getBase64Id(id:String):String {
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ class FolderScanner(var ctx: Context) {
|
|||||||
)
|
)
|
||||||
localLibraryItem.localFiles.add(localFile)
|
localLibraryItem.localFiles.add(localFile)
|
||||||
|
|
||||||
|
val trackFileMetadata = FileMetadata(file.name, file.extension, file.absolutePath, file.getBasePath(ctx), file.length())
|
||||||
// Create new audio track
|
// Create new audio track
|
||||||
val track = AudioTrack(
|
val track = AudioTrack(
|
||||||
audioTrackFromServer.index,
|
audioTrackFromServer.index,
|
||||||
@@ -78,7 +79,7 @@ class FolderScanner(var ctx: Context) {
|
|||||||
localFile.filename ?: "",
|
localFile.filename ?: "",
|
||||||
localFile.contentUrl,
|
localFile.contentUrl,
|
||||||
localFile.mimeType ?: "",
|
localFile.mimeType ?: "",
|
||||||
null,
|
trackFileMetadata,
|
||||||
true,
|
true,
|
||||||
localFileId,
|
localFileId,
|
||||||
null,
|
null,
|
||||||
@@ -274,7 +275,8 @@ class FolderScanner(var ctx: Context) {
|
|||||||
localLibraryItem.localFiles.add(localFile)
|
localLibraryItem.localFiles.add(localFile)
|
||||||
|
|
||||||
// Create new audio track
|
// Create new audio track
|
||||||
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioTrackFromServer.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, null, audioTrackFromServer.index)
|
val trackFileMetadata = FileMetadata(docFile.name ?: "", docFile.extension ?: "", docFile.getAbsolutePath(ctx), docFile.getBasePath(ctx), docFile.length())
|
||||||
|
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioTrackFromServer.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", trackFileMetadata, true, localFileId, null, audioTrackFromServer.index)
|
||||||
audioTracks.add(track)
|
audioTracks.add(track)
|
||||||
|
|
||||||
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
|
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
|
||||||
|
|||||||
@@ -247,14 +247,18 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
|
private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
|
||||||
Log.d(tag, "checkSetValidServerConnectionConfig | $serverConfigIdUsed")
|
Log.d(tag, "checkSetValidServerConnectionConfig | serverConfigIdUsed=$serverConfigIdUsed | lastServerConnectionConfigId=${DeviceManager.deviceData.lastServerConnectionConfigId}")
|
||||||
|
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
if (!DeviceManager.checkConnectivity(ctx)) {
|
if (!DeviceManager.checkConnectivity(ctx)) {
|
||||||
serverUserMediaProgress = mutableListOf()
|
serverUserMediaProgress = mutableListOf()
|
||||||
|
Log.d(tag, "checkSetValidServerConnectionConfig: No connectivity")
|
||||||
|
cb(false)
|
||||||
|
} else if (DeviceManager.deviceData.lastServerConnectionConfigId.isNullOrBlank()) { // If in offline mode last server connection config is unset
|
||||||
|
serverUserMediaProgress = mutableListOf()
|
||||||
|
Log.d(tag, "checkSetValidServerConnectionConfig: No last server connection config")
|
||||||
cb(false)
|
cb(false)
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
var hasValidConn = false
|
var hasValidConn = false
|
||||||
var lookupMediaProgress = true
|
var lookupMediaProgress = true
|
||||||
|
|
||||||
@@ -332,6 +336,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
checkSetValidServerConnectionConfig { isConnected ->
|
checkSetValidServerConnectionConfig { isConnected ->
|
||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
serverConfigIdUsed = DeviceManager.serverConnectionConfigId
|
serverConfigIdUsed = DeviceManager.serverConnectionConfigId
|
||||||
|
Log.d(tag, "loadAndroidAutoItems: Connected to server config id=$serverConfigIdUsed")
|
||||||
|
|
||||||
loadLibraries { libraries ->
|
loadLibraries { libraries ->
|
||||||
if (libraries.isEmpty()) {
|
if (libraries.isEmpty()) {
|
||||||
@@ -358,6 +363,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else { // Not connected to server
|
} else { // Not connected to server
|
||||||
|
Log.d(tag, "loadAndroidAutoItems: Not connected to server")
|
||||||
cb()
|
cb()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,6 +238,8 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
|||||||
saveLocalProgress(it)
|
saveLocalProgress(it)
|
||||||
lastSyncTime = System.currentTimeMillis()
|
lastSyncTime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
Log.d(tag, "Sync local device current serverConnectionConfigId=${DeviceManager.serverConnectionConfig?.id}")
|
||||||
|
|
||||||
// Local library item is linked to a server library item
|
// Local library item is linked to a server library item
|
||||||
// Send sync to server also if connected to this server and local item belongs to this server
|
// Send sync to server also if connected to this server and local item belongs to this server
|
||||||
if (hasNetworkConnection && shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
|
if (hasNetworkConnection && shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
|
||||||
|
|||||||
+2
-4
@@ -15,8 +15,6 @@ import com.google.android.exoplayer2.Player
|
|||||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
|
|
||||||
const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px
|
|
||||||
|
|
||||||
class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, private val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
|
class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, private val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
|
||||||
private val tag = "MediaDescriptionAdapter"
|
private val tag = "MediaDescriptionAdapter"
|
||||||
|
|
||||||
@@ -77,7 +75,7 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
|||||||
.load(uri)
|
.load(uri)
|
||||||
.placeholder(R.drawable.icon)
|
.placeholder(R.drawable.icon)
|
||||||
.error(R.drawable.icon)
|
.error(R.drawable.icon)
|
||||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
.submit()
|
||||||
.get()
|
.get()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
@@ -85,7 +83,7 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
|||||||
Glide.with(playerNotificationService)
|
Glide.with(playerNotificationService)
|
||||||
.asBitmap()
|
.asBitmap()
|
||||||
.load(Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon))
|
.load(Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon))
|
||||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
.submit()
|
||||||
.get()
|
.get()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-10
@@ -34,6 +34,7 @@ import com.audiobookshelf.app.managers.SleepTimerManager
|
|||||||
import com.audiobookshelf.app.media.MediaManager
|
import com.audiobookshelf.app.media.MediaManager
|
||||||
import com.audiobookshelf.app.media.MediaProgressSyncer
|
import com.audiobookshelf.app.media.MediaProgressSyncer
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
|
import com.audiobookshelf.app.BuildConfig
|
||||||
import com.google.android.exoplayer2.*
|
import com.google.android.exoplayer2.*
|
||||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||||
@@ -329,15 +330,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaSessionConnector.setEnabledPlaybackActions(
|
setMediaSessionConnectorPlaybackActions()
|
||||||
PlaybackStateCompat.ACTION_PLAY_PAUSE
|
|
||||||
or PlaybackStateCompat.ACTION_PLAY
|
|
||||||
or PlaybackStateCompat.ACTION_PAUSE
|
|
||||||
or PlaybackStateCompat.ACTION_SEEK_TO
|
|
||||||
or PlaybackStateCompat.ACTION_FAST_FORWARD
|
|
||||||
or PlaybackStateCompat.ACTION_REWIND
|
|
||||||
or PlaybackStateCompat.ACTION_STOP
|
|
||||||
)
|
|
||||||
mediaSessionConnector.setQueueNavigator(queueNavigator)
|
mediaSessionConnector.setQueueNavigator(queueNavigator)
|
||||||
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
|
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
|
||||||
|
|
||||||
@@ -512,6 +505,20 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray())
|
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setMediaSessionConnectorPlaybackActions() {
|
||||||
|
var playbackActions = PlaybackStateCompat.ACTION_PLAY_PAUSE or
|
||||||
|
PlaybackStateCompat.ACTION_PLAY or
|
||||||
|
PlaybackStateCompat.ACTION_PAUSE or
|
||||||
|
PlaybackStateCompat.ACTION_FAST_FORWARD or
|
||||||
|
PlaybackStateCompat.ACTION_REWIND or
|
||||||
|
PlaybackStateCompat.ACTION_STOP
|
||||||
|
|
||||||
|
if (deviceSettings.allowSeekingOnMediaControls) {
|
||||||
|
playbackActions = playbackActions or PlaybackStateCompat.ACTION_SEEK_TO
|
||||||
|
}
|
||||||
|
mediaSessionConnector.setEnabledPlaybackActions(playbackActions)
|
||||||
|
}
|
||||||
|
|
||||||
fun handlePlayerPlaybackError(errorMessage:String) {
|
fun handlePlayerPlaybackError(errorMessage:String) {
|
||||||
// On error and was attempting to direct play - fallback to transcode
|
// On error and was attempting to direct play - fallback to transcode
|
||||||
currentPlaybackSession?.let { playbackSession ->
|
currentPlaybackSession?.let { playbackSession ->
|
||||||
@@ -941,7 +948,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
appVersion: 0.9.46-beta
|
appVersion: 0.9.46-beta
|
||||||
*/
|
*/
|
||||||
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
|
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
|
||||||
return DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, Build.VERSION.RELEASE)
|
return DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
|
private val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.audiobookshelf.app.plugins
|
package com.audiobookshelf.app.plugins
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.MainActivity
|
import com.audiobookshelf.app.MainActivity
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
@@ -493,9 +495,16 @@ class AbsDatabase : Plugin() {
|
|||||||
fun updateDeviceSettings(call:PluginCall) { // Returns device data
|
fun updateDeviceSettings(call:PluginCall) { // Returns device data
|
||||||
Log.d(tag, "updateDeviceSettings ${call.data}")
|
Log.d(tag, "updateDeviceSettings ${call.data}")
|
||||||
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
|
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
DeviceManager.deviceData.deviceSettings = newDeviceSettings
|
DeviceManager.deviceData.deviceSettings = newDeviceSettings
|
||||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||||
|
|
||||||
|
// Updates playback actions for media notification (handles media control seek locking setting)
|
||||||
|
if (mainActivity.isPlayerNotificationServiceInitialized()) {
|
||||||
|
mainActivity.foregroundService.setMediaSessionConnectorPlaybackActions()
|
||||||
|
}
|
||||||
|
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.deviceData)))
|
call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.deviceData)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package com.audiobookshelf.app.server
|
package com.audiobookshelf.app.server
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
@@ -8,6 +11,7 @@ import com.audiobookshelf.app.media.MediaEventManager
|
|||||||
import com.audiobookshelf.app.media.MediaProgressSyncData
|
import com.audiobookshelf.app.media.MediaProgressSyncData
|
||||||
import com.audiobookshelf.app.media.SyncResult
|
import com.audiobookshelf.app.media.SyncResult
|
||||||
import com.audiobookshelf.app.models.User
|
import com.audiobookshelf.app.models.User
|
||||||
|
import com.audiobookshelf.app.BuildConfig
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
@@ -31,7 +35,7 @@ class ApiHandler(var ctx:Context) {
|
|||||||
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
|
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
|
||||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||||
|
|
||||||
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>)
|
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>, val deviceInfo:DeviceInfo)
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class LocalSessionSyncResult(val id:String, val success:Boolean, val progressSynced:Boolean?, val error:String?)
|
data class LocalSessionSyncResult(val id:String, val success:Boolean, val progressSynced:Boolean?, val error:String?)
|
||||||
data class LocalSessionsSyncResponsePayload(val results:List<LocalSessionSyncResult>)
|
data class LocalSessionsSyncResponsePayload(val results:List<LocalSessionSyncResult>)
|
||||||
@@ -322,7 +326,11 @@ class ApiHandler(var ctx:Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun sendSyncLocalSessions(playbackSessions:List<PlaybackSession>, cb: (Boolean, String?) -> Unit) {
|
fun sendSyncLocalSessions(playbackSessions:List<PlaybackSession>, cb: (Boolean, String?) -> Unit) {
|
||||||
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions)))
|
@SuppressLint("HardwareIds")
|
||||||
|
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
|
||||||
|
val deviceInfo = DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||||
|
|
||||||
|
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions, deviceInfo)))
|
||||||
Log.d(tag, "Sending ${playbackSessions.size} saved local playback sessions to server")
|
Log.d(tag, "Sending ${playbackSessions.size} saved local playback sessions to server")
|
||||||
postRequest("/api/session/local-all", payload, null) {
|
postRequest("/api/session/local-all", payload, null) {
|
||||||
if (!it.getString("error").isNullOrEmpty()) {
|
if (!it.getString("error").isNullOrEmpty()) {
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
include ':capacitor-android'
|
include ':capacitor-android'
|
||||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||||
|
|
||||||
|
include ':byteowls-capacitor-filesharer'
|
||||||
|
project(':byteowls-capacitor-filesharer').projectDir = new File('../node_modules/@byteowls/capacitor-filesharer/android')
|
||||||
|
|
||||||
include ':capacitor-app'
|
include ':capacitor-app'
|
||||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
ext {
|
ext {
|
||||||
minSdkVersion = 24
|
minSdkVersion = 24
|
||||||
compileSdkVersion = 34
|
compileSdkVersion = 34
|
||||||
targetSdkVersion = 33
|
targetSdkVersion = 34
|
||||||
androidxActivityVersion = '1.7.0'
|
androidxActivityVersion = '1.7.0'
|
||||||
androidxAppCompatVersion = '1.6.1'
|
androidxAppCompatVersion = '1.6.1'
|
||||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
class WrappingMarquee {
|
||||||
|
#scrollDelay = 2000
|
||||||
|
#scrollSpeed = 30
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} el
|
||||||
|
*/
|
||||||
|
constructor(el) {
|
||||||
|
this.el = el
|
||||||
|
/** @type {HTMLElement} */
|
||||||
|
this.pEl = el?.firstElementChild
|
||||||
|
|
||||||
|
this.innerText = ''
|
||||||
|
this.isScrolling = false
|
||||||
|
|
||||||
|
/** @type {NodeJS.Timeout} */
|
||||||
|
this.timer = null
|
||||||
|
/** @type {number} */
|
||||||
|
this.animationId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transparent gradient mask shown when text is scrolling left and overflowing right
|
||||||
|
*
|
||||||
|
* @param {boolean} showLeft
|
||||||
|
*/
|
||||||
|
setMask(showLeft) {
|
||||||
|
if (!this.el) return
|
||||||
|
this.el.style.maskImage = showLeft ? 'linear-gradient(90deg, transparent 0%, #fff 10%, #000 90%, transparent)' : 'linear-gradient(90deg, #000 90%, transparent)'
|
||||||
|
}
|
||||||
|
|
||||||
|
startScroll() {
|
||||||
|
if (this.isScrolling) {
|
||||||
|
console.warn('Already scrolling')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isScrolling = true
|
||||||
|
this.setMask(true)
|
||||||
|
|
||||||
|
let textScrollAmount = this.el.scrollWidth
|
||||||
|
this.pEl.innerHTML = this.innerText + ' '.repeat(15)
|
||||||
|
let totalScrollAmount = this.el.scrollWidth
|
||||||
|
let scrollDuration = totalScrollAmount * this.#scrollSpeed
|
||||||
|
|
||||||
|
this.pEl.innerHTML = this.pEl.innerHTML + this.innerText
|
||||||
|
|
||||||
|
let done = false
|
||||||
|
let start, previousTimeStamp
|
||||||
|
|
||||||
|
const step = (timeStamp) => {
|
||||||
|
if (start === undefined) {
|
||||||
|
start = timeStamp
|
||||||
|
}
|
||||||
|
const elapsed = timeStamp - start
|
||||||
|
|
||||||
|
if (this.isScrolling && previousTimeStamp !== timeStamp) {
|
||||||
|
const amountToMove = Math.min(elapsed / scrollDuration * totalScrollAmount, totalScrollAmount)
|
||||||
|
this.pEl.style.transform = `translateX(-${amountToMove}px)`
|
||||||
|
if (amountToMove === totalScrollAmount) done = true
|
||||||
|
if (amountToMove > textScrollAmount) this.setMask(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isScrolling || done) { // canceled or done
|
||||||
|
this.isScrolling = false
|
||||||
|
this.pEl.style.transform = 'translateX(0px)'
|
||||||
|
this.pEl.innerText = this.innerText
|
||||||
|
this.setMask(false)
|
||||||
|
if (done) {
|
||||||
|
this.startTimer()
|
||||||
|
}
|
||||||
|
} else if (elapsed < scrollDuration) { // step
|
||||||
|
previousTimeStamp = timeStamp
|
||||||
|
this.animationId = window.requestAnimationFrame(step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.animationId = window.requestAnimationFrame(step)
|
||||||
|
}
|
||||||
|
|
||||||
|
startTimer() {
|
||||||
|
clearTimeout(this.timer)
|
||||||
|
this.timer = setTimeout(() => {
|
||||||
|
this.startScroll()
|
||||||
|
}, this.#scrollDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
clearTimeout(this.timer)
|
||||||
|
this.timer = null
|
||||||
|
this.isScrolling = false
|
||||||
|
window.cancelAnimationFrame(this.animationId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize and start marquee if text overflows container
|
||||||
|
* resets the marquee if already active
|
||||||
|
*
|
||||||
|
* @param {string} innerText
|
||||||
|
*/
|
||||||
|
init(innerText) {
|
||||||
|
if (!this.el || !this.pEl) return
|
||||||
|
|
||||||
|
this.reset()
|
||||||
|
|
||||||
|
this.innerText = innerText
|
||||||
|
this.pEl.innerText = innerText
|
||||||
|
this.pEl.style.transform = 'translateX(0px)'
|
||||||
|
|
||||||
|
if (this.el.scrollWidth > this.el.clientWidth) {
|
||||||
|
this.setMask(false)
|
||||||
|
this.startTimer()
|
||||||
|
} else {
|
||||||
|
this.el.style.maskImage = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export default WrappingMarquee
|
||||||
+1
-4
@@ -2,6 +2,7 @@
|
|||||||
@import "./fonts.css";
|
@import "./fonts.css";
|
||||||
@import './defaultStyles.css';
|
@import './defaultStyles.css';
|
||||||
@import './absicons.css';
|
@import './absicons.css';
|
||||||
|
@import './transitions.css';
|
||||||
|
|
||||||
* {
|
* {
|
||||||
-webkit-touch-callout: none;
|
-webkit-touch-callout: none;
|
||||||
@@ -16,10 +17,6 @@ textarea {
|
|||||||
user-select: auto;
|
user-select: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: #262626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-wrapper {
|
.layout-wrapper {
|
||||||
height: calc(100vh - env(safe-area-inset-top));
|
height: calc(100vh - env(safe-area-inset-top));
|
||||||
min-height: calc(100vh - env(safe-area-inset-top));
|
min-height: calc(100vh - env(safe-area-inset-top));
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
(function (global, factory) {
|
|
||||||
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
|
|
||||||
typeof define === 'function' && define.amd ? define(['exports'], factory) :
|
|
||||||
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global['fast-sort'] = {}));
|
|
||||||
}(this, (function (exports) {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// >>> INTERFACES <<<
|
|
||||||
// >>> HELPERS <<<
|
|
||||||
var castComparer = function (comparer) { return function (a, b, order) { return comparer(a, b, order) * order; }; };
|
|
||||||
var throwInvalidConfigErrorIfTrue = function (condition, context) {
|
|
||||||
if (condition)
|
|
||||||
throw Error("Invalid sort config: " + context);
|
|
||||||
};
|
|
||||||
var unpackObjectSorter = function (sortByObj) {
|
|
||||||
var _a = sortByObj || {}, asc = _a.asc, desc = _a.desc;
|
|
||||||
var order = asc ? 1 : -1;
|
|
||||||
var sortBy = (asc || desc);
|
|
||||||
// Validate object config
|
|
||||||
throwInvalidConfigErrorIfTrue(!sortBy, 'Expected `asc` or `desc` property');
|
|
||||||
throwInvalidConfigErrorIfTrue(asc && desc, 'Ambiguous object with `asc` and `desc` config properties');
|
|
||||||
var comparer = sortByObj.comparer && castComparer(sortByObj.comparer);
|
|
||||||
return { order: order, sortBy: sortBy, comparer: comparer };
|
|
||||||
};
|
|
||||||
// >>> SORTERS <<<
|
|
||||||
var multiPropertySorterProvider = function (defaultComparer) {
|
|
||||||
return function multiPropertySorter(sortBy, sortByArr, depth, order, comparer, a, b) {
|
|
||||||
var valA;
|
|
||||||
var valB;
|
|
||||||
if (typeof sortBy === 'string') {
|
|
||||||
valA = a[sortBy];
|
|
||||||
valB = b[sortBy];
|
|
||||||
}
|
|
||||||
else if (typeof sortBy === 'function') {
|
|
||||||
valA = sortBy(a);
|
|
||||||
valB = sortBy(b);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
var objectSorterConfig = unpackObjectSorter(sortBy);
|
|
||||||
return multiPropertySorter(objectSorterConfig.sortBy, sortByArr, depth, objectSorterConfig.order, objectSorterConfig.comparer || defaultComparer, a, b);
|
|
||||||
}
|
|
||||||
var equality = comparer(valA, valB, order);
|
|
||||||
if ((equality === 0 || (valA == null && valB == null)) &&
|
|
||||||
sortByArr.length > depth) {
|
|
||||||
return multiPropertySorter(sortByArr[depth], sortByArr, depth + 1, order, comparer, a, b);
|
|
||||||
}
|
|
||||||
return equality;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
function getSortStrategy(sortBy, comparer, order) {
|
|
||||||
// Flat array sorter
|
|
||||||
if (sortBy === undefined || sortBy === true) {
|
|
||||||
return function (a, b) { return comparer(a, b, order); };
|
|
||||||
}
|
|
||||||
// Sort list of objects by single object key
|
|
||||||
if (typeof sortBy === 'string') {
|
|
||||||
throwInvalidConfigErrorIfTrue(sortBy.includes('.'), 'String syntax not allowed for nested properties.');
|
|
||||||
return function (a, b) { return comparer(a[sortBy], b[sortBy], order); };
|
|
||||||
}
|
|
||||||
// Sort list of objects by single function sorter
|
|
||||||
if (typeof sortBy === 'function') {
|
|
||||||
return function (a, b) { return comparer(sortBy(a), sortBy(b), order); };
|
|
||||||
}
|
|
||||||
// Sort by multiple properties
|
|
||||||
if (Array.isArray(sortBy)) {
|
|
||||||
var multiPropSorter_1 = multiPropertySorterProvider(comparer);
|
|
||||||
return function (a, b) { return multiPropSorter_1(sortBy[0], sortBy, 1, order, comparer, a, b); };
|
|
||||||
}
|
|
||||||
// Unpack object config to get actual sorter strategy
|
|
||||||
var objectSorterConfig = unpackObjectSorter(sortBy);
|
|
||||||
return getSortStrategy(objectSorterConfig.sortBy, objectSorterConfig.comparer || comparer, objectSorterConfig.order);
|
|
||||||
}
|
|
||||||
var sortArray = function (order, ctx, sortBy, comparer) {
|
|
||||||
var _a;
|
|
||||||
if (!Array.isArray(ctx)) {
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
// Unwrap sortBy if array with only 1 value to get faster sort strategy
|
|
||||||
if (Array.isArray(sortBy) && sortBy.length < 2) {
|
|
||||||
_a = sortBy, sortBy = _a[0];
|
|
||||||
}
|
|
||||||
return ctx.sort(getSortStrategy(sortBy, comparer, order));
|
|
||||||
};
|
|
||||||
// >>> Public <<<
|
|
||||||
var createNewSortInstance = function (opts) {
|
|
||||||
var comparer = castComparer(opts.comparer);
|
|
||||||
return function (_ctx) {
|
|
||||||
var ctx = Array.isArray(_ctx) && !opts.inPlaceSorting
|
|
||||||
? _ctx.slice()
|
|
||||||
: _ctx;
|
|
||||||
return {
|
|
||||||
/**
|
|
||||||
* Sort array in ascending order.
|
|
||||||
* @example
|
|
||||||
* sort([3, 1, 4]).asc();
|
|
||||||
* sort(users).asc(u => u.firstName);
|
|
||||||
* sort(users).asc([
|
|
||||||
* U => u.firstName
|
|
||||||
* u => u.lastName,
|
|
||||||
* ]);
|
|
||||||
*/
|
|
||||||
asc: function (sortBy) {
|
|
||||||
return sortArray(1, ctx, sortBy, comparer);
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Sort array in descending order.
|
|
||||||
* @example
|
|
||||||
* sort([3, 1, 4]).desc();
|
|
||||||
* sort(users).desc(u => u.firstName);
|
|
||||||
* sort(users).desc([
|
|
||||||
* U => u.firstName
|
|
||||||
* u => u.lastName,
|
|
||||||
* ]);
|
|
||||||
*/
|
|
||||||
desc: function (sortBy) {
|
|
||||||
return sortArray(-1, ctx, sortBy, comparer);
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Sort array in ascending or descending order. It allows sorting on multiple props
|
|
||||||
* in different order for each of them.
|
|
||||||
* @example
|
|
||||||
* sort(users).by([
|
|
||||||
* { asc: u => u.score }
|
|
||||||
* { desc: u => u.age }
|
|
||||||
* ]);
|
|
||||||
*/
|
|
||||||
by: function (sortBy) {
|
|
||||||
return sortArray(1, ctx, sortBy, comparer);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
var defaultComparer = function (a, b, order) {
|
|
||||||
if (a == null)
|
|
||||||
return order;
|
|
||||||
if (b == null)
|
|
||||||
return -order;
|
|
||||||
if (a < b)
|
|
||||||
return -1;
|
|
||||||
if (a === b)
|
|
||||||
return 0;
|
|
||||||
return 1;
|
|
||||||
};
|
|
||||||
var sort = createNewSortInstance({
|
|
||||||
comparer: defaultComparer,
|
|
||||||
});
|
|
||||||
var inPlaceSort = createNewSortInstance({
|
|
||||||
comparer: defaultComparer,
|
|
||||||
inPlaceSorting: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
exports.createNewSortInstance = createNewSortInstance;
|
|
||||||
exports.inPlaceSort = inPlaceSort;
|
|
||||||
exports.sort = sort;
|
|
||||||
|
|
||||||
Object.defineProperty(exports, '__esModule', { value: true });
|
|
||||||
|
|
||||||
})));
|
|
||||||
+6
-2
@@ -18,7 +18,7 @@
|
|||||||
--color-track: 107 114 128;
|
--color-track: 107 114 128;
|
||||||
--color-track-buffered: 75 85 99;
|
--color-track-buffered: 75 85 99;
|
||||||
--gradient-item-page: linear-gradient(169deg, rgba(0, 0, 0, 0.4) 0%, rgba(55, 56, 56, 1) 80%);
|
--gradient-item-page: linear-gradient(169deg, rgba(0, 0, 0, 0.4) 0%, rgba(55, 56, 56, 1) 80%);
|
||||||
--gradient-audio-player: linear-gradient(169deg, rgba(0, 0, 0, 0) 0%, rgba(38, 38, 38, 1) 80%);
|
--gradient-audio-player: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(38, 38, 38, 1) 80%);
|
||||||
--gradient-minimized-audio-player: linear-gradient(145deg, rgba(38, 38, 38, 0.5) 0%, rgba(38, 38, 38, 0.9) 20%, rgb(38, 38, 38) 60%);
|
--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%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +37,11 @@
|
|||||||
--color-track: 189 191 191;
|
--color-track: 189 191 191;
|
||||||
--color-track-buffered: 129 131 131;
|
--color-track-buffered: 129 131 131;
|
||||||
--gradient-item-page: linear-gradient(169deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 1) 80%);
|
--gradient-item-page: linear-gradient(169deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 1) 80%);
|
||||||
--gradient-audio-player: linear-gradient(169deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 1) 80%);
|
--gradient-audio-player: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 80%);
|
||||||
--gradient-minimized-audio-player: linear-gradient(145deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0.9) 20%, rgb(255, 255, 255) 60%);
|
--gradient-minimized-audio-player: linear-gradient(145deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0.9) 20%, rgb(255, 255, 255) 60%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-primary
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
.slide-enter-active {
|
||||||
|
-moz-transition-duration: 0.1s;
|
||||||
|
-webkit-transition-duration: 0.1s;
|
||||||
|
-o-transition-duration: 0.1s;
|
||||||
|
transition-duration: 0.1s;
|
||||||
|
-moz-transition-timing-function: ease-in;
|
||||||
|
-webkit-transition-timing-function: ease-in;
|
||||||
|
-o-transition-timing-function: ease-in;
|
||||||
|
transition-timing-function: ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-leave-active {
|
||||||
|
-moz-transition-duration: 0.2s;
|
||||||
|
-webkit-transition-duration: 0.2s;
|
||||||
|
-o-transition-duration: 0.2s;
|
||||||
|
transition-duration: 0.2s;
|
||||||
|
-moz-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||||
|
-webkit-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||||
|
-o-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||||
|
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-enter-to,
|
||||||
|
.slide-leave {
|
||||||
|
max-height: 600px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-enter,
|
||||||
|
.slide-leave-to {
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-enter,
|
||||||
|
.menu-leave-active {
|
||||||
|
transform: translateY(-15px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-enter-active {
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-leave-active {
|
||||||
|
transition: all 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-enter,
|
||||||
|
.menu-leave-active {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<a v-if="showBack" @click="back" class="rounded-full h-10 w-10 flex items-center justify-center mr-2 cursor-pointer">
|
<a v-if="showBack" @click="back" class="rounded-full h-10 w-10 flex items-center justify-center mr-2 cursor-pointer">
|
||||||
<span class="material-icons text-3xl text-fg">arrow_back</span>
|
<span class="material-icons text-3xl text-fg">arrow_back</span>
|
||||||
</a>
|
</a>
|
||||||
<div v-if="user && currentLibrary">
|
<div v-if="user && currentLibrary && networkConnected">
|
||||||
<div class="pl-1.5 pr-2.5 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
|
<div class="pl-1.5 pr-2.5 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
|
||||||
<ui-library-icon :icon="currentLibraryIcon" :size="4" font-size="base" />
|
<ui-library-icon :icon="currentLibraryIcon" :size="4" font-size="base" />
|
||||||
<p class="text-sm leading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
|
<p class="text-sm leading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
|
||||||
@@ -54,14 +54,17 @@ export default {
|
|||||||
this.$store.commit('setCastAvailable', val)
|
this.$store.commit('setCastAvailable', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
networkConnected() {
|
||||||
|
return this.$store.state.networkConnected
|
||||||
|
},
|
||||||
currentLibrary() {
|
currentLibrary() {
|
||||||
return this.$store.getters['libraries/getCurrentLibrary']
|
return this.$store.getters['libraries/getCurrentLibrary']
|
||||||
},
|
},
|
||||||
currentLibraryName() {
|
currentLibraryName() {
|
||||||
return this.currentLibrary ? this.currentLibrary.name : ''
|
return this.currentLibrary?.name || ''
|
||||||
},
|
},
|
||||||
currentLibraryIcon() {
|
currentLibraryIcon() {
|
||||||
return this.currentLibrary ? this.currentLibrary.icon : 'database'
|
return this.currentLibrary?.icon || 'database'
|
||||||
},
|
},
|
||||||
showBack() {
|
showBack() {
|
||||||
if (!this.$route.name) return true
|
if (!this.$route.name) return true
|
||||||
@@ -71,7 +74,7 @@ export default {
|
|||||||
return this.$store.state.user.user
|
return this.$store.state.user.user
|
||||||
},
|
},
|
||||||
username() {
|
username() {
|
||||||
return this.user ? this.user.username : 'err'
|
return this.user?.username || 'err'
|
||||||
},
|
},
|
||||||
isCasting() {
|
isCasting() {
|
||||||
return this.$store.state.isCasting
|
return this.$store.state.isCasting
|
||||||
|
|||||||
+182
-81
@@ -15,7 +15,7 @@
|
|||||||
<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 }" style="font-size: 10px">{{ isDirectPlayMethod ? $strings.LabelPlaybackDirect : isLocalPlayMethod ? $strings.LabelPlaybackLocal : $strings.LabelPlaybackTranscode }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="useChapterTrack && useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
|
<div v-if="playerSettings.useChapterTrack && playerSettings.useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ currentTimePretty }}</p>
|
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ currentTimePretty }}</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
@@ -41,7 +41,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor">
|
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor">
|
||||||
<p class="title-text truncate">{{ title }}</p>
|
<div ref="titlewrapper" class="overflow-hidden relative">
|
||||||
|
<p class="title-text whitespace-nowrap"></p>
|
||||||
|
</div>
|
||||||
<p class="author-text text-fg text-opacity-75 truncate">{{ authorName }}</p>
|
<p class="author-text text-fg text-opacity-75 truncate">{{ authorName }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -66,17 +68,17 @@
|
|||||||
<div v-else class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-minimized-audio-player)" />
|
<div v-else class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-minimized-audio-player)" />
|
||||||
|
|
||||||
<div id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px">
|
<div id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px">
|
||||||
<div class="flex items-center max-w-full" :class="lockUi ? 'justify-center' : 'justify-between'">
|
<div class="flex items-center max-w-full" :class="playerSettings.lockUi ? 'justify-center' : 'justify-between'">
|
||||||
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-icons next-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||||
<span v-show="!lockUi" class="material-icons jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
<span v-show="!playerSettings.lockUi" class="material-icons jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
||||||
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4 relative overflow-hidden" :style="{ backgroundColor: coverRgb }" :class="{ 'animate-spin': seekLoading }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
|
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4 relative overflow-hidden" :style="{ backgroundColor: coverRgb }" :class="{ 'animate-spin': seekLoading }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
|
||||||
<div v-if="!coverBgIsLight" class="absolute top-0 left-0 w-full h-full bg-white bg-opacity-20 pointer-events-none" />
|
<div v-if="!coverBgIsLight" class="absolute top-0 left-0 w-full h-full bg-white bg-opacity-20 pointer-events-none" />
|
||||||
|
|
||||||
<span v-if="!isLoading" class="material-icons" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
<span v-if="!isLoading" class="material-icons" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
||||||
<widgets-spinner-icon v-else class="h-8 w-8" />
|
<widgets-spinner-icon v-else class="h-8 w-8" />
|
||||||
</div>
|
</div>
|
||||||
<span v-show="!lockUi" class="material-icons jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
|
<span v-show="!playerSettings.lockUi" class="material-icons jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
|
||||||
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-fg cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-icons next-icon text-fg cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -90,7 +92,7 @@
|
|||||||
<div ref="readyTrack" class="h-full bg-track-buffered absolute top-0 left-0 rounded-full pointer-events-none" />
|
<div ref="readyTrack" class="h-full bg-track-buffered absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||||
<div ref="bufferedTrack" class="h-full bg-track absolute top-0 left-0 rounded-full pointer-events-none" />
|
<div ref="bufferedTrack" class="h-full bg-track absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||||
<div ref="playedTrack" class="h-full bg-track-cursor absolute top-0 left-0 rounded-full pointer-events-none" />
|
<div ref="playedTrack" class="h-full bg-track-cursor absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||||
<div ref="trackCursor" class="h-7 w-7 rounded-full absolute pointer-events-auto flex items-center justify-center" :style="{ top: '-11px' }" :class="{ 'opacity-0': lockUi || !showFullscreen }" @touchstart="touchstartCursor">
|
<div ref="trackCursor" class="h-7 w-7 rounded-full absolute pointer-events-auto flex items-center justify-center" :style="{ top: '-11px' }" :class="{ 'opacity-0': playerSettings.lockUi || !showFullscreen }" @touchstart="touchstartCursor">
|
||||||
<div class="bg-track-cursor rounded-full w-3.5 h-3.5 pointer-events-none" />
|
<div class="bg-track-cursor rounded-full w-3.5 h-3.5 pointer-events-none" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,7 +100,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" :playback-rate="currentPlaybackRate" @select="selectChapter" />
|
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" :playback-rate="currentPlaybackRate" @select="selectChapter" />
|
||||||
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction" />
|
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" width="80vw" @action="clickMenuAction" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -106,6 +108,7 @@
|
|||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
||||||
import { FastAverageColor } from 'fast-average-color'
|
import { FastAverageColor } from 'fast-average-color'
|
||||||
|
import WrappingMarquee from '@/assets/WrappingMarquee.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
@@ -145,10 +148,12 @@ export default {
|
|||||||
onPlaybackSpeedChangedListener: null,
|
onPlaybackSpeedChangedListener: null,
|
||||||
touchStartY: 0,
|
touchStartY: 0,
|
||||||
touchStartTime: 0,
|
touchStartTime: 0,
|
||||||
touchEndY: 0,
|
playerSettings: {
|
||||||
useChapterTrack: false,
|
useChapterTrack: false,
|
||||||
useTotalTrack: true,
|
useTotalTrack: true,
|
||||||
lockUi: false,
|
scaleElapsedTimeBySpeed: true,
|
||||||
|
lockUi: false
|
||||||
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isDraggingCursor: false,
|
isDraggingCursor: false,
|
||||||
draggingTouchStartX: 0,
|
draggingTouchStartX: 0,
|
||||||
@@ -157,16 +162,22 @@ export default {
|
|||||||
syncStatus: 0,
|
syncStatus: 0,
|
||||||
showMoreMenuDialog: false,
|
showMoreMenuDialog: false,
|
||||||
coverRgb: 'rgb(55, 56, 56)',
|
coverRgb: 'rgb(55, 56, 56)',
|
||||||
coverBgIsLight: false
|
coverBgIsLight: false,
|
||||||
|
titleMarquee: null,
|
||||||
|
isRefreshingUI: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
showFullscreen(val) {
|
showFullscreen(val) {
|
||||||
this.updateScreenSize()
|
this.updateScreenSize()
|
||||||
this.$store.commit('setPlayerFullscreen', !!val)
|
this.$store.commit('setPlayerFullscreen', !!val)
|
||||||
|
document.querySelector('body').style.backgroundColor = this.showFullscreen ? this.coverRgb : ''
|
||||||
},
|
},
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
this.updateScreenSize()
|
this.updateScreenSize()
|
||||||
|
},
|
||||||
|
title(val) {
|
||||||
|
if (this.titleMarquee) this.titleMarquee.init(val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -186,17 +197,22 @@ export default {
|
|||||||
{
|
{
|
||||||
text: this.$strings.LabelTotalTrack,
|
text: this.$strings.LabelTotalTrack,
|
||||||
value: 'total_track',
|
value: 'total_track',
|
||||||
icon: this.useTotalTrack ? 'check_box' : 'check_box_outline_blank'
|
icon: this.playerSettings.useTotalTrack ? 'check_box' : 'check_box_outline_blank'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelChapterTrack,
|
text: this.$strings.LabelChapterTrack,
|
||||||
value: 'chapter_track',
|
value: 'chapter_track',
|
||||||
icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
|
icon: this.playerSettings.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: this.lockUi ? this.$strings.LabelUnlockPlayer : this.$strings.LabelLockPlayer,
|
text: this.$strings.LabelScaleElapsedTimeBySpeed,
|
||||||
|
value: 'scale_elapsed_time',
|
||||||
|
icon: this.playerSettings.scaleElapsedTimeBySpeed ? 'check_box' : 'check_box_outline_blank'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.playerSettings.lockUi ? this.$strings.LabelUnlockPlayer : this.$strings.LabelLockPlayer,
|
||||||
value: 'lock',
|
value: 'lock',
|
||||||
icon: this.lockUi ? 'lock' : 'lock_open'
|
icon: this.playerSettings.lockUi ? 'lock' : 'lock_open'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: this.$strings.LabelClosePlayer,
|
text: this.$strings.LabelClosePlayer,
|
||||||
@@ -256,22 +272,22 @@ export default {
|
|||||||
return this.mediaPlayer === 'cast-player'
|
return this.mediaPlayer === 'cast-player'
|
||||||
},
|
},
|
||||||
mediaPlayer() {
|
mediaPlayer() {
|
||||||
return this.playbackSession ? this.playbackSession.mediaPlayer : null
|
return this.playbackSession?.mediaPlayer || null
|
||||||
},
|
},
|
||||||
mediaType() {
|
mediaType() {
|
||||||
return this.playbackSession ? this.playbackSession.mediaType : null
|
return this.playbackSession?.mediaType || null
|
||||||
},
|
},
|
||||||
isPodcast() {
|
isPodcast() {
|
||||||
return this.mediaType === 'podcast'
|
return this.mediaType === 'podcast'
|
||||||
},
|
},
|
||||||
mediaMetadata() {
|
mediaMetadata() {
|
||||||
return this.playbackSession ? this.playbackSession.mediaMetadata : null
|
return this.playbackSession?.mediaMetadata || null
|
||||||
},
|
},
|
||||||
libraryItem() {
|
libraryItem() {
|
||||||
return this.playbackSession ? this.playbackSession.libraryItem || null : null
|
return this.playbackSession?.libraryItem || null
|
||||||
},
|
},
|
||||||
localLibraryItem() {
|
localLibraryItem() {
|
||||||
return this.playbackSession ? this.playbackSession.localLibraryItem || null : null
|
return this.playbackSession?.localLibraryItem || null
|
||||||
},
|
},
|
||||||
localLibraryItemCoverSrc() {
|
localLibraryItemCoverSrc() {
|
||||||
var localItemCover = this.localLibraryItem?.coverContentUrl || null
|
var localItemCover = this.localLibraryItem?.coverContentUrl || null
|
||||||
@@ -279,7 +295,7 @@ export default {
|
|||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
playMethod() {
|
playMethod() {
|
||||||
return this.playbackSession ? this.playbackSession.playMethod : null
|
return this.playbackSession?.playMethod || 0
|
||||||
},
|
},
|
||||||
isLocalPlayMethod() {
|
isLocalPlayMethod() {
|
||||||
return this.playMethod == this.$constants.PlayMethod.LOCAL
|
return this.playMethod == this.$constants.PlayMethod.LOCAL
|
||||||
@@ -288,19 +304,19 @@ export default {
|
|||||||
return this.playMethod == this.$constants.PlayMethod.DIRECTPLAY
|
return this.playMethod == this.$constants.PlayMethod.DIRECTPLAY
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
if (this.currentChapterTitle && this.showFullscreen) return this.currentChapterTitle
|
const mediaItemTitle = this.playbackSession?.displayTitle || this.mediaMetadata?.title || 'Title'
|
||||||
if (this.playbackSession) return this.playbackSession.displayTitle
|
if (this.currentChapterTitle) {
|
||||||
return this.mediaMetadata ? this.mediaMetadata.title : 'Title'
|
if (this.showFullscreen) return this.currentChapterTitle
|
||||||
|
return `${mediaItemTitle} | ${this.currentChapterTitle}`
|
||||||
|
}
|
||||||
|
return mediaItemTitle
|
||||||
},
|
},
|
||||||
authorName() {
|
authorName() {
|
||||||
if (this.playbackSession) return this.playbackSession.displayAuthor
|
if (this.playbackSession) return this.playbackSession.displayAuthor
|
||||||
return this.mediaMetadata ? this.mediaMetadata.authorName : 'Author'
|
return this.mediaMetadata?.authorName || 'Author'
|
||||||
},
|
},
|
||||||
chapters() {
|
chapters() {
|
||||||
if (this.playbackSession && this.playbackSession.chapters) {
|
return this.playbackSession?.chapters || []
|
||||||
return this.playbackSession.chapters
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
},
|
},
|
||||||
currentChapter() {
|
currentChapter() {
|
||||||
if (!this.chapters.length) return null
|
if (!this.chapters.length) return null
|
||||||
@@ -311,7 +327,7 @@ export default {
|
|||||||
return this.chapters.find((c) => Number(Number(c.start).toFixed(2)) > this.currentTime)
|
return this.chapters.find((c) => Number(Number(c.start).toFixed(2)) > this.currentTime)
|
||||||
},
|
},
|
||||||
currentChapterTitle() {
|
currentChapterTitle() {
|
||||||
return this.currentChapter ? this.currentChapter.title : ''
|
return this.currentChapter?.title || ''
|
||||||
},
|
},
|
||||||
currentChapterDuration() {
|
currentChapterDuration() {
|
||||||
return this.currentChapter ? this.currentChapter.end - this.currentChapter.start : this.totalDuration
|
return this.currentChapter ? this.currentChapter.end - this.currentChapter.start : this.totalDuration
|
||||||
@@ -321,11 +337,14 @@ export default {
|
|||||||
},
|
},
|
||||||
currentTimePretty() {
|
currentTimePretty() {
|
||||||
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
||||||
return this.$secondsToTimestamp(currentTimeToUse / this.currentPlaybackRate)
|
if (this.playerSettings.scaleElapsedTimeBySpeed) {
|
||||||
|
currentTimeToUse = currentTimeToUse / this.currentPlaybackRate
|
||||||
|
}
|
||||||
|
return this.$secondsToTimestamp(currentTimeToUse)
|
||||||
},
|
},
|
||||||
timeRemaining() {
|
timeRemaining() {
|
||||||
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
||||||
if (this.useChapterTrack && this.currentChapter) {
|
if (this.playerSettings.useChapterTrack && this.currentChapter) {
|
||||||
var currChapTime = currentTimeToUse - this.currentChapter.start
|
var currChapTime = currentTimeToUse - this.currentChapter.start
|
||||||
return (this.currentChapterDuration - currChapTime) / this.currentPlaybackRate
|
return (this.currentChapterDuration - currChapTime) / this.currentPlaybackRate
|
||||||
}
|
}
|
||||||
@@ -411,7 +430,11 @@ export default {
|
|||||||
AbsAudioPlayer.requestSession()
|
AbsAudioPlayer.requestSession()
|
||||||
},
|
},
|
||||||
clickContainer() {
|
clickContainer() {
|
||||||
|
this.expandToFullscreen()
|
||||||
|
},
|
||||||
|
expandToFullscreen() {
|
||||||
this.showFullscreen = true
|
this.showFullscreen = true
|
||||||
|
if (this.titleMarquee) this.titleMarquee.reset()
|
||||||
|
|
||||||
// Update track for total time bar if useChapterTrack is set
|
// Update track for total time bar if useChapterTrack is set
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
@@ -420,6 +443,8 @@ export default {
|
|||||||
},
|
},
|
||||||
collapseFullscreen() {
|
collapseFullscreen() {
|
||||||
this.showFullscreen = false
|
this.showFullscreen = false
|
||||||
|
if (this.titleMarquee) this.titleMarquee.reset()
|
||||||
|
|
||||||
this.forceCloseDropdownMenu()
|
this.forceCloseDropdownMenu()
|
||||||
},
|
},
|
||||||
async jumpNextChapter() {
|
async jumpNextChapter() {
|
||||||
@@ -493,7 +518,7 @@ export default {
|
|||||||
this.updateReadyTrack()
|
this.updateReadyTrack()
|
||||||
},
|
},
|
||||||
updateReadyTrack() {
|
updateReadyTrack() {
|
||||||
if (this.useChapterTrack) {
|
if (this.playerSettings.useChapterTrack) {
|
||||||
if (this.$refs.totalReadyTrack) {
|
if (this.$refs.totalReadyTrack) {
|
||||||
this.$refs.totalReadyTrack.style.width = this.readyTrackWidth + 'px'
|
this.$refs.totalReadyTrack.style.width = this.readyTrackWidth + 'px'
|
||||||
}
|
}
|
||||||
@@ -510,11 +535,14 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let currentTime = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
let currentTime = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
||||||
if (this.useChapterTrack && this.currentChapter) {
|
if (this.playerSettings.useChapterTrack && this.currentChapter) {
|
||||||
currentTime = Math.max(0, currentTime - this.currentChapter.start)
|
currentTime = Math.max(0, currentTime - this.currentChapter.start)
|
||||||
}
|
}
|
||||||
|
if (this.playerSettings.scaleElapsedTimeBySpeed) {
|
||||||
|
currentTime = currentTime / this.currentPlaybackRate
|
||||||
|
}
|
||||||
|
|
||||||
ts.innerText = this.$secondsToTimestamp(currentTime / this.currentPlaybackRate)
|
ts.innerText = this.$secondsToTimestamp(currentTime)
|
||||||
},
|
},
|
||||||
timeupdate() {
|
timeupdate() {
|
||||||
if (!this.$refs.playedTrack) {
|
if (!this.$refs.playedTrack) {
|
||||||
@@ -542,7 +570,7 @@ export default {
|
|||||||
let bufferedPercent = this.bufferedTime / this.totalDuration
|
let bufferedPercent = this.bufferedTime / this.totalDuration
|
||||||
const totalBufferedPercent = bufferedPercent
|
const totalBufferedPercent = bufferedPercent
|
||||||
|
|
||||||
if (this.useChapterTrack && this.currentChapter) {
|
if (this.playerSettings.useChapterTrack && this.currentChapter) {
|
||||||
const currChapTime = currentTimeToUse - this.currentChapter.start
|
const currChapTime = currentTimeToUse - this.currentChapter.start
|
||||||
percentDone = currChapTime / this.currentChapterDuration
|
percentDone = currChapTime / this.currentChapterDuration
|
||||||
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration))
|
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration))
|
||||||
@@ -556,7 +584,7 @@ export default {
|
|||||||
this.$refs.trackCursor.style.left = ptWidth - 14 + 'px'
|
this.$refs.trackCursor.style.left = ptWidth - 14 + 'px'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.useChapterTrack) {
|
if (this.playerSettings.useChapterTrack) {
|
||||||
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px'
|
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px'
|
||||||
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px'
|
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px'
|
||||||
}
|
}
|
||||||
@@ -583,7 +611,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async touchstartCursor(e) {
|
async touchstartCursor(e) {
|
||||||
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return
|
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.playerSettings.lockUi) return
|
||||||
|
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
this.isDraggingCursor = true
|
this.isDraggingCursor = true
|
||||||
@@ -624,38 +652,49 @@ export default {
|
|||||||
resetStream(startTime) {
|
resetStream(startTime) {
|
||||||
this.closePlayback()
|
this.closePlayback()
|
||||||
},
|
},
|
||||||
handleGesture() {
|
|
||||||
var touchDistance = this.touchEndY - this.touchStartY
|
|
||||||
if (touchDistance > 100) {
|
|
||||||
this.collapseFullscreen()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
touchstart(e) {
|
touchstart(e) {
|
||||||
if (!this.showFullscreen || !e.changedTouches) return
|
if (!e.changedTouches || this.$store.state.globals.isModalOpen) return
|
||||||
|
const touchPosY = e.changedTouches[0].pageY
|
||||||
|
// when minimized only listen to touchstart on the player
|
||||||
|
if (!this.showFullscreen && touchPosY < window.innerHeight - 120) return
|
||||||
|
|
||||||
this.touchStartY = e.changedTouches[0].screenY
|
// for ios
|
||||||
if (this.touchStartY > window.innerHeight / 3) {
|
if (!this.showFullscreen && e.pageX < 20) {
|
||||||
// console.log('touch too low')
|
e.preventDefault()
|
||||||
return
|
e.stopImmediatePropagation()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.touchStartY = touchPosY
|
||||||
this.touchStartTime = Date.now()
|
this.touchStartTime = Date.now()
|
||||||
},
|
},
|
||||||
touchend(e) {
|
touchend(e) {
|
||||||
if (!e.changedTouches) return
|
if (!e.changedTouches) return
|
||||||
|
const touchDuration = Date.now() - this.touchStartTime
|
||||||
|
const touchEndY = e.changedTouches[0].pageY
|
||||||
|
const touchDistanceY = touchEndY - this.touchStartY
|
||||||
|
|
||||||
|
// reset touch start data
|
||||||
|
this.touchStartTime = 0
|
||||||
|
this.touchStartY = 0
|
||||||
|
|
||||||
if (this.isDraggingCursor) {
|
if (this.isDraggingCursor) {
|
||||||
if (this.draggingCurrentTime !== this.currentTime) {
|
if (this.draggingCurrentTime !== this.currentTime) {
|
||||||
this.seek(this.draggingCurrentTime)
|
this.seek(this.draggingCurrentTime)
|
||||||
}
|
}
|
||||||
this.isDraggingCursor = false
|
this.isDraggingCursor = false
|
||||||
} else if (this.showFullscreen) {
|
} else {
|
||||||
this.touchEndY = e.changedTouches[0].screenY
|
|
||||||
var touchDuration = Date.now() - this.touchStartTime
|
|
||||||
if (touchDuration > 1200) {
|
if (touchDuration > 1200) {
|
||||||
// console.log('touch too long', touchDuration)
|
// console.log('touch too long', touchDuration)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.handleGesture()
|
if (this.showFullscreen) {
|
||||||
|
// Touch start higher than touchend
|
||||||
|
if (touchDistanceY > 100) {
|
||||||
|
this.collapseFullscreen()
|
||||||
|
}
|
||||||
|
} else if (touchDistanceY < -100) {
|
||||||
|
this.expandToFullscreen()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
touchmove(e) {
|
touchmove(e) {
|
||||||
@@ -665,7 +704,7 @@ export default {
|
|||||||
let duration = this.totalDuration
|
let duration = this.totalDuration
|
||||||
let minTime = 0
|
let minTime = 0
|
||||||
let maxTime = duration
|
let maxTime = duration
|
||||||
if (this.useChapterTrack && this.currentChapter) {
|
if (this.playerSettings.useChapterTrack && this.currentChapter) {
|
||||||
duration = this.currentChapterDuration
|
duration = this.currentChapterDuration
|
||||||
minTime = this.currentChapter.start
|
minTime = this.currentChapter.start
|
||||||
maxTime = minTime + duration
|
maxTime = minTime + duration
|
||||||
@@ -685,32 +724,42 @@ export default {
|
|||||||
if (action === 'history') {
|
if (action === 'history') {
|
||||||
this.$router.push(`/media/${this.mediaId}/history?title=${this.title}`)
|
this.$router.push(`/media/${this.mediaId}/history?title=${this.title}`)
|
||||||
this.showFullscreen = false
|
this.showFullscreen = false
|
||||||
|
} else if (action === 'scale_elapsed_time') {
|
||||||
|
this.playerSettings.scaleElapsedTimeBySpeed = !this.playerSettings.scaleElapsedTimeBySpeed
|
||||||
|
this.updateTimestamp()
|
||||||
|
this.savePlayerSettings()
|
||||||
} else if (action === 'lock') {
|
} else if (action === 'lock') {
|
||||||
this.lockUi = !this.lockUi
|
this.playerSettings.lockUi = !this.playerSettings.lockUi
|
||||||
this.$localStore.setPlayerLock(this.lockUi)
|
this.savePlayerSettings()
|
||||||
} else if (action === 'chapter_track') {
|
} else if (action === 'chapter_track') {
|
||||||
this.useChapterTrack = !this.useChapterTrack
|
this.playerSettings.useChapterTrack = !this.playerSettings.useChapterTrack
|
||||||
this.useTotalTrack = !this.useChapterTrack || this.useTotalTrack
|
this.playerSettings.useTotalTrack = !this.playerSettings.useChapterTrack || this.playerSettings.useTotalTrack
|
||||||
|
|
||||||
this.updateTimestamp()
|
this.updateTimestamp()
|
||||||
this.updateTrack()
|
this.updateTrack()
|
||||||
this.updateReadyTrack()
|
this.updateReadyTrack()
|
||||||
this.$localStore.setUseTotalTrack(this.useTotalTrack)
|
this.updateUseChapterTrack()
|
||||||
this.$localStore.setUseChapterTrack(this.useChapterTrack)
|
this.savePlayerSettings()
|
||||||
} else if (action === 'total_track') {
|
} else if (action === 'total_track') {
|
||||||
this.useTotalTrack = !this.useTotalTrack
|
this.playerSettings.useTotalTrack = !this.playerSettings.useTotalTrack
|
||||||
this.useChapterTrack = !this.useTotalTrack || this.useChapterTrack
|
this.playerSettings.useChapterTrack = !this.playerSettings.useTotalTrack || this.playerSettings.useChapterTrack
|
||||||
|
|
||||||
this.updateTimestamp()
|
this.updateTimestamp()
|
||||||
this.updateTrack()
|
this.updateTrack()
|
||||||
this.updateReadyTrack()
|
this.updateReadyTrack()
|
||||||
this.$localStore.setUseTotalTrack(this.useTotalTrack)
|
this.updateUseChapterTrack()
|
||||||
this.$localStore.setUseChapterTrack(this.useChapterTrack)
|
this.savePlayerSettings()
|
||||||
} else if (action === 'close') {
|
} else if (action === 'close') {
|
||||||
this.closePlayback()
|
this.closePlayback()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
updateUseChapterTrack() {
|
||||||
|
// Chapter track in NowPlaying only supported on iOS for now
|
||||||
|
if (this.$platform === 'ios') {
|
||||||
|
AbsAudioPlayer.setChapterTrack({ enabled: this.playerSettings.useChapterTrack })
|
||||||
|
}
|
||||||
|
},
|
||||||
forceCloseDropdownMenu() {
|
forceCloseDropdownMenu() {
|
||||||
if (this.$refs.dropdownMenu && this.$refs.dropdownMenu.closeMenu) {
|
if (this.$refs.dropdownMenu && this.$refs.dropdownMenu.closeMenu) {
|
||||||
this.$refs.dropdownMenu.closeMenu()
|
this.$refs.dropdownMenu.closeMenu()
|
||||||
@@ -727,6 +776,30 @@ export default {
|
|||||||
this.isLoading = false
|
this.isLoading = false
|
||||||
this.playbackSession = null
|
this.playbackSession = null
|
||||||
},
|
},
|
||||||
|
async loadPlayerSettings() {
|
||||||
|
const savedPlayerSettings = await this.$localStore.getPlayerSettings()
|
||||||
|
if (!savedPlayerSettings) {
|
||||||
|
// In 0.9.72-beta 'useChapterTrack', 'useTotalTrack' and 'playerLock' was replaced with 'playerSettings' JSON object
|
||||||
|
// Check if this old key was set and if so migrate them over to 'playerSettings'
|
||||||
|
const chapterTrackPref = await this.$localStore.getPreferenceByKey('useChapterTrack')
|
||||||
|
if (chapterTrackPref) {
|
||||||
|
this.playerSettings.useChapterTrack = chapterTrackPref === '1'
|
||||||
|
const totalTrackPref = await this.$localStore.getPreferenceByKey('useTotalTrack')
|
||||||
|
this.playerSettings.useTotalTrack = totalTrackPref === '1'
|
||||||
|
const playerLockPref = await this.$localStore.getPreferenceByKey('playerLock')
|
||||||
|
this.playerSettings.lockUi = playerLockPref === '1'
|
||||||
|
}
|
||||||
|
this.savePlayerSettings()
|
||||||
|
} else {
|
||||||
|
this.playerSettings.useChapterTrack = !!savedPlayerSettings.useChapterTrack
|
||||||
|
this.playerSettings.useTotalTrack = !!savedPlayerSettings.useTotalTrack
|
||||||
|
this.playerSettings.lockUi = !!savedPlayerSettings.lockUi
|
||||||
|
this.playerSettings.scaleElapsedTimeBySpeed = !!savedPlayerSettings.scaleElapsedTimeBySpeed
|
||||||
|
}
|
||||||
|
},
|
||||||
|
savePlayerSettings() {
|
||||||
|
return this.$localStore.setPlayerSettings({ ...this.playerSettings })
|
||||||
|
},
|
||||||
//
|
//
|
||||||
// Listeners from audio AbsAudioPlayer
|
// Listeners from audio AbsAudioPlayer
|
||||||
//
|
//
|
||||||
@@ -771,6 +844,10 @@ export default {
|
|||||||
|
|
||||||
// Set track width
|
// Set track width
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
|
if (this.titleMarquee) this.titleMarquee.reset()
|
||||||
|
this.titleMarquee = new WrappingMarquee(this.$refs.titlewrapper)
|
||||||
|
this.titleMarquee.init(this.title)
|
||||||
|
|
||||||
if (this.$refs.track) {
|
if (this.$refs.track) {
|
||||||
this.trackWidth = this.$refs.track.clientWidth
|
this.trackWidth = this.$refs.track.clientWidth
|
||||||
} else {
|
} else {
|
||||||
@@ -793,9 +870,7 @@ export default {
|
|||||||
this.updateTimestamp()
|
this.updateTimestamp()
|
||||||
},
|
},
|
||||||
async init() {
|
async init() {
|
||||||
this.useChapterTrack = await this.$localStore.getUseChapterTrack()
|
await this.loadPlayerSettings()
|
||||||
this.useTotalTrack = await this.$localStore.getUseTotalTrack()
|
|
||||||
this.lockUi = await this.$localStore.getPlayerLock()
|
|
||||||
|
|
||||||
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
|
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
|
||||||
this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
|
this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
|
||||||
@@ -806,26 +881,51 @@ export default {
|
|||||||
this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
|
this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
|
||||||
this.onPlaybackSpeedChangedListener = AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged)
|
this.onPlaybackSpeedChangedListener = AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged)
|
||||||
},
|
},
|
||||||
screenOrientationChange() {
|
async screenOrientationChange() {
|
||||||
setTimeout(() => {
|
if (this.isRefreshingUI) return
|
||||||
this.updateScreenSize()
|
this.isRefreshingUI = true
|
||||||
if (this.$refs.track) {
|
const windowWidth = window.innerWidth
|
||||||
this.trackWidth = this.$refs.track.clientWidth
|
this.refreshUI()
|
||||||
this.updateTrack()
|
|
||||||
this.updateReadyTrack()
|
// Window width does not always change right away. Wait up to 250ms for a change.
|
||||||
|
// iPhone 10 on iOS 16 took between 100 - 200ms to update when going from portrait to landscape
|
||||||
|
// but landscape to portrait was immediate
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
if (window.innerWidth !== windowWidth) {
|
||||||
|
this.refreshUI()
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}, 50)
|
}
|
||||||
|
|
||||||
|
this.isRefreshingUI = false
|
||||||
|
},
|
||||||
|
refreshUI() {
|
||||||
|
this.updateScreenSize()
|
||||||
|
if (this.$refs.track) {
|
||||||
|
this.trackWidth = this.$refs.track.clientWidth
|
||||||
|
this.updateTrack()
|
||||||
|
this.updateReadyTrack()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
updateScreenSize() {
|
updateScreenSize() {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.titleMarquee) this.titleMarquee.init(this.title)
|
||||||
|
}, 500)
|
||||||
|
|
||||||
this.windowHeight = window.innerHeight
|
this.windowHeight = window.innerHeight
|
||||||
this.windowWidth = window.innerWidth
|
this.windowWidth = window.innerWidth
|
||||||
const coverHeight = this.fullscreenBookCoverWidth * this.bookCoverAspectRatio
|
const coverHeight = this.fullscreenBookCoverWidth * this.bookCoverAspectRatio
|
||||||
const coverImageWidthCollapsed = 46 / this.bookCoverAspectRatio
|
const coverImageWidthCollapsed = 46 / this.bookCoverAspectRatio
|
||||||
|
const titleAuthorLeftOffsetCollapsed = 30 + coverImageWidthCollapsed
|
||||||
|
const titleAuthorWidthCollapsed = this.windowWidth - 128 - titleAuthorLeftOffsetCollapsed - 10
|
||||||
|
|
||||||
document.documentElement.style.setProperty('--cover-image-width', this.fullscreenBookCoverWidth + 'px')
|
document.documentElement.style.setProperty('--cover-image-width', this.fullscreenBookCoverWidth + 'px')
|
||||||
document.documentElement.style.setProperty('--cover-image-height', coverHeight + 'px')
|
document.documentElement.style.setProperty('--cover-image-height', coverHeight + 'px')
|
||||||
document.documentElement.style.setProperty('--cover-image-width-collapsed', coverImageWidthCollapsed + 'px')
|
document.documentElement.style.setProperty('--cover-image-width-collapsed', coverImageWidthCollapsed + 'px')
|
||||||
document.documentElement.style.setProperty('--cover-image-height-collapsed', 46 + 'px')
|
document.documentElement.style.setProperty('--cover-image-height-collapsed', 46 + 'px')
|
||||||
document.documentElement.style.setProperty('--title-author-left-offset-collapsed', 30 + coverImageWidthCollapsed + 'px')
|
document.documentElement.style.setProperty('--title-author-left-offset-collapsed', titleAuthorLeftOffsetCollapsed + 'px')
|
||||||
|
document.documentElement.style.setProperty('--title-author-width-collapsed', titleAuthorWidthCollapsed + 'px')
|
||||||
},
|
},
|
||||||
minimizePlayerEvt() {
|
minimizePlayerEvt() {
|
||||||
this.collapseFullscreen()
|
this.collapseFullscreen()
|
||||||
@@ -848,7 +948,7 @@ export default {
|
|||||||
window.addEventListener('resize', this.screenOrientationChange)
|
window.addEventListener('resize', this.screenOrientationChange)
|
||||||
|
|
||||||
this.$eventBus.$on('minimize-player', this.minimizePlayerEvt)
|
this.$eventBus.$on('minimize-player', this.minimizePlayerEvt)
|
||||||
document.body.addEventListener('touchstart', this.touchstart)
|
document.body.addEventListener('touchstart', this.touchstart, { passive: false })
|
||||||
document.body.addEventListener('touchend', this.touchend)
|
document.body.addEventListener('touchend', this.touchend)
|
||||||
document.body.addEventListener('touchmove', this.touchmove)
|
document.body.addEventListener('touchmove', this.touchmove)
|
||||||
this.$nextTick(this.init)
|
this.$nextTick(this.init)
|
||||||
@@ -893,6 +993,7 @@ export default {
|
|||||||
--cover-image-width-collapsed: 46px;
|
--cover-image-width-collapsed: 46px;
|
||||||
--cover-image-height-collapsed: 46px;
|
--cover-image-height-collapsed: 46px;
|
||||||
--title-author-left-offset-collapsed: 80px;
|
--title-author-left-offset-collapsed: 80px;
|
||||||
|
--title-author-width-collapsed: 40%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playerContainer {
|
.playerContainer {
|
||||||
@@ -940,7 +1041,7 @@ export default {
|
|||||||
transition-property: left, bottom, width, height;
|
transition-property: left, bottom, width, height;
|
||||||
transform-origin: left bottom;
|
transform-origin: left bottom;
|
||||||
|
|
||||||
width: 40%;
|
width: var(--title-author-width-collapsed);
|
||||||
bottom: 76px;
|
bottom: 76px;
|
||||||
left: var(--title-author-left-offset-collapsed);
|
left: var(--title-author-left-offset-collapsed);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
||||||
import { Dialog } from '@capacitor/dialog'
|
import { Dialog } from '@capacitor/dialog'
|
||||||
|
import CellularPermissionHelpers from '@/mixins/cellularPermissionHelpers'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
@@ -39,6 +40,7 @@ export default {
|
|||||||
serverEpisodeId: null
|
serverEpisodeId: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
mixins: [CellularPermissionHelpers],
|
||||||
computed: {
|
computed: {
|
||||||
bookmarks() {
|
bookmarks() {
|
||||||
if (!this.serverLibraryItemId) return []
|
if (!this.serverLibraryItemId) return []
|
||||||
@@ -193,12 +195,21 @@ export default {
|
|||||||
const startTime = payload.startTime
|
const startTime = payload.startTime
|
||||||
const startWhenReady = !payload.paused
|
const startWhenReady = !payload.paused
|
||||||
|
|
||||||
|
const isLocal = libraryItemId.startsWith('local')
|
||||||
|
if (!isLocal) {
|
||||||
|
const hasPermission = await this.checkCellularPermission('streaming')
|
||||||
|
if (!hasPermission) {
|
||||||
|
this.$store.commit('setPlayerDoneStartingPlayback')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// When playing local library item and can also play this item from the server
|
// When playing local library item and can also play this item from the server
|
||||||
// then store the server library item id so it can be used if a cast is made
|
// then store the server library item id so it can be used if a cast is made
|
||||||
const serverLibraryItemId = payload.serverLibraryItemId || null
|
const serverLibraryItemId = payload.serverLibraryItemId || null
|
||||||
const serverEpisodeId = payload.serverEpisodeId || null
|
const serverEpisodeId = payload.serverEpisodeId || null
|
||||||
|
|
||||||
if (libraryItemId.startsWith('local') && this.$store.state.isCasting) {
|
if (isLocal && this.$store.state.isCasting) {
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Warning',
|
title: 'Warning',
|
||||||
message: `Cannot cast downloaded media items. Confirm to close cast and play on your device.`
|
message: `Cannot cast downloaded media items. Confirm to close cast and play on your device.`
|
||||||
|
|||||||
@@ -79,7 +79,10 @@ export default {
|
|||||||
return this.$store.state.serverSettings || {}
|
return this.$store.state.serverSettings || {}
|
||||||
},
|
},
|
||||||
username() {
|
username() {
|
||||||
return this.user ? this.user.username : ''
|
return this.user?.username || ''
|
||||||
|
},
|
||||||
|
userIsAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
},
|
},
|
||||||
navItems() {
|
navItems() {
|
||||||
var items = [
|
var items = [
|
||||||
@@ -132,6 +135,12 @@ export default {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (this.serverConnectionConfig) {
|
if (this.serverConnectionConfig) {
|
||||||
|
items.push({
|
||||||
|
icon: 'language',
|
||||||
|
text: this.$strings.ButtonGoToWebClient,
|
||||||
|
action: 'openWebClient'
|
||||||
|
})
|
||||||
|
|
||||||
items.push({
|
items.push({
|
||||||
icon: 'login',
|
icon: 'login',
|
||||||
text: this.$strings.ButtonSwitchServerUser,
|
text: this.$strings.ButtonSwitchServerUser,
|
||||||
@@ -151,6 +160,10 @@ export default {
|
|||||||
if (action === 'logout') {
|
if (action === 'logout') {
|
||||||
await this.logout()
|
await this.logout()
|
||||||
this.$router.push('/connect')
|
this.$router.push('/connect')
|
||||||
|
} else if (action === 'openWebClient') {
|
||||||
|
this.show = false
|
||||||
|
let path = `/library/${this.$store.state.libraries.currentLibraryId}`
|
||||||
|
await this.$store.dispatch('user/openWebClient', path)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
clickBackground() {
|
clickBackground() {
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export default {
|
|||||||
return this.page
|
return this.page
|
||||||
},
|
},
|
||||||
hasFilter() {
|
hasFilter() {
|
||||||
if (this.page === 'series' || this.page === 'collections') return false
|
if (this.page === 'series' || this.page === 'collections' || this.page === 'playlists') return false
|
||||||
return this.filterBy !== 'all'
|
return this.filterBy !== 'all'
|
||||||
},
|
},
|
||||||
orderBy() {
|
orderBy() {
|
||||||
|
|||||||
@@ -7,9 +7,12 @@
|
|||||||
|
|
||||||
<!-- Alternative bookshelf title/author/sort -->
|
<!-- Alternative bookshelf title/author/sort -->
|
||||||
<div v-if="isAltViewEnabled" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
<div v-if="isAltViewEnabled" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
||||||
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
<div :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }" class="flex items-center">
|
||||||
{{ displayTitle }}
|
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||||
</p>
|
{{ displayTitle }}
|
||||||
|
</p>
|
||||||
|
<widgets-explicit-indicator v-if="isExplicit" />
|
||||||
|
</div>
|
||||||
<p class="truncate text-fg-muted" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || ' ' }}</p>
|
<p class="truncate text-fg-muted" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || ' ' }}</p>
|
||||||
<p v-if="displaySortLine" class="truncate text-fg-muted" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
<p v-if="displaySortLine" class="truncate text-fg-muted" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,7 +62,7 @@
|
|||||||
<div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
<div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||||
|
|
||||||
<!-- Downloaded icon -->
|
<!-- Downloaded icon -->
|
||||||
<div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast ? 1.75 : 0.375) * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
<div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast || (seriesSequence && showSequence) ? 1.75 : 0.375) * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||||
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
|
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -310,7 +313,7 @@ export default {
|
|||||||
return !!this.userProgress?.isFinished
|
return !!this.userProgress?.isFinished
|
||||||
},
|
},
|
||||||
showError() {
|
showError() {
|
||||||
return this.numMissingParts || this.isMissing || this.isInvalid
|
return this.isMissing || this.isInvalid
|
||||||
},
|
},
|
||||||
localLibraryItemId() {
|
localLibraryItemId() {
|
||||||
if (this.isLocal) return this.libraryItemId
|
if (this.isLocal) return this.libraryItemId
|
||||||
@@ -341,9 +344,8 @@ export default {
|
|||||||
isInvalid() {
|
isInvalid() {
|
||||||
return this._libraryItem.isInvalid
|
return this._libraryItem.isInvalid
|
||||||
},
|
},
|
||||||
numMissingParts() {
|
isExplicit() {
|
||||||
if (this.isPodcast) return 0
|
return !!this.mediaMetadata.explicit
|
||||||
return this.media.numMissingParts
|
|
||||||
},
|
},
|
||||||
overlayWrapperClasslist() {
|
overlayWrapperClasslist() {
|
||||||
var classes = []
|
var classes = []
|
||||||
@@ -366,9 +368,6 @@ export default {
|
|||||||
userCanDownload() {
|
userCanDownload() {
|
||||||
return this.store.getters['user/getUserCanDownload']
|
return this.store.getters['user/getUserCanDownload']
|
||||||
},
|
},
|
||||||
userIsRoot() {
|
|
||||||
return this.store.getters['user/getIsRoot']
|
|
||||||
},
|
|
||||||
titleFontSize() {
|
titleFontSize() {
|
||||||
return 0.75 * this.sizeMultiplier
|
return 0.75 * this.sizeMultiplier
|
||||||
},
|
},
|
||||||
@@ -493,7 +492,12 @@ export default {
|
|||||||
if (router) {
|
if (router) {
|
||||||
if (this.recentEpisode) router.push(`/item/${this.libraryItemId}/${this.recentEpisode.id}`)
|
if (this.recentEpisode) router.push(`/item/${this.libraryItemId}/${this.recentEpisode.id}`)
|
||||||
else if (this.collapsedSeries) router.push(`/bookshelf/series/${this.collapsedSeries.id}`)
|
else if (this.collapsedSeries) router.push(`/bookshelf/series/${this.collapsedSeries.id}`)
|
||||||
else router.push(`/item/${this.libraryItemId}`)
|
else if (this.localLibraryItem) {
|
||||||
|
// Pass local library item id to server page to allow falling back to offline page
|
||||||
|
router.push(`/item/${this.libraryItemId}?localLibraryItemId=${this.localLibraryItemId}`)
|
||||||
|
} else {
|
||||||
|
router.push(`/item/${this.libraryItemId}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<div v-if="!isPodcast && !collapsedSeries" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * userProgressPercent + 'px' }"></div>
|
<div v-if="!isPodcast && !collapsedSeries" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * userProgressPercent + 'px' }"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-2">
|
<div class="flex-grow px-2">
|
||||||
<p class="whitespace-normal" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
|
<p class="whitespace-normal line-clamp-2" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
|
||||||
<span v-if="seriesSequence">#{{ seriesSequence }} </span>{{ displayTitle }}
|
<span v-if="seriesSequence">#{{ seriesSequence }} </span>{{ displayTitle }}
|
||||||
</p>
|
</p>
|
||||||
<p class="truncate text-fg-muted" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
|
<p class="truncate text-fg-muted" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
|
||||||
@@ -215,7 +215,7 @@ export default {
|
|||||||
return !!this.userProgress?.isFinished
|
return !!this.userProgress?.isFinished
|
||||||
},
|
},
|
||||||
showError() {
|
showError() {
|
||||||
return this.numMissingParts || this.isMissing || this.isInvalid
|
return this.isMissing || this.isInvalid
|
||||||
},
|
},
|
||||||
isStreaming() {
|
isStreaming() {
|
||||||
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
|
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
|
||||||
@@ -235,10 +235,6 @@ export default {
|
|||||||
isInvalid() {
|
isInvalid() {
|
||||||
return this._libraryItem.isInvalid
|
return this._libraryItem.isInvalid
|
||||||
},
|
},
|
||||||
numMissingParts() {
|
|
||||||
if (this.isPodcast) return 0
|
|
||||||
return this.media.numMissingParts
|
|
||||||
},
|
|
||||||
store() {
|
store() {
|
||||||
return this.$store || this.$nuxt.$store
|
return this.$store || this.$nuxt.$store
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
<ui-text-input v-model="password" type="password" :disabled="processing" :placeholder="$strings.LabelPassword" class="w-full mb-2 text-lg" />
|
<ui-text-input v-model="password" type="password" :disabled="processing" :placeholder="$strings.LabelPassword" class="w-full mb-2 text-lg" />
|
||||||
|
|
||||||
<div class="flex items-center pt-2">
|
<div class="flex items-center pt-2">
|
||||||
<ui-icon-btn v-if="serverConfig.id" small bg-color="error" icon="delete" @click="removeServerConfigClick" />
|
<ui-icon-btn v-if="serverConfig.id" small bg-color="error" icon="delete" type="button" @click="removeServerConfigClick" />
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? $strings.ButtonSubmit : $strings.MessageNoNetworkConnection }}</ui-btn>
|
<ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? $strings.ButtonSubmit : $strings.MessageNoNetworkConnection }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,14 +64,14 @@ export default {
|
|||||||
return this.$route.query || {}
|
return this.$route.query || {}
|
||||||
},
|
},
|
||||||
entityTitle() {
|
entityTitle() {
|
||||||
if (this.isPodcast) return this.$strings.LabelPodcasts
|
if (this.page === 'library') {
|
||||||
if (this.page === 'library') return this.$strings.LabelBooks
|
return this.isPodcast ? this.$strings.LabelPodcasts : this.$strings.LabelBooks
|
||||||
else if (this.page === 'series') {
|
} else if (this.page === 'playlists') {
|
||||||
|
return this.$strings.ButtonPlaylists
|
||||||
|
} else if (this.page === 'series') {
|
||||||
return this.$strings.LabelSeries
|
return this.$strings.LabelSeries
|
||||||
} else if (this.page === 'collections') {
|
} else if (this.page === 'collections') {
|
||||||
return this.$strings.ButtonCollections
|
return this.$strings.ButtonCollections
|
||||||
} else if (this.page === 'playlists') {
|
|
||||||
return this.$strings.ButtonPlaylists
|
|
||||||
} else if (this.page === 'authors') {
|
} else if (this.page === 'authors') {
|
||||||
return this.$strings.LabelAuthors
|
return this.$strings.LabelAuthors
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="200" height="100%">
|
<modals-modal v-model="show" :width="200" height="100%">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-8 left-4 z-40">
|
<div class="absolute top-11 left-4 z-40">
|
||||||
<p class="text-white text-2xl truncate capitalize">{{ $strings.LabelAutoRewindTime }}</p>
|
<p class="text-white text-2xl truncate capitalize">{{ $strings.LabelAutoRewindTime }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div
|
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="
|
||||||
class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center"
|
|
||||||
@click="
|
|
||||||
show = false
|
show = false
|
||||||
manualTimerModal = false
|
manualTimerModal = false
|
||||||
"
|
">
|
||||||
>
|
|
||||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||||
<div v-if="manualTimerModal" class="p-4">
|
<div v-if="manualTimerModal" class="p-4">
|
||||||
<div class="flex mb-4" @click="manualTimerModal = false">
|
<div class="flex mb-4" @click="manualTimerModal = false">
|
||||||
<span class="material-icons text-3xl">arrow_back</span>
|
<span class="material-icons text-3xl">arrow_back</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex my-2 justify-between">
|
<div class="flex my-2 justify-between">
|
||||||
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
|
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px">
|
||||||
|
<span class="material-icons">remove</span>
|
||||||
|
</ui-btn>
|
||||||
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
|
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
|
||||||
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
|
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px">
|
||||||
|
<span class="material-icons">add</span>
|
||||||
|
</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">{{ $strings.ButtonSetTimer }}</ui-btn>
|
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">{{ $strings.ButtonSetTimer }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="400" height="100%">
|
<modals-modal v-model="show" :width="400" height="100%">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-8 left-4 z-40">
|
<div class="absolute top-11 left-4 z-40">
|
||||||
<p class="text-white text-2xl truncate">{{ $strings.LabelYourBookmarks }}</p>
|
<p class="text-white text-2xl truncate">{{ $strings.LabelYourBookmarks }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -14,9 +14,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="text-xl pl-2">{{ selectedBookmark ? 'Edit Bookmark' : 'New Bookmark' }}</p>
|
<p class="text-xl pl-2">{{ selectedBookmark ? 'Edit Bookmark' : 'New Bookmark' }}</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p class="text-xl font-mono">
|
<p class="text-xl font-mono">{{ this.$secondsToTimestamp(currentTime) }}</p>
|
||||||
{{ this.$secondsToTimestamp(currentTime) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ui-text-input-with-label v-model="newBookmarkTitle" label="Note" />
|
<ui-text-input-with-label v-model="newBookmarkTitle" label="Note" />
|
||||||
@@ -34,9 +32,7 @@
|
|||||||
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center justify-between border-b border-fg/10 bg-success cursor-pointer text-white text-opacity-80" @click.stop="createBookmark">
|
<div v-show="canCreateBookmark" class="flex px-4 py-2 items-center text-center justify-between border-b border-fg/10 bg-success cursor-pointer text-white text-opacity-80" @click.stop="createBookmark">
|
||||||
<span class="material-icons">add</span>
|
<span class="material-icons">add</span>
|
||||||
<p class="text-base pl-2">{{ $strings.ButtonCreateBookmark }}</p>
|
<p class="text-base pl-2">{{ $strings.ButtonCreateBookmark }}</p>
|
||||||
<p class="text-sm font-mono">
|
<p class="text-sm font-mono">{{ this.$secondsToTimestamp(currentTime) }}</p>
|
||||||
{{ this.$secondsToTimestamp(currentTime) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default {
|
|||||||
default: '16px'
|
default: '16px'
|
||||||
},
|
},
|
||||||
width: {
|
width: {
|
||||||
type: Number,
|
type: [String, Number],
|
||||||
default: 300
|
default: 300
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" width="90%" height="100%">
|
<modals-modal v-model="show" width="90%" height="100%">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div v-show="selected !== 'all'" class="absolute top-10 left-4 z-40">
|
<div v-show="selected !== 'all'" class="absolute top-12 left-4 z-40">
|
||||||
<ui-btn class="text-lg border-yellow-400 border-opacity-40" @click="clearSelected">{{ $strings.ButtonClearFilter }}</ui-btn>
|
<ui-btn class="text-lg border-yellow-400 border-opacity-40 h-10" :padding-y="0" @click="clearSelected">{{ $strings.ButtonClearFilter }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-fg/20" style="max-height: 75%" @click.stop>
|
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-fg/20 mt-8" style="max-height: 75%" @click.stop>
|
||||||
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="item in items">
|
<template v-for="item in items">
|
||||||
<li :key="item.value" class="text-fg select-none relative py-4 pr-9 cursor-pointer" :class="item.value === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
|
<li :key="item.value" class="text-fg select-none relative py-4 pr-9 cursor-pointer" :class="item.value === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
|
||||||
@@ -210,7 +210,7 @@ export default {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
sublistItems() {
|
sublistItems() {
|
||||||
return (this[this.sublist] || []).map((item) => {
|
const sublistItems = (this[this.sublist] || []).map((item) => {
|
||||||
if (typeof item === 'string') {
|
if (typeof item === 'string') {
|
||||||
return {
|
return {
|
||||||
text: item,
|
text: item,
|
||||||
@@ -223,6 +223,13 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
if (this.sublist === 'series') {
|
||||||
|
sublistItems.unshift({
|
||||||
|
text: this.$strings.MessageNoSeries,
|
||||||
|
value: this.$encode('no-series')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return sublistItems
|
||||||
},
|
},
|
||||||
filterData() {
|
filterData() {
|
||||||
return this.$store.state.libraries.filterData || {}
|
return this.$store.state.libraries.filterData || {}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="wrapper" class="modal modal-bg w-screen fixed bottom-0 left-0 flex items-center justify-center z-50" :class="halfScreen ? 'h-[50vh]' : 'h-screen'" @click.stop @touchstart.stop @touchend.stop>
|
<div ref="wrapper" class="modal modal-bg w-screen fixed bottom-0 left-0 flex items-center justify-center z-50" :class="halfScreen ? 'h-[50vh] min-h-[400px]' : 'h-screen'" @click.stop @touchstart.stop @touchend.stop>
|
||||||
<div ref="content" class="relative text-fg h-full w-full bg-bg">
|
<div ref="content" class="relative text-fg h-full w-full bg-bg">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="400" height="100%">
|
<modals-modal v-model="show" :width="400" height="100%">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-8 left-4 z-40">
|
<div class="absolute top-11 left-4 z-40">
|
||||||
<p class="text-white text-2xl truncate">{{ $strings.HeaderDetails }}</p>
|
<p class="text-white text-2xl truncate">{{ $strings.HeaderDetails }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
},
|
},
|
||||||
|
playlist: {
|
||||||
|
type: Object,
|
||||||
|
default: () => null
|
||||||
|
},
|
||||||
hideRssFeedOption: Boolean
|
hideRssFeedOption: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -92,6 +96,15 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If on playlist page show remove from playlist option
|
||||||
|
if (this.playlist) {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.LabelRemoveFromPlaylist,
|
||||||
|
value: 'removeFromPlaylist',
|
||||||
|
icon: 'playlist_remove'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (this.showRSSFeedOption) {
|
if (this.showRSSFeedOption) {
|
||||||
items.push({
|
items.push({
|
||||||
text: this.rssFeed ? this.$strings.HeaderRSSFeed : this.$strings.HeaderOpenRSSFeed,
|
text: this.rssFeed ? this.$strings.HeaderRSSFeed : this.$strings.HeaderOpenRSSFeed,
|
||||||
@@ -122,6 +135,14 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.isConnectedToServer) {
|
||||||
|
items.push({
|
||||||
|
text: this.$strings.ButtonGoToWebClient,
|
||||||
|
value: 'openWebClient',
|
||||||
|
icon: 'language'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.episode) {
|
if (!this.episode) {
|
||||||
items.push({
|
items.push({
|
||||||
text: this.$strings.LabelMoreInfo,
|
text: this.$strings.LabelMoreInfo,
|
||||||
@@ -255,6 +276,8 @@ export default {
|
|||||||
} else if (action === 'playlist') {
|
} else if (action === 'playlist') {
|
||||||
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.episode }])
|
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.episode }])
|
||||||
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
|
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
|
||||||
|
} else if (action === 'removeFromPlaylist') {
|
||||||
|
this.removeFromPlaylistClick()
|
||||||
} else if (action === 'markFinished') {
|
} else if (action === 'markFinished') {
|
||||||
if (this.episode) this.toggleEpisodeFinished()
|
if (this.episode) this.toggleEpisodeFinished()
|
||||||
else this.toggleFinished()
|
else this.toggleFinished()
|
||||||
@@ -270,6 +293,8 @@ export default {
|
|||||||
this.clickRSSFeed()
|
this.clickRSSFeed()
|
||||||
} else if (action === 'sendEbook') {
|
} else if (action === 'sendEbook') {
|
||||||
this.showSendEbookDevicesModal = true
|
this.showSendEbookDevicesModal = true
|
||||||
|
} else if (action === 'openWebClient') {
|
||||||
|
this.$store.dispatch('user/openWebClient', `/item/${this.serverLibraryItemId}`)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async toggleFinished() {
|
async toggleFinished() {
|
||||||
@@ -456,6 +481,29 @@ export default {
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.$emit('update:processing', false)
|
this.$emit('update:processing', false)
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
removeFromPlaylistClick() {
|
||||||
|
if (!this.playlist) {
|
||||||
|
this.$toast.error('Invalid: No Playlist')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$emit('update:processing', true)
|
||||||
|
let url = `/api/playlists/${this.playlist.id}/item/${this.serverLibraryItemId}`
|
||||||
|
if (this.serverEpisodeId) url += `/${this.serverEpisodeId}`
|
||||||
|
this.$nativeHttp
|
||||||
|
.delete(url)
|
||||||
|
.then(() => {
|
||||||
|
this.$toast.success('Item removed from playlist')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const errorMsg = error.response?.data || 'Unknown error'
|
||||||
|
console.error('Failed to remove item from playlist', error)
|
||||||
|
this.$toast.error('Failed to remove from playlist: ' + errorMsg)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.$emit('update:processing', false)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="300" :processing="processing" height="100%">
|
<modals-modal v-model="show" :width="300" :processing="processing" height="100%">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div class="absolute top-8 left-4 z-40" style="max-width: 80%">
|
<div class="absolute top-11 left-4 z-40" style="max-width: 80%">
|
||||||
<p class="text-white text-2xl truncate">{{ $strings.HeaderLibraries }}</p>
|
<p class="text-white text-2xl truncate">{{ $strings.HeaderLibraries }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div ref="wrapper" class="modal modal-bg w-full h-full max-h-screen fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-50 opacity-0">
|
<div ref="wrapper" class="modal modal-bg w-full h-full max-h-screen fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-50 opacity-0">
|
||||||
<div class="absolute top-0 left-0 w-full h-40 bg-gradient-to-b from-black to-transparent opacity-90 pointer-events-none" />
|
<div class="absolute top-0 left-0 w-full h-40 bg-gradient-to-b from-black to-transparent opacity-90 pointer-events-none" />
|
||||||
|
|
||||||
<div class="absolute z-40 top-10 right-4 h-10 w-10 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
|
<div class="absolute z-40 top-11 right-4 h-10 w-10 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
|
||||||
<span class="material-icons text-4xl">close</span>
|
<span class="material-icons text-4xl">close</span>
|
||||||
</div>
|
</div>
|
||||||
<slot name="outer" />
|
<slot name="outer" />
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ export default {
|
|||||||
{
|
{
|
||||||
text: this.$strings.LabelFileModified,
|
text: this.$strings.LabelFileModified,
|
||||||
value: 'mtimeMs'
|
value: 'mtimeMs'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelRandomly,
|
||||||
|
value: 'random'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
podcastItems: [
|
podcastItems: [
|
||||||
@@ -89,6 +93,10 @@ export default {
|
|||||||
{
|
{
|
||||||
text: this.$strings.LabelFileModified,
|
text: this.$strings.LabelFileModified,
|
||||||
value: 'mtimeMs'
|
value: 'mtimeMs'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelRandomly,
|
||||||
|
value: 'random'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
episodeItems: [
|
episodeItems: [
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
rates() {
|
rates() {
|
||||||
return [0.5, 1, 1.2, 1.5, 2, 3]
|
return [0.5, 1, 1.2, 1.5, 1.7, 2, 3]
|
||||||
},
|
},
|
||||||
canIncrement() {
|
canIncrement() {
|
||||||
return this.playbackRate + 0.1 <= this.MAX_SPEED
|
return this.playbackRate + 0.1 <= this.MAX_SPEED
|
||||||
|
|||||||
@@ -6,13 +6,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div
|
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||||
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-border" style="max-height: 75%" @click.stop>
|
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-border" style="max-height: 75%" @click.stop>
|
||||||
<div v-if="manualTimerModal" class="p-4">
|
<div v-if="manualTimerModal" class="p-4">
|
||||||
<div class="flex mb-4" @click="manualTimerModal = false">
|
<div class="flex mb-4" @click="manualTimerModal = false">
|
||||||
@@ -71,7 +65,7 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
manualTimerModal: null,
|
manualTimerModal: false,
|
||||||
manualTimeoutMin: 1
|
manualTimeoutMin: 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -81,6 +75,9 @@ export default {
|
|||||||
return this.value
|
return this.value
|
||||||
},
|
},
|
||||||
set(val) {
|
set(val) {
|
||||||
|
if (!val) {
|
||||||
|
this.manualTimerModal = false
|
||||||
|
}
|
||||||
this.$emit('input', val)
|
this.$emit('input', val)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -349,7 +349,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
parseFilenames(filenames) {
|
parseFilenames(filenames) {
|
||||||
const acceptableImages = ['.jpeg', '.jpg', '.png']
|
const acceptableImages = ['.jpeg', '.jpg', '.png', '.webp']
|
||||||
var imageFiles = filenames.filter((f) => {
|
var imageFiles = filenames.filter((f) => {
|
||||||
return acceptableImages.includes((Path.extname(f) || '').toLowerCase())
|
return acceptableImages.includes((Path.extname(f) || '').toLowerCase())
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -34,16 +34,18 @@ export default {
|
|||||||
currentLocationNum: 0,
|
currentLocationNum: 0,
|
||||||
currentLocationCfi: null,
|
currentLocationCfi: null,
|
||||||
inittingDisplay: true,
|
inittingDisplay: true,
|
||||||
|
isRefreshingUI: false,
|
||||||
ereaderSettings: {
|
ereaderSettings: {
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
fontScale: 100,
|
fontScale: 100,
|
||||||
lineSpacing: 115
|
lineSpacing: 115,
|
||||||
|
textStroke: 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
isPlayerOpen() {
|
isPlayerOpen() {
|
||||||
this.updateHeight()
|
this.refreshUI()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -112,7 +114,8 @@ export default {
|
|||||||
'*': {
|
'*': {
|
||||||
color: `${fontColor}!important`,
|
color: `${fontColor}!important`,
|
||||||
'background-color': `${backgroundColor}!important`,
|
'background-color': `${backgroundColor}!important`,
|
||||||
'line-height': this.ereaderSettings.lineSpacing + '%!important'
|
'line-height': this.ereaderSettings.lineSpacing + '%!important',
|
||||||
|
'-webkit-text-stroke': this.ereaderSettings.textStroke/100 + 'px ' + fontColor + '!important'
|
||||||
},
|
},
|
||||||
a: {
|
a: {
|
||||||
color: `${fontColor}!important`
|
color: `${fontColor}!important`
|
||||||
@@ -135,11 +138,6 @@ export default {
|
|||||||
goToChapter(href) {
|
goToChapter(href) {
|
||||||
return this.rendition?.display(href)
|
return this.rendition?.display(href)
|
||||||
},
|
},
|
||||||
updateHeight() {
|
|
||||||
if (this.rendition && this.rendition.resize) {
|
|
||||||
this.rendition.resize(window.innerWidth, window.innerHeight - this.readerHeightOffset)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
prev() {
|
prev() {
|
||||||
if (this.rendition) {
|
if (this.rendition) {
|
||||||
this.rendition.prev()
|
this.rendition.prev()
|
||||||
@@ -382,14 +380,54 @@ export default {
|
|||||||
this.rendition.getContents().forEach((c) => {
|
this.rendition.getContents().forEach((c) => {
|
||||||
c.addStylesheetRules(this.themeRules)
|
c.addStylesheetRules(this.themeRules)
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
async screenOrientationChange() {
|
||||||
|
if (this.isRefreshingUI) return
|
||||||
|
this.isRefreshingUI = true
|
||||||
|
const windowWidth = window.innerWidth
|
||||||
|
this.refreshUI()
|
||||||
|
|
||||||
|
// Window width does not always change right away. Wait up to 250ms for a change.
|
||||||
|
// iPhone 10 on iOS 16 took between 100 - 200ms to update when going from portrait to landscape
|
||||||
|
// but landscape to portrait was immediate
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
if (window.innerWidth !== windowWidth) {
|
||||||
|
this.refreshUI()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isRefreshingUI = false
|
||||||
|
},
|
||||||
|
refreshUI() {
|
||||||
|
if (this.rendition?.resize) {
|
||||||
|
this.rendition.resize(window.innerWidth, window.innerHeight - this.readerHeightOffset)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
|
||||||
this.book?.destroy()
|
|
||||||
},
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.initEpub()
|
this.initEpub()
|
||||||
}
|
|
||||||
|
if (screen.orientation) {
|
||||||
|
// Not available on ios
|
||||||
|
screen.orientation.addEventListener('change', this.screenOrientationChange)
|
||||||
|
} else {
|
||||||
|
document.addEventListener('orientationchange', this.screenOrientationChange)
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', this.screenOrientationChange)
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.book?.destroy()
|
||||||
|
|
||||||
|
if (screen.orientation) {
|
||||||
|
// Not available on ios
|
||||||
|
screen.orientation.removeEventListener('change', this.screenOrientationChange)
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('orientationchange', this.screenOrientationChange)
|
||||||
|
}
|
||||||
|
window.removeEventListener('resize', this.screenOrientationChange)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<div class="h-full flex items-center justify-center">
|
<div class="h-full flex items-center justify-center">
|
||||||
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto">
|
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto">
|
||||||
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
|
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
|
||||||
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md bg-white" :src="pdfDocInitParams" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event" @loaded="loadedEvt"></pdf>
|
<pdf v-if="pdfDocInitParams" ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md bg-white" :src="pdfDocInitParams" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event" @loaded="loadedEvt"></pdf>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -48,7 +48,8 @@ export default {
|
|||||||
page: 1,
|
page: 1,
|
||||||
numPages: 0,
|
numPages: 0,
|
||||||
windowWidth: 0,
|
windowWidth: 0,
|
||||||
windowHeight: 0
|
windowHeight: 0,
|
||||||
|
pdfDocInitParams: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -106,14 +107,6 @@ export default {
|
|||||||
if (!this.userItemProgress?.ebookLocation || isNaN(this.userItemProgress.ebookLocation)) return 0
|
if (!this.userItemProgress?.ebookLocation || isNaN(this.userItemProgress.ebookLocation)) return 0
|
||||||
return Number(this.userItemProgress.ebookLocation)
|
return Number(this.userItemProgress.ebookLocation)
|
||||||
},
|
},
|
||||||
pdfDocInitParams() {
|
|
||||||
return {
|
|
||||||
url: this.url,
|
|
||||||
httpHeaders: {
|
|
||||||
Authorization: `Bearer ${this.userToken}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
isPlayerOpen() {
|
isPlayerOpen() {
|
||||||
return this.$store.getters['getIsPlayerOpen']
|
return this.$store.getters['getIsPlayerOpen']
|
||||||
}
|
}
|
||||||
@@ -157,6 +150,8 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
numPagesLoaded(e) {
|
numPagesLoaded(e) {
|
||||||
|
if (!e) return
|
||||||
|
|
||||||
this.numPages = e
|
this.numPages = e
|
||||||
},
|
},
|
||||||
prev() {
|
prev() {
|
||||||
@@ -175,6 +170,14 @@ export default {
|
|||||||
screenOrientationChange() {
|
screenOrientationChange() {
|
||||||
this.windowWidth = window.innerWidth
|
this.windowWidth = window.innerWidth
|
||||||
this.windowHeight = window.innerHeight
|
this.windowHeight = window.innerHeight
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.pdfDocInitParams = {
|
||||||
|
url: this.url,
|
||||||
|
httpHeaders: {
|
||||||
|
Authorization: `Bearer ${this.userToken}`
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
@@ -188,6 +191,8 @@ export default {
|
|||||||
document.addEventListener('orientationchange', this.screenOrientationChange)
|
document.addEventListener('orientationchange', this.screenOrientationChange)
|
||||||
}
|
}
|
||||||
window.addEventListener('resize', this.screenOrientationChange)
|
window.addEventListener('resize', this.screenOrientationChange)
|
||||||
|
|
||||||
|
this.init()
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
if (screen.orientation) {
|
if (screen.orientation) {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
<!-- ereader settings modal -->
|
<!-- ereader settings modal -->
|
||||||
<modals-fullscreen-modal v-model="showSettingsModal" :theme="ereaderTheme" half-screen>
|
<modals-fullscreen-modal v-model="showSettingsModal" :theme="ereaderTheme" half-screen>
|
||||||
<div style="box-shadow: 0px -8px 8px #11111155">
|
<div style="box-shadow: 0px -8px 8px #11111155">
|
||||||
<div class="flex items-end justify-between h-20 px-4 pb-2 mb-8">
|
<div class="flex items-end justify-between h-20 px-4 pb-2 mb-6">
|
||||||
<h1 class="text-lg">{{ $strings.HeaderEreaderSettings }}</h1>
|
<h1 class="text-lg">{{ $strings.HeaderEreaderSettings }}</h1>
|
||||||
<button class="flex" @click="showSettingsModal = false">
|
<button class="flex" @click="showSettingsModal = false">
|
||||||
<span class="material-icons">close</span>
|
<span class="material-icons">close</span>
|
||||||
@@ -64,24 +64,36 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(100vh-85px)]">
|
<div class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(100vh-85px)]">
|
||||||
<div class="w-full h-full px-4">
|
<div class="w-full h-full px-4">
|
||||||
<div class="flex items-center mb-8">
|
<div class="flex items-center mb-6">
|
||||||
<div class="w-32">
|
<div class="w-32">
|
||||||
<p class="text-base">{{ $strings.LabelTheme }}:</p>
|
<p class="text-base">{{ $strings.LabelTheme }}:</p>
|
||||||
</div>
|
</div>
|
||||||
<ui-toggle-btns v-model="ereaderSettings.theme" :items="themeItems" @input="settingsUpdated" />
|
<ui-toggle-btns v-model="ereaderSettings.theme" :items="themeItems" @input="settingsUpdated" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center mb-8">
|
<div class="flex items-center mb-6">
|
||||||
<div class="w-32">
|
<div class="w-32">
|
||||||
<p class="text-base">{{ $strings.LabelFontScale }}:</p>
|
<p class="text-base">{{ $strings.LabelFontScale }}:</p>
|
||||||
</div>
|
</div>
|
||||||
<ui-range-input v-model="ereaderSettings.fontScale" :min="5" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
|
<ui-range-input v-model="ereaderSettings.fontScale" :min="5" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center mb-8">
|
<div class="flex items-center mb-6">
|
||||||
<div class="w-32">
|
<div class="w-32">
|
||||||
<p class="text-base">{{ $strings.LabelLineSpacing }}:</p>
|
<p class="text-base">{{ $strings.LabelLineSpacing }}:</p>
|
||||||
</div>
|
</div>
|
||||||
<ui-range-input v-model="ereaderSettings.lineSpacing" :min="100" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
|
<ui-range-input v-model="ereaderSettings.lineSpacing" :min="100" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center mb-6">
|
||||||
|
<div class="w-32">
|
||||||
|
<p class="text-base">{{ $strings.LabelFontBoldness }}:</p>
|
||||||
|
</div>
|
||||||
|
<ui-range-input v-model="ereaderSettings.textStroke" :min="0" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-32">
|
||||||
|
<p class="text-base">{{ $strings.LabelLayout }}:</p>
|
||||||
|
</div>
|
||||||
|
<ui-toggle-btns v-model="ereaderSettings.spread" :items="spreadItems" @input="settingsUpdated" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,7 +121,9 @@ export default {
|
|||||||
ereaderSettings: {
|
ereaderSettings: {
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
fontScale: 100,
|
fontScale: 100,
|
||||||
lineSpacing: 115
|
lineSpacing: 115,
|
||||||
|
spread: 'auto',
|
||||||
|
textStroke: 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -154,6 +168,18 @@ export default {
|
|||||||
if (this.isEpub) return this.ereaderSettings.theme
|
if (this.isEpub) return this.ereaderSettings.theme
|
||||||
return document.documentElement.dataset.theme || 'dark'
|
return document.documentElement.dataset.theme || 'dark'
|
||||||
},
|
},
|
||||||
|
spreadItems() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelLayoutSinglePage,
|
||||||
|
value: 'none'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: this.$strings.LabelLayoutAuto,
|
||||||
|
value: 'auto'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
themeItems() {
|
themeItems() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -350,7 +376,12 @@ export default {
|
|||||||
try {
|
try {
|
||||||
const settings = localStorage.getItem('ereaderSettings')
|
const settings = localStorage.getItem('ereaderSettings')
|
||||||
if (settings) {
|
if (settings) {
|
||||||
this.ereaderSettings = JSON.parse(settings)
|
const _ereaderSettings = JSON.parse(settings)
|
||||||
|
for (const key in this.ereaderSettings) {
|
||||||
|
if (_ereaderSettings[key] !== undefined) {
|
||||||
|
this.ereaderSettings[key] = _ereaderSettings[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
this.settingsUpdated()
|
this.settingsUpdated()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
|
||||||
|
<widgets-loading-spinner />
|
||||||
|
</div>
|
||||||
|
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { FileSharer } from '@byteowls/capacitor-filesharer'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
variant: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
year: Number,
|
||||||
|
processing: Boolean
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
canvas: null,
|
||||||
|
dataUrl: null,
|
||||||
|
yearStats: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
variant() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async initCanvas() {
|
||||||
|
if (!this.yearStats) return
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = 800
|
||||||
|
canvas.height = 800
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
|
||||||
|
const createRoundedRect = (x, y, w, h) => {
|
||||||
|
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
|
||||||
|
grd1.addColorStop(0, '#44444455')
|
||||||
|
grd1.addColorStop(1, '#ffffff11')
|
||||||
|
ctx.fillStyle = grd1
|
||||||
|
ctx.strokeStyle = '#C0C0C088'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.roundRect(x, y, w, h, [20])
|
||||||
|
ctx.fill()
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
|
||||||
|
ctx.letterSpacing = letterSpacing
|
||||||
|
|
||||||
|
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
|
||||||
|
if (maxWidth) {
|
||||||
|
let txtWidth = ctx.measureText(text).width
|
||||||
|
while (txtWidth > maxWidth) {
|
||||||
|
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
|
||||||
|
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
|
||||||
|
else text = text.slice(0, -3) // First check remove last 3 chars
|
||||||
|
text += '...'
|
||||||
|
txtWidth = ctx.measureText(text).width
|
||||||
|
console.log(`Checking text "${text}" (width:${txtWidth})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillText(text, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addIcon = (icon, color, fontSize, x, y) => {
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.font = `${fontSize} Material Icons Outlined`
|
||||||
|
ctx.fillText(icon, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bg color
|
||||||
|
ctx.fillStyle = '#232323'
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// Cover image tiles
|
||||||
|
const bookCovers = this.yearStats.finishedBooksWithCovers
|
||||||
|
bookCovers.push(...this.yearStats.booksWithCovers)
|
||||||
|
|
||||||
|
let finishedBookCoverImgs = {}
|
||||||
|
|
||||||
|
if (bookCovers.length) {
|
||||||
|
let index = 0
|
||||||
|
ctx.globalAlpha = 0.25
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(canvas.width / 2, canvas.height / 2)
|
||||||
|
ctx.rotate((-Math.PI / 180) * 25)
|
||||||
|
ctx.translate(-canvas.width / 2, -canvas.height / 2)
|
||||||
|
ctx.translate(-130, -120)
|
||||||
|
for (let x = 0; x < 5; x++) {
|
||||||
|
for (let y = 0; y < 5; y++) {
|
||||||
|
const coverIndex = index % bookCovers.length
|
||||||
|
let libraryItemId = bookCovers[coverIndex]
|
||||||
|
index++
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const img = new Image()
|
||||||
|
img.crossOrigin = 'anonymous'
|
||||||
|
img.addEventListener('load', () => {
|
||||||
|
let sw = img.width
|
||||||
|
if (img.width > img.height) {
|
||||||
|
sw = img.height
|
||||||
|
}
|
||||||
|
let sx = -(sw - img.width) / 2
|
||||||
|
let sy = -(sw - img.height) / 2
|
||||||
|
ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215)
|
||||||
|
resolve()
|
||||||
|
if (this.yearStats.finishedBooksWithCovers.includes(libraryItemId) && !finishedBookCoverImgs[libraryItemId]) {
|
||||||
|
finishedBookCoverImgs[libraryItemId] = {
|
||||||
|
img,
|
||||||
|
sx,
|
||||||
|
sy,
|
||||||
|
sw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
img.addEventListener('error', () => {
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
ctx.textBaseline = 'middle'
|
||||||
|
|
||||||
|
// Create gradient
|
||||||
|
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
|
||||||
|
grd1.addColorStop(0, '#000000aa')
|
||||||
|
grd1.addColorStop(1, '#cd9d49aa')
|
||||||
|
ctx.fillStyle = grd1
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// Top Abs icon
|
||||||
|
let tanColor = '#ffdb70'
|
||||||
|
ctx.fillStyle = tanColor
|
||||||
|
ctx.font = '42px absicons'
|
||||||
|
ctx.fillText('\ue900', 15, 36)
|
||||||
|
|
||||||
|
// Top text
|
||||||
|
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
|
||||||
|
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
|
||||||
|
|
||||||
|
// Top left box
|
||||||
|
createRoundedRect(50, 100, 340, 160)
|
||||||
|
addText(this.yearStats.numBooksFinished, '64px', 'bold', 'white', '0px', 160, 165)
|
||||||
|
addText('books finished', '28px', 'normal', tanColor, '0px', 160, 210)
|
||||||
|
const readIconPath = new Path2D()
|
||||||
|
readIconPath.addPath(new Path2D('M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z'), { a: 2, d: 2, e: 100, f: 160 })
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.fill(readIconPath)
|
||||||
|
|
||||||
|
// Box top right
|
||||||
|
createRoundedRect(410, 100, 340, 160)
|
||||||
|
addText(this.$elapsedPrettyExtended(this.yearStats.totalListeningTime, true, false), '40px', 'bold', 'white', '0px', 500, 165)
|
||||||
|
addText('spent listening', '28px', 'normal', tanColor, '0px', 500, 205)
|
||||||
|
addIcon('watch_later', 'white', '52px', 440, 180)
|
||||||
|
|
||||||
|
// Box bottom left
|
||||||
|
createRoundedRect(50, 280, 340, 160)
|
||||||
|
addText(this.yearStats.totalListeningSessions, '64px', 'bold', 'white', '0px', 160, 345)
|
||||||
|
addText('sessions', '28px', 'normal', tanColor, '1px', 160, 390)
|
||||||
|
addIcon('headphones', 'white', '52px', 95, 360)
|
||||||
|
|
||||||
|
// Box bottom right
|
||||||
|
createRoundedRect(410, 280, 340, 160)
|
||||||
|
addText(this.yearStats.numBooksListened, '64px', 'bold', 'white', '0px', 500, 345)
|
||||||
|
addText('books listened to', '28px', 'normal', tanColor, '0px', 500, 390)
|
||||||
|
addIcon('local_library', 'white', '52px', 440, 360)
|
||||||
|
|
||||||
|
if (!this.variant) {
|
||||||
|
// Text stats
|
||||||
|
const topNarrator = this.yearStats.mostListenedNarrator
|
||||||
|
if (topNarrator) {
|
||||||
|
addText('TOP NARRATOR', '24px', 'normal', tanColor, '1px', 70, 520)
|
||||||
|
addText(topNarrator.name, '36px', 'bolder', 'white', '0px', 70, 564, 330)
|
||||||
|
addText(this.$elapsedPrettyExtended(topNarrator.time, true, false), '24px', 'lighter', 'white', '1px', 70, 599)
|
||||||
|
}
|
||||||
|
|
||||||
|
const topGenre = this.yearStats.topGenres[0]
|
||||||
|
if (topGenre) {
|
||||||
|
addText('TOP GENRE', '24px', 'normal', tanColor, '1px', 430, 520)
|
||||||
|
addText(topGenre.genre, '36px', 'bolder', 'white', '0px', 430, 564, 330)
|
||||||
|
addText(this.$elapsedPrettyExtended(topGenre.time, true, false), '24px', 'lighter', 'white', '1px', 430, 599)
|
||||||
|
}
|
||||||
|
|
||||||
|
const topAuthor = this.yearStats.topAuthors[0]
|
||||||
|
if (topAuthor) {
|
||||||
|
addText('TOP AUTHOR', '24px', 'normal', tanColor, '1px', 70, 670)
|
||||||
|
addText(topAuthor.name, '36px', 'bolder', 'white', '0px', 70, 714, 330)
|
||||||
|
addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '24px', 'lighter', 'white', '1px', 70, 749)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.yearStats.mostListenedMonth?.time) {
|
||||||
|
const jsdate = new Date(this.year, this.yearStats.mostListenedMonth.month, 1)
|
||||||
|
const monthName = this.$formatJsDate(jsdate, 'LLLL')
|
||||||
|
addText('TOP MONTH', '24px', 'normal', tanColor, '1px', 430, 670)
|
||||||
|
addText(monthName, '36px', 'bolder', 'white', '0px', 430, 714, 330)
|
||||||
|
addText(this.$elapsedPrettyExtended(this.yearStats.mostListenedMonth.time, true, false), '24px', 'lighter', 'white', '1px', 430, 749)
|
||||||
|
}
|
||||||
|
} else if (this.variant === 1) {
|
||||||
|
// Bottom images
|
||||||
|
finishedBookCoverImgs = Object.values(finishedBookCoverImgs)
|
||||||
|
if (finishedBookCoverImgs.length > 0) {
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
addText('Some books finished this year...', '28px', 'normal', tanColor, '0px', canvas.width / 2, 530)
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.min(5, finishedBookCoverImgs.length); i++) {
|
||||||
|
let imgToAdd = finishedBookCoverImgs[i]
|
||||||
|
ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 570, 140, 140)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (this.variant === 2) {
|
||||||
|
// Text stats
|
||||||
|
if (this.yearStats.topAuthors.length) {
|
||||||
|
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 524)
|
||||||
|
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
|
||||||
|
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 584 + i * 60, 330)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.yearStats.topGenres.length) {
|
||||||
|
addText('TOP GENRES', '24px', 'normal', tanColor, '1px', 430, 524)
|
||||||
|
for (let i = 0; i < this.yearStats.topGenres.length; i++) {
|
||||||
|
addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 584 + i * 60, 330)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.canvas = canvas
|
||||||
|
this.dataUrl = canvas.toDataURL('png')
|
||||||
|
},
|
||||||
|
refresh() {
|
||||||
|
this.init()
|
||||||
|
},
|
||||||
|
share() {
|
||||||
|
const base64Data = this.dataUrl.split(';base64,').pop()
|
||||||
|
FileSharer.share({
|
||||||
|
filename: `audiobookshelf_my_${this.year}.png`,
|
||||||
|
contentType: 'image/png',
|
||||||
|
base64Data
|
||||||
|
}).catch((error) => {
|
||||||
|
if (error.message !== 'USER_CANCELLED') {
|
||||||
|
console.error('Failed to share', error.message)
|
||||||
|
this.$toast.error('Failed to share: ' + error.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.$emit('update:processing', true)
|
||||||
|
this.$nativeHttp
|
||||||
|
.get(`/api/me/stats/year/${this.year}`)
|
||||||
|
.then((data) => {
|
||||||
|
this.yearStats = data || []
|
||||||
|
return this.initCanvas()
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
this.$toast.error('Failed to load year stats')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.$emit('update:processing', false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-2 sm:p-4 mb-4">
|
||||||
|
<!-- hack to get icon fonts loaded on init -->
|
||||||
|
<div class="h-0 w-0 overflow-hidden opacity-0">
|
||||||
|
<span class="material-icons-outlined">close</span>
|
||||||
|
<span class="abs-icons icon-audiobookshelf" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<p class="hidden md:block text-xl font-semibold">{{ yearInReviewYear }} Year in Review</p>
|
||||||
|
<div class="hidden md:block flex-grow" />
|
||||||
|
<ui-btn class="w-full md:w-auto" @click.stop="clickShowYearInReview">{{ showYearInReview ? 'Hide Year in Review' : `See ${yearInReviewYear} Year in Review` }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- your year in review -->
|
||||||
|
<div v-if="showYearInReview">
|
||||||
|
<div class="w-full h-px bg-slate-200/10 my-4" />
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center mb-2 max-w-[800px] mx-auto">
|
||||||
|
<!-- previous button -->
|
||||||
|
<ui-btn small :disabled="!yearInReviewVariant || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant--">
|
||||||
|
<span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||||
|
<span class="hidden sm:inline-block pr-2">Previous</span>
|
||||||
|
</ui-btn>
|
||||||
|
<!-- share button -->
|
||||||
|
<ui-btn small :disabled="processingYearInReview" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReview"> Share </ui-btn>
|
||||||
|
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<p class="hidden sm:block text-lg font-semibold">Your Year in Review ({{ yearInReviewVariant + 1 }})</p>
|
||||||
|
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewVariant + 1 }}</p>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
|
||||||
|
<!-- refresh button -->
|
||||||
|
<ui-btn small :disabled="processingYearInReview" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReview">
|
||||||
|
<span class="hidden sm:inline-block">Refresh</span>
|
||||||
|
<span class="material-icons sm:!hidden text-lg py-px">refresh</span>
|
||||||
|
</ui-btn>
|
||||||
|
<!-- next button -->
|
||||||
|
<ui-btn small :disabled="yearInReviewVariant >= 2 || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant++">
|
||||||
|
<span class="hidden sm:inline-block pl-2">Next</span>
|
||||||
|
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||||
|
</ui-btn>
|
||||||
|
</div>
|
||||||
|
<stats-year-in-review ref="yearInReview" :variant="yearInReviewVariant" :year="yearInReviewYear" :processing.sync="processingYearInReview" />
|
||||||
|
|
||||||
|
<!-- your year in review short -->
|
||||||
|
<div class="w-full max-w-[800px] mx-auto my-4">
|
||||||
|
<!-- share button -->
|
||||||
|
<ui-btn small :disabled="processingYearInReviewShort" class="inline-flex sm:hidden items-center font-semibold mb-1" @click="shareYearInReviewShort"> Share </ui-btn>
|
||||||
|
<stats-year-in-review-short ref="yearInReviewShort" :year="yearInReviewYear" :processing.sync="processingYearInReviewShort" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- your server in review -->
|
||||||
|
<div v-if="isAdminOrUp" class="w-full max-w-[800px] mx-auto mb-2 mt-4 border-t pt-4 border-white/10">
|
||||||
|
<div class="flex items-center justify-center mb-2">
|
||||||
|
<!-- previous button -->
|
||||||
|
<ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant--">
|
||||||
|
<span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||||
|
<span class="hidden sm:inline-block pr-2">Previous</span>
|
||||||
|
</ui-btn>
|
||||||
|
<!-- share button -->
|
||||||
|
<ui-btn small :disabled="processingYearInReviewServer" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReviewServer"> Share </ui-btn>
|
||||||
|
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<p class="hidden sm:block text-lg font-semibold">Server Year in Review ({{ yearInReviewServerVariant + 1 }})</p>
|
||||||
|
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewServerVariant + 1 }}</p>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
|
||||||
|
<!-- refresh button -->
|
||||||
|
<ui-btn small :disabled="processingYearInReviewServer" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReviewServer">
|
||||||
|
<span class="hidden sm:inline-block">Refresh</span>
|
||||||
|
<span class="material-icons sm:!hidden text-lg py-px">refresh</span>
|
||||||
|
</ui-btn>
|
||||||
|
<!-- next button -->
|
||||||
|
<ui-btn small :disabled="yearInReviewServerVariant >= 2 || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant++">
|
||||||
|
<span class="hidden sm:inline-block pl-2">Next</span>
|
||||||
|
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||||
|
</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<stats-year-in-review-server v-if="isAdminOrUp" ref="yearInReviewServer" :year="yearInReviewYear" :variant="yearInReviewServerVariant" :processing.sync="processingYearInReviewServer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showYearInReview: false,
|
||||||
|
yearInReviewYear: 0,
|
||||||
|
yearInReviewVariant: 0,
|
||||||
|
yearInReviewServerVariant: 0,
|
||||||
|
processingYearInReview: false,
|
||||||
|
processingYearInReviewShort: false,
|
||||||
|
processingYearInReviewServer: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
shareYearInReviewServer() {
|
||||||
|
this.$refs.yearInReviewServer.share()
|
||||||
|
},
|
||||||
|
shareYearInReview() {
|
||||||
|
this.$refs.yearInReview.share()
|
||||||
|
},
|
||||||
|
shareYearInReviewShort() {
|
||||||
|
this.$refs.yearInReviewShort.share()
|
||||||
|
},
|
||||||
|
refreshYearInReviewServer() {
|
||||||
|
this.$refs.yearInReviewServer.refresh()
|
||||||
|
},
|
||||||
|
refreshYearInReview() {
|
||||||
|
this.$refs.yearInReview.refresh()
|
||||||
|
this.$refs.yearInReviewShort.refresh()
|
||||||
|
},
|
||||||
|
clickShowYearInReview() {
|
||||||
|
this.showYearInReview = !this.showYearInReview
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeMount() {
|
||||||
|
this.yearInReviewYear = new Date().getFullYear()
|
||||||
|
// When not December show previous year
|
||||||
|
if (new Date().getMonth() < 11) {
|
||||||
|
this.yearInReviewYear--
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
|
||||||
|
<widgets-loading-spinner />
|
||||||
|
</div>
|
||||||
|
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { FileSharer } from '@byteowls/capacitor-filesharer'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
variant: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
processing: Boolean,
|
||||||
|
year: Number
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
canvas: null,
|
||||||
|
dataUrl: null,
|
||||||
|
yearStats: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
variant() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async initCanvas() {
|
||||||
|
if (!this.yearStats) return
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = 800
|
||||||
|
canvas.height = 800
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
|
||||||
|
const createRoundedRect = (x, y, w, h) => {
|
||||||
|
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
|
||||||
|
grd1.addColorStop(0, '#44444455')
|
||||||
|
grd1.addColorStop(1, '#ffffff11')
|
||||||
|
ctx.fillStyle = grd1
|
||||||
|
ctx.strokeStyle = '#C0C0C088'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.roundRect(x, y, w, h, [20])
|
||||||
|
ctx.fill()
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
|
||||||
|
ctx.letterSpacing = letterSpacing
|
||||||
|
|
||||||
|
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
|
||||||
|
if (maxWidth) {
|
||||||
|
let txtWidth = ctx.measureText(text).width
|
||||||
|
while (txtWidth > maxWidth) {
|
||||||
|
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
|
||||||
|
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
|
||||||
|
else text = text.slice(0, -3) // First check remove last 3 chars
|
||||||
|
text += '...'
|
||||||
|
txtWidth = ctx.measureText(text).width
|
||||||
|
console.log(`Checking text "${text}" (width:${txtWidth})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillText(text, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bg color
|
||||||
|
ctx.fillStyle = '#232323'
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// Cover image tiles
|
||||||
|
let imgsToAdd = {}
|
||||||
|
|
||||||
|
if (this.yearStats.booksAddedWithCovers.length) {
|
||||||
|
let index = 0
|
||||||
|
ctx.globalAlpha = 0.25
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(canvas.width / 2, canvas.height / 2)
|
||||||
|
ctx.rotate((-Math.PI / 180) * 25)
|
||||||
|
ctx.translate(-canvas.width / 2, -canvas.height / 2)
|
||||||
|
ctx.translate(-130, -120)
|
||||||
|
for (let x = 0; x < 5; x++) {
|
||||||
|
for (let y = 0; y < 5; y++) {
|
||||||
|
const coverIndex = index % this.yearStats.booksAddedWithCovers.length
|
||||||
|
let libraryItemId = this.yearStats.booksAddedWithCovers[coverIndex]
|
||||||
|
index++
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const img = new Image()
|
||||||
|
img.crossOrigin = 'anonymous'
|
||||||
|
img.addEventListener('load', () => {
|
||||||
|
let sw = img.width
|
||||||
|
if (img.width > img.height) {
|
||||||
|
sw = img.height
|
||||||
|
}
|
||||||
|
let sx = -(sw - img.width) / 2
|
||||||
|
let sy = -(sw - img.height) / 2
|
||||||
|
ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215)
|
||||||
|
if (!imgsToAdd[libraryItemId]) {
|
||||||
|
imgsToAdd[libraryItemId] = {
|
||||||
|
img,
|
||||||
|
sx,
|
||||||
|
sy,
|
||||||
|
sw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
img.addEventListener('error', () => {
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
ctx.textBaseline = 'middle'
|
||||||
|
|
||||||
|
// Create gradient
|
||||||
|
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
|
||||||
|
grd1.addColorStop(0, '#000000aa')
|
||||||
|
grd1.addColorStop(1, '#cd9d49aa')
|
||||||
|
ctx.fillStyle = grd1
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// Top Abs icon
|
||||||
|
let tanColor = '#ffdb70'
|
||||||
|
ctx.fillStyle = tanColor
|
||||||
|
ctx.font = '42px absicons'
|
||||||
|
ctx.fillText('\ue900', 15, 36)
|
||||||
|
|
||||||
|
// Top text
|
||||||
|
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
|
||||||
|
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
|
||||||
|
|
||||||
|
// Top left box
|
||||||
|
createRoundedRect(40, 100, 230, 100)
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
addText(this.yearStats.numBooksAdded, '48px', 'bold', 'white', '0px', 155, 140)
|
||||||
|
addText('books added', '18px', 'normal', tanColor, '0px', 155, 170)
|
||||||
|
|
||||||
|
// Box top right
|
||||||
|
createRoundedRect(285, 100, 230, 100)
|
||||||
|
addText(this.yearStats.numAuthorsAdded, '48px', 'bold', 'white', '0px', 400, 140)
|
||||||
|
addText('authors added', '18px', 'normal', tanColor, '0px', 400, 170)
|
||||||
|
|
||||||
|
// Box bottom left
|
||||||
|
createRoundedRect(530, 100, 230, 100)
|
||||||
|
addText(this.yearStats.numListeningSessions, '48px', 'bold', 'white', '0px', 645, 140)
|
||||||
|
addText('sessions', '18px', 'normal', tanColor, '1px', 645, 170)
|
||||||
|
|
||||||
|
// Text stats
|
||||||
|
if (this.yearStats.totalBooksAddedSize) {
|
||||||
|
addText('Your book collection grew to...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 260)
|
||||||
|
addText(this.$bytesPretty(this.yearStats.totalBooksSize), '36px', 'bolder', 'white', '0px', canvas.width / 2, 300)
|
||||||
|
addText('+' + this.$bytesPretty(this.yearStats.totalBooksAddedSize), '20px', 'lighter', 'white', '0px', canvas.width / 2, 330)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.yearStats.totalBooksAddedDuration) {
|
||||||
|
addText('With a total duration of...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 400)
|
||||||
|
addText(this.$elapsedPrettyExtended(this.yearStats.totalBooksDuration, true, false), '36px', 'bolder', 'white', '0px', canvas.width / 2, 440)
|
||||||
|
addText('+' + this.$elapsedPrettyExtended(this.yearStats.totalBooksAddedDuration, true, false), '20px', 'lighter', 'white', '0px', canvas.width / 2, 470)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.variant) {
|
||||||
|
// Bottom images
|
||||||
|
imgsToAdd = Object.values(imgsToAdd)
|
||||||
|
if (imgsToAdd.length > 0) {
|
||||||
|
addText('Some additions include...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 540)
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.min(5, imgsToAdd.length); i++) {
|
||||||
|
let imgToAdd = imgsToAdd[i]
|
||||||
|
ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 580, 140, 140)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (this.variant === 1) {
|
||||||
|
// Text stats
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
if (this.yearStats.topAuthors.length) {
|
||||||
|
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 549)
|
||||||
|
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
|
||||||
|
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.yearStats.topNarrators.length) {
|
||||||
|
addText('TOP NARRATORS', '24px', 'normal', tanColor, '1px', 430, 549)
|
||||||
|
for (let i = 0; i < this.yearStats.topNarrators.length; i++) {
|
||||||
|
addText(this.yearStats.topNarrators[i].name, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (this.variant === 2) {
|
||||||
|
// Text stats
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
if (this.yearStats.topAuthors.length) {
|
||||||
|
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 549)
|
||||||
|
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
|
||||||
|
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.yearStats.topGenres.length) {
|
||||||
|
addText('TOP GENRES', '24px', 'normal', tanColor, '1px', 430, 549)
|
||||||
|
for (let i = 0; i < this.yearStats.topGenres.length; i++) {
|
||||||
|
addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.canvas = canvas
|
||||||
|
this.dataUrl = canvas.toDataURL('png')
|
||||||
|
},
|
||||||
|
share() {
|
||||||
|
const base64Data = this.dataUrl.split(';base64,').pop()
|
||||||
|
FileSharer.share({
|
||||||
|
filename: `audiobookshelf_server_${this.year}.png`,
|
||||||
|
contentType: 'image/png',
|
||||||
|
base64Data
|
||||||
|
}).catch((error) => {
|
||||||
|
if (error.message !== 'USER_CANCELLED') {
|
||||||
|
console.error('Failed to share', error.message)
|
||||||
|
this.$toast.error('Failed to share: ' + error.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
refresh() {
|
||||||
|
this.init()
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.$emit('update:processing', true)
|
||||||
|
this.$nativeHttp
|
||||||
|
.get(`/api/stats/year/${this.year}`)
|
||||||
|
.then((data) => {
|
||||||
|
this.yearStats = data || []
|
||||||
|
return this.initCanvas()
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
this.$toast.error('Failed to load year stats')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.$emit('update:processing', false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="processing" class="max-w-[600px] h-32 sm:h-[200px] flex items-center justify-center">
|
||||||
|
<widgets-loading-spinner />
|
||||||
|
</div>
|
||||||
|
<img v-else-if="dataUrl" :src="dataUrl" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { FileSharer } from '@byteowls/capacitor-filesharer'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
processing: Boolean,
|
||||||
|
year: Number
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
canvas: null,
|
||||||
|
dataUrl: null,
|
||||||
|
yearStats: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async initCanvas() {
|
||||||
|
if (!this.yearStats) return
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = 600
|
||||||
|
canvas.height = 200
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
|
||||||
|
const createRoundedRect = (x, y, w, h) => {
|
||||||
|
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
|
||||||
|
grd1.addColorStop(0, '#44444455')
|
||||||
|
grd1.addColorStop(1, '#ffffff11')
|
||||||
|
ctx.fillStyle = grd1
|
||||||
|
ctx.strokeStyle = '#C0C0C088'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.roundRect(x, y, w, h, [20])
|
||||||
|
ctx.fill()
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
|
||||||
|
ctx.letterSpacing = letterSpacing
|
||||||
|
|
||||||
|
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
|
||||||
|
if (maxWidth) {
|
||||||
|
let txtWidth = ctx.measureText(text).width
|
||||||
|
while (txtWidth > maxWidth) {
|
||||||
|
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
|
||||||
|
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
|
||||||
|
else text = text.slice(0, -3) // First check remove last 3 chars
|
||||||
|
text += '...'
|
||||||
|
txtWidth = ctx.measureText(text).width
|
||||||
|
console.log(`Checking text "${text}" (width:${txtWidth})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillText(text, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addIcon = (icon, color, fontSize, x, y) => {
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.font = `${fontSize} Material Icons Outlined`
|
||||||
|
ctx.fillText(icon, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bg color
|
||||||
|
ctx.fillStyle = '#232323'
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// Cover image tiles
|
||||||
|
const bookCovers = this.yearStats.finishedBooksWithCovers
|
||||||
|
bookCovers.push(...this.yearStats.booksWithCovers)
|
||||||
|
|
||||||
|
if (bookCovers.length) {
|
||||||
|
let index = 0
|
||||||
|
ctx.globalAlpha = 0.25
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(canvas.width / 2, canvas.height / 2)
|
||||||
|
ctx.rotate((-Math.PI / 180) * 25)
|
||||||
|
ctx.translate(-canvas.width / 2, -canvas.height / 2)
|
||||||
|
ctx.translate(-10, -90)
|
||||||
|
for (let x = 0; x < 4; x++) {
|
||||||
|
for (let y = 0; y < 3; y++) {
|
||||||
|
const coverIndex = index % bookCovers.length
|
||||||
|
let libraryItemId = bookCovers[coverIndex]
|
||||||
|
index++
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const img = new Image()
|
||||||
|
img.crossOrigin = 'anonymous'
|
||||||
|
img.addEventListener('load', () => {
|
||||||
|
let sw = img.width
|
||||||
|
if (img.width > img.height) {
|
||||||
|
sw = img.height
|
||||||
|
}
|
||||||
|
let sx = -(sw - img.width) / 2
|
||||||
|
let sy = -(sw - img.height) / 2
|
||||||
|
ctx.drawImage(img, sx, sy, sw, sw, 155 * x, 155 * y, 155, 155)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
img.addEventListener('error', () => {
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
ctx.textBaseline = 'middle'
|
||||||
|
|
||||||
|
// Create gradient
|
||||||
|
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
|
||||||
|
grd1.addColorStop(0, '#000000aa')
|
||||||
|
grd1.addColorStop(1, '#cd9d49aa')
|
||||||
|
ctx.fillStyle = grd1
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
// Top Abs icon
|
||||||
|
let tanColor = '#ffdb70'
|
||||||
|
ctx.fillStyle = tanColor
|
||||||
|
ctx.font = '42px absicons'
|
||||||
|
ctx.fillText('\ue900', 15, 36)
|
||||||
|
|
||||||
|
// Top text
|
||||||
|
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
|
||||||
|
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
|
||||||
|
|
||||||
|
// Top left box
|
||||||
|
createRoundedRect(15, 75, 280, 110)
|
||||||
|
addText(this.yearStats.numBooksFinished, '48px', 'bold', 'white', '0px', 105, 120)
|
||||||
|
addText('books finished', '20px', 'normal', tanColor, '0px', 105, 155)
|
||||||
|
const readIconPath = new Path2D()
|
||||||
|
readIconPath.addPath(new Path2D('M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z'), { a: 1.5, d: 1.5, e: 55, f: 115 })
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.fill(readIconPath)
|
||||||
|
|
||||||
|
createRoundedRect(305, 75, 280, 110)
|
||||||
|
addText(this.yearStats.numBooksListened, '48px', 'bold', 'white', '0px', 400, 120)
|
||||||
|
addText('books listened to', '20px', 'normal', tanColor, '0px', 400, 155)
|
||||||
|
addIcon('local_library', 'white', '42px', 345, 130)
|
||||||
|
|
||||||
|
this.canvas = canvas
|
||||||
|
this.dataUrl = canvas.toDataURL('png')
|
||||||
|
},
|
||||||
|
share() {
|
||||||
|
const base64Data = this.dataUrl.split(';base64,').pop()
|
||||||
|
FileSharer.share({
|
||||||
|
filename: `audiobookshelf_my_${this.year}_short.png`,
|
||||||
|
contentType: 'image/png',
|
||||||
|
base64Data
|
||||||
|
}).catch((error) => {
|
||||||
|
if (error.message !== 'USER_CANCELLED') {
|
||||||
|
console.error('Failed to share', error.message)
|
||||||
|
this.$toast.error('Failed to share: ' + error.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
refresh() {
|
||||||
|
this.init()
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
this.$emit('update:processing', true)
|
||||||
|
this.$nativeHttp
|
||||||
|
.get(`/api/me/stats/year/${this.year}`)
|
||||||
|
.then((data) => {
|
||||||
|
this.yearStats = data || []
|
||||||
|
return this.initCanvas()
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
this.$toast.error('Failed to load year stats')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.$emit('update:processing', false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -58,6 +58,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
|
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
|
||||||
|
import cellularPermissionHelpers from '@/mixins/cellularPermissionHelpers'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
@@ -73,6 +74,7 @@ export default {
|
|||||||
},
|
},
|
||||||
isLocal: Boolean
|
isLocal: Boolean
|
||||||
},
|
},
|
||||||
|
mixins: [cellularPermissionHelpers],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isProcessingReadUpdate: false,
|
isProcessingReadUpdate: false,
|
||||||
@@ -180,6 +182,9 @@ export default {
|
|||||||
async downloadClick() {
|
async downloadClick() {
|
||||||
if (this.downloadItem || this.startingDownload) return
|
if (this.downloadItem || this.startingDownload) return
|
||||||
|
|
||||||
|
const hasPermission = await this.checkCellularPermission('download')
|
||||||
|
if (!hasPermission) return
|
||||||
|
|
||||||
this.startingDownload = true
|
this.startingDownload = true
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.startingDownload = false
|
this.startingDownload = false
|
||||||
|
|||||||
@@ -6,8 +6,11 @@
|
|||||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)" :width="40" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="md:hidden" />
|
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)" :width="40" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="md:hidden" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow px-2">
|
<div class="flex-grow px-2">
|
||||||
<div class="-mt-0.5 mb-0.5" @click.stop>
|
<div class="flex items-center">
|
||||||
<nuxt-link :to="`/item/${libraryItemId}`" class="text-sm text-fg underline">{{ podcast.metadata.title }}</nuxt-link>
|
<div class="-mt-0.5 mb-0.5" @click.stop>
|
||||||
|
<nuxt-link :to="`/item/${libraryItemId}`" class="text-sm text-fg underline">{{ podcast.metadata.title }}</nuxt-link>
|
||||||
|
</div>
|
||||||
|
<widgets-explicit-indicator v-if="podcast.metadata.explicit" />
|
||||||
</div>
|
</div>
|
||||||
<p v-if="publishedAt" class="text-xs text-fg-muted">{{ $dateDistanceFromNow(publishedAt) }}</p>
|
<p v-if="publishedAt" class="text-xs text-fg-muted">{{ $dateDistanceFromNow(publishedAt) }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,6 +61,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
|
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
|
||||||
|
import CellularPermissionHelpers from '@/mixins/cellularPermissionHelpers'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
@@ -80,6 +84,7 @@ export default {
|
|||||||
processing: false
|
processing: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
mixins: [CellularPermissionHelpers],
|
||||||
computed: {
|
computed: {
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
@@ -184,6 +189,10 @@ export default {
|
|||||||
},
|
},
|
||||||
async downloadClick() {
|
async downloadClick() {
|
||||||
if (this.downloadItem || this.pendingDownload) return
|
if (this.downloadItem || this.pendingDownload) return
|
||||||
|
|
||||||
|
const hasPermission = await this.checkCellularPermission('download')
|
||||||
|
if (!hasPermission) return
|
||||||
|
|
||||||
this.pendingDownload = true
|
this.pendingDownload = true
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
if (this.isIos) {
|
if (this.isIos) {
|
||||||
@@ -259,7 +268,6 @@ export default {
|
|||||||
|
|
||||||
if (this.localEpisode && this.localLibraryItemId) {
|
if (this.localEpisode && this.localLibraryItemId) {
|
||||||
console.log('Play local episode', this.localEpisode.id, this.localLibraryItemId)
|
console.log('Play local episode', this.localEpisode.id, this.localLibraryItemId)
|
||||||
|
|
||||||
this.$eventBus.$emit('play-item', {
|
this.$eventBus.$emit('play-item', {
|
||||||
libraryItemId: this.localLibraryItemId,
|
libraryItemId: this.localLibraryItemId,
|
||||||
episodeId: this.localEpisode.id,
|
episodeId: this.localEpisode.id,
|
||||||
@@ -282,7 +290,11 @@ export default {
|
|||||||
const isFinished = !this.userIsFinished
|
const isFinished = !this.userIsFinished
|
||||||
const localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId
|
const localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId
|
||||||
const localEpisodeId = this.isLocal ? this.episode.id : this.localEpisode.id
|
const localEpisodeId = this.isLocal ? this.episode.id : this.localEpisode.id
|
||||||
const payload = await this.$db.updateLocalMediaProgressFinished({ localLibraryItemId, localEpisodeId, isFinished })
|
const payload = await this.$db.updateLocalMediaProgressFinished({
|
||||||
|
localLibraryItemId,
|
||||||
|
localEpisodeId,
|
||||||
|
isFinished
|
||||||
|
})
|
||||||
console.log('toggleFinished payload', JSON.stringify(payload))
|
console.log('toggleFinished payload', JSON.stringify(payload))
|
||||||
if (payload?.error) {
|
if (payload?.error) {
|
||||||
this.$toast.error(payload?.error || 'Unknown error')
|
this.$toast.error(payload?.error || 'Unknown error')
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export default {
|
|||||||
default: ''
|
default: ''
|
||||||
},
|
},
|
||||||
paddingX: Number,
|
paddingX: Number,
|
||||||
|
paddingY: Number,
|
||||||
small: Boolean,
|
small: Boolean,
|
||||||
loading: Boolean,
|
loading: Boolean,
|
||||||
disabled: Boolean
|
disabled: Boolean
|
||||||
@@ -48,14 +49,17 @@ export default {
|
|||||||
if (this.small) {
|
if (this.small) {
|
||||||
list.push('text-sm')
|
list.push('text-sm')
|
||||||
if (this.paddingX === undefined) list.push('px-4')
|
if (this.paddingX === undefined) list.push('px-4')
|
||||||
list.push('py-1')
|
if (this.paddingY === undefined) list.push('py-1')
|
||||||
} else {
|
} else {
|
||||||
if (this.paddingX === undefined) list.push('px-8')
|
if (this.paddingX === undefined) list.push('px-8')
|
||||||
list.push('py-2')
|
if (this.paddingY === undefined) list.push('py-2')
|
||||||
}
|
}
|
||||||
if (this.paddingX !== undefined) {
|
if (this.paddingX !== undefined) {
|
||||||
list.push(`px-${this.paddingX}`)
|
list.push(`px-${this.paddingX}`)
|
||||||
}
|
}
|
||||||
|
if (this.paddingY !== undefined) {
|
||||||
|
list.push(`py-${this.paddingY}`)
|
||||||
|
}
|
||||||
if (this.disabled) {
|
if (this.disabled) {
|
||||||
list.push('cursor-not-allowed')
|
list.push('cursor-not-allowed')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" @mousedown.prevent :disabled="disabled || loading" :class="className" @click="clickBtn">
|
<button class="icon-btn rounded-md flex items-center justify-center h-9 w-9 relative" :disabled="disabled || loading" :class="className" :type="type" @mousedown.prevent @click="clickBtn">
|
||||||
<div v-if="loading" class="text-fg absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
<div v-if="loading" class="text-fg absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
||||||
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
<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" />
|
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||||
@@ -13,6 +13,10 @@
|
|||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
icon: String,
|
icon: String,
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'button'
|
||||||
|
},
|
||||||
disabled: Boolean,
|
disabled: Boolean,
|
||||||
bgColor: {
|
bgColor: {
|
||||||
type: String,
|
type: String,
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12px" height="12px" viewBox="0 0 512 512" class="ml-1">
|
||||||
|
<path
|
||||||
|
fill="white"
|
||||||
|
d="M 89.00,40.12
|
||||||
|
C 89.00,40.12 127.00,40.12 127.00,40.12
|
||||||
|
127.00,40.12 198.00,40.12 198.00,40.12
|
||||||
|
198.00,40.12 416.00,40.12 416.00,40.12
|
||||||
|
446.58,40.05 472.95,66.42 473.00,97.00
|
||||||
|
473.00,97.00 473.00,303.00 473.00,303.00
|
||||||
|
473.00,303.00 473.00,418.00 473.00,418.00
|
||||||
|
472.65,447.55 445.06,472.95 416.00,473.00
|
||||||
|
416.00,473.00 210.00,473.00 210.00,473.00
|
||||||
|
210.00,473.00 95.00,473.00 95.00,473.00
|
||||||
|
65.45,472.65 40.05,445.06 40.00,416.00
|
||||||
|
40.00,416.00 40.00,136.00 40.00,136.00
|
||||||
|
40.00,136.00 40.00,109.00 40.00,109.00
|
||||||
|
40.00,109.00 40.00,96.00 40.00,96.00
|
||||||
|
40.07,81.58 46.89,67.14 57.01,57.01
|
||||||
|
61.17,52.86 64.86,50.13 70.00,47.31
|
||||||
|
77.25,43.33 81.02,42.18 89.00,40.12 Z
|
||||||
|
M 337.00,121.00
|
||||||
|
C 337.00,121.00 175.00,121.00 175.00,121.00
|
||||||
|
175.00,121.00 175.00,392.00 175.00,392.00
|
||||||
|
175.00,392.00 337.00,392.00 337.00,392.00
|
||||||
|
337.00,392.00 337.00,349.00 337.00,349.00
|
||||||
|
337.00,349.00 226.00,349.00 226.00,349.00
|
||||||
|
226.00,349.00 226.00,274.00 226.00,274.00
|
||||||
|
226.00,274.00 332.00,274.00 332.00,274.00
|
||||||
|
332.00,274.00 332.00,232.00 332.00,232.00
|
||||||
|
332.00,232.00 226.00,232.00 226.00,232.00
|
||||||
|
226.00,232.00 226.00,164.00 226.00,164.00
|
||||||
|
226.00,164.00 337.00,164.00 337.00,164.00
|
||||||
|
337.00,164.00 337.00,121.00 337.00,121.00 Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
computed: {},
|
||||||
|
methods: {},
|
||||||
|
mounted() {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -65,6 +65,9 @@
|
|||||||
E9E8814D28DA6B9000D750C1 /* PlayerTimeUtilsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E8814C28DA6B9000D750C1 /* PlayerTimeUtilsTests.swift */; };
|
E9E8814D28DA6B9000D750C1 /* PlayerTimeUtilsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E8814C28DA6B9000D750C1 /* PlayerTimeUtilsTests.swift */; };
|
||||||
E9E985F828B02D9400957F23 /* PlayerProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E985F728B02D9400957F23 /* PlayerProgress.swift */; };
|
E9E985F828B02D9400957F23 /* PlayerProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E985F728B02D9400957F23 /* PlayerProgress.swift */; };
|
||||||
E9FA07E328C82848005520B0 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9FA07E228C82848005520B0 /* Logger.swift */; };
|
E9FA07E328C82848005520B0 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9FA07E228C82848005520B0 /* Logger.swift */; };
|
||||||
|
EACB38122BCCA1330060DA4A /* AudioPlayerRateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EACB38112BCCA1330060DA4A /* AudioPlayerRateManager.swift */; };
|
||||||
|
EACB38142BCCA1410060DA4A /* LegacyAudioPlayerRateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EACB38132BCCA1410060DA4A /* LegacyAudioPlayerRateManager.swift */; };
|
||||||
|
EACB38162BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EACB38152BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -142,6 +145,9 @@
|
|||||||
E9E8814C28DA6B9000D750C1 /* PlayerTimeUtilsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerTimeUtilsTests.swift; sourceTree = "<group>"; };
|
E9E8814C28DA6B9000D750C1 /* PlayerTimeUtilsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerTimeUtilsTests.swift; sourceTree = "<group>"; };
|
||||||
E9E985F728B02D9400957F23 /* PlayerProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerProgress.swift; sourceTree = "<group>"; };
|
E9E985F728B02D9400957F23 /* PlayerProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerProgress.swift; sourceTree = "<group>"; };
|
||||||
E9FA07E228C82848005520B0 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
|
E9FA07E228C82848005520B0 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
|
||||||
|
EACB38112BCCA1330060DA4A /* AudioPlayerRateManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioPlayerRateManager.swift; sourceTree = "<group>"; };
|
||||||
|
EACB38132BCCA1410060DA4A /* LegacyAudioPlayerRateManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LegacyAudioPlayerRateManager.swift; sourceTree = "<group>"; };
|
||||||
|
EACB38152BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DefaultedAudioPlayerRateManager.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -183,6 +189,9 @@
|
|||||||
3ABF6190280432610070250E /* player */ = {
|
3ABF6190280432610070250E /* player */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
EACB38152BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift */,
|
||||||
|
EACB38132BCCA1410060DA4A /* LegacyAudioPlayerRateManager.swift */,
|
||||||
|
EACB38112BCCA1330060DA4A /* AudioPlayerRateManager.swift */,
|
||||||
E9E8814828DA641B00D750C1 /* util */,
|
E9E8814828DA641B00D750C1 /* util */,
|
||||||
3A200C1427D64D7E00CBF02E /* AudioPlayer.swift */,
|
3A200C1427D64D7E00CBF02E /* AudioPlayer.swift */,
|
||||||
E9DFCBFA28C28F4A00B36356 /* AudioPlayerSleepTimer.swift */,
|
E9DFCBFA28C28F4A00B36356 /* AudioPlayerSleepTimer.swift */,
|
||||||
@@ -521,6 +530,7 @@
|
|||||||
E9D5506228AC1CC900C746DD /* PlayerState.swift in Sources */,
|
E9D5506228AC1CC900C746DD /* PlayerState.swift in Sources */,
|
||||||
3AD4FCE728043E72006DB301 /* AbsDatabase.m in Sources */,
|
3AD4FCE728043E72006DB301 /* AbsDatabase.m in Sources */,
|
||||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||||
|
EACB38122BCCA1330060DA4A /* AudioPlayerRateManager.swift in Sources */,
|
||||||
E9FA07E328C82848005520B0 /* Logger.swift in Sources */,
|
E9FA07E328C82848005520B0 /* Logger.swift in Sources */,
|
||||||
3A90295F280968E700E1D427 /* PlaybackReport.swift in Sources */,
|
3A90295F280968E700E1D427 /* PlaybackReport.swift in Sources */,
|
||||||
E9D5505A28AC1C4500C746DD /* Folder.swift in Sources */,
|
E9D5505A28AC1C4500C746DD /* Folder.swift in Sources */,
|
||||||
@@ -540,6 +550,7 @@
|
|||||||
3A200C1527D64D7E00CBF02E /* AudioPlayer.swift in Sources */,
|
3A200C1527D64D7E00CBF02E /* AudioPlayer.swift in Sources */,
|
||||||
E9D5507128AC1EC700C746DD /* DownloadItemPart.swift in Sources */,
|
E9D5507128AC1EC700C746DD /* DownloadItemPart.swift in Sources */,
|
||||||
4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */,
|
4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */,
|
||||||
|
EACB38142BCCA1410060DA4A /* LegacyAudioPlayerRateManager.swift in Sources */,
|
||||||
3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */,
|
3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */,
|
||||||
3AB34053280829BF0039308B /* Extensions.swift in Sources */,
|
3AB34053280829BF0039308B /* Extensions.swift in Sources */,
|
||||||
E9D5505828AC1C1A00C746DD /* Library.swift in Sources */,
|
E9D5505828AC1C1A00C746DD /* Library.swift in Sources */,
|
||||||
@@ -548,6 +559,7 @@
|
|||||||
4DABC04F2B0139CA000F6264 /* User.swift in Sources */,
|
4DABC04F2B0139CA000F6264 /* User.swift in Sources */,
|
||||||
4D66B952282EE822008272D4 /* AbsDownloader.m in Sources */,
|
4D66B952282EE822008272D4 /* AbsDownloader.m in Sources */,
|
||||||
E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */,
|
E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */,
|
||||||
|
EACB38162BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift in Sources */,
|
||||||
E9D5505228AC1B5D00C746DD /* Chapter.swift in Sources */,
|
E9D5505228AC1B5D00C746DD /* Chapter.swift in Sources */,
|
||||||
E9D5506028AC1CA900C746DD /* PlaybackMetadata.swift in Sources */,
|
E9D5506028AC1CA900C746DD /* PlaybackMetadata.swift in Sources */,
|
||||||
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */,
|
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */,
|
||||||
@@ -728,12 +740,12 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 27;
|
CURRENT_PROJECT_VERSION = 32;
|
||||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 0.9.70;
|
MARKETING_VERSION = 0.9.75;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -752,12 +764,12 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 27;
|
CURRENT_PROJECT_VERSION = 32;
|
||||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
MARKETING_VERSION = 0.9.70;
|
MARKETING_VERSION = 0.9.75;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
// Override point for customization after application launch.
|
// Override point for customization after application launch.
|
||||||
|
|
||||||
let configuration = Realm.Configuration(
|
let configuration = Realm.Configuration(
|
||||||
schemaVersion: 15,
|
schemaVersion: 18,
|
||||||
migrationBlock: { [weak self] migration, oldSchemaVersion in
|
migrationBlock: { [weak self] migration, oldSchemaVersion in
|
||||||
if (oldSchemaVersion < 1) {
|
if (oldSchemaVersion < 1) {
|
||||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)")
|
self?.logger.log("Realm schema version was \(oldSchemaVersion)")
|
||||||
@@ -48,6 +48,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
newObject?["languageCode"] = "en-us"
|
newObject?["languageCode"] = "en-us"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (oldSchemaVersion < 16) {
|
||||||
|
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding chapterTrack setting")
|
||||||
|
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
|
||||||
|
newObject?["chapterTrack"] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (oldSchemaVersion < 17) {
|
||||||
|
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding downloadUsingCellular and streamingUsingCellular settings")
|
||||||
|
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
|
||||||
|
newObject?["downloadUsingCellular"] = "ALWAYS"
|
||||||
|
newObject?["streamingUsingCellular"] = "ALWAYS"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
Realm.Configuration.defaultConfiguration = configuration
|
Realm.Configuration.defaultConfiguration = configuration
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ CAP_PLUGIN(AbsAudioPlayer, "AbsAudioPlayer",
|
|||||||
CAP_PLUGIN_METHOD(closePlayback, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(closePlayback, CAPPluginReturnPromise);
|
||||||
|
|
||||||
CAP_PLUGIN_METHOD(setPlaybackSpeed, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(setPlaybackSpeed, CAPPluginReturnPromise);
|
||||||
|
CAP_PLUGIN_METHOD(setChapterTrack, CAPPluginReturnPromise);
|
||||||
|
|
||||||
CAP_PLUGIN_METHOD(playPlayer, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(playPlayer, CAPPluginReturnPromise);
|
||||||
CAP_PLUGIN_METHOD(pausePlayer, CAPPluginReturnPromise);
|
CAP_PLUGIN_METHOD(pausePlayer, CAPPluginReturnPromise);
|
||||||
|
|||||||
@@ -8,12 +8,15 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Capacitor
|
import Capacitor
|
||||||
import RealmSwift
|
import RealmSwift
|
||||||
|
import Network
|
||||||
|
|
||||||
@objc(AbsAudioPlayer)
|
@objc(AbsAudioPlayer)
|
||||||
public class AbsAudioPlayer: CAPPlugin {
|
public class AbsAudioPlayer: CAPPlugin {
|
||||||
private let logger = AppLogger(category: "AbsAudioPlayer")
|
private let logger = AppLogger(category: "AbsAudioPlayer")
|
||||||
|
|
||||||
private var initialPlayWhenReady = false
|
private var initialPlayWhenReady = false
|
||||||
|
private var monitor: NWPathMonitor?
|
||||||
|
private let queue = DispatchQueue.global(qos: .background)
|
||||||
|
|
||||||
override public func load() {
|
override public func load() {
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(sendMetadata), name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(sendMetadata), name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
|
||||||
@@ -28,6 +31,11 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
self.bridge?.webView?.allowsBackForwardNavigationGestures = true;
|
self.bridge?.webView?.allowsBackForwardNavigationGestures = true;
|
||||||
self.bridge?.webView?.scrollView.alwaysBounceVertical = false;
|
self.bridge?.webView?.scrollView.alwaysBounceVertical = false;
|
||||||
|
|
||||||
|
setupNetworkMonitor()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
monitor?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func onReady(_ call: CAPPluginCall) {
|
@objc func onReady(_ call: CAPPluginCall) {
|
||||||
@@ -120,6 +128,17 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func setChapterTrack(_ call: CAPPluginCall) {
|
||||||
|
let chapterTrack = call.getBool("enabled", true)
|
||||||
|
logger.log(String(chapterTrack))
|
||||||
|
let settings = PlayerSettings.main()
|
||||||
|
try? settings.update {
|
||||||
|
settings.chapterTrack = chapterTrack
|
||||||
|
}
|
||||||
|
PlayerHandler.setChapterTrack()
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
@objc func playPlayer(_ call: CAPPluginCall) {
|
@objc func playPlayer(_ call: CAPPluginCall) {
|
||||||
PlayerHandler.paused = false
|
PlayerHandler.paused = false
|
||||||
call.resolve()
|
call.resolve()
|
||||||
@@ -264,6 +283,25 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
@objc func sendPlaybackSession(session: [String: Any]) {
|
@objc func sendPlaybackSession(session: [String: Any]) {
|
||||||
self.notifyListeners("onPlaybackSession", data: session)
|
self.notifyListeners("onPlaybackSession", data: session)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func setupNetworkMonitor() {
|
||||||
|
monitor = NWPathMonitor()
|
||||||
|
monitor?.pathUpdateHandler = { [weak self] path in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
let isUnmetered = !path.isExpensive && !path.isConstrained
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.notifyNetworkMeteredChanged(isUnmetered: isUnmetered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
monitor?.start(queue: queue)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func notifyNetworkMeteredChanged(isUnmetered: Bool) {
|
||||||
|
let data: [String: Any] = ["value": isUnmetered]
|
||||||
|
self.notifyListeners("onNetworkMeteredChanged", data: data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PlayerError: String, Error {
|
enum PlayerError: String, Error {
|
||||||
|
|||||||
@@ -147,14 +147,15 @@ public class AbsDatabase: CAPPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc func syncLocalSessionsWithServer(_ call: CAPPluginCall) {
|
@objc func syncLocalSessionsWithServer(_ call: CAPPluginCall) {
|
||||||
logger.log("syncLocalSessionsWithServer: Starting")
|
let isFirstSync = call.getBool("isFirstSync", false)
|
||||||
|
logger.log("syncLocalSessionsWithServer: Starting (First sync: \(isFirstSync))")
|
||||||
guard Store.serverConfig != nil else {
|
guard Store.serverConfig != nil else {
|
||||||
call.reject("syncLocalSessionsWithServer not connected to server")
|
call.reject("syncLocalSessionsWithServer not connected to server")
|
||||||
return call.resolve()
|
return call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
await ApiClient.syncLocalSessionsWithServer()
|
await ApiClient.syncLocalSessionsWithServer(isFirstSync: isFirstSync)
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,23 +238,31 @@ public class AbsDatabase: CAPPlugin {
|
|||||||
@objc func updateDeviceSettings(_ call: CAPPluginCall) {
|
@objc func updateDeviceSettings(_ call: CAPPluginCall) {
|
||||||
let disableAutoRewind = call.getBool("disableAutoRewind") ?? false
|
let disableAutoRewind = call.getBool("disableAutoRewind") ?? false
|
||||||
let enableAltView = call.getBool("enableAltView") ?? false
|
let enableAltView = call.getBool("enableAltView") ?? false
|
||||||
|
let allowSeekingOnMediaControls = call.getBool("allowSeekingOnMediaControls") ?? false
|
||||||
let jumpBackwardsTime = call.getInt("jumpBackwardsTime") ?? 10
|
let jumpBackwardsTime = call.getInt("jumpBackwardsTime") ?? 10
|
||||||
let jumpForwardTime = call.getInt("jumpForwardTime") ?? 10
|
let jumpForwardTime = call.getInt("jumpForwardTime") ?? 10
|
||||||
let lockOrientation = call.getString("lockOrientation") ?? "NONE"
|
let lockOrientation = call.getString("lockOrientation") ?? "NONE"
|
||||||
let hapticFeedback = call.getString("hapticFeedback") ?? "LIGHT"
|
let hapticFeedback = call.getString("hapticFeedback") ?? "LIGHT"
|
||||||
let languageCode = call.getString("languageCode") ?? "en-us"
|
let languageCode = call.getString("languageCode") ?? "en-us"
|
||||||
|
let downloadUsingCellular = call.getString("downloadUsingCellular") ?? "ALWAYS"
|
||||||
|
let streamingUsingCellular = call.getString("streamingUsingCellular") ?? "ALWAYS"
|
||||||
let settings = DeviceSettings()
|
let settings = DeviceSettings()
|
||||||
settings.disableAutoRewind = disableAutoRewind
|
settings.disableAutoRewind = disableAutoRewind
|
||||||
settings.enableAltView = enableAltView
|
settings.enableAltView = enableAltView
|
||||||
|
settings.allowSeekingOnMediaControls = allowSeekingOnMediaControls
|
||||||
settings.jumpBackwardsTime = jumpBackwardsTime
|
settings.jumpBackwardsTime = jumpBackwardsTime
|
||||||
settings.jumpForwardTime = jumpForwardTime
|
settings.jumpForwardTime = jumpForwardTime
|
||||||
settings.lockOrientation = lockOrientation
|
settings.lockOrientation = lockOrientation
|
||||||
settings.hapticFeedback = hapticFeedback
|
settings.hapticFeedback = hapticFeedback
|
||||||
settings.languageCode = languageCode
|
settings.languageCode = languageCode
|
||||||
|
settings.downloadUsingCellular = downloadUsingCellular
|
||||||
|
settings.streamingUsingCellular = streamingUsingCellular
|
||||||
|
|
||||||
Database.shared.setDeviceSettings(deviceSettings: settings)
|
Database.shared.setDeviceSettings(deviceSettings: settings)
|
||||||
|
|
||||||
// call.resolve([ "value": [] ])
|
// Updates the media notification controls (for allowSeekingOnMediaControls setting)
|
||||||
|
PlayerHandler.updateRemoteTransportControls()
|
||||||
|
|
||||||
getDeviceData(call)
|
getDeviceData(call)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ install! 'cocoapods', :disable_input_output_paths => true
|
|||||||
def capacitor_pods
|
def capacitor_pods
|
||||||
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
|
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
|
||||||
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
|
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
|
||||||
|
pod 'ByteowlsCapacitorFilesharer', :path => '../../node_modules/@byteowls/capacitor-filesharer'
|
||||||
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
|
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
|
||||||
pod 'CapacitorBrowser', :path => '../../node_modules/@capacitor/browser'
|
pod 'CapacitorBrowser', :path => '../../node_modules/@capacitor/browser'
|
||||||
pod 'CapacitorClipboard', :path => '../../node_modules/@capacitor/clipboard'
|
pod 'CapacitorClipboard', :path => '../../node_modules/@capacitor/clipboard'
|
||||||
|
|||||||
+16
-10
@@ -1,5 +1,7 @@
|
|||||||
PODS:
|
PODS:
|
||||||
- Alamofire (5.6.4)
|
- Alamofire (5.8.1)
|
||||||
|
- ByteowlsCapacitorFilesharer (5.0.0):
|
||||||
|
- Capacitor
|
||||||
- Capacitor (5.4.0):
|
- Capacitor (5.4.0):
|
||||||
- CapacitorCordova
|
- CapacitorCordova
|
||||||
- CapacitorApp (5.0.6):
|
- CapacitorApp (5.0.6):
|
||||||
@@ -21,14 +23,15 @@ PODS:
|
|||||||
- Capacitor
|
- Capacitor
|
||||||
- CordovaPlugins (5.4.0):
|
- CordovaPlugins (5.4.0):
|
||||||
- CapacitorCordova
|
- CapacitorCordova
|
||||||
- Realm (10.36.0):
|
- Realm (10.47.0):
|
||||||
- Realm/Headers (= 10.36.0)
|
- Realm/Headers (= 10.47.0)
|
||||||
- Realm/Headers (10.36.0)
|
- Realm/Headers (10.47.0)
|
||||||
- RealmSwift (10.36.0):
|
- RealmSwift (10.47.0):
|
||||||
- Realm (= 10.36.0)
|
- Realm (= 10.47.0)
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- Alamofire (~> 5.5)
|
- Alamofire (~> 5.5)
|
||||||
|
- "ByteowlsCapacitorFilesharer (from `../../node_modules/@byteowls/capacitor-filesharer`)"
|
||||||
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
|
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
|
||||||
- "CapacitorApp (from `../../node_modules/@capacitor/app`)"
|
- "CapacitorApp (from `../../node_modules/@capacitor/app`)"
|
||||||
- "CapacitorBrowser (from `../../node_modules/@capacitor/browser`)"
|
- "CapacitorBrowser (from `../../node_modules/@capacitor/browser`)"
|
||||||
@@ -49,6 +52,8 @@ SPEC REPOS:
|
|||||||
- RealmSwift
|
- RealmSwift
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
|
ByteowlsCapacitorFilesharer:
|
||||||
|
:path: "../../node_modules/@byteowls/capacitor-filesharer"
|
||||||
Capacitor:
|
Capacitor:
|
||||||
:path: "../../node_modules/@capacitor/ios"
|
:path: "../../node_modules/@capacitor/ios"
|
||||||
CapacitorApp:
|
CapacitorApp:
|
||||||
@@ -73,7 +78,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../capacitor-cordova-ios-plugins"
|
:path: "../capacitor-cordova-ios-plugins"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
Alamofire: 4e95d97098eacb88856099c4fc79b526a299e48c
|
Alamofire: 3ca42e259043ee0dc5c0cdd76c4bc568b8e42af7
|
||||||
|
ByteowlsCapacitorFilesharer: f6a773825632d65d5404a34764c4a3fd857bb176
|
||||||
Capacitor: a5cd803e02b471591c81165f400ace01f40b11d3
|
Capacitor: a5cd803e02b471591c81165f400ace01f40b11d3
|
||||||
CapacitorApp: 024e1b1bea5f883d79f6330d309bc441c88ad04a
|
CapacitorApp: 024e1b1bea5f883d79f6330d309bc441c88ad04a
|
||||||
CapacitorBrowser: 7a0fb6a1011abfaaf2dfedfd8248f942a8eda3d6
|
CapacitorBrowser: 7a0fb6a1011abfaaf2dfedfd8248f942a8eda3d6
|
||||||
@@ -85,9 +91,9 @@ SPEC CHECKSUMS:
|
|||||||
CapacitorPreferences: f03954bcb0ff09c792909e46bff88e3183c16b10
|
CapacitorPreferences: f03954bcb0ff09c792909e46bff88e3183c16b10
|
||||||
CapacitorStatusBar: 565c0a1ebd79bb40d797606a8992b4a105885309
|
CapacitorStatusBar: 565c0a1ebd79bb40d797606a8992b4a105885309
|
||||||
CordovaPlugins: a5db67e5ac1061b9869a0efd754f2c2f776aeccc
|
CordovaPlugins: a5db67e5ac1061b9869a0efd754f2c2f776aeccc
|
||||||
Realm: 3fd136cb4c83a927482a7f1612496d37beed3cf5
|
Realm: e43fb540ae947497e3ea8a662443256920602060
|
||||||
RealmSwift: 513d4dcbf5bfc4d573454088b592685fc48dd716
|
RealmSwift: 8b06ed06b5d16749ae0c4d91c0cba414a9e28189
|
||||||
|
|
||||||
PODFILE CHECKSUM: 7a8fc177ef0646dd60a1ee8aa387964975fcc1e3
|
PODFILE CHECKSUM: 02e6ffe2f51a453ce222ee9af0e55e9448d8514c
|
||||||
|
|
||||||
COCOAPODS: 1.12.1
|
COCOAPODS: 1.12.1
|
||||||
|
|||||||
@@ -11,11 +11,14 @@ import RealmSwift
|
|||||||
class DeviceSettings: Object {
|
class DeviceSettings: Object {
|
||||||
@Persisted var disableAutoRewind: Bool = false
|
@Persisted var disableAutoRewind: Bool = false
|
||||||
@Persisted var enableAltView: Bool = true
|
@Persisted var enableAltView: Bool = true
|
||||||
|
@Persisted var allowSeekingOnMediaControls: Bool = false
|
||||||
@Persisted var jumpBackwardsTime: Int = 10
|
@Persisted var jumpBackwardsTime: Int = 10
|
||||||
@Persisted var jumpForwardTime: Int = 10
|
@Persisted var jumpForwardTime: Int = 10
|
||||||
@Persisted var lockOrientation: String = "NONE"
|
@Persisted var lockOrientation: String = "NONE"
|
||||||
@Persisted var hapticFeedback: String = "LIGHT"
|
@Persisted var hapticFeedback: String = "LIGHT"
|
||||||
@Persisted var languageCode: String = "en-us"
|
@Persisted var languageCode: String = "en-us"
|
||||||
|
@Persisted var downloadUsingCellular: String = "ALWAYS"
|
||||||
|
@Persisted var streamingUsingCellular: String = "ALWAYS"
|
||||||
}
|
}
|
||||||
|
|
||||||
func getDefaultDeviceSettings() -> DeviceSettings {
|
func getDefaultDeviceSettings() -> DeviceSettings {
|
||||||
@@ -26,10 +29,13 @@ func deviceSettingsToJSON(settings: DeviceSettings) -> Dictionary<String, Any> {
|
|||||||
return [
|
return [
|
||||||
"disableAutoRewind": settings.disableAutoRewind,
|
"disableAutoRewind": settings.disableAutoRewind,
|
||||||
"enableAltView": settings.enableAltView,
|
"enableAltView": settings.enableAltView,
|
||||||
|
"allowSeekingOnMediaControls": settings.allowSeekingOnMediaControls,
|
||||||
"jumpBackwardsTime": settings.jumpBackwardsTime,
|
"jumpBackwardsTime": settings.jumpBackwardsTime,
|
||||||
"jumpForwardTime": settings.jumpForwardTime,
|
"jumpForwardTime": settings.jumpForwardTime,
|
||||||
"lockOrientation": settings.lockOrientation,
|
"lockOrientation": settings.lockOrientation,
|
||||||
"hapticFeedback": settings.hapticFeedback,
|
"hapticFeedback": settings.hapticFeedback,
|
||||||
"languageCode": settings.languageCode
|
"languageCode": settings.languageCode,
|
||||||
|
"downloadUsingCellular": settings.downloadUsingCellular,
|
||||||
|
"streamingUsingCellular": settings.streamingUsingCellular
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,3 +160,11 @@ class PlaybackSession: Object, Codable, Deletable {
|
|||||||
try container.encode(localMediaProgressId, forKey: .localMediaProgressId)
|
try container.encode(localMediaProgressId, forKey: .localMediaProgressId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension PlaybackSession {
|
||||||
|
func getCurrentChapter() -> Chapter? {
|
||||||
|
return chapters.first { chapter in
|
||||||
|
chapter.start <= self.currentTime && chapter.end > self.currentTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class PlayerSettings: Object {
|
|||||||
// The webapp has a persisted setting for playback speed, but it's not always available to the native code
|
// The webapp has a persisted setting for playback speed, but it's not always available to the native code
|
||||||
// Lets track it natively as well, so we never have a situation where the UI and native player are out of sync
|
// Lets track it natively as well, so we never have a situation where the UI and native player are out of sync
|
||||||
@Persisted var playbackRate: Float = 1.0
|
@Persisted var playbackRate: Float = 1.0
|
||||||
|
@Persisted var chapterTrack: Bool = true
|
||||||
|
|
||||||
|
|
||||||
// Singleton pattern for Realm objects
|
// Singleton pattern for Realm objects
|
||||||
static func main() -> PlayerSettings {
|
static func main() -> PlayerSettings {
|
||||||
|
|||||||
@@ -39,3 +39,13 @@ class Chapter: EmbeddedObject, Codable {
|
|||||||
try container.encode(title, forKey: .title)
|
try container.encode(title, forKey: .title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension Chapter {
|
||||||
|
func getRelativeChapterCurrentTime(sessionCurrentTime: Double) -> Double {
|
||||||
|
return sessionCurrentTime - self.start
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRelativeChapterEndTime() -> Double {
|
||||||
|
return self.end - self.start
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,9 +29,7 @@ class AudioPlayer: NSObject {
|
|||||||
internal let logger = AppLogger(category: "AudioPlayer")
|
internal let logger = AppLogger(category: "AudioPlayer")
|
||||||
|
|
||||||
private var status: PlayerStatus
|
private var status: PlayerStatus
|
||||||
internal var rate: Float
|
internal var rateManager: AudioPlayerRateManager
|
||||||
|
|
||||||
private var tmpRate: Float = 1.0
|
|
||||||
|
|
||||||
private var playerContext = 0
|
private var playerContext = 0
|
||||||
private var playerItemContext = 0
|
private var playerItemContext = 0
|
||||||
@@ -64,11 +62,18 @@ class AudioPlayer: NSObject {
|
|||||||
self.audioPlayer.automaticallyWaitsToMinimizeStalling = true
|
self.audioPlayer.automaticallyWaitsToMinimizeStalling = true
|
||||||
self.sessionId = sessionId
|
self.sessionId = sessionId
|
||||||
self.status = .uninitialized
|
self.status = .uninitialized
|
||||||
self.rate = 0.0
|
|
||||||
self.tmpRate = playbackRate
|
if #available(iOS 16.0, *) {
|
||||||
|
self.rateManager = DefaultedAudioPlayerRateManager(audioPlayer: self.audioPlayer, defaultRate: playbackRate)
|
||||||
|
} else {
|
||||||
|
self.rateManager = LegacyAudioPlayerRateManager(audioPlayer: self.audioPlayer, defaultRate: playbackRate)
|
||||||
|
}
|
||||||
|
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
|
self.rateManager.rateChangedCompletion = self.updateNowPlaying
|
||||||
|
self.rateManager.defaultRateChangedCompletion = self.setupTimeObservers
|
||||||
|
|
||||||
initAudioSession()
|
initAudioSession()
|
||||||
setupRemoteTransportControls()
|
setupRemoteTransportControls()
|
||||||
|
|
||||||
@@ -81,7 +86,6 @@ class AudioPlayer: NSObject {
|
|||||||
|
|
||||||
// Listen to player events
|
// Listen to player events
|
||||||
self.setupAudioSessionNotifications()
|
self.setupAudioSessionNotifications()
|
||||||
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.rate), options: .new, context: &playerContext)
|
|
||||||
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), options: .new, context: &playerContext)
|
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), options: .new, context: &playerContext)
|
||||||
|
|
||||||
for track in playbackSession.audioTracks {
|
for track in playbackSession.audioTracks {
|
||||||
@@ -150,7 +154,6 @@ class AudioPlayer: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove observers
|
// Remove observers
|
||||||
self.audioPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.rate), context: &playerContext)
|
|
||||||
self.audioPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), context: &playerContext)
|
self.audioPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), context: &playerContext)
|
||||||
self.removeTimeObservers()
|
self.removeTimeObservers()
|
||||||
|
|
||||||
@@ -224,6 +227,8 @@ class AudioPlayer: NSObject {
|
|||||||
// Update the UI
|
// Update the UI
|
||||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
|
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
|
||||||
}
|
}
|
||||||
|
// Update the now playing and chapter info
|
||||||
|
self.updateNowPlaying()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,7 +335,7 @@ class AudioPlayer: NSObject {
|
|||||||
self.markAudioSessionAs(active: true)
|
self.markAudioSessionAs(active: true)
|
||||||
DispatchQueue.runOnMainQueue {
|
DispatchQueue.runOnMainQueue {
|
||||||
self.audioPlayer.play()
|
self.audioPlayer.play()
|
||||||
self.audioPlayer.rate = self.tmpRate
|
self.rateManager.handlePlayEvent()
|
||||||
}
|
}
|
||||||
self.status = .playing
|
self.status = .playing
|
||||||
|
|
||||||
@@ -422,25 +427,12 @@ class AudioPlayer: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func setPlaybackRate(_ rate: Float, observed: Bool = false) {
|
public func setPlaybackRate(_ rate: Float) {
|
||||||
let playbackSpeedChanged = rate > 0.0 && rate != self.tmpRate && !(observed && rate == 1)
|
self.rateManager.setPlaybackRate(rate)
|
||||||
|
}
|
||||||
|
|
||||||
if self.audioPlayer.rate != rate {
|
public func setChapterTrack() {
|
||||||
logger.log("setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)")
|
|
||||||
DispatchQueue.runOnMainQueue {
|
|
||||||
self.audioPlayer.rate = rate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.rate = rate
|
|
||||||
self.updateNowPlaying()
|
self.updateNowPlaying()
|
||||||
|
|
||||||
if playbackSpeedChanged {
|
|
||||||
self.tmpRate = rate
|
|
||||||
|
|
||||||
// Setup the time observer again at the new rate
|
|
||||||
self.setupTimeObservers()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func getCurrentTime() -> Double? {
|
public func getCurrentTime() -> Double? {
|
||||||
@@ -528,14 +520,24 @@ class AudioPlayer: NSObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch type {
|
// When interruption is from the app suspending then don't resume playback
|
||||||
case .ended:
|
if #available(iOS 14.5, *) {
|
||||||
guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
|
let reasonValue = userInfo[AVAudioSessionInterruptionReasonKey] as? UInt ?? 0
|
||||||
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
let reason = AVAudioSession.InterruptionReason(rawValue: reasonValue)
|
||||||
if options.contains(.shouldResume) {
|
if (reason == .appWasSuspended) {
|
||||||
self.play(allowSeekBack: true)
|
logger.log("AVAudioSession was suspended")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
default: ()
|
}
|
||||||
|
|
||||||
|
switch type {
|
||||||
|
case .ended:
|
||||||
|
guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
|
||||||
|
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
||||||
|
if options.contains(.shouldResume) {
|
||||||
|
self.play(allowSeekBack: true)
|
||||||
|
}
|
||||||
|
default: ()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,7 +574,7 @@ class AudioPlayer: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Now playing
|
// MARK: - Now playing
|
||||||
private func setupRemoteTransportControls() {
|
func setupRemoteTransportControls() {
|
||||||
DispatchQueue.runOnMainQueue {
|
DispatchQueue.runOnMainQueue {
|
||||||
UIApplication.shared.beginReceivingRemoteControlEvents()
|
UIApplication.shared.beginReceivingRemoteControlEvents()
|
||||||
}
|
}
|
||||||
@@ -582,17 +584,43 @@ class AudioPlayer: NSObject {
|
|||||||
let jumpBackwardsTime = deviceSettings.jumpBackwardsTime
|
let jumpBackwardsTime = deviceSettings.jumpBackwardsTime
|
||||||
|
|
||||||
commandCenter.playCommand.isEnabled = true
|
commandCenter.playCommand.isEnabled = true
|
||||||
|
commandCenter.playCommand.removeTarget(nil)
|
||||||
commandCenter.playCommand.addTarget { [weak self] event in
|
commandCenter.playCommand.addTarget { [weak self] event in
|
||||||
self?.play(allowSeekBack: true)
|
guard let strongSelf = self else { return .commandFailed }
|
||||||
|
if strongSelf.isPlaying() {
|
||||||
|
strongSelf.pause()
|
||||||
|
} else {
|
||||||
|
strongSelf.play(allowSeekBack: true)
|
||||||
|
}
|
||||||
return .success
|
return .success
|
||||||
}
|
}
|
||||||
|
|
||||||
commandCenter.pauseCommand.isEnabled = true
|
commandCenter.pauseCommand.isEnabled = true
|
||||||
|
commandCenter.pauseCommand.removeTarget(nil)
|
||||||
commandCenter.pauseCommand.addTarget { [weak self] event in
|
commandCenter.pauseCommand.addTarget { [weak self] event in
|
||||||
self?.pause()
|
guard let strongSelf = self else { return .commandFailed }
|
||||||
|
if strongSelf.isPlaying() {
|
||||||
|
strongSelf.pause()
|
||||||
|
} else {
|
||||||
|
strongSelf.play(allowSeekBack: true)
|
||||||
|
}
|
||||||
|
return .success
|
||||||
|
}
|
||||||
|
|
||||||
|
commandCenter.togglePlayPauseCommand.isEnabled = true
|
||||||
|
commandCenter.togglePlayPauseCommand.removeTarget(nil)
|
||||||
|
commandCenter.togglePlayPauseCommand.addTarget { [weak self] event in
|
||||||
|
guard let strongSelf = self else { return .commandFailed }
|
||||||
|
if strongSelf.isPlaying() {
|
||||||
|
strongSelf.pause()
|
||||||
|
} else {
|
||||||
|
strongSelf.play(allowSeekBack: true)
|
||||||
|
}
|
||||||
return .success
|
return .success
|
||||||
}
|
}
|
||||||
|
|
||||||
commandCenter.skipForwardCommand.isEnabled = true
|
commandCenter.skipForwardCommand.isEnabled = true
|
||||||
|
commandCenter.skipForwardCommand.removeTarget(nil)
|
||||||
commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: jumpForwardTime)]
|
commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: jumpForwardTime)]
|
||||||
commandCenter.skipForwardCommand.addTarget { [weak self] event in
|
commandCenter.skipForwardCommand.addTarget { [weak self] event in
|
||||||
guard let command = event.command as? MPSkipIntervalCommand else {
|
guard let command = event.command as? MPSkipIntervalCommand else {
|
||||||
@@ -605,6 +633,7 @@ class AudioPlayer: NSObject {
|
|||||||
return .success
|
return .success
|
||||||
}
|
}
|
||||||
commandCenter.skipBackwardCommand.isEnabled = true
|
commandCenter.skipBackwardCommand.isEnabled = true
|
||||||
|
commandCenter.skipBackwardCommand.removeTarget(nil)
|
||||||
commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: jumpBackwardsTime)]
|
commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: jumpBackwardsTime)]
|
||||||
commandCenter.skipBackwardCommand.addTarget { [weak self] event in
|
commandCenter.skipBackwardCommand.addTarget { [weak self] event in
|
||||||
guard let command = event.command as? MPSkipIntervalCommand else {
|
guard let command = event.command as? MPSkipIntervalCommand else {
|
||||||
@@ -618,6 +647,7 @@ class AudioPlayer: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
commandCenter.nextTrackCommand.isEnabled = true
|
commandCenter.nextTrackCommand.isEnabled = true
|
||||||
|
commandCenter.nextTrackCommand.removeTarget(nil)
|
||||||
commandCenter.nextTrackCommand.addTarget { [weak self] _ in
|
commandCenter.nextTrackCommand.addTarget { [weak self] _ in
|
||||||
guard let currentTime = self?.getCurrentTime() else {
|
guard let currentTime = self?.getCurrentTime() else {
|
||||||
return .commandFailed
|
return .commandFailed
|
||||||
@@ -626,6 +656,7 @@ class AudioPlayer: NSObject {
|
|||||||
return .success
|
return .success
|
||||||
}
|
}
|
||||||
commandCenter.previousTrackCommand.isEnabled = true
|
commandCenter.previousTrackCommand.isEnabled = true
|
||||||
|
commandCenter.previousTrackCommand.removeTarget(nil)
|
||||||
commandCenter.previousTrackCommand.addTarget { [weak self] _ in
|
commandCenter.previousTrackCommand.addTarget { [weak self] _ in
|
||||||
guard let currentTime = self?.getCurrentTime() else {
|
guard let currentTime = self?.getCurrentTime() else {
|
||||||
return .commandFailed
|
return .commandFailed
|
||||||
@@ -634,17 +665,26 @@ class AudioPlayer: NSObject {
|
|||||||
return .success
|
return .success
|
||||||
}
|
}
|
||||||
|
|
||||||
commandCenter.changePlaybackPositionCommand.isEnabled = true
|
commandCenter.changePlaybackPositionCommand.isEnabled = deviceSettings.allowSeekingOnMediaControls
|
||||||
|
commandCenter.changePlaybackPositionCommand.removeTarget(nil)
|
||||||
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
|
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
|
||||||
guard let event = event as? MPChangePlaybackPositionCommandEvent else {
|
guard let event = event as? MPChangePlaybackPositionCommandEvent else {
|
||||||
return .noSuchContent
|
return .noSuchContent
|
||||||
}
|
}
|
||||||
|
|
||||||
self?.seek(event.positionTime, from: "remote")
|
// Adjust seek time if chapter track is being used
|
||||||
|
var seekTime = event.positionTime
|
||||||
|
if PlayerSettings.main().chapterTrack {
|
||||||
|
if let session = self?.getPlaybackSession(), let currentChapter = session.getCurrentChapter() {
|
||||||
|
seekTime += currentChapter.start
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self?.seek(seekTime, from: "remote")
|
||||||
return .success
|
return .success
|
||||||
}
|
}
|
||||||
|
|
||||||
commandCenter.changePlaybackRateCommand.isEnabled = true
|
commandCenter.changePlaybackRateCommand.isEnabled = true
|
||||||
|
commandCenter.changePlaybackRateCommand.removeTarget(nil)
|
||||||
commandCenter.changePlaybackRateCommand.supportedPlaybackRates = [0.5, 0.75, 1.0, 1.25, 1.5, 2]
|
commandCenter.changePlaybackRateCommand.supportedPlaybackRates = [0.5, 0.75, 1.0, 1.25, 1.5, 2]
|
||||||
commandCenter.changePlaybackRateCommand.addTarget { [weak self] event in
|
commandCenter.changePlaybackRateCommand.addTarget { [weak self] event in
|
||||||
guard let event = event as? MPChangePlaybackRateCommandEvent else {
|
guard let event = event as? MPChangePlaybackRateCommandEvent else {
|
||||||
@@ -657,18 +697,25 @@ class AudioPlayer: NSObject {
|
|||||||
}
|
}
|
||||||
private func updateNowPlaying() {
|
private func updateNowPlaying() {
|
||||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
|
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
|
||||||
if let duration = self.getDuration(), let currentTime = self.getCurrentTime() {
|
if let session = self.getPlaybackSession(), let currentChapter = session.getCurrentChapter(), PlayerSettings.main().chapterTrack {
|
||||||
NowPlayingInfo.shared.update(duration: duration, currentTime: currentTime, rate: rate)
|
NowPlayingInfo.shared.update(
|
||||||
|
duration: currentChapter.getRelativeChapterEndTime(),
|
||||||
|
currentTime: currentChapter.getRelativeChapterCurrentTime(sessionCurrentTime: session.currentTime),
|
||||||
|
rate: self.rateManager.rate,
|
||||||
|
defaultRate: self.rateManager.defaultRate,
|
||||||
|
chapterName: currentChapter.title,
|
||||||
|
chapterNumber: (session.chapters.firstIndex(of: currentChapter) ?? 0) + 1,
|
||||||
|
chapterCount: session.chapters.count
|
||||||
|
)
|
||||||
|
} else if let duration = self.getDuration(), let currentTime = self.getCurrentTime() {
|
||||||
|
NowPlayingInfo.shared.update(duration: duration, currentTime: currentTime, rate: self.rateManager.rate, defaultRate: self.rateManager.defaultRate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Observer
|
// MARK: - Observer
|
||||||
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
||||||
if context == &playerContext {
|
if context == &playerContext {
|
||||||
if keyPath == #keyPath(AVPlayer.rate) {
|
if keyPath == #keyPath(AVPlayer.currentItem) {
|
||||||
logger.log("playerContext observer player rate")
|
|
||||||
self.setPlaybackRate(change?[.newKey] as? Float ?? 1.0, observed: true)
|
|
||||||
} else if keyPath == #keyPath(AVPlayer.currentItem) {
|
|
||||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
|
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
|
||||||
logger.log("WARNING: Item ended")
|
logger.log("WARNING: Item ended")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
//
|
||||||
|
// AudioPlayerRateManager.swift
|
||||||
|
// Audiobookshelf
|
||||||
|
//
|
||||||
|
// Created by Marke Hallowell on 4/14/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
protocol AudioPlayerRateManager {
|
||||||
|
var rate: Float { get }
|
||||||
|
var defaultRate: Float { get }
|
||||||
|
var rateChangedCompletion: () -> Void { get set }
|
||||||
|
var defaultRateChangedCompletion: () -> Void { get set }
|
||||||
|
|
||||||
|
init(audioPlayer: AVPlayer, defaultRate: Float)
|
||||||
|
|
||||||
|
func setPlaybackRate(_ rate: Float)
|
||||||
|
|
||||||
|
// Callback for play events (e.g. LegacyAudioPlayerRateManager uses this set rate immediately after playback resumes)
|
||||||
|
func handlePlayEvent() -> Void
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ extension AudioPlayer {
|
|||||||
// Return the player time until sleep
|
// Return the player time until sleep
|
||||||
var sleepTimeRemaining: Double? = nil
|
var sleepTimeRemaining: Double? = nil
|
||||||
if let chapterStopAt = self.sleepTimeChapterStopAt {
|
if let chapterStopAt = self.sleepTimeChapterStopAt {
|
||||||
sleepTimeRemaining = (chapterStopAt - currentTime) / Double(self.rate > 0 ? self.rate : 1.0)
|
sleepTimeRemaining = (chapterStopAt - currentTime) / Double(self.rateManager.rate > 0 ? self.rateManager.rate : 1.0)
|
||||||
} else if self.isCountdownSleepTimerSet() {
|
} else if self.isCountdownSleepTimerSet() {
|
||||||
sleepTimeRemaining = self.sleepTimeRemaining
|
sleepTimeRemaining = self.sleepTimeRemaining
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
//
|
||||||
|
// DefaultedAudioPlayerRateManager.swift
|
||||||
|
// Audiobookshelf
|
||||||
|
//
|
||||||
|
// Created by Marke Hallowell on 4/14/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
@available(iOS 16.0, *)
|
||||||
|
class DefaultedAudioPlayerRateManager: NSObject, AudioPlayerRateManager {
|
||||||
|
internal let logger = AppLogger(category: "DefaultedAudioPlayerRateManager")
|
||||||
|
|
||||||
|
internal var audioPlayer: AVPlayer
|
||||||
|
|
||||||
|
// MARK: - AudioPlayerRateManager
|
||||||
|
public private(set) var defaultRate: Float
|
||||||
|
public private(set) var rate: Float
|
||||||
|
public var rateChangedCompletion: () -> Void
|
||||||
|
public var defaultRateChangedCompletion: () -> Void
|
||||||
|
|
||||||
|
required init(audioPlayer: AVPlayer, defaultRate: Float) {
|
||||||
|
self.audioPlayer = audioPlayer
|
||||||
|
self.rateChangedCompletion = {}
|
||||||
|
self.defaultRateChangedCompletion = {}
|
||||||
|
self.rate = self.audioPlayer.rate
|
||||||
|
self.defaultRate = defaultRate
|
||||||
|
self.audioPlayer.defaultRate = defaultRate
|
||||||
|
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
NotificationCenter.default.addObserver(self, selector: #selector(handleObservedRateChange), name: AVPlayer.rateDidChangeNotification, object: self.audioPlayer)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setPlaybackRate(_ rate: Float) {
|
||||||
|
self.handlePlaybackRateChange(rate, observed: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No-op (player automatically resumes at last-known defaultRate)
|
||||||
|
public func handlePlayEvent() { }
|
||||||
|
|
||||||
|
// MARK: - Destructor
|
||||||
|
public func destroy() {
|
||||||
|
NotificationCenter.default.removeObserver(self, name: AVPlayer.rateDidChangeNotification, object: self.audioPlayer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internal
|
||||||
|
internal func handlePlaybackRateChange(_ rate: Float, observed: Bool = false) {
|
||||||
|
let playbackSpeedChanged = rate > 0.0 && rate != self.defaultRate
|
||||||
|
|
||||||
|
if playbackSpeedChanged {
|
||||||
|
self.defaultRate = rate
|
||||||
|
self.audioPlayer.defaultRate = rate
|
||||||
|
self.defaultRateChangedCompletion()
|
||||||
|
|
||||||
|
// Check to see if we also need to make a temporary rate change to player
|
||||||
|
if self.audioPlayer.rate > 0.0 {
|
||||||
|
if self.audioPlayer.rate != rate {
|
||||||
|
self.rate = rate
|
||||||
|
self.audioPlayer.rate = rate
|
||||||
|
self.rateChangedCompletion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.rate = rate
|
||||||
|
self.rateChangedCompletion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - iOS rate change notification handler
|
||||||
|
@objc internal func handleObservedRateChange(notification: Notification) {
|
||||||
|
// TODO: Consider handling cases individually (e.g. overall session impact?)
|
||||||
|
/*
|
||||||
|
guard let reason = notification.userInfo?[AVPlayer.rateDidChangeReasonKey] as? AVPlayer.RateDidChangeReason else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch reason {
|
||||||
|
case .appBackgrounded:
|
||||||
|
// App transitioned to the background.
|
||||||
|
case .audioSessionInterrupted:
|
||||||
|
// The system interrupts the app's audio session.
|
||||||
|
case .setRateCalled:
|
||||||
|
// The app set the player's rate.
|
||||||
|
case .setRateFailed:
|
||||||
|
// An attempt to change the player's rate failed.
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
self.handlePlaybackRateChange(self.audioPlayer.rate, observed: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//
|
||||||
|
// LegacyAudioPlayerRateManager.swift
|
||||||
|
// Audiobookshelf
|
||||||
|
//
|
||||||
|
// Created by Marke Hallowell on 4/14/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
class LegacyAudioPlayerRateManager: NSObject, AudioPlayerRateManager {
|
||||||
|
internal let logger = AppLogger(category: "AudioPlayer")
|
||||||
|
|
||||||
|
internal var audioPlayer: AVPlayer
|
||||||
|
|
||||||
|
internal var managerContext = 0
|
||||||
|
|
||||||
|
// MARK: - AudioPlayerRateManager
|
||||||
|
public private(set) var defaultRate: Float
|
||||||
|
public private(set) var rate: Float
|
||||||
|
public var rateChangedCompletion: () -> Void
|
||||||
|
public var defaultRateChangedCompletion: () -> Void
|
||||||
|
|
||||||
|
required init(audioPlayer: AVPlayer, defaultRate: Float) {
|
||||||
|
self.rate = 0.0
|
||||||
|
self.defaultRate = defaultRate
|
||||||
|
self.audioPlayer = audioPlayer
|
||||||
|
self.rateChangedCompletion = {}
|
||||||
|
self.defaultRateChangedCompletion = {}
|
||||||
|
|
||||||
|
super.init()
|
||||||
|
|
||||||
|
self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.rate), options: .new, context: &managerContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setPlaybackRate(_ rate: Float) {
|
||||||
|
self.handlePlaybackRateChange(rate, observed: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func handlePlayEvent() {
|
||||||
|
DispatchQueue.runOnMainQueue {
|
||||||
|
self.audioPlayer.rate = self.defaultRate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Destructor
|
||||||
|
public func destroy() {
|
||||||
|
// Remove Observer
|
||||||
|
self.audioPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.rate), context: &managerContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internal
|
||||||
|
internal func handlePlaybackRateChange(_ rate: Float, observed: Bool = false) {
|
||||||
|
let playbackSpeedChanged = rate > 0.0 && rate != self.defaultRate && !(observed && rate == 1)
|
||||||
|
|
||||||
|
if self.audioPlayer.rate != rate {
|
||||||
|
logger.log("setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)")
|
||||||
|
DispatchQueue.runOnMainQueue {
|
||||||
|
self.audioPlayer.rate = rate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.rate = rate
|
||||||
|
self.rateChangedCompletion()
|
||||||
|
|
||||||
|
if playbackSpeedChanged {
|
||||||
|
self.defaultRate = rate
|
||||||
|
self.defaultRateChangedCompletion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Observer
|
||||||
|
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
||||||
|
if context == &managerContext {
|
||||||
|
if keyPath == #keyPath(AVPlayer.rate) {
|
||||||
|
logger.log("playerContext observer player rate")
|
||||||
|
self.handlePlaybackRateChange(change?[.newKey] as? Float ?? 1.0, observed: true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ class PlayerHandler {
|
|||||||
public static var paused: Bool {
|
public static var paused: Bool {
|
||||||
get {
|
get {
|
||||||
guard let player = player else { return true }
|
guard let player = player else { return true }
|
||||||
return player.rate == 0.0
|
return player.rateManager.rate == 0.0
|
||||||
}
|
}
|
||||||
set(paused) {
|
set(paused) {
|
||||||
if paused {
|
if paused {
|
||||||
@@ -61,6 +61,10 @@ class PlayerHandler {
|
|||||||
self.player?.setPlaybackRate(speed)
|
self.player?.setPlaybackRate(speed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static func setChapterTrack() {
|
||||||
|
self.player?.setChapterTrack()
|
||||||
|
}
|
||||||
|
|
||||||
public static func getSleepTimeRemaining() -> Double? {
|
public static func getSleepTimeRemaining() -> Double? {
|
||||||
return self.player?.getSleepTimeRemaining()
|
return self.player?.getSleepTimeRemaining()
|
||||||
}
|
}
|
||||||
@@ -131,6 +135,10 @@ class PlayerHandler {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static func updateRemoteTransportControls() {
|
||||||
|
self.player?.setupRemoteTransportControls()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Helper logic
|
// MARK: - Helper logic
|
||||||
|
|
||||||
private static func cleanupOldSessions(currentSessionId: String?) {
|
private static func cleanupOldSessions(currentSessionId: String?) {
|
||||||
|
|||||||
@@ -190,10 +190,10 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static func reportAllLocalPlaybackSessions(_ sessions: [PlaybackSession]) async -> Bool {
|
public static func reportAllLocalPlaybackSessions(_ sessions: [PlaybackSession]) async -> Bool {
|
||||||
return await postResource(endpoint: "api/session/local-all", parameters: LocalPlaybackSessionSyncAllPayload(sessions: sessions))
|
return await postResource(endpoint: "api/session/local-all", parameters: LocalPlaybackSessionSyncAllPayload(sessions: sessions, deviceInfo: sessions.first?.deviceInfo))
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func syncLocalSessionsWithServer() async {
|
public static func syncLocalSessionsWithServer(isFirstSync: Bool) async {
|
||||||
do {
|
do {
|
||||||
// Sync server progress with local media progress
|
// Sync server progress with local media progress
|
||||||
let localMediaProgressList = Database.shared.getAllLocalMediaProgress().filter {
|
let localMediaProgressList = Database.shared.getAllLocalMediaProgress().filter {
|
||||||
@@ -232,13 +232,15 @@ class ApiClient {
|
|||||||
let playbackSessions = Database.shared.getAllPlaybackSessions().filter {
|
let playbackSessions = Database.shared.getAllPlaybackSessions().filter {
|
||||||
$0.serverConnectionConfigId == Store.serverConfig?.id
|
$0.serverConnectionConfigId == Store.serverConfig?.id
|
||||||
}.map { $0.freeze() }
|
}.map { $0.freeze() }
|
||||||
logger.log("syncLocalSessionsWithServer: Found \(playbackSessions.count) playback sessions for server")
|
logger.log("syncLocalSessionsWithServer: Found \(playbackSessions.count) playback sessions for server (first sync: \(isFirstSync))")
|
||||||
if (!playbackSessions.isEmpty) {
|
if (!playbackSessions.isEmpty) {
|
||||||
let success = await ApiClient.reportAllLocalPlaybackSessions(playbackSessions)
|
let success = await ApiClient.reportAllLocalPlaybackSessions(playbackSessions)
|
||||||
if (success) {
|
if (success) {
|
||||||
// Remove sessions from db
|
// Remove sessions from db
|
||||||
try playbackSessions.forEach { session in
|
try playbackSessions.forEach { session in
|
||||||
if (!session.isActiveSession) {
|
logger.log("syncLocalSessionsWithServer: Handling \(session.displayTitle ?? "") (\(session.id)) \(session.isActiveSession)")
|
||||||
|
// On first sync then remove all sessions
|
||||||
|
if (!session.isActiveSession || isFirstSync) {
|
||||||
if let session = session.thaw() {
|
if let session = session.thaw() {
|
||||||
try session.delete()
|
try session.delete()
|
||||||
}
|
}
|
||||||
@@ -327,6 +329,7 @@ struct LocalMediaProgressSyncResultsPayload: Codable {
|
|||||||
|
|
||||||
struct LocalPlaybackSessionSyncAllPayload: Codable {
|
struct LocalPlaybackSessionSyncAllPayload: Codable {
|
||||||
var sessions: [PlaybackSession]
|
var sessions: [PlaybackSession]
|
||||||
|
var deviceInfo: [String: String?]?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Connectivity {
|
struct Connectivity {
|
||||||
|
|||||||
@@ -53,14 +53,26 @@ class NowPlayingInfo {
|
|||||||
self.setMetadata(artwork: artwork, metadata: metadata)
|
self.setMetadata(artwork: artwork, metadata: metadata)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public func update(duration: Double, currentTime: Double, rate: Float) {
|
public func update(duration: Double, currentTime: Double, rate: Float, defaultRate: Float, chapterName: String? = nil, chapterNumber: Int? = nil, chapterCount: Int? = nil) {
|
||||||
// Update on the main to prevent access collisions
|
// Update on the main to prevent access collisions
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
if let self = self {
|
if let self = self {
|
||||||
self.nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration
|
self.nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration
|
||||||
self.nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
|
self.nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
|
||||||
self.nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = rate
|
self.nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = rate
|
||||||
self.nowPlayingInfo[MPNowPlayingInfoPropertyDefaultPlaybackRate] = 1.0
|
self.nowPlayingInfo[MPNowPlayingInfoPropertyDefaultPlaybackRate] = defaultRate
|
||||||
|
|
||||||
|
|
||||||
|
if let chapterName = chapterName, let chapterNumber = chapterNumber, let chapterCount = chapterCount {
|
||||||
|
self.nowPlayingInfo[MPMediaItemPropertyTitle] = chapterName
|
||||||
|
self.nowPlayingInfo[MPNowPlayingInfoPropertyChapterNumber] = chapterNumber
|
||||||
|
self.nowPlayingInfo[MPNowPlayingInfoPropertyChapterCount] = chapterCount
|
||||||
|
} else {
|
||||||
|
// Set the title back to the book title
|
||||||
|
self.nowPlayingInfo[MPMediaItemPropertyTitle] = self.nowPlayingInfo[MPMediaItemPropertyAlbumTitle]
|
||||||
|
self.nowPlayingInfo[MPNowPlayingInfoPropertyChapterNumber] = nil
|
||||||
|
self.nowPlayingInfo[MPNowPlayingInfoPropertyChapterCount] = nil
|
||||||
|
}
|
||||||
|
|
||||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = self.nowPlayingInfo
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = self.nowPlayingInfo
|
||||||
}
|
}
|
||||||
@@ -89,7 +101,7 @@ class NowPlayingInfo {
|
|||||||
|
|
||||||
nowPlayingInfo[MPMediaItemPropertyTitle] = metadata!.title
|
nowPlayingInfo[MPMediaItemPropertyTitle] = metadata!.title
|
||||||
nowPlayingInfo[MPMediaItemPropertyArtist] = metadata!.author ?? "unknown"
|
nowPlayingInfo[MPMediaItemPropertyArtist] = metadata!.author ?? "unknown"
|
||||||
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = metadata!.series
|
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = metadata!.title
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shouldFetchCover(id: String) -> Bool {
|
private func shouldFetchCover(id: String) -> Bool {
|
||||||
|
|||||||
+6
-9
@@ -45,7 +45,7 @@ export default {
|
|||||||
if (timeSinceDisconnect > 5000) {
|
if (timeSinceDisconnect > 5000) {
|
||||||
console.log('Time since disconnect was', timeSinceDisconnect, 'sync with server')
|
console.log('Time since disconnect was', timeSinceDisconnect, 'sync with server')
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.syncLocalSessions()
|
this.syncLocalSessions(false)
|
||||||
}, 4000)
|
}, 4000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,7 +139,7 @@ export default {
|
|||||||
|
|
||||||
console.log(`[default] Got server config, attempt authorize ${serverConfig.address}`)
|
console.log(`[default] Got server config, attempt authorize ${serverConfig.address}`)
|
||||||
|
|
||||||
const authRes = await this.postRequest(`${serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${serverConfig.token}` }, 10000).catch((error) => {
|
const authRes = await this.postRequest(`${serverConfig.address}/api/authorize`, null, { Authorization: `Bearer ${serverConfig.token}` }, 6000).catch((error) => {
|
||||||
console.error('[default] Server auth failed', error)
|
console.error('[default] Server auth failed', error)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
@@ -195,14 +195,14 @@ export default {
|
|||||||
this.$eventBus.$emit('library-changed')
|
this.$eventBus.$emit('library-changed')
|
||||||
this.inittingLibraries = false
|
this.inittingLibraries = false
|
||||||
},
|
},
|
||||||
async syncLocalSessions() {
|
async syncLocalSessions(isFirstSync) {
|
||||||
if (!this.user) {
|
if (!this.user) {
|
||||||
console.log('[default] No need to sync local sessions - not connected to server')
|
console.log('[default] No need to sync local sessions - not connected to server')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[default] Calling syncLocalSessions')
|
console.log('[default] Calling syncLocalSessions')
|
||||||
const response = await this.$db.syncLocalSessionsWithServer()
|
const response = await this.$db.syncLocalSessionsWithServer(isFirstSync)
|
||||||
if (response?.error) {
|
if (response?.error) {
|
||||||
console.error('[default] Failed to sync local sessions', response.error)
|
console.error('[default] Failed to sync local sessions', response.error)
|
||||||
} else {
|
} else {
|
||||||
@@ -212,8 +212,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
userUpdated(user) {
|
userUpdated(user) {
|
||||||
// console.log('[default] userUpdated:', JSON.stringify(user))
|
if (this.user?.id == user.id) {
|
||||||
if (this.user && this.user.id == user.id) {
|
|
||||||
this.$store.commit('user/setUser', user)
|
this.$store.commit('user/setUser', user)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -326,8 +325,6 @@ export default {
|
|||||||
|
|
||||||
await this.$store.dispatch('setupNetworkListener')
|
await this.$store.dispatch('setupNetworkListener')
|
||||||
|
|
||||||
await this.$store.dispatch('globals/loadLocalMediaProgress')
|
|
||||||
|
|
||||||
if (this.$store.state.user.serverConnectionConfig) {
|
if (this.$store.state.user.serverConnectionConfig) {
|
||||||
await this.initLibraries()
|
await this.initLibraries()
|
||||||
} else {
|
} else {
|
||||||
@@ -335,7 +332,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[default] finished connection attempt or already connected ${!!this.user}`)
|
console.log(`[default] finished connection attempt or already connected ${!!this.user}`)
|
||||||
await this.syncLocalSessions()
|
await this.syncLocalSessions(true)
|
||||||
|
|
||||||
this.hasMounted = true
|
this.hasMounted = true
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Dialog } from '@capacitor/dialog';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
methods: {
|
||||||
|
async checkCellularPermission(actionType) {
|
||||||
|
if (this.$store.state.networkConnectionType !== 'cellular') return true
|
||||||
|
|
||||||
|
let permission;
|
||||||
|
if (actionType === 'download') {
|
||||||
|
permission = this.$store.getters['getCanDownloadUsingCellular']
|
||||||
|
if (permission === 'NEVER') {
|
||||||
|
this.$toast.error(this.$strings.ToastDownloadNotAllowedOnCellular)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else if (actionType === 'streaming') {
|
||||||
|
permission = this.$store.getters['getCanStreamingUsingCellular']
|
||||||
|
if (permission === 'NEVER') {
|
||||||
|
this.$toast.error(this.$strings.ToastStreamingNotAllowedOnCellular)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission === 'ASK') {
|
||||||
|
const confirmed = await this.confirmAction(actionType)
|
||||||
|
return confirmed
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
async confirmAction(actionType) {
|
||||||
|
const message = actionType === 'download' ?
|
||||||
|
this.$strings.MessageConfirmDownloadUsingCellular :
|
||||||
|
this.$strings.MessageConfirmStreamingUsingCellular
|
||||||
|
|
||||||
|
const { value } = await Dialog.confirm({
|
||||||
|
title: 'Confirm',
|
||||||
|
message
|
||||||
|
})
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-5
@@ -2,7 +2,8 @@ const pkg = require('./package.json')
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
|
target: 'static',
|
||||||
|
telemetry: false,
|
||||||
env: {
|
env: {
|
||||||
PROD: '1',
|
PROD: '1',
|
||||||
ANDROID_APP_URL: 'https://play.google.com/store/apps/details?id=com.audiobookshelf.app',
|
ANDROID_APP_URL: 'https://play.google.com/store/apps/details?id=com.audiobookshelf.app',
|
||||||
@@ -35,6 +36,7 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
css: [
|
css: [
|
||||||
|
'@/assets/tailwind.css',
|
||||||
'@/assets/app.css'
|
'@/assets/app.css'
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -55,10 +57,6 @@ export default {
|
|||||||
|
|
||||||
components: true,
|
components: true,
|
||||||
|
|
||||||
buildModules: [
|
|
||||||
'@nuxtjs/tailwindcss',
|
|
||||||
],
|
|
||||||
|
|
||||||
modules: [
|
modules: [
|
||||||
'@nuxtjs/axios'
|
'@nuxtjs/axios'
|
||||||
],
|
],
|
||||||
@@ -66,6 +64,12 @@ export default {
|
|||||||
axios: {},
|
axios: {},
|
||||||
|
|
||||||
build: {
|
build: {
|
||||||
|
postcss: {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
babel: {
|
babel: {
|
||||||
plugins: [['@babel/plugin-proposal-private-property-in-object', { loose: true }]],
|
plugins: [['@babel/plugin-proposal-private-property-in-object', { loose: true }]],
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+2374
-17835
File diff suppressed because it is too large
Load Diff
+5
-4
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-app",
|
"name": "audiobookshelf-app",
|
||||||
"version": "0.9.70-beta",
|
"version": "0.9.76-beta",
|
||||||
"author": "advplyr",
|
"author": "advplyr",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
|
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"ionic:serve": "npm run start"
|
"ionic:serve": "npm run start"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@byteowls/capacitor-filesharer": "^5.0.0",
|
||||||
"@capacitor/android": "^5.0.0",
|
"@capacitor/android": "^5.0.0",
|
||||||
"@capacitor/app": "^5.0.6",
|
"@capacitor/app": "^5.0.6",
|
||||||
"@capacitor/browser": "^5.1.0",
|
"@capacitor/browser": "^5.1.0",
|
||||||
@@ -43,8 +44,8 @@
|
|||||||
"@babel/core": "7.13.15",
|
"@babel/core": "7.13.15",
|
||||||
"@babel/preset-env": "7.13.15",
|
"@babel/preset-env": "7.13.15",
|
||||||
"@capacitor/cli": "^5.0.0",
|
"@capacitor/cli": "^5.0.0",
|
||||||
"@nuxtjs/tailwindcss": "^4.2.1",
|
"autoprefixer": "^10.4.17",
|
||||||
"postcss": "^8.3.5",
|
"postcss": "^8.4.33",
|
||||||
"tailwindcss": "^3.3.2"
|
"tailwindcss": "^3.4.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<div id="bookshelf" class="w-full h-full p-4 overflow-y-auto">
|
<div id="bookshelf" class="w-full h-full p-4 overflow-y-auto">
|
||||||
<div class="flex flex-wrap justify-center">
|
<div class="flex flex-wrap justify-center">
|
||||||
<template v-for="author in authors">
|
<template v-for="author in authors">
|
||||||
<cards-author-card :key="author.id" :author="author" :width="96" :height="120" class="p-2" />
|
<cards-author-card :key="author.id" :author="author" :width="cardWidth" :height="cardHeight" class="p-2" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -16,16 +16,21 @@ export default {
|
|||||||
return {
|
return {
|
||||||
loading: true,
|
loading: true,
|
||||||
authors: [],
|
authors: [],
|
||||||
loadedLibraryId: null
|
loadedLibraryId: null,
|
||||||
|
cardWidth: 200
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
cardHeight() {
|
||||||
|
return this.cardWidth * 1.25
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async init() {
|
async init() {
|
||||||
|
this.cardWidth = (window.innerWidth - 64) / 2
|
||||||
if (!this.currentLibraryId) {
|
if (!this.currentLibraryId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ export default {
|
|||||||
console.log('[categories] Local shelves set', this.shelves.length, this.lastLocalFetch)
|
console.log('[categories] Local shelves set', this.shelves.length, this.lastLocalFetch)
|
||||||
|
|
||||||
if (isConnectedToServerWithInternet) {
|
if (isConnectedToServerWithInternet) {
|
||||||
const categories = await this.$nativeHttp.get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1&include=rssfeed,numEpisodesIncomplete`).catch((error) => {
|
const categories = await this.$nativeHttp.get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1&include=rssfeed,numEpisodesIncomplete`, { connectTimeout: 10000 }).catch((error) => {
|
||||||
console.error('[categories] Failed to fetch categories', error)
|
console.error('[categories] Failed to fetch categories', error)
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
@@ -327,8 +327,13 @@ export default {
|
|||||||
this.$eventBus.$off('library-changed', this.libraryChanged)
|
this.$eventBus.$off('library-changed', this.libraryChanged)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
async mounted() {
|
||||||
|
if (this.$route.query.error) {
|
||||||
|
this.$toast.error(this.$route.query.error)
|
||||||
|
}
|
||||||
|
|
||||||
this.initListeners()
|
this.initListeners()
|
||||||
|
await this.$store.dispatch('globals/loadLocalMediaProgress')
|
||||||
console.log(`[categories] mounted so fetching categories`)
|
console.log(`[categories] mounted so fetching categories`)
|
||||||
this.fetchCategories()
|
this.fetchCategories()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<h1 class="text-xl mb-2 font-semibold">{{ $strings.HeaderLatestEpisodes }}</h1>
|
<h1 class="text-xl mb-2 font-semibold">{{ $strings.HeaderLatestEpisodes }}</h1>
|
||||||
|
|
||||||
<template v-for="episode in recentEpisodes">
|
<template v-for="episode in recentEpisodes">
|
||||||
<tables-podcast-latest-episode-row :episode="episode" :local-episode="localEpisodeMap[episode.id]" :library-item-id="episode.libraryItemId" :local-library-item-id="null" :is-local="isLocal" :key="episode.id" @addToPlaylist="addEpisodeToPlaylist" />
|
<tables-podcast-latest-episode-row :episode="episode" :local-episode="localEpisodeMap[episode.id]" :library-item-id="episode.libraryItemId" :local-library-item-id="localEpisodeMap[episode.id]?.localLibraryItemId" :key="episode.id" @addToPlaylist="addEpisodeToPlaylist" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -17,7 +17,6 @@ export default {
|
|||||||
totalEpisodes: 0,
|
totalEpisodes: 0,
|
||||||
currentPage: 0,
|
currentPage: 0,
|
||||||
localLibraryItems: [],
|
localLibraryItems: [],
|
||||||
isLocal: false,
|
|
||||||
loadedLibraryId: null
|
loadedLibraryId: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -30,7 +29,10 @@ export default {
|
|||||||
const episodes = []
|
const episodes = []
|
||||||
this.localLibraryItems.forEach((li) => {
|
this.localLibraryItems.forEach((li) => {
|
||||||
if (li.media.episodes?.length) {
|
if (li.media.episodes?.length) {
|
||||||
episodes.push(...li.media.episodes)
|
li.media.episodes.map((ep) => {
|
||||||
|
ep.localLibraryItemId = li.id
|
||||||
|
episodes.push(ep)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return episodes
|
return episodes
|
||||||
|
|||||||
+1
-5
@@ -34,11 +34,7 @@ export default {
|
|||||||
deviceData: null
|
deviceData: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {},
|
||||||
networkConnected() {
|
|
||||||
return this.$store.state.networkConnected
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
methods: {
|
||||||
async init() {
|
async init() {
|
||||||
this.deviceData = await this.$db.getDeviceData()
|
this.deviceData = await this.$db.getDeviceData()
|
||||||
|
|||||||
@@ -23,6 +23,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="localLibraryItems.length" class="mt-4 text-sm text-fg-muted">
|
||||||
|
{{ $strings.LabelTotalSize }}: {{ $bytesPretty(localLibraryItems.reduce((acc, item) => acc + item.size, 0)) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
</ui-btn>
|
</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-sm text-fg mt-1.5 mb-0.5 default-style" v-html="description" />
|
<p class="text-sm text-fg mt-1.5 mb-0.5 default-style description-container" v-html="transformedDescription"></p>
|
||||||
|
|
||||||
<!-- loading overlay -->
|
<!-- loading overlay -->
|
||||||
<div v-if="processing" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-30 flex items-center justify-center">
|
<div v-if="processing" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-30 flex items-center justify-center">
|
||||||
@@ -56,6 +56,7 @@
|
|||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
import { Dialog } from '@capacitor/dialog'
|
import { Dialog } from '@capacitor/dialog'
|
||||||
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
|
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
|
||||||
|
import cellularPermissionHelpers from '@/mixins/cellularPermissionHelpers'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
async asyncData({ store, params, redirect, app }) {
|
async asyncData({ store, params, redirect, app }) {
|
||||||
@@ -115,7 +116,11 @@ export default {
|
|||||||
startingDownload: false
|
startingDownload: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
mixins: [cellularPermissionHelpers],
|
||||||
computed: {
|
computed: {
|
||||||
|
transformedDescription() {
|
||||||
|
return this.parseDescription(this.description)
|
||||||
|
},
|
||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
@@ -310,6 +315,24 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
parseDescription(description) {
|
||||||
|
const timeMarkerLinkRegex = /<a href="#([^"]*?\b\d{1,2}:\d{1,2}(?::\d{1,2})?)">(.*?)<\/a>/g
|
||||||
|
const timeMarkerRegex = /\b\d{1,2}:\d{1,2}(?::\d{1,2})?\b/g
|
||||||
|
|
||||||
|
function convertToSeconds(time) {
|
||||||
|
const timeParts = time.split(':').map(Number)
|
||||||
|
return timeParts.reduce((acc, part, index) => acc * 60 + part, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return description.replace(timeMarkerLinkRegex, (match, href, displayTime) => {
|
||||||
|
const time = displayTime.match(timeMarkerRegex)[0]
|
||||||
|
const seekTimeInSeconds = convertToSeconds(time)
|
||||||
|
return `<span class="time-marker cursor-pointer text-blue-400 hover:text-blue-300" data-time="${seekTimeInSeconds}">${displayTime}</span>`
|
||||||
|
}).replace(timeMarkerRegex, (match) => {
|
||||||
|
const seekTimeInSeconds = convertToSeconds(match)
|
||||||
|
return `<span class="time-marker cursor-pointer text-blue-400 hover:text-blue-300" data-time="${seekTimeInSeconds}">${match}</span>`
|
||||||
|
})
|
||||||
|
},
|
||||||
async deleteLocalEpisode() {
|
async deleteLocalEpisode() {
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
|
|
||||||
@@ -351,28 +374,56 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
this.$store.commit('setPlayerIsStartingPlayback', this.episode.id)
|
this.$store.commit('setPlayerIsStartingPlayback', this.episode.id)
|
||||||
|
|
||||||
if (this.localEpisodeId && this.localLibraryItemId && !this.isLocal) {
|
const playbackData = this.generatePlaybackData()
|
||||||
console.log('Play local episode', this.localEpisodeId, this.localLibraryItemId)
|
this.emitPlayItemEvent(playbackData)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async clickPlaybackTime(event) {
|
||||||
|
const startTime = event.target.getAttribute('data-time')
|
||||||
|
if (this.playerIsStartingPlayback) return
|
||||||
|
|
||||||
this.$eventBus.$emit('play-item', {
|
await this.$hapticsImpact()
|
||||||
libraryItemId: this.localLibraryItemId,
|
|
||||||
episodeId: this.localEpisodeId,
|
this.$store.commit('setPlayerIsStartingPlayback', this.episode.id)
|
||||||
serverLibraryItemId: this.serverLibraryItemId,
|
|
||||||
serverEpisodeId: this.serverEpisodeId
|
const playbackData = this.generatePlaybackData(startTime)
|
||||||
})
|
this.emitPlayItemEvent(playbackData)
|
||||||
} else {
|
},
|
||||||
this.$eventBus.$emit('play-item', {
|
generatePlaybackData(startTime) {
|
||||||
libraryItemId: this.libraryItemId,
|
const playbackData = {
|
||||||
episodeId: this.episode.id,
|
libraryItemId: this.libraryItemId,
|
||||||
serverLibraryItemId: this.serverLibraryItemId,
|
episodeId: this.episode.id,
|
||||||
serverEpisodeId: this.serverEpisodeId
|
serverLibraryItemId: this.serverLibraryItemId,
|
||||||
})
|
serverEpisodeId: this.serverEpisodeId,
|
||||||
}
|
startTime
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.localEpisodeId && this.localLibraryItemId && !this.isLocal) {
|
||||||
|
playbackData.libraryItemId = this.localLibraryItemId
|
||||||
|
playbackData.episodeId = this.localEpisodeId
|
||||||
|
}
|
||||||
|
|
||||||
|
return playbackData
|
||||||
|
},
|
||||||
|
emitPlayItemEvent(playbackData) {
|
||||||
|
this.$eventBus.$emit('play-item', playbackData)
|
||||||
|
},
|
||||||
|
bindTimeMarkerEvents() {
|
||||||
|
const container = document.querySelector('.description-container')
|
||||||
|
if (container) {
|
||||||
|
container.addEventListener('click', (event) => {
|
||||||
|
if (event.target.classList.contains('time-marker')) {
|
||||||
|
this.clickPlaybackTime(event)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async downloadClick() {
|
async downloadClick() {
|
||||||
if (this.downloadItem || this.startingDownload) return
|
if (this.downloadItem || this.startingDownload) return
|
||||||
|
|
||||||
|
const hasPermission = await this.checkCellularPermission('download')
|
||||||
|
if (!hasPermission) return
|
||||||
|
|
||||||
this.startingDownload = true
|
this.startingDownload = true
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.startingDownload = false
|
this.startingDownload = false
|
||||||
@@ -557,9 +608,13 @@ export default {
|
|||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem)
|
this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem)
|
||||||
|
this.bindTimeMarkerEvents()
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.$eventBus.$off('new-local-library-item', this.newLocalLibraryItem)
|
this.$eventBus.$off('new-local-library-item', this.newLocalLibraryItem)
|
||||||
|
document.querySelectorAll('.time-marker').forEach(marker => {
|
||||||
|
marker.removeEventListener('click', this.clickPlaybackTime)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
+251
-186
@@ -1,149 +1,166 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="item-page" class="w-full h-full px-3 pb-4 overflow-y-auto overflow-x-hidden relative bg-bg">
|
<div v-if="!libraryItem" class="w-full h-full relative flex items-center justify-center bg-bg">
|
||||||
<div class="fixed top-0 left-0 w-full h-full pointer-events-none p-px z-10">
|
<ui-loading-indicator />
|
||||||
<div class="w-full h-full" :style="{ backgroundColor: coverRgb }" />
|
</div>
|
||||||
<div class="w-full h-full absolute top-0 left-0" style="background: var(--gradient-item-page)" />
|
<div v-else id="item-page" class="w-full h-full overflow-y-auto overflow-x-hidden relative bg-bg">
|
||||||
</div>
|
<!-- cover -->
|
||||||
|
<div class="w-full flex justify-center relative">
|
||||||
<div class="z-10 relative">
|
<div style="width: 0; transform: translateX(-50vw); overflow: visible">
|
||||||
<!-- cover -->
|
<div style="width: 150vw; overflow: hidden">
|
||||||
<div class="w-full flex justify-center relative mb-4">
|
<div id="coverBg" style="filter: blur(5vw)">
|
||||||
<div style="width: 0; transform: translateX(-50vw); overflow: visible">
|
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" @imageLoaded="coverImageLoaded" />
|
||||||
<div style="width: 150vw; overflow: hidden">
|
|
||||||
<div id="coverBg" style="filter: blur(5vw)">
|
|
||||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" @imageLoaded="coverImageLoaded" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative" @click="showFullscreenCover = true">
|
|
||||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" no-bg raw @imageLoaded="coverImageLoaded" />
|
|
||||||
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * progressPercent + 'px' }"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="relative" @click="showFullscreenCover = true">
|
||||||
<!-- title -->
|
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" no-bg raw @imageLoaded="coverImageLoaded" />
|
||||||
<div class="text-center mb-2">
|
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * progressPercent + 'px' }"></div>
|
||||||
<h1 class="text-xl font-semibold">{{ title }}</h1>
|
|
||||||
<p v-if="subtitle" class="text-fg text-base">{{ subtitle }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="hasLocal" class="mx-1">
|
|
||||||
<div v-if="isLocalOnly" class="w-full rounded-md bg-warning/10 border border-warning p-4">
|
|
||||||
<p class="text-sm">{{ $strings.MessageMediaNotLinkedToServer }}</p>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="currentServerConnectionConfigId && !isLocalMatchingServerAddress" class="w-full rounded-md bg-warning/10 border border-warning p-4">
|
|
||||||
<p class="text-sm">{{ $getString('MessageMediaLinkedToADifferentServer', [localLibraryItem.serverAddress]) }}</p>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="currentServerConnectionConfigId && !isLocalMatchingUser" class="w-full rounded-md bg-warning/10 border border-warning p-4">
|
|
||||||
<p class="text-sm">{{ $strings.MessageMediaLinkedToADifferentUser }}</p>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="currentServerConnectionConfigId && !isLocalMatchingConnectionConfig" class="w-full rounded-md bg-warning/10 border border-warning p-4">
|
|
||||||
<p class="text-sm">Media is linked to a different server connection config. Downloaded User Id: {{ localLibraryItem.serverUserId }}. Downloaded Server Address: {{ localLibraryItem.serverAddress }}. Currently connected User Id: {{ user.id }}. Currently connected server address: {{ currentServerAddress }}.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- action buttons -->
|
|
||||||
<div class="col-span-full">
|
|
||||||
<div v-if="showPlay || showRead" class="flex mt-4 -mx-1">
|
|
||||||
<ui-btn v-if="showPlay" color="success" class="flex items-center justify-center flex-grow mx-1" :loading="playerIsStartingForThisMedia" :padding-x="4" @click="playClick">
|
|
||||||
<span class="material-icons">{{ playerIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
|
||||||
<span class="px-1 text-sm">{{ playerIsPlaying ? $strings.ButtonPause : isPodcast ? $strings.ButtonNextEpisode : hasLocal ? $strings.ButtonPlay : $strings.ButtonStream }}</span>
|
|
||||||
</ui-btn>
|
|
||||||
<ui-btn v-if="showRead" color="info" class="flex items-center justify-center mx-1" :class="showPlay ? '' : 'flex-grow'" :padding-x="2" @click="readBook">
|
|
||||||
<span class="material-icons">auto_stories</span>
|
|
||||||
<span v-if="!showPlay" class="px-2 text-base">{{ $strings.ButtonRead }} {{ ebookFormat }}</span>
|
|
||||||
</ui-btn>
|
|
||||||
<ui-btn v-if="showDownload" :color="downloadItem ? 'warning' : 'primary'" class="flex items-center justify-center mx-1" :padding-x="2" @click="downloadClick">
|
|
||||||
<span class="material-icons" :class="(downloadItem || startingDownload) ? 'animate-pulse' : ''">{{ (downloadItem || startingDownload) ? 'downloading' : 'download' }}</span>
|
|
||||||
</ui-btn>
|
|
||||||
<ui-btn color="primary" class="flex items-center justify-center mx-1" :padding-x="2" @click="moreButtonPress">
|
|
||||||
<span class="material-icons">more_vert</span>
|
|
||||||
</ui-btn>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-fg mt-4 text-center">
|
|
||||||
<p>{{ $strings.LabelYourProgress }}: {{ Math.round(progressPercent * 100) }}%</p>
|
|
||||||
<p v-if="!useEBookProgress && !userIsFinished" class="text-fg-muted text-xs">{{ $getString('LabelTimeRemaining', [$elapsedPretty(userTimeRemaining)]) }}</p>
|
|
||||||
<p v-else-if="userIsFinished" class="text-fg-muted text-xs">{{ $strings.LabelFinished }} {{ $formatDate(userProgressFinishedAt) }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="downloadItem" class="py-3">
|
|
||||||
<p v-if="downloadItem.itemProgress == 1" class="text-center text-lg">{{ $strings.MessageDownloadCompleteProcessing }}</p>
|
|
||||||
<p v-else class="text-center text-lg">{{ $strings.MessageDownloading }} ({{ Math.round(downloadItem.itemProgress * 100) }}%)</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- metadata -->
|
|
||||||
<div id="metadata" class="grid gap-2 my-2" style>
|
|
||||||
<div v-if="podcastAuthor || (bookAuthors && bookAuthors.length)" class="text-fg-muted uppercase text-sm">{{ $strings.LabelAuthor }}</div>
|
|
||||||
<div v-if="podcastAuthor" class="text-sm">{{ podcastAuthor }}</div>
|
|
||||||
<div v-else-if="bookAuthors && bookAuthors.length" class="text-sm">
|
|
||||||
<template v-for="(author, index) in bookAuthors">
|
|
||||||
<nuxt-link :key="author.id" :to="`/bookshelf/library?filter=authors.${$encode(author.id)}`" class="underline">{{ author.name }}</nuxt-link>
|
|
||||||
<span :key="`${author.id}-comma`" v-if="index < bookAuthors.length - 1">,</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="podcastType" class="text-fg-muted uppercase text-sm">{{ $strings.LabelType }}</div>
|
|
||||||
<div v-if="podcastType" class="text-sm capitalize">{{ podcastType }}</div>
|
|
||||||
|
|
||||||
<div v-if="series && series.length" class="text-fg-muted uppercase text-sm">{{ $strings.LabelSeries }}</div>
|
|
||||||
<div v-if="series && series.length" class="truncate text-sm">
|
|
||||||
<template v-for="(series, index) in seriesList">
|
|
||||||
<nuxt-link :key="series.id" :to="`/bookshelf/series/${series.id}`" class="underline">{{ series.text }}</nuxt-link>
|
|
||||||
<span :key="`${series.id}-comma`" v-if="index < seriesList.length - 1">,</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="numTracks" class="text-fg-muted uppercase text-sm">{{ $strings.LabelDuration }}</div>
|
|
||||||
<div v-if="numTracks" class="text-sm">{{ $elapsedPretty(duration) }}</div>
|
|
||||||
|
|
||||||
<div v-if="narrators && narrators.length" class="text-fg-muted uppercase text-sm">{{ $strings.LabelNarrators }}</div>
|
|
||||||
<div v-if="narrators && narrators.length" class="truncate text-sm">
|
|
||||||
<template v-for="(narrator, index) in narrators">
|
|
||||||
<nuxt-link :key="narrator" :to="`/bookshelf/library?filter=narrators.${$encode(narrator)}`" class="underline">{{ narrator }}</nuxt-link>
|
|
||||||
<span :key="index" v-if="index < narrators.length - 1">,</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="genres.length" class="text-fg-muted uppercase text-sm">{{ $strings.LabelGenres }}</div>
|
|
||||||
<div v-if="genres.length" class="truncate text-sm">
|
|
||||||
<template v-for="(genre, index) in genres">
|
|
||||||
<nuxt-link :key="genre" :to="`/bookshelf/library?filter=genres.${$encode(genre)}`" class="underline">{{ genre }}</nuxt-link>
|
|
||||||
<span :key="index" v-if="index < genres.length - 1">,</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="publishedYear" class="text-fg-muted uppercase text-sm">{{ $strings.LabelPublishYear }}</div>
|
|
||||||
<div v-if="publishedYear" class="text-sm">{{ publishedYear }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="description" class="w-full py-2">
|
|
||||||
<p ref="description" class="text-sm text-justify whitespace-pre-line font-light" :class="{ 'line-clamp-4': !showFullDescription }" style="hyphens: auto">{{ description }}</p>
|
|
||||||
|
|
||||||
<div v-if="descriptionClamped" class="text-fg text-sm py-2" @click="showFullDescription = !showFullDescription">
|
|
||||||
{{ showFullDescription ? 'Read less' : 'Read more' }}
|
|
||||||
<span class="material-icons align-middle text-base -mt-px">{{ showFullDescription ? 'expand_less' : 'expand_more' }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- tables -->
|
|
||||||
<tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" :local-library-item-id="localLibraryItemId" :episodes="episodes" :local-episodes="localLibraryItemEpisodes" :is-local="isLocal" />
|
|
||||||
|
|
||||||
<tables-chapters-table v-if="numChapters" :library-item="libraryItem" @playAtTimestamp="playAtTimestamp" />
|
|
||||||
|
|
||||||
<tables-tracks-table v-if="numTracks" :tracks="tracks" :library-item-id="libraryItemId" />
|
|
||||||
|
|
||||||
<tables-ebook-files-table v-if="ebookFiles.length" :library-item="libraryItem" />
|
|
||||||
|
|
||||||
<!-- modals -->
|
|
||||||
<modals-item-more-menu-modal v-model="showMoreMenu" :library-item="libraryItem" :rss-feed="rssFeed" :processing.sync="processing" />
|
|
||||||
|
|
||||||
<modals-select-local-folder-modal v-model="showSelectLocalFolder" :media-type="mediaType" @select="selectedLocalFolder" />
|
|
||||||
|
|
||||||
<modals-fullscreen-cover v-model="showFullscreenCover" :library-item="libraryItem" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<!-- background gradient -->
|
||||||
|
<div id="item-page-bg-gradient" class="absolute top-0 left-0 w-full pointer-events-none z-0" :style="{ opacity: coverRgb ? 1 : 0 }">
|
||||||
|
<div class="w-full h-full" :style="{ backgroundColor: coverRgb }" />
|
||||||
|
<div class="w-full h-full absolute top-0 left-0" style="background: var(--gradient-item-page)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative z-10 px-3 py-4">
|
||||||
|
<!-- title -->
|
||||||
|
<div class="text-center mb-2">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<h1 class="text-xl font-semibold">{{ title }}</h1>
|
||||||
|
<widgets-explicit-indicator v-if="isExplicit" />
|
||||||
|
</div>
|
||||||
|
<p v-if="subtitle" class="text-fg text-base">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasLocal" class="mx-1">
|
||||||
|
<div v-if="isLocalOnly" class="w-full rounded-md bg-warning/10 border border-warning p-4">
|
||||||
|
<p class="text-sm">{{ $strings.MessageMediaNotLinkedToServer }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="currentServerConnectionConfigId && !isLocalMatchingServerAddress" class="w-full rounded-md bg-warning/10 border border-warning p-4">
|
||||||
|
<p class="text-sm">{{ $getString('MessageMediaLinkedToADifferentServer', [localLibraryItem.serverAddress]) }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="currentServerConnectionConfigId && !isLocalMatchingUser" class="w-full rounded-md bg-warning/10 border border-warning p-4">
|
||||||
|
<p class="text-sm">{{ $strings.MessageMediaLinkedToADifferentUser }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="currentServerConnectionConfigId && !isLocalMatchingConnectionConfig" class="w-full rounded-md bg-warning/10 border border-warning p-4">
|
||||||
|
<p class="text-sm">Media is linked to a different server connection config. Downloaded User Id: {{ localLibraryItem.serverUserId }}. Downloaded Server Address: {{ localLibraryItem.serverAddress }}. Currently connected User Id: {{ user.id }}. Currently connected server address: {{ currentServerAddress }}.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- action buttons -->
|
||||||
|
<div class="col-span-full">
|
||||||
|
<div v-if="showPlay || showRead" class="flex mt-4 -mx-1">
|
||||||
|
<ui-btn v-if="showPlay" color="success" class="flex items-center justify-center flex-grow mx-1" :loading="playerIsStartingForThisMedia" :padding-x="4" @click="playClick">
|
||||||
|
<span class="material-icons">{{ playerIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||||
|
<span class="px-1 text-sm">{{ playerIsPlaying ? $strings.ButtonPause : isPodcast ? $strings.ButtonNextEpisode : hasLocal ? $strings.ButtonPlay : $strings.ButtonStream }}</span>
|
||||||
|
</ui-btn>
|
||||||
|
<ui-btn v-if="showRead" color="info" class="flex items-center justify-center mx-1" :class="showPlay ? '' : 'flex-grow'" :padding-x="2" @click="readBook">
|
||||||
|
<span class="material-icons">auto_stories</span>
|
||||||
|
<span v-if="!showPlay" class="px-2 text-base">{{ $strings.ButtonRead }} {{ ebookFormat }}</span>
|
||||||
|
</ui-btn>
|
||||||
|
<ui-btn v-if="showDownload" :color="downloadItem ? 'warning' : 'primary'" class="flex items-center justify-center mx-1" :padding-x="2" @click="downloadClick">
|
||||||
|
<span class="material-icons" :class="downloadItem || startingDownload ? 'animate-pulse' : ''">{{ downloadItem || startingDownload ? 'downloading' : 'download' }}</span>
|
||||||
|
</ui-btn>
|
||||||
|
<ui-btn color="primary" class="flex items-center justify-center mx-1" :padding-x="2" @click="moreButtonPress">
|
||||||
|
<span class="material-icons">more_vert</span>
|
||||||
|
</ui-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-fg mt-4 text-center">
|
||||||
|
<p>{{ $strings.LabelYourProgress }}: {{ Math.round(progressPercent * 100) }}%</p>
|
||||||
|
<p v-if="!useEBookProgress && !userIsFinished" class="text-fg-muted text-xs">{{ $getString('LabelTimeRemaining', [$elapsedPretty(userTimeRemaining)]) }}</p>
|
||||||
|
<p v-else-if="userIsFinished" class="text-fg-muted text-xs">{{ $strings.LabelFinished }} {{ $formatDate(userProgressFinishedAt) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="downloadItem" class="py-3">
|
||||||
|
<p v-if="downloadItem.itemProgress == 1" class="text-center text-lg">{{ $strings.MessageDownloadCompleteProcessing }}</p>
|
||||||
|
<p v-else class="text-center text-lg">{{ $strings.MessageDownloading }} ({{ Math.round(downloadItem.itemProgress * 100) }}%)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- metadata -->
|
||||||
|
<div id="metadata" class="grid gap-2 my-2" style>
|
||||||
|
<div v-if="podcastAuthor || bookAuthors?.length" class="text-fg-muted uppercase text-sm">{{ $strings.LabelAuthor }}</div>
|
||||||
|
<div v-if="podcastAuthor" class="text-sm">{{ podcastAuthor }}</div>
|
||||||
|
<div v-else-if="bookAuthors?.length" class="text-sm">
|
||||||
|
<template v-for="(author, index) in bookAuthors">
|
||||||
|
<nuxt-link :key="author.id" :to="`/bookshelf/library?filter=authors.${$encode(author.id)}`" class="underline whitespace-nowrap">{{ author.name }}</nuxt-link
|
||||||
|
><span :key="`${author.id}-comma`" v-if="index < bookAuthors.length - 1">, </span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="podcastType" class="text-fg-muted uppercase text-sm">{{ $strings.LabelType }}</div>
|
||||||
|
<div v-if="podcastType" class="text-sm capitalize">{{ podcastType }}</div>
|
||||||
|
|
||||||
|
<div v-if="series?.length" class="text-fg-muted uppercase text-sm">{{ $strings.LabelSeries }}</div>
|
||||||
|
<div v-if="series?.length" class="text-sm">
|
||||||
|
<template v-for="(series, index) in seriesList">
|
||||||
|
<nuxt-link :key="series.id" :to="`/bookshelf/series/${series.id}`" class="underline whitespace-nowrap">{{ series.text }}</nuxt-link
|
||||||
|
><span :key="`${series.id}-comma`" v-if="index < seriesList.length - 1">, </span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="numTracks" class="text-fg-muted uppercase text-sm">{{ $strings.LabelDuration }}</div>
|
||||||
|
<div v-if="numTracks" class="text-sm">{{ $elapsedPretty(duration) }}</div>
|
||||||
|
|
||||||
|
<div v-if="narrators?.length" class="text-fg-muted uppercase text-sm">{{ $strings.LabelNarrators }}</div>
|
||||||
|
<div v-if="narrators?.length" class="text-sm">
|
||||||
|
<template v-for="(narrator, index) in narrators">
|
||||||
|
<nuxt-link :key="narrator" :to="`/bookshelf/library?filter=narrators.${$encode(narrator)}`" class="underline whitespace-nowrap">{{ narrator }}</nuxt-link
|
||||||
|
><span :key="index" v-if="index < narrators.length - 1">, </span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="genres.length" class="text-fg-muted uppercase text-sm">{{ $strings.LabelGenres }}</div>
|
||||||
|
<div v-if="genres.length" class="text-sm">
|
||||||
|
<template v-for="(genre, index) in genres">
|
||||||
|
<nuxt-link :key="genre" :to="`/bookshelf/library?filter=genres.${$encode(genre)}`" class="underline whitespace-nowrap">{{ genre }}</nuxt-link
|
||||||
|
><span :key="index" v-if="index < genres.length - 1">, </span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="tags.length" class="text-fg-muted uppercase text-sm">{{ $strings.LabelTags }}</div>
|
||||||
|
<div v-if="tags.length" class="text-sm">
|
||||||
|
<template v-for="(tag, index) in tags">
|
||||||
|
<nuxt-link :key="tag" :to="`/bookshelf/library?filter=tags.${$encode(tag)}`" class="underline whitespace-nowrap">{{ tag }}</nuxt-link
|
||||||
|
><span :key="index" v-if="index < tags.length - 1">, </span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="publishedYear" class="text-fg-muted uppercase text-sm">{{ $strings.LabelPublishYear }}</div>
|
||||||
|
<div v-if="publishedYear" class="text-sm">{{ publishedYear }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="description" class="w-full py-2">
|
||||||
|
<p ref="description" class="text-sm text-justify whitespace-pre-line font-light" :class="{ 'line-clamp-4': !showFullDescription }" style="hyphens: auto">{{ description }}</p>
|
||||||
|
|
||||||
|
<div v-if="descriptionClamped" class="text-fg text-sm py-2" @click="showFullDescription = !showFullDescription">
|
||||||
|
{{ showFullDescription ? 'Read less' : 'Read more' }}
|
||||||
|
<span class="material-icons align-middle text-base -mt-px">{{ showFullDescription ? 'expand_less' : 'expand_more' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- tables -->
|
||||||
|
<tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" :local-library-item-id="localLibraryItemId" :episodes="episodes" :local-episodes="localLibraryItemEpisodes" :is-local="isLocal" />
|
||||||
|
|
||||||
|
<tables-chapters-table v-if="numChapters" :library-item="libraryItem" @playAtTimestamp="playAtTimestamp" />
|
||||||
|
|
||||||
|
<tables-tracks-table v-if="numTracks" :tracks="tracks" :library-item-id="libraryItemId" />
|
||||||
|
|
||||||
|
<tables-ebook-files-table v-if="ebookFiles.length" :library-item="libraryItem" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- modals -->
|
||||||
|
<modals-item-more-menu-modal v-model="showMoreMenu" :library-item="libraryItem" :rss-feed="rssFeed" :processing.sync="processing" />
|
||||||
|
|
||||||
|
<modals-select-local-folder-modal v-model="showSelectLocalFolder" :media-type="mediaType" @select="selectedLocalFolder" />
|
||||||
|
|
||||||
|
<modals-fullscreen-cover v-model="showFullscreenCover" :library-item="libraryItem" />
|
||||||
|
|
||||||
<div v-show="processing" class="fixed top-0 left-0 w-screen h-screen flex items-center justify-center bg-black/50 z-50">
|
<div v-show="processing" class="fixed top-0 left-0 w-screen h-screen flex items-center justify-center bg-black/50 z-50">
|
||||||
<ui-loading-indicator />
|
<ui-loading-indicator />
|
||||||
</div>
|
</div>
|
||||||
@@ -154,45 +171,40 @@
|
|||||||
import { Dialog } from '@capacitor/dialog'
|
import { Dialog } from '@capacitor/dialog'
|
||||||
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
|
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
|
||||||
import { FastAverageColor } from 'fast-average-color'
|
import { FastAverageColor } from 'fast-average-color'
|
||||||
|
import cellularPermissionHelpers from '@/mixins/cellularPermissionHelpers'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
async asyncData({ store, params, redirect, app }) {
|
async asyncData({ store, params, redirect, app, query }) {
|
||||||
const libraryItemId = params.id
|
const libraryItemId = params.id
|
||||||
let libraryItem = null
|
let libraryItem = null
|
||||||
|
|
||||||
if (libraryItemId.startsWith('local')) {
|
if (libraryItemId.startsWith('local')) {
|
||||||
libraryItem = await app.$db.getLocalLibraryItem(libraryItemId)
|
libraryItem = await app.$db.getLocalLibraryItem(libraryItemId)
|
||||||
console.log('Got lli', libraryItemId)
|
if (!libraryItem) {
|
||||||
|
return redirect('/?error=Failed to get downloaded library item')
|
||||||
|
}
|
||||||
|
|
||||||
// If library item is linked to the currently connected server then redirect to the page using the server library item id
|
// If library item is linked to the currently connected server then redirect to the page using the server library item id
|
||||||
if (libraryItem?.libraryItemId?.startsWith('li_')) {
|
if (libraryItem?.libraryItemId?.startsWith('li_')) {
|
||||||
// Detect old library item id
|
// Detect old library item id
|
||||||
console.error('Local library item has old server library item id', libraryItem.libraryItemId)
|
console.error('Local library item has old server library item id', libraryItem.libraryItemId)
|
||||||
} else if (libraryItem?.libraryItemId && libraryItem?.serverAddress === store.getters['user/getServerAddress'] && store.state.networkConnected) {
|
} else if (query.noredirect !== '1' && libraryItem?.libraryItemId && libraryItem?.serverAddress === store.getters['user/getServerAddress'] && store.state.networkConnected) {
|
||||||
let query = ''
|
const queryParams = new URLSearchParams()
|
||||||
if (libraryItem.mediaType === 'podcast') query = '?episodefilter=downloaded' // Filter by downloaded when redirecting from the local copy
|
queryParams.set('localLibraryItemId', libraryItemId)
|
||||||
return redirect(`/item/${libraryItem.libraryItemId}${query}`)
|
if (libraryItem.mediaType === 'podcast') {
|
||||||
}
|
// Filter by downloaded when redirecting from the local copy
|
||||||
} else if (store.state.user.serverConnectionConfig) {
|
queryParams.set('episodefilter', 'downloaded')
|
||||||
libraryItem = await app.$nativeHttp.get(`/api/items/${libraryItemId}?expanded=1&include=rssfeed`).catch((error) => {
|
|
||||||
console.error('Failed', error)
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
if (libraryItem) {
|
|
||||||
const localLibraryItem = await app.$db.getLocalLibraryItemByLId(libraryItemId)
|
|
||||||
if (localLibraryItem) {
|
|
||||||
console.log('Library item has local library item also', localLibraryItem.id)
|
|
||||||
libraryItem.localLibraryItem = localLibraryItem
|
|
||||||
}
|
}
|
||||||
|
return redirect(`/item/${libraryItem.libraryItemId}?${queryParams.toString()}`)
|
||||||
}
|
}
|
||||||
|
} else if (!store.state.user.serverConnectionConfig) {
|
||||||
|
// Not connected to server
|
||||||
|
return redirect('/?error=No server connection to get library item')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!libraryItem) {
|
|
||||||
console.error('No item...', params.id)
|
|
||||||
return redirect('/')
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
libraryItem,
|
libraryItem,
|
||||||
rssFeed: libraryItem.rssFeed || null
|
libraryItemId
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -201,7 +213,7 @@ export default {
|
|||||||
showSelectLocalFolder: false,
|
showSelectLocalFolder: false,
|
||||||
showMoreMenu: false,
|
showMoreMenu: false,
|
||||||
showFullscreenCover: false,
|
showFullscreenCover: false,
|
||||||
coverRgb: 'rgb(55, 56, 56)',
|
coverRgb: null,
|
||||||
coverBgIsLight: false,
|
coverBgIsLight: false,
|
||||||
windowWidth: 0,
|
windowWidth: 0,
|
||||||
descriptionClamped: false,
|
descriptionClamped: false,
|
||||||
@@ -210,6 +222,7 @@ export default {
|
|||||||
startingDownload: false
|
startingDownload: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
mixins: [cellularPermissionHelpers],
|
||||||
computed: {
|
computed: {
|
||||||
isIos() {
|
isIos() {
|
||||||
return this.$platform === 'ios'
|
return this.$platform === 'ios'
|
||||||
@@ -285,8 +298,8 @@ export default {
|
|||||||
bookCoverAspectRatio() {
|
bookCoverAspectRatio() {
|
||||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||||
},
|
},
|
||||||
libraryItemId() {
|
rssFeed() {
|
||||||
return this.libraryItem.id
|
return this.libraryItem?.rssFeed
|
||||||
},
|
},
|
||||||
mediaType() {
|
mediaType() {
|
||||||
return this.libraryItem.mediaType
|
return this.libraryItem.mediaType
|
||||||
@@ -297,6 +310,9 @@ export default {
|
|||||||
media() {
|
media() {
|
||||||
return this.libraryItem.media || {}
|
return this.libraryItem.media || {}
|
||||||
},
|
},
|
||||||
|
tags() {
|
||||||
|
return this.media.tags || []
|
||||||
|
},
|
||||||
mediaMetadata() {
|
mediaMetadata() {
|
||||||
return this.media.metadata || {}
|
return this.media.metadata || {}
|
||||||
},
|
},
|
||||||
@@ -425,6 +441,9 @@ export default {
|
|||||||
isInvalid() {
|
isInvalid() {
|
||||||
return this.libraryItem.isInvalid
|
return this.libraryItem.isInvalid
|
||||||
},
|
},
|
||||||
|
isExplicit() {
|
||||||
|
return !!this.mediaMetadata.explicit
|
||||||
|
},
|
||||||
showPlay() {
|
showPlay() {
|
||||||
return !this.isMissing && !this.isInvalid && (this.numTracks || this.episodes.length)
|
return !this.isMissing && !this.isInvalid && (this.numTracks || this.episodes.length)
|
||||||
},
|
},
|
||||||
@@ -464,6 +483,9 @@ export default {
|
|||||||
|
|
||||||
if (width * this.bookCoverAspectRatio > 325) width = 325 / this.bookCoverAspectRatio
|
if (width * this.bookCoverAspectRatio > 325) width = 325 / this.bookCoverAspectRatio
|
||||||
return width
|
return width
|
||||||
|
},
|
||||||
|
coverHeight() {
|
||||||
|
return this.coverWidth * this.bookCoverAspectRatio
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -587,9 +609,11 @@ export default {
|
|||||||
this.download(localFolder)
|
this.download(localFolder)
|
||||||
},
|
},
|
||||||
async downloadClick() {
|
async downloadClick() {
|
||||||
if (this.downloadItem || this.startingDownload) {
|
if (this.downloadItem || this.startingDownload) return
|
||||||
return
|
|
||||||
}
|
const hasPermission = await this.checkCellularPermission('download')
|
||||||
|
if (!hasPermission) return
|
||||||
|
|
||||||
this.startingDownload = true
|
this.startingDownload = true
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.startingDownload = false
|
this.startingDownload = false
|
||||||
@@ -705,27 +729,59 @@ export default {
|
|||||||
if (!this.libraryItem.libraryId) return
|
if (!this.libraryItem.libraryId) return
|
||||||
await this.$store.dispatch('libraries/fetch', this.libraryItem.libraryId)
|
await this.$store.dispatch('libraries/fetch', this.libraryItem.libraryId)
|
||||||
this.$localStore.setLastLibraryId(this.libraryItem.libraryId)
|
this.$localStore.setLastLibraryId(this.libraryItem.libraryId)
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
// If library of this item is different from current library then switch libraries
|
||||||
|
if (this.$store.state.libraries.currentLibraryId !== this.libraryItem.libraryId) {
|
||||||
|
this.setLibrary()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.windowWidth = window.innerWidth
|
||||||
|
window.addEventListener('resize', this.windowResized)
|
||||||
|
this.$eventBus.$on('library-changed', this.libraryChanged)
|
||||||
|
this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem)
|
||||||
|
this.$socket.$on('item_updated', this.itemUpdated)
|
||||||
|
this.$socket.$on('rss_feed_open', this.rssFeedOpen)
|
||||||
|
this.$socket.$on('rss_feed_closed', this.rssFeedClosed)
|
||||||
|
this.checkDescriptionClamped()
|
||||||
|
|
||||||
|
// Set height of page below cover image
|
||||||
|
const itemPageBgGradientHeight = window.outerHeight - 64 - this.coverHeight
|
||||||
|
document.documentElement.style.setProperty('--item-page-bg-gradient-height', itemPageBgGradientHeight + 'px')
|
||||||
|
|
||||||
|
// Set last scroll position if was set for this item
|
||||||
|
if (this.$store.state.lastItemScrollData.id === this.libraryItemId && window['item-page']) {
|
||||||
|
window['item-page'].scrollTop = this.$store.state.lastItemScrollData.scrollTop || 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadServerLibraryItem() {
|
||||||
|
console.log(`Fetching library item "${this.libraryItemId}" from server`)
|
||||||
|
const libraryItem = await this.$nativeHttp.get(`/api/items/${this.libraryItemId}?expanded=1&include=rssfeed`, { connectTimeout: 5000 }).catch((error) => {
|
||||||
|
console.error('Failed', error)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
if (libraryItem) {
|
||||||
|
const localLibraryItem = await this.$db.getLocalLibraryItemByLId(this.libraryItemId)
|
||||||
|
if (localLibraryItem) {
|
||||||
|
console.log('Library item has local library item also', localLibraryItem.id)
|
||||||
|
libraryItem.localLibraryItem = localLibraryItem
|
||||||
|
}
|
||||||
|
this.libraryItem = libraryItem
|
||||||
|
} else if (this.$route.query.localLibraryItemId) {
|
||||||
|
// Failed to get server library item but is local library item so redirect
|
||||||
|
return this.$router.replace(`/item/${this.$route.query.localLibraryItemId}?noredirect=1`)
|
||||||
|
} else {
|
||||||
|
this.$toast.error('Failed to get library item from server')
|
||||||
|
return this.$router.replace('/bookshelf')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
async mounted() {
|
||||||
// If library of this item is different from current library then switch libraries
|
if (!this.libraryItem) {
|
||||||
if (this.$store.state.libraries.currentLibraryId !== this.libraryItem.libraryId) {
|
await this.loadServerLibraryItem()
|
||||||
this.setLibrary()
|
|
||||||
}
|
|
||||||
|
|
||||||
this.windowWidth = window.innerWidth
|
|
||||||
window.addEventListener('resize', this.windowResized)
|
|
||||||
this.$eventBus.$on('library-changed', this.libraryChanged)
|
|
||||||
this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem)
|
|
||||||
this.$socket.$on('item_updated', this.itemUpdated)
|
|
||||||
this.$socket.$on('rss_feed_open', this.rssFeedOpen)
|
|
||||||
this.$socket.$on('rss_feed_closed', this.rssFeedClosed)
|
|
||||||
this.checkDescriptionClamped()
|
|
||||||
|
|
||||||
// Set last scroll position if was set for this item
|
|
||||||
if (this.$store.state.lastItemScrollData.id === this.libraryItemId && window['item-page']) {
|
|
||||||
window['item-page'].scrollTop = this.$store.state.lastItemScrollData.scrollTop || 0
|
|
||||||
}
|
}
|
||||||
|
this.init()
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
window.removeEventListener('resize', this.windowResized)
|
window.removeEventListener('resize', this.windowResized)
|
||||||
@@ -744,6 +800,15 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
:root {
|
||||||
|
--item-page-bg-gradient-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#item-page-bg-gradient {
|
||||||
|
transition: opacity 0.5s ease-in-out;
|
||||||
|
height: var(--item-page-bg-gradient-height);
|
||||||
|
}
|
||||||
|
|
||||||
.title-container {
|
.title-container {
|
||||||
width: calc(100% - 64px);
|
width: calc(100% - 64px);
|
||||||
max-width: calc(100% - 64px);
|
max-width: calc(100% - 64px);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="w-full h-full py-6 px-2">
|
<div class="w-full h-full py-6 px-2">
|
||||||
<div v-if="localLibraryItem" class="w-full h-full">
|
<div v-if="localLibraryItem" class="w-full h-full">
|
||||||
<div class="px-2 flex items-center mb-2">
|
<div class="px-2 flex items-center mb-2">
|
||||||
<p class="text-base font-semibold">{{ mediaMetadata.title }}</p>
|
<p class="text-base font-semibold truncate">{{ mediaMetadata.title }}</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
<button v-if="audioTracks.length && !isPodcast" class="shadow-sm text-success flex items-center justify-center rounded-full mx-2" @click.stop="play">
|
<button v-if="audioTracks.length && !isPodcast" class="shadow-sm text-success flex items-center justify-center rounded-full mx-2" @click.stop="play">
|
||||||
@@ -17,7 +17,10 @@
|
|||||||
|
|
||||||
<div class="w-full max-w-full media-item-container overflow-y-auto overflow-x-hidden relative pb-4" :class="{ 'media-order-changed': orderChanged }">
|
<div class="w-full max-w-full media-item-container overflow-y-auto overflow-x-hidden relative pb-4" :class="{ 'media-order-changed': orderChanged }">
|
||||||
<div v-if="!isPodcast && audioTracksCopy.length" class="w-full py-2">
|
<div v-if="!isPodcast && audioTracksCopy.length" class="w-full py-2">
|
||||||
<p class="text-base mb-2">Audio Tracks ({{ audioTracks.length }})</p>
|
<div class="flex justify-between items-center mb-2">
|
||||||
|
<p class="text-base">Audio Tracks ({{ audioTracks.length }})</p>
|
||||||
|
<p class="text-xs text-fg-muted px-2">{{ $strings.LabelTotalSize }}: {{ $bytesPretty(totalAudioSize) }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<draggable v-model="audioTracksCopy" v-bind="dragOptions" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate" :disabled="isIos">
|
<draggable v-model="audioTracksCopy" v-bind="dragOptions" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate" :disabled="isIos">
|
||||||
<transition-group type="transition" :name="!drag ? 'dragtrack' : null">
|
<transition-group type="transition" :name="!drag ? 'dragtrack' : null">
|
||||||
@@ -46,7 +49,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isPodcast" class="w-full py-2">
|
<div v-if="isPodcast" class="w-full py-2">
|
||||||
<p class="text-base mb-2">Episodes ({{ episodes.length }})</p>
|
<div class="flex justify-between items-center mb-2">
|
||||||
|
<p class="text-base">Episodes ({{ episodes.length }})</p>
|
||||||
|
<p class="text-xs text-fg-muted px-2">{{ $strings.LabelTotalSize }}: {{ $bytesPretty(totalEpisodesSize) }}</p>
|
||||||
|
</div>
|
||||||
<template v-for="episode in episodes">
|
<template v-for="episode in episodes">
|
||||||
<div :key="episode.id" class="flex items-center my-1">
|
<div :key="episode.id" class="flex items-center my-1">
|
||||||
<div class="w-10 h-12 flex items-center justify-center" style="min-width: 48px">
|
<div class="w-10 h-12 flex items-center justify-center" style="min-width: 48px">
|
||||||
@@ -83,22 +89,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="otherFiles.length" class="text-lg py-2">Other Files</p>
|
<div v-if="otherFiles.length">
|
||||||
<template v-for="file in otherFiles">
|
<div class="flex justify-between items-center py-2">
|
||||||
<div :key="file.id" class="flex items-center my-1">
|
<p class="text-lg">Other Files</p>
|
||||||
<div class="w-12 h-12 flex items-center justify-center">
|
<p class="text-xs text-fg-muted px-2">{{ $strings.LabelTotalSize }}: {{ $bytesPretty(totalOtherFilesSize) }}</p>
|
||||||
<img v-if="(file.mimeType || '').startsWith('image')" :src="getCapImageSrc(file.contentUrl)" class="w-full h-full object-contain" />
|
|
||||||
<span v-else class="material-icons">music_note</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow px-2">
|
|
||||||
<p class="text-sm">{{ file.filename }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="w-24 text-center text-fg-muted" style="min-width: 96px">
|
|
||||||
<p class="text-xs">{{ file.mimeType }}</p>
|
|
||||||
<p class="text-sm">{{ $bytesPretty(file.size) }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<template v-for="file in otherFiles">
|
||||||
|
<div :key="file.id" class="flex items-center my-1">
|
||||||
|
<div class="w-12 h-12 flex items-center justify-center">
|
||||||
|
<img v-if="(file.mimeType || '').startsWith('image')" :src="getCapImageSrc(file.contentUrl)" class="w-full h-full object-contain" />
|
||||||
|
<span v-else class="material-icons">music_note</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow px-2">
|
||||||
|
<p class="text-sm">{{ file.filename }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-24 text-center text-fg-muted" style="min-width: 96px">
|
||||||
|
<p class="text-xs">{{ file.mimeType }}</p>
|
||||||
|
<p class="text-sm">{{ $bytesPretty(file.size) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 text-sm text-fg-muted">
|
||||||
|
{{ $strings.LabelTotalSize }}: {{ $bytesPretty(totalLibraryItemSize) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="px-2 w-full h-full">
|
<div v-else class="px-2 w-full h-full">
|
||||||
@@ -218,17 +233,27 @@ export default {
|
|||||||
},
|
},
|
||||||
dialogItems() {
|
dialogItems() {
|
||||||
if (this.selectedAudioTrack || this.selectedEpisode) {
|
if (this.selectedAudioTrack || this.selectedEpisode) {
|
||||||
return [
|
const items = [
|
||||||
{
|
{
|
||||||
text: this.$strings.ButtonDeleteLocalFile,
|
text: this.$strings.ButtonDeleteLocalFile,
|
||||||
value: 'track-delete'
|
value: 'track-delete',
|
||||||
|
icon: 'delete'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
if (this.isPodcast && this.selectedEpisode) {
|
||||||
|
items.unshift({
|
||||||
|
text: this.$strings.ButtonPlayEpisode,
|
||||||
|
value: 'play-episode',
|
||||||
|
icon: 'play_arrow'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
} else {
|
} else {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
text: this.$strings.ButtonDeleteLocalItem,
|
text: this.$strings.ButtonDeleteLocalItem,
|
||||||
value: 'delete'
|
value: 'delete',
|
||||||
|
icon: 'delete'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -236,6 +261,18 @@ export default {
|
|||||||
playerIsStartingPlayback() {
|
playerIsStartingPlayback() {
|
||||||
// Play has been pressed and waiting for native play response
|
// Play has been pressed and waiting for native play response
|
||||||
return this.$store.state.playerIsStartingPlayback
|
return this.$store.state.playerIsStartingPlayback
|
||||||
|
},
|
||||||
|
totalAudioSize() {
|
||||||
|
return this.audioTracks.reduce((acc, item) => item.metadata ? acc + item.metadata.size : acc, 0)
|
||||||
|
},
|
||||||
|
totalEpisodesSize() {
|
||||||
|
return this.episodes.reduce((acc, item) => acc + item.size, 0)
|
||||||
|
},
|
||||||
|
totalOtherFilesSize() {
|
||||||
|
return this.otherFiles.reduce((acc, item) => acc + item.size, 0)
|
||||||
|
},
|
||||||
|
totalLibraryItemSize() {
|
||||||
|
return this.localFiles.reduce((acc, item) => acc + item.size, 0)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -291,6 +328,19 @@ export default {
|
|||||||
getCapImageSrc(contentUrl) {
|
getCapImageSrc(contentUrl) {
|
||||||
return Capacitor.convertFileSrc(contentUrl)
|
return Capacitor.convertFileSrc(contentUrl)
|
||||||
},
|
},
|
||||||
|
async playEpisode() {
|
||||||
|
if (!this.selectedEpisode) return
|
||||||
|
if (this.playerIsStartingPlayback) return
|
||||||
|
await this.$hapticsImpact()
|
||||||
|
this.$store.commit('setPlayerIsStartingPlayback', this.selectedEpisode.serverEpisodeId)
|
||||||
|
|
||||||
|
this.$eventBus.$emit('play-item', {
|
||||||
|
libraryItemId: this.localLibraryItemId,
|
||||||
|
episodeId: this.selectedEpisode.id,
|
||||||
|
serverLibraryItemId: this.libraryItemId,
|
||||||
|
serverEpisodeId: this.selectedEpisode.serverEpisodeId
|
||||||
|
})
|
||||||
|
},
|
||||||
async dialogAction(action) {
|
async dialogAction(action) {
|
||||||
console.log('Dialog action', action)
|
console.log('Dialog action', action)
|
||||||
await this.$hapticsImpact()
|
await this.$hapticsImpact()
|
||||||
@@ -300,8 +350,10 @@ export default {
|
|||||||
} else if (action == 'track-delete') {
|
} else if (action == 'track-delete') {
|
||||||
if (this.isPodcast) this.deleteEpisode()
|
if (this.isPodcast) this.deleteEpisode()
|
||||||
else this.deleteTrack()
|
else this.deleteTrack()
|
||||||
|
} else if (action == 'play-episode') {
|
||||||
|
this.playEpisode()
|
||||||
}
|
}
|
||||||
this.showDialog = false
|
this.showDialog = false;
|
||||||
},
|
},
|
||||||
getLocalFileForTrack(localFileId) {
|
getLocalFileForTrack(localFileId) {
|
||||||
return this.localFiles.find((lf) => lf.id == localFileId)
|
return this.localFiles.find((lf) => lf.id == localFileId)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user