mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-07-25 22:18: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
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
labels: bug
|
||||
|
||||
---
|
||||
|
||||
### Steps to reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Expected behaviour
|
||||
- Tell us what should happen
|
||||
|
||||
### Actual behaviour
|
||||
- Tell us what happens
|
||||
|
||||
### Environment data
|
||||
|
||||
Audiobookshelf Version:
|
||||
|
||||
- [ ] Android App?
|
||||
- [ ] iOS App?
|
||||
|
||||
#### Android Issue
|
||||
Android version:
|
||||
|
||||
Device model:
|
||||
|
||||
Stock or customized system:
|
||||
|
||||
#### iOS Issue
|
||||
iOS Version:
|
||||
|
||||
iPhone model:
|
||||
@@ -0,0 +1,88 @@
|
||||
name: 🐞 ABS App Bug Report
|
||||
description: File a bug/issue and help us improve the Audiobookshelf mobile apps.
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "## App Bug Description"
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "Thank you for filing a bug report! 🐛"
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "Join the [discord server](https://discord.gg/HQgCbd6E75) for questions or if you are not sure about a bug."
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What was the Problem?
|
||||
description: Please help us understand the problem.
|
||||
placeholder: Describe the problem you saw or experienced in detail! Include a screenshot or video, if applicable.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: Steps to Reproduce the Issue
|
||||
description: Please help us understand how we can reliably reproduce the issue.
|
||||
placeholder: "1. Go to the library page of a Podcast library and..."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-was-expected
|
||||
attributes:
|
||||
label: What was Expected?
|
||||
description: Please help us understand what should have happened.
|
||||
placeholder: Please help us visualize by including a doodle or screenshot.
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: "## Mobile Environment"
|
||||
- type: input
|
||||
id: phone-model
|
||||
attributes:
|
||||
label: Phone Model
|
||||
description: What kind of phone are you using?
|
||||
placeholder: e.g. Pixel 6, iPhone 14, Samusung Galaxy s23, etc
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: phone-os
|
||||
attributes:
|
||||
label: Phone OS
|
||||
description: What operating system? If Android, is it stock or a custom system?
|
||||
placeholder: e.g. Android 14, iOS 17.4, etc
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: app-version
|
||||
attributes:
|
||||
label: Audiobookshelf App Version
|
||||
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
||||
multiple: true
|
||||
options:
|
||||
- Android App - 0.9.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
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: use Node.js 16.x
|
||||
- name: use Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 20
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v2
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Close fixed issues on release.
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close issues marked as fixed upon a release.
|
||||
uses: gcampbell-msft/fixed-pending-release@7fa1b75a0c04bcd4b375110522878e5f6100cff5
|
||||
with:
|
||||
label: 'awaiting release'
|
||||
removeLabel: true
|
||||
applyToAll: true
|
||||
message: Fixed in [${releaseTag}](${releaseUrl}).
|
||||
@@ -15,10 +15,10 @@ jobs:
|
||||
- name: checkout sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: use Node.js 16.x
|
||||
- name: use Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 20
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v2
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Verify all i18n files are alphabetized
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
paths:
|
||||
- strings/** # Should only check if any strings changed
|
||||
|
||||
jobs:
|
||||
update_translations:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Check out the repository
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Set up node to run the javascript
|
||||
- name: Set up node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
# The only argument is the `directory`, which is where the i18n files are
|
||||
# stored.
|
||||
- name: Run Update JSON Files action
|
||||
uses: audiobookshelf/audiobookshelf-i18n-updater@v1.3.0
|
||||
with:
|
||||
directory: 'strings/' # Adjust the directory path as needed
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 400,
|
||||
"proseWrap": "never",
|
||||
"trailingComma": "none",
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.html"],
|
||||
"options": {
|
||||
"singleQuote": false,
|
||||
"wrapAttributes": false,
|
||||
"sortAttributes": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"EditorConfig.EditorConfig",
|
||||
"esbenp.prettier-vscode",
|
||||
"octref.vetur"
|
||||
]
|
||||
}
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"vetur.format.defaultFormatterOptions": {
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 400,
|
||||
"proseWrap": "never",
|
||||
"trailingComma": "none"
|
||||
},
|
||||
"prettyhtml": {
|
||||
"printWidth": 400,
|
||||
"singleQuote": false,
|
||||
"wrapAttributes": false,
|
||||
"sortAttributes": false
|
||||
}
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"editor.detectIndentation": true,
|
||||
"editor.tabSize": 2,
|
||||
"javascript.format.semicolons": "remove",
|
||||
"[javascript][json][jsonc]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[vue]": {
|
||||
"editor.defaultFormatter": "octref.vetur"
|
||||
}
|
||||
}
|
||||
@@ -33,8 +33,8 @@ android {
|
||||
applicationId "com.audiobookshelf.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 101
|
||||
versionName "0.9.70-beta"
|
||||
versionCode 107
|
||||
versionName "0.9.76-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
manifestPlaceholders = [
|
||||
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
||||
@@ -64,6 +64,8 @@ repositories {
|
||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||
}
|
||||
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 {
|
||||
@@ -118,7 +120,7 @@ dependencies {
|
||||
implementation 'io.github.pilgr:paperdb:2.7.2'
|
||||
|
||||
// Simple Storage
|
||||
implementation "com.anggrayudi:storage:1.5.4"
|
||||
implementation "com.anggrayudi:storage:1.5.5-SNAPSHOT"
|
||||
|
||||
// OK HTTP
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
|
||||
|
||||
@@ -9,6 +9,7 @@ android {
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':byteowls-capacitor-filesharer')
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-browser')
|
||||
implementation project(':capacitor-clipboard')
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
<!-- Permissions -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
[
|
||||
{
|
||||
"pkg": "@byteowls/capacitor-filesharer",
|
||||
"classpath": "com.byteowls.capacitor.filesharer.FileSharerPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/app",
|
||||
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
||||
|
||||
@@ -103,6 +103,9 @@ class MainActivity : BridgeActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
fun isPlayerNotificationServiceInitialized():Boolean {
|
||||
return ::foregroundService.isInitialized
|
||||
}
|
||||
|
||||
fun stopMyService() {
|
||||
if (mBounded) {
|
||||
@@ -116,6 +119,7 @@ class MainActivity : BridgeActivity() {
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
storageHelper.onSaveInstanceState(outState)
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.clear()
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
|
||||
@@ -96,7 +96,7 @@ class Podcast(
|
||||
@JsonIgnore
|
||||
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
|
||||
val localEpisodeId = "local_ep_" + episode.id
|
||||
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,null,null,audioTrack,episode.chapters,audioTrack.duration,0, episode.id, localEpisodeId)
|
||||
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,null,null,audioTrack,episode.chapters,audioTrack.duration,episode.size, episode.id, localEpisodeId)
|
||||
episodes?.add(newEpisode)
|
||||
|
||||
var index = 1
|
||||
|
||||
@@ -20,6 +20,14 @@ enum class ShakeSensitivitySetting {
|
||||
VERY_LOW, LOW, MEDIUM, HIGH, VERY_HIGH
|
||||
}
|
||||
|
||||
enum class DownloadUsingCellularSetting {
|
||||
ASK, ALWAYS, NEVER
|
||||
}
|
||||
|
||||
enum class StreamingUsingCellularSetting {
|
||||
ASK, ALWAYS, NEVER
|
||||
}
|
||||
|
||||
data class ServerConnectionConfig(
|
||||
var id:String,
|
||||
var index:Int,
|
||||
@@ -107,6 +115,7 @@ data class PlayItemRequestPayload(
|
||||
data class DeviceSettings(
|
||||
var disableAutoRewind:Boolean,
|
||||
var enableAltView:Boolean,
|
||||
var allowSeekingOnMediaControls:Boolean,
|
||||
var jumpBackwardsTime:Int,
|
||||
var jumpForwardTime:Int,
|
||||
var enableMp3IndexSeeking:Boolean,
|
||||
@@ -122,7 +131,9 @@ data class DeviceSettings(
|
||||
var sleepTimerLength: Long, // Time in milliseconds
|
||||
var disableSleepTimerFadeOut: Boolean,
|
||||
var disableSleepTimerResetFeedback: Boolean,
|
||||
var languageCode: String
|
||||
var languageCode: String,
|
||||
var downloadUsingCellular: DownloadUsingCellularSetting,
|
||||
var streamingUsingCellular: StreamingUsingCellularSetting
|
||||
) {
|
||||
companion object {
|
||||
// Static method to get default device settings
|
||||
@@ -130,6 +141,7 @@ data class DeviceSettings(
|
||||
return DeviceSettings(
|
||||
disableAutoRewind = false,
|
||||
enableAltView = true,
|
||||
allowSeekingOnMediaControls = false,
|
||||
jumpBackwardsTime = 10,
|
||||
jumpForwardTime = 10,
|
||||
enableMp3IndexSeeking = false,
|
||||
@@ -145,7 +157,9 @@ data class DeviceSettings(
|
||||
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
|
||||
disableSleepTimerFadeOut = 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) {
|
||||
deviceData.deviceSettings?.languageCode = "en-us"
|
||||
}
|
||||
|
||||
if (deviceData.deviceSettings?.downloadUsingCellular == null) {
|
||||
deviceData.deviceSettings?.downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS
|
||||
}
|
||||
|
||||
if (deviceData.deviceSettings?.streamingUsingCellular == null) {
|
||||
deviceData.deviceSettings?.streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS
|
||||
}
|
||||
}
|
||||
|
||||
fun getBase64Id(id:String):String {
|
||||
|
||||
@@ -70,6 +70,7 @@ class FolderScanner(var ctx: Context) {
|
||||
)
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
val trackFileMetadata = FileMetadata(file.name, file.extension, file.absolutePath, file.getBasePath(ctx), file.length())
|
||||
// Create new audio track
|
||||
val track = AudioTrack(
|
||||
audioTrackFromServer.index,
|
||||
@@ -78,7 +79,7 @@ class FolderScanner(var ctx: Context) {
|
||||
localFile.filename ?: "",
|
||||
localFile.contentUrl,
|
||||
localFile.mimeType ?: "",
|
||||
null,
|
||||
trackFileMetadata,
|
||||
true,
|
||||
localFileId,
|
||||
null,
|
||||
@@ -274,7 +275,8 @@ class FolderScanner(var ctx: Context) {
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
// Create new audio track
|
||||
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioTrackFromServer.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, null, audioTrackFromServer.index)
|
||||
val trackFileMetadata = FileMetadata(docFile.name ?: "", docFile.extension ?: "", docFile.getAbsolutePath(ctx), docFile.getBasePath(ctx), docFile.length())
|
||||
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioTrackFromServer.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", trackFileMetadata, true, localFileId, null, audioTrackFromServer.index)
|
||||
audioTracks.add(track)
|
||||
|
||||
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
|
||||
|
||||
@@ -247,14 +247,18 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
}
|
||||
|
||||
private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig | $serverConfigIdUsed")
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig | serverConfigIdUsed=$serverConfigIdUsed | lastServerConnectionConfigId=${DeviceManager.deviceData.lastServerConnectionConfigId}")
|
||||
|
||||
coroutineScope {
|
||||
if (!DeviceManager.checkConnectivity(ctx)) {
|
||||
serverUserMediaProgress = mutableListOf()
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig: No connectivity")
|
||||
cb(false)
|
||||
} else if (DeviceManager.deviceData.lastServerConnectionConfigId.isNullOrBlank()) { // If in offline mode last server connection config is unset
|
||||
serverUserMediaProgress = mutableListOf()
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig: No last server connection config")
|
||||
cb(false)
|
||||
} else {
|
||||
|
||||
var hasValidConn = false
|
||||
var lookupMediaProgress = true
|
||||
|
||||
@@ -332,6 +336,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
checkSetValidServerConnectionConfig { isConnected ->
|
||||
if (isConnected) {
|
||||
serverConfigIdUsed = DeviceManager.serverConnectionConfigId
|
||||
Log.d(tag, "loadAndroidAutoItems: Connected to server config id=$serverConfigIdUsed")
|
||||
|
||||
loadLibraries { libraries ->
|
||||
if (libraries.isEmpty()) {
|
||||
@@ -358,6 +363,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
}
|
||||
}
|
||||
} else { // Not connected to server
|
||||
Log.d(tag, "loadAndroidAutoItems: Not connected to server")
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +238,8 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
||||
saveLocalProgress(it)
|
||||
lastSyncTime = System.currentTimeMillis()
|
||||
|
||||
Log.d(tag, "Sync local device current serverConnectionConfigId=${DeviceManager.serverConnectionConfig?.id}")
|
||||
|
||||
// Local library item is linked to a server library item
|
||||
// Send sync to server also if connected to this server and local item belongs to this server
|
||||
if (hasNetworkConnection && shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
|
||||
|
||||
+2
-4
@@ -15,8 +15,6 @@ import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px
|
||||
|
||||
class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, private val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
|
||||
private val tag = "MediaDescriptionAdapter"
|
||||
|
||||
@@ -77,7 +75,7 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
||||
.load(uri)
|
||||
.placeholder(R.drawable.icon)
|
||||
.error(R.drawable.icon)
|
||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
||||
.submit()
|
||||
.get()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
@@ -85,7 +83,7 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
||||
Glide.with(playerNotificationService)
|
||||
.asBitmap()
|
||||
.load(Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon))
|
||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
||||
.submit()
|
||||
.get()
|
||||
}
|
||||
}
|
||||
|
||||
+17
-10
@@ -34,6 +34,7 @@ import com.audiobookshelf.app.managers.SleepTimerManager
|
||||
import com.audiobookshelf.app.media.MediaManager
|
||||
import com.audiobookshelf.app.media.MediaProgressSyncer
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.google.android.exoplayer2.*
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||
@@ -329,15 +330,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
}
|
||||
|
||||
mediaSessionConnector.setEnabledPlaybackActions(
|
||||
PlaybackStateCompat.ACTION_PLAY_PAUSE
|
||||
or PlaybackStateCompat.ACTION_PLAY
|
||||
or PlaybackStateCompat.ACTION_PAUSE
|
||||
or PlaybackStateCompat.ACTION_SEEK_TO
|
||||
or PlaybackStateCompat.ACTION_FAST_FORWARD
|
||||
or PlaybackStateCompat.ACTION_REWIND
|
||||
or PlaybackStateCompat.ACTION_STOP
|
||||
)
|
||||
setMediaSessionConnectorPlaybackActions()
|
||||
mediaSessionConnector.setQueueNavigator(queueNavigator)
|
||||
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
|
||||
|
||||
@@ -512,6 +505,20 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray())
|
||||
}
|
||||
|
||||
fun setMediaSessionConnectorPlaybackActions() {
|
||||
var playbackActions = PlaybackStateCompat.ACTION_PLAY_PAUSE or
|
||||
PlaybackStateCompat.ACTION_PLAY or
|
||||
PlaybackStateCompat.ACTION_PAUSE or
|
||||
PlaybackStateCompat.ACTION_FAST_FORWARD or
|
||||
PlaybackStateCompat.ACTION_REWIND or
|
||||
PlaybackStateCompat.ACTION_STOP
|
||||
|
||||
if (deviceSettings.allowSeekingOnMediaControls) {
|
||||
playbackActions = playbackActions or PlaybackStateCompat.ACTION_SEEK_TO
|
||||
}
|
||||
mediaSessionConnector.setEnabledPlaybackActions(playbackActions)
|
||||
}
|
||||
|
||||
fun handlePlayerPlaybackError(errorMessage:String) {
|
||||
// On error and was attempting to direct play - fallback to transcode
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
@@ -941,7 +948,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
appVersion: 0.9.46-beta
|
||||
*/
|
||||
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
|
||||
return DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, Build.VERSION.RELEASE)
|
||||
return DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||
}
|
||||
|
||||
private val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.audiobookshelf.app.plugins
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
import com.audiobookshelf.app.data.*
|
||||
@@ -493,9 +495,16 @@ class AbsDatabase : Plugin() {
|
||||
fun updateDeviceSettings(call:PluginCall) { // Returns device data
|
||||
Log.d(tag, "updateDeviceSettings ${call.data}")
|
||||
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
DeviceManager.deviceData.deviceSettings = newDeviceSettings
|
||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
|
||||
// Updates playback actions for media notification (handles media control seek locking setting)
|
||||
if (mainActivity.isPlayerNotificationServiceInitialized()) {
|
||||
mainActivity.foregroundService.setMediaSessionConnectorPlaybackActions()
|
||||
}
|
||||
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.deviceData)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.audiobookshelf.app.server
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
@@ -8,6 +11,7 @@ import com.audiobookshelf.app.media.MediaEventManager
|
||||
import com.audiobookshelf.app.media.MediaProgressSyncData
|
||||
import com.audiobookshelf.app.media.SyncResult
|
||||
import com.audiobookshelf.app.models.User
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
@@ -31,7 +35,7 @@ class ApiHandler(var ctx:Context) {
|
||||
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>)
|
||||
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>, val deviceInfo:DeviceInfo)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LocalSessionSyncResult(val id:String, val success:Boolean, val progressSynced:Boolean?, val error:String?)
|
||||
data class LocalSessionsSyncResponsePayload(val results:List<LocalSessionSyncResult>)
|
||||
@@ -322,7 +326,11 @@ class ApiHandler(var ctx:Context) {
|
||||
}
|
||||
|
||||
fun sendSyncLocalSessions(playbackSessions:List<PlaybackSession>, cb: (Boolean, String?) -> Unit) {
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions)))
|
||||
@SuppressLint("HardwareIds")
|
||||
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
|
||||
val deviceInfo = DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions, deviceInfo)))
|
||||
Log.d(tag, "Sending ${playbackSessions.size} saved local playback sessions to server")
|
||||
postRequest("/api/session/local-all", payload, null) {
|
||||
if (!it.getString("error").isNullOrEmpty()) {
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':byteowls-capacitor-filesharer'
|
||||
project(':byteowls-capacitor-filesharer').projectDir = new File('../node_modules/@byteowls/capacitor-filesharer/android')
|
||||
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 34
|
||||
targetSdkVersion = 33
|
||||
targetSdkVersion = 34
|
||||
androidxActivityVersion = '1.7.0'
|
||||
androidxAppCompatVersion = '1.6.1'
|
||||
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 './defaultStyles.css';
|
||||
@import './absicons.css';
|
||||
@import './transitions.css';
|
||||
|
||||
* {
|
||||
-webkit-touch-callout: none;
|
||||
@@ -16,10 +17,6 @@ textarea {
|
||||
user-select: auto;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #262626;
|
||||
}
|
||||
|
||||
.layout-wrapper {
|
||||
height: calc(100vh - env(safe-area-inset-top));
|
||||
min-height: calc(100vh - env(safe-area-inset-top));
|
||||
|
||||
@@ -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-buffered: 75 85 99;
|
||||
--gradient-item-page: linear-gradient(169deg, rgba(0, 0, 0, 0.4) 0%, rgba(55, 56, 56, 1) 80%);
|
||||
--gradient-audio-player: linear-gradient(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%);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,11 @@
|
||||
--color-track: 189 191 191;
|
||||
--color-track-buffered: 129 131 131;
|
||||
--gradient-item-page: linear-gradient(169deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 1) 80%);
|
||||
--gradient-audio-player: linear-gradient(169deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 1) 80%);
|
||||
--gradient-audio-player: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 80%);
|
||||
--gradient-minimized-audio-player: linear-gradient(145deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0.9) 20%, rgb(255, 255, 255) 60%);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-primary
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
.slide-enter-active {
|
||||
-moz-transition-duration: 0.1s;
|
||||
-webkit-transition-duration: 0.1s;
|
||||
-o-transition-duration: 0.1s;
|
||||
transition-duration: 0.1s;
|
||||
-moz-transition-timing-function: ease-in;
|
||||
-webkit-transition-timing-function: ease-in;
|
||||
-o-transition-timing-function: ease-in;
|
||||
transition-timing-function: ease-in;
|
||||
}
|
||||
|
||||
.slide-leave-active {
|
||||
-moz-transition-duration: 0.2s;
|
||||
-webkit-transition-duration: 0.2s;
|
||||
-o-transition-duration: 0.2s;
|
||||
transition-duration: 0.2s;
|
||||
-moz-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||
-webkit-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||
-o-transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||
transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
|
||||
}
|
||||
|
||||
.slide-enter-to,
|
||||
.slide-leave {
|
||||
max-height: 600px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slide-enter,
|
||||
.slide-leave-to {
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.menu-enter,
|
||||
.menu-leave-active {
|
||||
transform: translateY(-15px);
|
||||
}
|
||||
|
||||
.menu-enter-active {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.menu-leave-active {
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.menu-enter,
|
||||
.menu-leave-active {
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -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">
|
||||
<span class="material-icons text-3xl text-fg">arrow_back</span>
|
||||
</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">
|
||||
<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>
|
||||
@@ -54,14 +54,17 @@ export default {
|
||||
this.$store.commit('setCastAvailable', val)
|
||||
}
|
||||
},
|
||||
networkConnected() {
|
||||
return this.$store.state.networkConnected
|
||||
},
|
||||
currentLibrary() {
|
||||
return this.$store.getters['libraries/getCurrentLibrary']
|
||||
},
|
||||
currentLibraryName() {
|
||||
return this.currentLibrary ? this.currentLibrary.name : ''
|
||||
return this.currentLibrary?.name || ''
|
||||
},
|
||||
currentLibraryIcon() {
|
||||
return this.currentLibrary ? this.currentLibrary.icon : 'database'
|
||||
return this.currentLibrary?.icon || 'database'
|
||||
},
|
||||
showBack() {
|
||||
if (!this.$route.name) return true
|
||||
@@ -71,7 +74,7 @@ export default {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
username() {
|
||||
return this.user ? this.user.username : 'err'
|
||||
return this.user?.username || 'err'
|
||||
},
|
||||
isCasting() {
|
||||
return this.$store.state.isCasting
|
||||
|
||||
+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>
|
||||
</div>
|
||||
|
||||
<div v-if="useChapterTrack && useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
|
||||
<div v-if="playerSettings.useChapterTrack && playerSettings.useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
|
||||
<div class="flex">
|
||||
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ currentTimePretty }}</p>
|
||||
<div class="flex-grow" />
|
||||
@@ -41,7 +41,9 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</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 id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px">
|
||||
<div class="flex items-center max-w-full" :class="lockUi ? 'justify-center' : 'justify-between'">
|
||||
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-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>
|
||||
<div class="flex items-center max-w-full" :class="playerSettings.lockUi ? 'justify-center' : 'justify-between'">
|
||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-icons next-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||
<span v-show="!playerSettings.lockUi" class="material-icons jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
||||
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4 relative overflow-hidden" :style="{ backgroundColor: coverRgb }" :class="{ 'animate-spin': seekLoading }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
|
||||
<div v-if="!coverBgIsLight" class="absolute top-0 left-0 w-full h-full bg-white bg-opacity-20 pointer-events-none" />
|
||||
|
||||
<span v-if="!isLoading" class="material-icons" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
||||
<widgets-spinner-icon v-else class="h-8 w-8" />
|
||||
</div>
|
||||
<span v-show="!lockUi" class="material-icons jump-icon text-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="!playerSettings.lockUi" class="material-icons jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
|
||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-icons next-icon text-fg cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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="bufferedTrack" class="h-full bg-track absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="playedTrack" class="h-full bg-track-cursor absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="trackCursor" class="h-7 w-7 rounded-full absolute pointer-events-auto flex items-center justify-center" :style="{ top: '-11px' }" :class="{ 'opacity-0': lockUi || !showFullscreen }" @touchstart="touchstartCursor">
|
||||
<div ref="trackCursor" class="h-7 w-7 rounded-full absolute pointer-events-auto flex items-center justify-center" :style="{ top: '-11px' }" :class="{ 'opacity-0': playerSettings.lockUi || !showFullscreen }" @touchstart="touchstartCursor">
|
||||
<div class="bg-track-cursor rounded-full w-3.5 h-3.5 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -98,7 +100,7 @@
|
||||
</div>
|
||||
|
||||
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" :playback-rate="currentPlaybackRate" @select="selectChapter" />
|
||||
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction" />
|
||||
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" width="80vw" @action="clickMenuAction" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -106,6 +108,7 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
||||
import { FastAverageColor } from 'fast-average-color'
|
||||
import WrappingMarquee from '@/assets/WrappingMarquee.js'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@@ -145,10 +148,12 @@ export default {
|
||||
onPlaybackSpeedChangedListener: null,
|
||||
touchStartY: 0,
|
||||
touchStartTime: 0,
|
||||
touchEndY: 0,
|
||||
useChapterTrack: false,
|
||||
useTotalTrack: true,
|
||||
lockUi: false,
|
||||
playerSettings: {
|
||||
useChapterTrack: false,
|
||||
useTotalTrack: true,
|
||||
scaleElapsedTimeBySpeed: true,
|
||||
lockUi: false
|
||||
},
|
||||
isLoading: false,
|
||||
isDraggingCursor: false,
|
||||
draggingTouchStartX: 0,
|
||||
@@ -157,16 +162,22 @@ export default {
|
||||
syncStatus: 0,
|
||||
showMoreMenuDialog: false,
|
||||
coverRgb: 'rgb(55, 56, 56)',
|
||||
coverBgIsLight: false
|
||||
coverBgIsLight: false,
|
||||
titleMarquee: null,
|
||||
isRefreshingUI: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
showFullscreen(val) {
|
||||
this.updateScreenSize()
|
||||
this.$store.commit('setPlayerFullscreen', !!val)
|
||||
document.querySelector('body').style.backgroundColor = this.showFullscreen ? this.coverRgb : ''
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
this.updateScreenSize()
|
||||
},
|
||||
title(val) {
|
||||
if (this.titleMarquee) this.titleMarquee.init(val)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -186,17 +197,22 @@ export default {
|
||||
{
|
||||
text: this.$strings.LabelTotalTrack,
|
||||
value: 'total_track',
|
||||
icon: this.useTotalTrack ? 'check_box' : 'check_box_outline_blank'
|
||||
icon: this.playerSettings.useTotalTrack ? 'check_box' : 'check_box_outline_blank'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelChapterTrack,
|
||||
value: 'chapter_track',
|
||||
icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
|
||||
icon: this.playerSettings.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
|
||||
},
|
||||
{
|
||||
text: this.lockUi ? 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',
|
||||
icon: this.lockUi ? 'lock' : 'lock_open'
|
||||
icon: this.playerSettings.lockUi ? 'lock' : 'lock_open'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelClosePlayer,
|
||||
@@ -256,22 +272,22 @@ export default {
|
||||
return this.mediaPlayer === 'cast-player'
|
||||
},
|
||||
mediaPlayer() {
|
||||
return this.playbackSession ? this.playbackSession.mediaPlayer : null
|
||||
return this.playbackSession?.mediaPlayer || null
|
||||
},
|
||||
mediaType() {
|
||||
return this.playbackSession ? this.playbackSession.mediaType : null
|
||||
return this.playbackSession?.mediaType || null
|
||||
},
|
||||
isPodcast() {
|
||||
return this.mediaType === 'podcast'
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.playbackSession ? this.playbackSession.mediaMetadata : null
|
||||
return this.playbackSession?.mediaMetadata || null
|
||||
},
|
||||
libraryItem() {
|
||||
return this.playbackSession ? this.playbackSession.libraryItem || null : null
|
||||
return this.playbackSession?.libraryItem || null
|
||||
},
|
||||
localLibraryItem() {
|
||||
return this.playbackSession ? this.playbackSession.localLibraryItem || null : null
|
||||
return this.playbackSession?.localLibraryItem || null
|
||||
},
|
||||
localLibraryItemCoverSrc() {
|
||||
var localItemCover = this.localLibraryItem?.coverContentUrl || null
|
||||
@@ -279,7 +295,7 @@ export default {
|
||||
return null
|
||||
},
|
||||
playMethod() {
|
||||
return this.playbackSession ? this.playbackSession.playMethod : null
|
||||
return this.playbackSession?.playMethod || 0
|
||||
},
|
||||
isLocalPlayMethod() {
|
||||
return this.playMethod == this.$constants.PlayMethod.LOCAL
|
||||
@@ -288,19 +304,19 @@ export default {
|
||||
return this.playMethod == this.$constants.PlayMethod.DIRECTPLAY
|
||||
},
|
||||
title() {
|
||||
if (this.currentChapterTitle && this.showFullscreen) return this.currentChapterTitle
|
||||
if (this.playbackSession) return this.playbackSession.displayTitle
|
||||
return this.mediaMetadata ? this.mediaMetadata.title : 'Title'
|
||||
const mediaItemTitle = this.playbackSession?.displayTitle || this.mediaMetadata?.title || 'Title'
|
||||
if (this.currentChapterTitle) {
|
||||
if (this.showFullscreen) return this.currentChapterTitle
|
||||
return `${mediaItemTitle} | ${this.currentChapterTitle}`
|
||||
}
|
||||
return mediaItemTitle
|
||||
},
|
||||
authorName() {
|
||||
if (this.playbackSession) return this.playbackSession.displayAuthor
|
||||
return this.mediaMetadata ? this.mediaMetadata.authorName : 'Author'
|
||||
return this.mediaMetadata?.authorName || 'Author'
|
||||
},
|
||||
chapters() {
|
||||
if (this.playbackSession && this.playbackSession.chapters) {
|
||||
return this.playbackSession.chapters
|
||||
}
|
||||
return []
|
||||
return this.playbackSession?.chapters || []
|
||||
},
|
||||
currentChapter() {
|
||||
if (!this.chapters.length) return null
|
||||
@@ -311,7 +327,7 @@ export default {
|
||||
return this.chapters.find((c) => Number(Number(c.start).toFixed(2)) > this.currentTime)
|
||||
},
|
||||
currentChapterTitle() {
|
||||
return this.currentChapter ? this.currentChapter.title : ''
|
||||
return this.currentChapter?.title || ''
|
||||
},
|
||||
currentChapterDuration() {
|
||||
return this.currentChapter ? this.currentChapter.end - this.currentChapter.start : this.totalDuration
|
||||
@@ -321,11 +337,14 @@ export default {
|
||||
},
|
||||
currentTimePretty() {
|
||||
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
||||
return this.$secondsToTimestamp(currentTimeToUse / this.currentPlaybackRate)
|
||||
if (this.playerSettings.scaleElapsedTimeBySpeed) {
|
||||
currentTimeToUse = currentTimeToUse / this.currentPlaybackRate
|
||||
}
|
||||
return this.$secondsToTimestamp(currentTimeToUse)
|
||||
},
|
||||
timeRemaining() {
|
||||
let currentTimeToUse = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
if (this.playerSettings.useChapterTrack && this.currentChapter) {
|
||||
var currChapTime = currentTimeToUse - this.currentChapter.start
|
||||
return (this.currentChapterDuration - currChapTime) / this.currentPlaybackRate
|
||||
}
|
||||
@@ -411,7 +430,11 @@ export default {
|
||||
AbsAudioPlayer.requestSession()
|
||||
},
|
||||
clickContainer() {
|
||||
this.expandToFullscreen()
|
||||
},
|
||||
expandToFullscreen() {
|
||||
this.showFullscreen = true
|
||||
if (this.titleMarquee) this.titleMarquee.reset()
|
||||
|
||||
// Update track for total time bar if useChapterTrack is set
|
||||
this.$nextTick(() => {
|
||||
@@ -420,6 +443,8 @@ export default {
|
||||
},
|
||||
collapseFullscreen() {
|
||||
this.showFullscreen = false
|
||||
if (this.titleMarquee) this.titleMarquee.reset()
|
||||
|
||||
this.forceCloseDropdownMenu()
|
||||
},
|
||||
async jumpNextChapter() {
|
||||
@@ -493,7 +518,7 @@ export default {
|
||||
this.updateReadyTrack()
|
||||
},
|
||||
updateReadyTrack() {
|
||||
if (this.useChapterTrack) {
|
||||
if (this.playerSettings.useChapterTrack) {
|
||||
if (this.$refs.totalReadyTrack) {
|
||||
this.$refs.totalReadyTrack.style.width = this.readyTrackWidth + 'px'
|
||||
}
|
||||
@@ -510,11 +535,14 @@ export default {
|
||||
}
|
||||
|
||||
let currentTime = this.isDraggingCursor ? this.draggingCurrentTime : this.currentTime
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
if (this.playerSettings.useChapterTrack && this.currentChapter) {
|
||||
currentTime = Math.max(0, currentTime - this.currentChapter.start)
|
||||
}
|
||||
if (this.playerSettings.scaleElapsedTimeBySpeed) {
|
||||
currentTime = currentTime / this.currentPlaybackRate
|
||||
}
|
||||
|
||||
ts.innerText = this.$secondsToTimestamp(currentTime / this.currentPlaybackRate)
|
||||
ts.innerText = this.$secondsToTimestamp(currentTime)
|
||||
},
|
||||
timeupdate() {
|
||||
if (!this.$refs.playedTrack) {
|
||||
@@ -542,7 +570,7 @@ export default {
|
||||
let bufferedPercent = this.bufferedTime / this.totalDuration
|
||||
const totalBufferedPercent = bufferedPercent
|
||||
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
if (this.playerSettings.useChapterTrack && this.currentChapter) {
|
||||
const currChapTime = currentTimeToUse - this.currentChapter.start
|
||||
percentDone = currChapTime / this.currentChapterDuration
|
||||
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration))
|
||||
@@ -556,7 +584,7 @@ export default {
|
||||
this.$refs.trackCursor.style.left = ptWidth - 14 + 'px'
|
||||
}
|
||||
|
||||
if (this.useChapterTrack) {
|
||||
if (this.playerSettings.useChapterTrack) {
|
||||
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px'
|
||||
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px'
|
||||
}
|
||||
@@ -583,7 +611,7 @@ export default {
|
||||
}
|
||||
},
|
||||
async touchstartCursor(e) {
|
||||
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return
|
||||
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.playerSettings.lockUi) return
|
||||
|
||||
await this.$hapticsImpact()
|
||||
this.isDraggingCursor = true
|
||||
@@ -624,38 +652,49 @@ export default {
|
||||
resetStream(startTime) {
|
||||
this.closePlayback()
|
||||
},
|
||||
handleGesture() {
|
||||
var touchDistance = this.touchEndY - this.touchStartY
|
||||
if (touchDistance > 100) {
|
||||
this.collapseFullscreen()
|
||||
}
|
||||
},
|
||||
touchstart(e) {
|
||||
if (!this.showFullscreen || !e.changedTouches) return
|
||||
if (!e.changedTouches || this.$store.state.globals.isModalOpen) return
|
||||
const touchPosY = e.changedTouches[0].pageY
|
||||
// when minimized only listen to touchstart on the player
|
||||
if (!this.showFullscreen && touchPosY < window.innerHeight - 120) return
|
||||
|
||||
this.touchStartY = e.changedTouches[0].screenY
|
||||
if (this.touchStartY > window.innerHeight / 3) {
|
||||
// console.log('touch too low')
|
||||
return
|
||||
// for ios
|
||||
if (!this.showFullscreen && e.pageX < 20) {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
}
|
||||
|
||||
this.touchStartY = touchPosY
|
||||
this.touchStartTime = Date.now()
|
||||
},
|
||||
touchend(e) {
|
||||
if (!e.changedTouches) return
|
||||
const touchDuration = Date.now() - this.touchStartTime
|
||||
const touchEndY = e.changedTouches[0].pageY
|
||||
const touchDistanceY = touchEndY - this.touchStartY
|
||||
|
||||
// reset touch start data
|
||||
this.touchStartTime = 0
|
||||
this.touchStartY = 0
|
||||
|
||||
if (this.isDraggingCursor) {
|
||||
if (this.draggingCurrentTime !== this.currentTime) {
|
||||
this.seek(this.draggingCurrentTime)
|
||||
}
|
||||
this.isDraggingCursor = false
|
||||
} else if (this.showFullscreen) {
|
||||
this.touchEndY = e.changedTouches[0].screenY
|
||||
var touchDuration = Date.now() - this.touchStartTime
|
||||
} else {
|
||||
if (touchDuration > 1200) {
|
||||
// console.log('touch too long', touchDuration)
|
||||
return
|
||||
}
|
||||
this.handleGesture()
|
||||
if (this.showFullscreen) {
|
||||
// Touch start higher than touchend
|
||||
if (touchDistanceY > 100) {
|
||||
this.collapseFullscreen()
|
||||
}
|
||||
} else if (touchDistanceY < -100) {
|
||||
this.expandToFullscreen()
|
||||
}
|
||||
}
|
||||
},
|
||||
touchmove(e) {
|
||||
@@ -665,7 +704,7 @@ export default {
|
||||
let duration = this.totalDuration
|
||||
let minTime = 0
|
||||
let maxTime = duration
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
if (this.playerSettings.useChapterTrack && this.currentChapter) {
|
||||
duration = this.currentChapterDuration
|
||||
minTime = this.currentChapter.start
|
||||
maxTime = minTime + duration
|
||||
@@ -685,32 +724,42 @@ export default {
|
||||
if (action === 'history') {
|
||||
this.$router.push(`/media/${this.mediaId}/history?title=${this.title}`)
|
||||
this.showFullscreen = false
|
||||
} else if (action === 'scale_elapsed_time') {
|
||||
this.playerSettings.scaleElapsedTimeBySpeed = !this.playerSettings.scaleElapsedTimeBySpeed
|
||||
this.updateTimestamp()
|
||||
this.savePlayerSettings()
|
||||
} else if (action === 'lock') {
|
||||
this.lockUi = !this.lockUi
|
||||
this.$localStore.setPlayerLock(this.lockUi)
|
||||
this.playerSettings.lockUi = !this.playerSettings.lockUi
|
||||
this.savePlayerSettings()
|
||||
} else if (action === 'chapter_track') {
|
||||
this.useChapterTrack = !this.useChapterTrack
|
||||
this.useTotalTrack = !this.useChapterTrack || this.useTotalTrack
|
||||
this.playerSettings.useChapterTrack = !this.playerSettings.useChapterTrack
|
||||
this.playerSettings.useTotalTrack = !this.playerSettings.useChapterTrack || this.playerSettings.useTotalTrack
|
||||
|
||||
this.updateTimestamp()
|
||||
this.updateTrack()
|
||||
this.updateReadyTrack()
|
||||
this.$localStore.setUseTotalTrack(this.useTotalTrack)
|
||||
this.$localStore.setUseChapterTrack(this.useChapterTrack)
|
||||
this.updateUseChapterTrack()
|
||||
this.savePlayerSettings()
|
||||
} else if (action === 'total_track') {
|
||||
this.useTotalTrack = !this.useTotalTrack
|
||||
this.useChapterTrack = !this.useTotalTrack || this.useChapterTrack
|
||||
this.playerSettings.useTotalTrack = !this.playerSettings.useTotalTrack
|
||||
this.playerSettings.useChapterTrack = !this.playerSettings.useTotalTrack || this.playerSettings.useChapterTrack
|
||||
|
||||
this.updateTimestamp()
|
||||
this.updateTrack()
|
||||
this.updateReadyTrack()
|
||||
this.$localStore.setUseTotalTrack(this.useTotalTrack)
|
||||
this.$localStore.setUseChapterTrack(this.useChapterTrack)
|
||||
this.updateUseChapterTrack()
|
||||
this.savePlayerSettings()
|
||||
} else if (action === 'close') {
|
||||
this.closePlayback()
|
||||
}
|
||||
})
|
||||
},
|
||||
updateUseChapterTrack() {
|
||||
// Chapter track in NowPlaying only supported on iOS for now
|
||||
if (this.$platform === 'ios') {
|
||||
AbsAudioPlayer.setChapterTrack({ enabled: this.playerSettings.useChapterTrack })
|
||||
}
|
||||
},
|
||||
forceCloseDropdownMenu() {
|
||||
if (this.$refs.dropdownMenu && this.$refs.dropdownMenu.closeMenu) {
|
||||
this.$refs.dropdownMenu.closeMenu()
|
||||
@@ -727,6 +776,30 @@ export default {
|
||||
this.isLoading = false
|
||||
this.playbackSession = null
|
||||
},
|
||||
async loadPlayerSettings() {
|
||||
const savedPlayerSettings = await this.$localStore.getPlayerSettings()
|
||||
if (!savedPlayerSettings) {
|
||||
// In 0.9.72-beta 'useChapterTrack', 'useTotalTrack' and 'playerLock' was replaced with 'playerSettings' JSON object
|
||||
// Check if this old key was set and if so migrate them over to 'playerSettings'
|
||||
const chapterTrackPref = await this.$localStore.getPreferenceByKey('useChapterTrack')
|
||||
if (chapterTrackPref) {
|
||||
this.playerSettings.useChapterTrack = chapterTrackPref === '1'
|
||||
const totalTrackPref = await this.$localStore.getPreferenceByKey('useTotalTrack')
|
||||
this.playerSettings.useTotalTrack = totalTrackPref === '1'
|
||||
const playerLockPref = await this.$localStore.getPreferenceByKey('playerLock')
|
||||
this.playerSettings.lockUi = playerLockPref === '1'
|
||||
}
|
||||
this.savePlayerSettings()
|
||||
} else {
|
||||
this.playerSettings.useChapterTrack = !!savedPlayerSettings.useChapterTrack
|
||||
this.playerSettings.useTotalTrack = !!savedPlayerSettings.useTotalTrack
|
||||
this.playerSettings.lockUi = !!savedPlayerSettings.lockUi
|
||||
this.playerSettings.scaleElapsedTimeBySpeed = !!savedPlayerSettings.scaleElapsedTimeBySpeed
|
||||
}
|
||||
},
|
||||
savePlayerSettings() {
|
||||
return this.$localStore.setPlayerSettings({ ...this.playerSettings })
|
||||
},
|
||||
//
|
||||
// Listeners from audio AbsAudioPlayer
|
||||
//
|
||||
@@ -771,6 +844,10 @@ export default {
|
||||
|
||||
// Set track width
|
||||
this.$nextTick(() => {
|
||||
if (this.titleMarquee) this.titleMarquee.reset()
|
||||
this.titleMarquee = new WrappingMarquee(this.$refs.titlewrapper)
|
||||
this.titleMarquee.init(this.title)
|
||||
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
} else {
|
||||
@@ -793,9 +870,7 @@ export default {
|
||||
this.updateTimestamp()
|
||||
},
|
||||
async init() {
|
||||
this.useChapterTrack = await this.$localStore.getUseChapterTrack()
|
||||
this.useTotalTrack = await this.$localStore.getUseTotalTrack()
|
||||
this.lockUi = await this.$localStore.getPlayerLock()
|
||||
await this.loadPlayerSettings()
|
||||
|
||||
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
|
||||
this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
|
||||
@@ -806,26 +881,51 @@ export default {
|
||||
this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
|
||||
this.onPlaybackSpeedChangedListener = AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged)
|
||||
},
|
||||
screenOrientationChange() {
|
||||
setTimeout(() => {
|
||||
this.updateScreenSize()
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
this.updateTrack()
|
||||
this.updateReadyTrack()
|
||||
async screenOrientationChange() {
|
||||
if (this.isRefreshingUI) return
|
||||
this.isRefreshingUI = true
|
||||
const windowWidth = window.innerWidth
|
||||
this.refreshUI()
|
||||
|
||||
// Window width does not always change right away. Wait up to 250ms for a change.
|
||||
// iPhone 10 on iOS 16 took between 100 - 200ms to update when going from portrait to landscape
|
||||
// but landscape to portrait was immediate
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
if (window.innerWidth !== windowWidth) {
|
||||
this.refreshUI()
|
||||
break
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
||||
this.isRefreshingUI = false
|
||||
},
|
||||
refreshUI() {
|
||||
this.updateScreenSize()
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
this.updateTrack()
|
||||
this.updateReadyTrack()
|
||||
}
|
||||
},
|
||||
updateScreenSize() {
|
||||
setTimeout(() => {
|
||||
if (this.titleMarquee) this.titleMarquee.init(this.title)
|
||||
}, 500)
|
||||
|
||||
this.windowHeight = window.innerHeight
|
||||
this.windowWidth = window.innerWidth
|
||||
const coverHeight = this.fullscreenBookCoverWidth * this.bookCoverAspectRatio
|
||||
const coverImageWidthCollapsed = 46 / this.bookCoverAspectRatio
|
||||
const titleAuthorLeftOffsetCollapsed = 30 + coverImageWidthCollapsed
|
||||
const titleAuthorWidthCollapsed = this.windowWidth - 128 - titleAuthorLeftOffsetCollapsed - 10
|
||||
|
||||
document.documentElement.style.setProperty('--cover-image-width', this.fullscreenBookCoverWidth + 'px')
|
||||
document.documentElement.style.setProperty('--cover-image-height', coverHeight + 'px')
|
||||
document.documentElement.style.setProperty('--cover-image-width-collapsed', coverImageWidthCollapsed + 'px')
|
||||
document.documentElement.style.setProperty('--cover-image-height-collapsed', 46 + 'px')
|
||||
document.documentElement.style.setProperty('--title-author-left-offset-collapsed', 30 + coverImageWidthCollapsed + 'px')
|
||||
document.documentElement.style.setProperty('--title-author-left-offset-collapsed', titleAuthorLeftOffsetCollapsed + 'px')
|
||||
document.documentElement.style.setProperty('--title-author-width-collapsed', titleAuthorWidthCollapsed + 'px')
|
||||
},
|
||||
minimizePlayerEvt() {
|
||||
this.collapseFullscreen()
|
||||
@@ -848,7 +948,7 @@ export default {
|
||||
window.addEventListener('resize', this.screenOrientationChange)
|
||||
|
||||
this.$eventBus.$on('minimize-player', this.minimizePlayerEvt)
|
||||
document.body.addEventListener('touchstart', this.touchstart)
|
||||
document.body.addEventListener('touchstart', this.touchstart, { passive: false })
|
||||
document.body.addEventListener('touchend', this.touchend)
|
||||
document.body.addEventListener('touchmove', this.touchmove)
|
||||
this.$nextTick(this.init)
|
||||
@@ -893,6 +993,7 @@ export default {
|
||||
--cover-image-width-collapsed: 46px;
|
||||
--cover-image-height-collapsed: 46px;
|
||||
--title-author-left-offset-collapsed: 80px;
|
||||
--title-author-width-collapsed: 40%;
|
||||
}
|
||||
|
||||
.playerContainer {
|
||||
@@ -940,7 +1041,7 @@ export default {
|
||||
transition-property: left, bottom, width, height;
|
||||
transform-origin: left bottom;
|
||||
|
||||
width: 40%;
|
||||
width: var(--title-author-width-collapsed);
|
||||
bottom: 76px;
|
||||
left: var(--title-author-left-offset-collapsed);
|
||||
text-align: left;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<script>
|
||||
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
import CellularPermissionHelpers from '@/mixins/cellularPermissionHelpers'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
@@ -39,6 +40,7 @@ export default {
|
||||
serverEpisodeId: null
|
||||
}
|
||||
},
|
||||
mixins: [CellularPermissionHelpers],
|
||||
computed: {
|
||||
bookmarks() {
|
||||
if (!this.serverLibraryItemId) return []
|
||||
@@ -193,12 +195,21 @@ export default {
|
||||
const startTime = payload.startTime
|
||||
const startWhenReady = !payload.paused
|
||||
|
||||
const isLocal = libraryItemId.startsWith('local')
|
||||
if (!isLocal) {
|
||||
const hasPermission = await this.checkCellularPermission('streaming')
|
||||
if (!hasPermission) {
|
||||
this.$store.commit('setPlayerDoneStartingPlayback')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// When playing local library item and can also play this item from the server
|
||||
// then store the server library item id so it can be used if a cast is made
|
||||
const serverLibraryItemId = payload.serverLibraryItemId || null
|
||||
const serverEpisodeId = payload.serverEpisodeId || null
|
||||
|
||||
if (libraryItemId.startsWith('local') && this.$store.state.isCasting) {
|
||||
if (isLocal && this.$store.state.isCasting) {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Warning',
|
||||
message: `Cannot cast downloaded media items. Confirm to close cast and play on your device.`
|
||||
@@ -373,4 +384,4 @@ export default {
|
||||
this.$eventBus.$off('device-focus-update', this.deviceFocused)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -79,7 +79,10 @@ export default {
|
||||
return this.$store.state.serverSettings || {}
|
||||
},
|
||||
username() {
|
||||
return this.user ? this.user.username : ''
|
||||
return this.user?.username || ''
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
navItems() {
|
||||
var items = [
|
||||
@@ -132,6 +135,12 @@ export default {
|
||||
})
|
||||
|
||||
if (this.serverConnectionConfig) {
|
||||
items.push({
|
||||
icon: 'language',
|
||||
text: this.$strings.ButtonGoToWebClient,
|
||||
action: 'openWebClient'
|
||||
})
|
||||
|
||||
items.push({
|
||||
icon: 'login',
|
||||
text: this.$strings.ButtonSwitchServerUser,
|
||||
@@ -151,6 +160,10 @@ export default {
|
||||
if (action === 'logout') {
|
||||
await this.logout()
|
||||
this.$router.push('/connect')
|
||||
} else if (action === 'openWebClient') {
|
||||
this.show = false
|
||||
let path = `/library/${this.$store.state.libraries.currentLibraryId}`
|
||||
await this.$store.dispatch('user/openWebClient', path)
|
||||
}
|
||||
},
|
||||
clickBackground() {
|
||||
|
||||
@@ -79,7 +79,7 @@ export default {
|
||||
return this.page
|
||||
},
|
||||
hasFilter() {
|
||||
if (this.page === 'series' || this.page === 'collections') return false
|
||||
if (this.page === 'series' || this.page === 'collections' || this.page === 'playlists') return false
|
||||
return this.filterBy !== 'all'
|
||||
},
|
||||
orderBy() {
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
|
||||
<!-- Alternative bookshelf title/author/sort -->
|
||||
<div v-if="isAltViewEnabled" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||
{{ displayTitle }}
|
||||
</p>
|
||||
<div :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }" class="flex items-center">
|
||||
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||
{{ displayTitle }}
|
||||
</p>
|
||||
<widgets-explicit-indicator v-if="isExplicit" />
|
||||
</div>
|
||||
<p class="truncate text-fg-muted" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || ' ' }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-fg-muted" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
</div>
|
||||
@@ -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>
|
||||
|
||||
<!-- Downloaded icon -->
|
||||
<div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast ? 1.75 : 0.375) * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast || (seriesSequence && showSequence) ? 1.75 : 0.375) * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
|
||||
</div>
|
||||
|
||||
@@ -310,7 +313,7 @@ export default {
|
||||
return !!this.userProgress?.isFinished
|
||||
},
|
||||
showError() {
|
||||
return this.numMissingParts || this.isMissing || this.isInvalid
|
||||
return this.isMissing || this.isInvalid
|
||||
},
|
||||
localLibraryItemId() {
|
||||
if (this.isLocal) return this.libraryItemId
|
||||
@@ -341,9 +344,8 @@ export default {
|
||||
isInvalid() {
|
||||
return this._libraryItem.isInvalid
|
||||
},
|
||||
numMissingParts() {
|
||||
if (this.isPodcast) return 0
|
||||
return this.media.numMissingParts
|
||||
isExplicit() {
|
||||
return !!this.mediaMetadata.explicit
|
||||
},
|
||||
overlayWrapperClasslist() {
|
||||
var classes = []
|
||||
@@ -366,9 +368,6 @@ export default {
|
||||
userCanDownload() {
|
||||
return this.store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userIsRoot() {
|
||||
return this.store.getters['user/getIsRoot']
|
||||
},
|
||||
titleFontSize() {
|
||||
return 0.75 * this.sizeMultiplier
|
||||
},
|
||||
@@ -493,7 +492,12 @@ export default {
|
||||
if (router) {
|
||||
if (this.recentEpisode) router.push(`/item/${this.libraryItemId}/${this.recentEpisode.id}`)
|
||||
else if (this.collapsedSeries) router.push(`/bookshelf/series/${this.collapsedSeries.id}`)
|
||||
else router.push(`/item/${this.libraryItemId}`)
|
||||
else if (this.localLibraryItem) {
|
||||
// Pass local library item id to server page to allow falling back to offline page
|
||||
router.push(`/item/${this.libraryItemId}?localLibraryItemId=${this.localLibraryItemId}`)
|
||||
} else {
|
||||
router.push(`/item/${this.libraryItemId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
<div class="flex-grow px-2">
|
||||
<p class="whitespace-normal" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
|
||||
<p class="whitespace-normal line-clamp-2" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
|
||||
<span v-if="seriesSequence">#{{ seriesSequence }} </span>{{ displayTitle }}
|
||||
</p>
|
||||
<p class="truncate text-fg-muted" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
|
||||
@@ -215,7 +215,7 @@ export default {
|
||||
return !!this.userProgress?.isFinished
|
||||
},
|
||||
showError() {
|
||||
return this.numMissingParts || this.isMissing || this.isInvalid
|
||||
return this.isMissing || this.isInvalid
|
||||
},
|
||||
isStreaming() {
|
||||
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
|
||||
@@ -235,10 +235,6 @@ export default {
|
||||
isInvalid() {
|
||||
return this._libraryItem.isInvalid
|
||||
},
|
||||
numMissingParts() {
|
||||
if (this.isPodcast) return 0
|
||||
return this.media.numMissingParts
|
||||
},
|
||||
store() {
|
||||
return this.$store || this.$nuxt.$store
|
||||
},
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<ui-text-input v-model="password" type="password" :disabled="processing" :placeholder="$strings.LabelPassword" class="w-full mb-2 text-lg" />
|
||||
|
||||
<div class="flex items-center pt-2">
|
||||
<ui-icon-btn v-if="serverConfig.id" small bg-color="error" icon="delete" @click="removeServerConfigClick" />
|
||||
<ui-icon-btn v-if="serverConfig.id" small bg-color="error" icon="delete" type="button" @click="removeServerConfigClick" />
|
||||
<div class="flex-grow" />
|
||||
<ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? $strings.ButtonSubmit : $strings.MessageNoNetworkConnection }}</ui-btn>
|
||||
</div>
|
||||
|
||||
@@ -64,14 +64,14 @@ export default {
|
||||
return this.$route.query || {}
|
||||
},
|
||||
entityTitle() {
|
||||
if (this.isPodcast) return this.$strings.LabelPodcasts
|
||||
if (this.page === 'library') return this.$strings.LabelBooks
|
||||
else if (this.page === 'series') {
|
||||
if (this.page === 'library') {
|
||||
return this.isPodcast ? this.$strings.LabelPodcasts : this.$strings.LabelBooks
|
||||
} else if (this.page === 'playlists') {
|
||||
return this.$strings.ButtonPlaylists
|
||||
} else if (this.page === 'series') {
|
||||
return this.$strings.LabelSeries
|
||||
} else if (this.page === 'collections') {
|
||||
return this.$strings.ButtonCollections
|
||||
} else if (this.page === 'playlists') {
|
||||
return this.$strings.ButtonPlaylists
|
||||
} else if (this.page === 'authors') {
|
||||
return this.$strings.LabelAuthors
|
||||
}
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="200" height="100%">
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center"
|
||||
@click="
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="
|
||||
show = false
|
||||
manualTimerModal = false
|
||||
"
|
||||
>
|
||||
">
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<div v-if="manualTimerModal" class="p-4">
|
||||
<div class="flex mb-4" @click="manualTimerModal = false">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<div class="flex my-2 justify-between">
|
||||
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
|
||||
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px">
|
||||
<span class="material-icons">remove</span>
|
||||
</ui-btn>
|
||||
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
|
||||
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
|
||||
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px">
|
||||
<span class="material-icons">add</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">{{ $strings.ButtonSetTimer }}</ui-btn>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="400" height="100%">
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
@@ -14,9 +14,7 @@
|
||||
</div>
|
||||
<p class="text-xl pl-2">{{ selectedBookmark ? 'Edit Bookmark' : 'New Bookmark' }}</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="text-xl font-mono">
|
||||
{{ this.$secondsToTimestamp(currentTime) }}
|
||||
</p>
|
||||
<p class="text-xl font-mono">{{ this.$secondsToTimestamp(currentTime) }}</p>
|
||||
</div>
|
||||
|
||||
<ui-text-input-with-label v-model="newBookmarkTitle" label="Note" />
|
||||
@@ -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">
|
||||
<span class="material-icons">add</span>
|
||||
<p class="text-base pl-2">{{ $strings.ButtonCreateBookmark }}</p>
|
||||
<p class="text-sm font-mono">
|
||||
{{ this.$secondsToTimestamp(currentTime) }}
|
||||
</p>
|
||||
<p class="text-sm font-mono">{{ this.$secondsToTimestamp(currentTime) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@ export default {
|
||||
default: '16px'
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
type: [String, Number],
|
||||
default: 300
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" width="90%" height="100%">
|
||||
<template #outer>
|
||||
<div v-show="selected !== 'all'" class="absolute top-10 left-4 z-40">
|
||||
<ui-btn class="text-lg border-yellow-400 border-opacity-40" @click="clearSelected">{{ $strings.ButtonClearFilter }}</ui-btn>
|
||||
<div v-show="selected !== 'all'" class="absolute top-12 left-4 z-40">
|
||||
<ui-btn class="text-lg border-yellow-400 border-opacity-40 h-10" :padding-y="0" @click="clearSelected">{{ $strings.ButtonClearFilter }}</ui-btn>
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-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">
|
||||
<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)">
|
||||
@@ -210,7 +210,7 @@ export default {
|
||||
]
|
||||
},
|
||||
sublistItems() {
|
||||
return (this[this.sublist] || []).map((item) => {
|
||||
const sublistItems = (this[this.sublist] || []).map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
return {
|
||||
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() {
|
||||
return this.$store.state.libraries.filterData || {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="400" height="100%">
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -26,6 +26,10 @@ export default {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
playlist: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
hideRssFeedOption: Boolean
|
||||
},
|
||||
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) {
|
||||
items.push({
|
||||
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) {
|
||||
items.push({
|
||||
text: this.$strings.LabelMoreInfo,
|
||||
@@ -255,6 +276,8 @@ export default {
|
||||
} else if (action === 'playlist') {
|
||||
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode: this.episode }])
|
||||
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
|
||||
} else if (action === 'removeFromPlaylist') {
|
||||
this.removeFromPlaylistClick()
|
||||
} else if (action === 'markFinished') {
|
||||
if (this.episode) this.toggleEpisodeFinished()
|
||||
else this.toggleFinished()
|
||||
@@ -270,6 +293,8 @@ export default {
|
||||
this.clickRSSFeed()
|
||||
} else if (action === 'sendEbook') {
|
||||
this.showSendEbookDevicesModal = true
|
||||
} else if (action === 'openWebClient') {
|
||||
this.$store.dispatch('user/openWebClient', `/item/${this.serverLibraryItemId}`)
|
||||
}
|
||||
},
|
||||
async toggleFinished() {
|
||||
@@ -456,6 +481,29 @@ export default {
|
||||
.finally(() => {
|
||||
this.$emit('update:processing', false)
|
||||
})
|
||||
},
|
||||
removeFromPlaylistClick() {
|
||||
if (!this.playlist) {
|
||||
this.$toast.error('Invalid: No Playlist')
|
||||
return
|
||||
}
|
||||
|
||||
this.$emit('update:processing', true)
|
||||
let url = `/api/playlists/${this.playlist.id}/item/${this.serverLibraryItemId}`
|
||||
if (this.serverEpisodeId) url += `/${this.serverEpisodeId}`
|
||||
this.$nativeHttp
|
||||
.delete(url)
|
||||
.then(() => {
|
||||
this.$toast.success('Item removed from playlist')
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMsg = error.response?.data || 'Unknown error'
|
||||
console.error('Failed to remove item from playlist', error)
|
||||
this.$toast.error('Failed to remove from playlist: ' + errorMsg)
|
||||
})
|
||||
.finally(() => {
|
||||
this.$emit('update:processing', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="300" :processing="processing" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-8 left-4 z-40" style="max-width: 80%">
|
||||
<div class="absolute top-11 left-4 z-40" style="max-width: 80%">
|
||||
<p class="text-white text-2xl truncate">{{ $strings.HeaderLibraries }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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 class="absolute top-0 left-0 w-full h-40 bg-gradient-to-b from-black to-transparent opacity-90 pointer-events-none" />
|
||||
|
||||
<div class="absolute z-40 top-10 right-4 h-10 w-10 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
|
||||
<div class="absolute z-40 top-11 right-4 h-10 w-10 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
|
||||
<span class="material-icons text-4xl">close</span>
|
||||
</div>
|
||||
<slot name="outer" />
|
||||
|
||||
@@ -63,6 +63,10 @@ export default {
|
||||
{
|
||||
text: this.$strings.LabelFileModified,
|
||||
value: 'mtimeMs'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelRandomly,
|
||||
value: 'random'
|
||||
}
|
||||
],
|
||||
podcastItems: [
|
||||
@@ -89,6 +93,10 @@ export default {
|
||||
{
|
||||
text: this.$strings.LabelFileModified,
|
||||
value: 'mtimeMs'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelRandomly,
|
||||
value: 'random'
|
||||
}
|
||||
],
|
||||
episodeItems: [
|
||||
|
||||
@@ -71,7 +71,7 @@ export default {
|
||||
}
|
||||
},
|
||||
rates() {
|
||||
return [0.5, 1, 1.2, 1.5, 2, 3]
|
||||
return [0.5, 1, 1.2, 1.5, 1.7, 2, 3]
|
||||
},
|
||||
canIncrement() {
|
||||
return this.playbackRate + 0.1 <= this.MAX_SPEED
|
||||
|
||||
@@ -6,13 +6,7 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center"
|
||||
@click="
|
||||
show = false
|
||||
manualTimerModal = false
|
||||
"
|
||||
>
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-border" style="max-height: 75%" @click.stop>
|
||||
<div v-if="manualTimerModal" class="p-4">
|
||||
<div class="flex mb-4" @click="manualTimerModal = false">
|
||||
@@ -71,7 +65,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
manualTimerModal: null,
|
||||
manualTimerModal: false,
|
||||
manualTimeoutMin: 1
|
||||
}
|
||||
},
|
||||
@@ -81,6 +75,9 @@ export default {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
if (!val) {
|
||||
this.manualTimerModal = false
|
||||
}
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -349,7 +349,7 @@ export default {
|
||||
}
|
||||
},
|
||||
parseFilenames(filenames) {
|
||||
const acceptableImages = ['.jpeg', '.jpg', '.png']
|
||||
const acceptableImages = ['.jpeg', '.jpg', '.png', '.webp']
|
||||
var imageFiles = filenames.filter((f) => {
|
||||
return acceptableImages.includes((Path.extname(f) || '').toLowerCase())
|
||||
})
|
||||
|
||||
@@ -34,16 +34,18 @@ export default {
|
||||
currentLocationNum: 0,
|
||||
currentLocationCfi: null,
|
||||
inittingDisplay: true,
|
||||
isRefreshingUI: false,
|
||||
ereaderSettings: {
|
||||
theme: 'dark',
|
||||
fontScale: 100,
|
||||
lineSpacing: 115
|
||||
lineSpacing: 115,
|
||||
textStroke: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
isPlayerOpen() {
|
||||
this.updateHeight()
|
||||
this.refreshUI()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -112,7 +114,8 @@ export default {
|
||||
'*': {
|
||||
color: `${fontColor}!important`,
|
||||
'background-color': `${backgroundColor}!important`,
|
||||
'line-height': this.ereaderSettings.lineSpacing + '%!important'
|
||||
'line-height': this.ereaderSettings.lineSpacing + '%!important',
|
||||
'-webkit-text-stroke': this.ereaderSettings.textStroke/100 + 'px ' + fontColor + '!important'
|
||||
},
|
||||
a: {
|
||||
color: `${fontColor}!important`
|
||||
@@ -135,11 +138,6 @@ export default {
|
||||
goToChapter(href) {
|
||||
return this.rendition?.display(href)
|
||||
},
|
||||
updateHeight() {
|
||||
if (this.rendition && this.rendition.resize) {
|
||||
this.rendition.resize(window.innerWidth, window.innerHeight - this.readerHeightOffset)
|
||||
}
|
||||
},
|
||||
prev() {
|
||||
if (this.rendition) {
|
||||
this.rendition.prev()
|
||||
@@ -382,14 +380,54 @@ export default {
|
||||
this.rendition.getContents().forEach((c) => {
|
||||
c.addStylesheetRules(this.themeRules)
|
||||
})
|
||||
},
|
||||
async screenOrientationChange() {
|
||||
if (this.isRefreshingUI) return
|
||||
this.isRefreshingUI = true
|
||||
const windowWidth = window.innerWidth
|
||||
this.refreshUI()
|
||||
|
||||
// Window width does not always change right away. Wait up to 250ms for a change.
|
||||
// iPhone 10 on iOS 16 took between 100 - 200ms to update when going from portrait to landscape
|
||||
// but landscape to portrait was immediate
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
if (window.innerWidth !== windowWidth) {
|
||||
this.refreshUI()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
this.isRefreshingUI = false
|
||||
},
|
||||
refreshUI() {
|
||||
if (this.rendition?.resize) {
|
||||
this.rendition.resize(window.innerWidth, window.innerHeight - this.readerHeightOffset)
|
||||
}
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.book?.destroy()
|
||||
},
|
||||
mounted() {
|
||||
this.initEpub()
|
||||
}
|
||||
|
||||
if (screen.orientation) {
|
||||
// Not available on ios
|
||||
screen.orientation.addEventListener('change', this.screenOrientationChange)
|
||||
} else {
|
||||
document.addEventListener('orientationchange', this.screenOrientationChange)
|
||||
}
|
||||
window.addEventListener('resize', this.screenOrientationChange)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.book?.destroy()
|
||||
|
||||
if (screen.orientation) {
|
||||
// Not available on ios
|
||||
screen.orientation.removeEventListener('change', this.screenOrientationChange)
|
||||
} else {
|
||||
document.removeEventListener('orientationchange', this.screenOrientationChange)
|
||||
}
|
||||
window.removeEventListener('resize', this.screenOrientationChange)
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -404,4 +442,4 @@ export default {
|
||||
max-height: calc(100% - 132px);
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto">
|
||||
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
|
||||
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md bg-white" :src="pdfDocInitParams" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event" @loaded="loadedEvt"></pdf>
|
||||
<pdf v-if="pdfDocInitParams" ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md bg-white" :src="pdfDocInitParams" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event" @loaded="loadedEvt"></pdf>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -48,7 +48,8 @@ export default {
|
||||
page: 1,
|
||||
numPages: 0,
|
||||
windowWidth: 0,
|
||||
windowHeight: 0
|
||||
windowHeight: 0,
|
||||
pdfDocInitParams: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -106,14 +107,6 @@ export default {
|
||||
if (!this.userItemProgress?.ebookLocation || isNaN(this.userItemProgress.ebookLocation)) return 0
|
||||
return Number(this.userItemProgress.ebookLocation)
|
||||
},
|
||||
pdfDocInitParams() {
|
||||
return {
|
||||
url: this.url,
|
||||
httpHeaders: {
|
||||
Authorization: `Bearer ${this.userToken}`
|
||||
}
|
||||
}
|
||||
},
|
||||
isPlayerOpen() {
|
||||
return this.$store.getters['getIsPlayerOpen']
|
||||
}
|
||||
@@ -157,6 +150,8 @@ export default {
|
||||
}
|
||||
},
|
||||
numPagesLoaded(e) {
|
||||
if (!e) return
|
||||
|
||||
this.numPages = e
|
||||
},
|
||||
prev() {
|
||||
@@ -175,6 +170,14 @@ export default {
|
||||
screenOrientationChange() {
|
||||
this.windowWidth = window.innerWidth
|
||||
this.windowHeight = window.innerHeight
|
||||
},
|
||||
init() {
|
||||
this.pdfDocInitParams = {
|
||||
url: this.url,
|
||||
httpHeaders: {
|
||||
Authorization: `Bearer ${this.userToken}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -188,6 +191,8 @@ export default {
|
||||
document.addEventListener('orientationchange', this.screenOrientationChange)
|
||||
}
|
||||
window.addEventListener('resize', this.screenOrientationChange)
|
||||
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (screen.orientation) {
|
||||
@@ -199,4 +204,4 @@ export default {
|
||||
window.removeEventListener('resize', this.screenOrientationChange)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<!-- ereader settings modal -->
|
||||
<modals-fullscreen-modal v-model="showSettingsModal" :theme="ereaderTheme" half-screen>
|
||||
<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>
|
||||
<button class="flex" @click="showSettingsModal = false">
|
||||
<span class="material-icons">close</span>
|
||||
@@ -64,24 +64,36 @@
|
||||
</div>
|
||||
<div class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(100vh-85px)]">
|
||||
<div class="w-full h-full px-4">
|
||||
<div class="flex items-center mb-8">
|
||||
<div class="flex items-center mb-6">
|
||||
<div class="w-32">
|
||||
<p class="text-base">{{ $strings.LabelTheme }}:</p>
|
||||
</div>
|
||||
<ui-toggle-btns v-model="ereaderSettings.theme" :items="themeItems" @input="settingsUpdated" />
|
||||
</div>
|
||||
<div class="flex items-center mb-8">
|
||||
<div class="flex items-center mb-6">
|
||||
<div class="w-32">
|
||||
<p class="text-base">{{ $strings.LabelFontScale }}:</p>
|
||||
</div>
|
||||
<ui-range-input v-model="ereaderSettings.fontScale" :min="5" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
|
||||
</div>
|
||||
<div class="flex items-center mb-8">
|
||||
<div class="flex items-center mb-6">
|
||||
<div class="w-32">
|
||||
<p class="text-base">{{ $strings.LabelLineSpacing }}:</p>
|
||||
</div>
|
||||
<ui-range-input v-model="ereaderSettings.lineSpacing" :min="100" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
|
||||
</div>
|
||||
<div class="flex items-center mb-6">
|
||||
<div class="w-32">
|
||||
<p class="text-base">{{ $strings.LabelFontBoldness }}:</p>
|
||||
</div>
|
||||
<ui-range-input v-model="ereaderSettings.textStroke" :min="0" :max="300" :step="5" input-width="180px" @input="settingsUpdated" />
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<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>
|
||||
@@ -109,7 +121,9 @@ export default {
|
||||
ereaderSettings: {
|
||||
theme: 'dark',
|
||||
fontScale: 100,
|
||||
lineSpacing: 115
|
||||
lineSpacing: 115,
|
||||
spread: 'auto',
|
||||
textStroke: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -154,6 +168,18 @@ export default {
|
||||
if (this.isEpub) return this.ereaderSettings.theme
|
||||
return document.documentElement.dataset.theme || 'dark'
|
||||
},
|
||||
spreadItems() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelLayoutSinglePage,
|
||||
value: 'none'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLayoutAuto,
|
||||
value: 'auto'
|
||||
}
|
||||
]
|
||||
},
|
||||
themeItems() {
|
||||
return [
|
||||
{
|
||||
@@ -350,7 +376,12 @@ export default {
|
||||
try {
|
||||
const settings = localStorage.getItem('ereaderSettings')
|
||||
if (settings) {
|
||||
this.ereaderSettings = JSON.parse(settings)
|
||||
const _ereaderSettings = JSON.parse(settings)
|
||||
for (const key in this.ereaderSettings) {
|
||||
if (_ereaderSettings[key] !== undefined) {
|
||||
this.ereaderSettings[key] = _ereaderSettings[key]
|
||||
}
|
||||
}
|
||||
this.settingsUpdated()
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -372,4 +403,4 @@ export default {
|
||||
this.unregisterListeners()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -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>
|
||||
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
|
||||
import cellularPermissionHelpers from '@/mixins/cellularPermissionHelpers'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@@ -73,6 +74,7 @@ export default {
|
||||
},
|
||||
isLocal: Boolean
|
||||
},
|
||||
mixins: [cellularPermissionHelpers],
|
||||
data() {
|
||||
return {
|
||||
isProcessingReadUpdate: false,
|
||||
@@ -180,6 +182,9 @@ export default {
|
||||
async downloadClick() {
|
||||
if (this.downloadItem || this.startingDownload) return
|
||||
|
||||
const hasPermission = await this.checkCellularPermission('download')
|
||||
if (!hasPermission) return
|
||||
|
||||
this.startingDownload = true
|
||||
setTimeout(() => {
|
||||
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" />
|
||||
</div>
|
||||
<div class="flex-grow px-2">
|
||||
<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 class="flex items-center">
|
||||
<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>
|
||||
<p v-if="publishedAt" class="text-xs text-fg-muted">{{ $dateDistanceFromNow(publishedAt) }}</p>
|
||||
</div>
|
||||
@@ -58,6 +61,7 @@
|
||||
|
||||
<script>
|
||||
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
|
||||
import CellularPermissionHelpers from '@/mixins/cellularPermissionHelpers'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@@ -80,6 +84,7 @@ export default {
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
mixins: [CellularPermissionHelpers],
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
@@ -184,6 +189,10 @@ export default {
|
||||
},
|
||||
async downloadClick() {
|
||||
if (this.downloadItem || this.pendingDownload) return
|
||||
|
||||
const hasPermission = await this.checkCellularPermission('download')
|
||||
if (!hasPermission) return
|
||||
|
||||
this.pendingDownload = true
|
||||
await this.$hapticsImpact()
|
||||
if (this.isIos) {
|
||||
@@ -259,7 +268,6 @@ export default {
|
||||
|
||||
if (this.localEpisode && this.localLibraryItemId) {
|
||||
console.log('Play local episode', this.localEpisode.id, this.localLibraryItemId)
|
||||
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.localLibraryItemId,
|
||||
episodeId: this.localEpisode.id,
|
||||
@@ -282,7 +290,11 @@ export default {
|
||||
const isFinished = !this.userIsFinished
|
||||
const localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId
|
||||
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))
|
||||
if (payload?.error) {
|
||||
this.$toast.error(payload?.error || 'Unknown error')
|
||||
|
||||
@@ -30,6 +30,7 @@ export default {
|
||||
default: ''
|
||||
},
|
||||
paddingX: Number,
|
||||
paddingY: Number,
|
||||
small: Boolean,
|
||||
loading: Boolean,
|
||||
disabled: Boolean
|
||||
@@ -48,14 +49,17 @@ export default {
|
||||
if (this.small) {
|
||||
list.push('text-sm')
|
||||
if (this.paddingX === undefined) list.push('px-4')
|
||||
list.push('py-1')
|
||||
if (this.paddingY === undefined) list.push('py-1')
|
||||
} else {
|
||||
if (this.paddingX === undefined) list.push('px-8')
|
||||
list.push('py-2')
|
||||
if (this.paddingY === undefined) list.push('py-2')
|
||||
}
|
||||
if (this.paddingX !== undefined) {
|
||||
list.push(`px-${this.paddingX}`)
|
||||
}
|
||||
if (this.paddingY !== undefined) {
|
||||
list.push(`py-${this.paddingY}`)
|
||||
}
|
||||
if (this.disabled) {
|
||||
list.push('cursor-not-allowed')
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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">
|
||||
<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" />
|
||||
@@ -13,6 +13,10 @@
|
||||
export default {
|
||||
props: {
|
||||
icon: String,
|
||||
type: {
|
||||
type: String,
|
||||
default: 'button'
|
||||
},
|
||||
disabled: Boolean,
|
||||
bgColor: {
|
||||
type: String,
|
||||
@@ -77,4 +81,4 @@ button.icon-btn:disabled::before {
|
||||
button.icon-btn:disabled span {
|
||||
color: #777;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -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 */; };
|
||||
E9E985F828B02D9400957F23 /* PlayerProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9E985F728B02D9400957F23 /* PlayerProgress.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 */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -142,6 +145,9 @@
|
||||
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>"; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -183,6 +189,9 @@
|
||||
3ABF6190280432610070250E /* player */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
EACB38152BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift */,
|
||||
EACB38132BCCA1410060DA4A /* LegacyAudioPlayerRateManager.swift */,
|
||||
EACB38112BCCA1330060DA4A /* AudioPlayerRateManager.swift */,
|
||||
E9E8814828DA641B00D750C1 /* util */,
|
||||
3A200C1427D64D7E00CBF02E /* AudioPlayer.swift */,
|
||||
E9DFCBFA28C28F4A00B36356 /* AudioPlayerSleepTimer.swift */,
|
||||
@@ -521,6 +530,7 @@
|
||||
E9D5506228AC1CC900C746DD /* PlayerState.swift in Sources */,
|
||||
3AD4FCE728043E72006DB301 /* AbsDatabase.m in Sources */,
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||
EACB38122BCCA1330060DA4A /* AudioPlayerRateManager.swift in Sources */,
|
||||
E9FA07E328C82848005520B0 /* Logger.swift in Sources */,
|
||||
3A90295F280968E700E1D427 /* PlaybackReport.swift in Sources */,
|
||||
E9D5505A28AC1C4500C746DD /* Folder.swift in Sources */,
|
||||
@@ -540,6 +550,7 @@
|
||||
3A200C1527D64D7E00CBF02E /* AudioPlayer.swift in Sources */,
|
||||
E9D5507128AC1EC700C746DD /* DownloadItemPart.swift in Sources */,
|
||||
4D66B956282EE951008272D4 /* AbsFileSystem.m in Sources */,
|
||||
EACB38142BCCA1410060DA4A /* LegacyAudioPlayerRateManager.swift in Sources */,
|
||||
3AFCB5E827EA240D00ECCC05 /* NowPlayingInfo.swift in Sources */,
|
||||
3AB34053280829BF0039308B /* Extensions.swift in Sources */,
|
||||
E9D5505828AC1C1A00C746DD /* Library.swift in Sources */,
|
||||
@@ -548,6 +559,7 @@
|
||||
4DABC04F2B0139CA000F6264 /* User.swift in Sources */,
|
||||
4D66B952282EE822008272D4 /* AbsDownloader.m in Sources */,
|
||||
E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */,
|
||||
EACB38162BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift in Sources */,
|
||||
E9D5505228AC1B5D00C746DD /* Chapter.swift in Sources */,
|
||||
E9D5506028AC1CA900C746DD /* PlaybackMetadata.swift in Sources */,
|
||||
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */,
|
||||
@@ -728,12 +740,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 32;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
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\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -752,12 +764,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 27;
|
||||
CURRENT_PROJECT_VERSION = 32;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
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_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
@@ -4,17 +4,17 @@ import RealmSwift
|
||||
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
|
||||
private let logger = AppLogger(category: "AppDelegate")
|
||||
|
||||
lazy var window: UIWindow? = UIWindow(frame: UIScreen.main.bounds)
|
||||
var backgroundCompletionHandler: (() -> Void)?
|
||||
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
// Override point for customization after application launch.
|
||||
|
||||
|
||||
let configuration = Realm.Configuration(
|
||||
schemaVersion: 15,
|
||||
schemaVersion: 18,
|
||||
migrationBlock: { [weak self] migration, oldSchemaVersion in
|
||||
if (oldSchemaVersion < 1) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)")
|
||||
@@ -48,10 +48,24 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
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
|
||||
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -93,7 +107,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
// tracking app url opens, make sure to keep this call
|
||||
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
|
||||
}
|
||||
|
||||
|
||||
func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) {
|
||||
// Stores the completion handler for background downloads
|
||||
// The identifier of this method can be ignored at this time as we only have one background url session
|
||||
|
||||
@@ -15,6 +15,7 @@ CAP_PLUGIN(AbsAudioPlayer, "AbsAudioPlayer",
|
||||
CAP_PLUGIN_METHOD(closePlayback, CAPPluginReturnPromise);
|
||||
|
||||
CAP_PLUGIN_METHOD(setPlaybackSpeed, CAPPluginReturnPromise);
|
||||
CAP_PLUGIN_METHOD(setChapterTrack, CAPPluginReturnPromise);
|
||||
|
||||
CAP_PLUGIN_METHOD(playPlayer, CAPPluginReturnPromise);
|
||||
CAP_PLUGIN_METHOD(pausePlayer, CAPPluginReturnPromise);
|
||||
|
||||
@@ -8,13 +8,16 @@
|
||||
import Foundation
|
||||
import Capacitor
|
||||
import RealmSwift
|
||||
import Network
|
||||
|
||||
@objc(AbsAudioPlayer)
|
||||
public class AbsAudioPlayer: CAPPlugin {
|
||||
private let logger = AppLogger(category: "AbsAudioPlayer")
|
||||
|
||||
|
||||
private var initialPlayWhenReady = false
|
||||
|
||||
private var monitor: NWPathMonitor?
|
||||
private let queue = DispatchQueue.global(qos: .background)
|
||||
|
||||
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(sendPlaybackClosedEvent), name: NSNotification.Name(PlayerEvents.closed.rawValue), object: nil)
|
||||
@@ -24,20 +27,25 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(sendSleepTimerEnded), name: NSNotification.Name(PlayerEvents.sleepEnded.rawValue), object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(onPlaybackFailed), name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(onLocalMediaProgressUpdate), name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil)
|
||||
|
||||
|
||||
self.bridge?.webView?.allowsBackForwardNavigationGestures = true;
|
||||
self.bridge?.webView?.scrollView.alwaysBounceVertical = false;
|
||||
|
||||
|
||||
setupNetworkMonitor()
|
||||
}
|
||||
|
||||
|
||||
deinit {
|
||||
monitor?.cancel()
|
||||
}
|
||||
|
||||
@objc func onReady(_ call: CAPPluginCall) {
|
||||
// TODO: Was used to notify when Abs UI was ready so that last played media could be opened - this was buggy and removed
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
|
||||
@objc func startPlaybackSession(_ session: PlaybackSession, playWhenReady: Bool, playbackRate: Float) throws {
|
||||
guard let libraryItemId = session.libraryItemId else { throw PlayerError.libraryItemIdNotSpecified }
|
||||
|
||||
|
||||
self.sendPrepareMetadataEvent(itemId: libraryItemId, playWhenReady: playWhenReady)
|
||||
self.sendPlaybackSession(session: try session.asDictionary())
|
||||
PlayerHandler.startPlayback(sessionId: session.id, playWhenReady: playWhenReady, playbackRate: playbackRate)
|
||||
@@ -50,14 +58,14 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
let playWhenReady = call.getBool("playWhenReady", true)
|
||||
let playbackRate = call.getFloat("playbackRate", 1)
|
||||
let startTimeOverride = call.getDouble("startTime")
|
||||
|
||||
|
||||
if libraryItemId == nil {
|
||||
logger.error("provide library item id")
|
||||
return call.resolve()
|
||||
}
|
||||
|
||||
|
||||
PlayerHandler.stopPlayback()
|
||||
|
||||
|
||||
let isLocalItem = libraryItemId?.starts(with: "local_") ?? false
|
||||
if (isLocalItem) {
|
||||
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: libraryItemId!)
|
||||
@@ -66,7 +74,7 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
logger.error("Failed to get local playback session")
|
||||
return call.resolve([:])
|
||||
}
|
||||
|
||||
|
||||
do {
|
||||
if (startTimeOverride != nil) {
|
||||
playbackSession.currentTime = startTimeOverride!
|
||||
@@ -96,14 +104,14 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func closePlayback(_ call: CAPPluginCall) {
|
||||
logger.log("Close playback")
|
||||
|
||||
|
||||
PlayerHandler.stopPlayback()
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
|
||||
@objc func getCurrentTime(_ call: CAPPluginCall) {
|
||||
call.resolve([
|
||||
"value": PlayerHandler.getCurrentTime() ?? 0,
|
||||
@@ -119,7 +127,18 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
PlayerHandler.setPlaybackSpeed(speed: settings.playbackRate)
|
||||
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) {
|
||||
PlayerHandler.paused = false
|
||||
call.resolve()
|
||||
@@ -133,7 +152,7 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
PlayerHandler.paused = !PlayerHandler.paused
|
||||
call.resolve([ "playing": !PlayerHandler.paused ])
|
||||
}
|
||||
|
||||
|
||||
@objc func seek(_ call: CAPPluginCall) {
|
||||
PlayerHandler.seek(amount: call.getDouble("value", 0.0))
|
||||
call.resolve()
|
||||
@@ -146,7 +165,7 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
PlayerHandler.seekBackward(amount: call.getDouble("value", 0.0))
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
|
||||
@objc func sendMetadata() {
|
||||
self.notifyListeners("onPlayingUpdate", data: [ "value": !PlayerHandler.paused ])
|
||||
if let metadata = try? PlayerHandler.getMetdata()?.asDictionary() {
|
||||
@@ -156,34 +175,34 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
@objc func sendPlaybackClosedEvent() {
|
||||
self.notifyListeners("onPlaybackClosed", data: [ "value": true ])
|
||||
}
|
||||
|
||||
|
||||
@objc func decreaseSleepTime(_ call: CAPPluginCall) {
|
||||
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
|
||||
guard let time = Double(timeString) else { return call.resolve([ "success": false ]) }
|
||||
guard let _ = PlayerHandler.getSleepTimeRemaining() else { return call.resolve([ "success": false ]) }
|
||||
|
||||
|
||||
let seconds = time/1000
|
||||
PlayerHandler.decreaseSleepTime(decreaseSeconds: seconds)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
|
||||
@objc func increaseSleepTime(_ call: CAPPluginCall) {
|
||||
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
|
||||
guard let time = Double(timeString) else { return call.resolve([ "success": false ]) }
|
||||
guard let _ = PlayerHandler.getSleepTimeRemaining() else { return call.resolve([ "success": false ]) }
|
||||
|
||||
|
||||
let seconds = time/1000
|
||||
PlayerHandler.increaseSleepTime(increaseSeconds: seconds)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
|
||||
@objc func setSleepTimer(_ call: CAPPluginCall) {
|
||||
guard let timeString = call.getString("time") else { return call.resolve([ "success": false ]) }
|
||||
guard let time = Double(timeString) else { return call.resolve([ "success": false ]) }
|
||||
let isChapterTime = call.getBool("isChapterTime", false)
|
||||
|
||||
|
||||
let seconds = time / 1000
|
||||
|
||||
|
||||
logger.log("chapter time: \(isChapterTime)")
|
||||
if isChapterTime {
|
||||
PlayerHandler.setChapterSleepTime(stopAt: seconds)
|
||||
@@ -193,30 +212,30 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
call.resolve([ "success": true ])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func cancelSleepTimer(_ call: CAPPluginCall) {
|
||||
PlayerHandler.cancelSleepTime()
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
|
||||
@objc func getSleepTimerTime(_ call: CAPPluginCall) {
|
||||
call.resolve([
|
||||
"value": PlayerHandler.getSleepTimeRemaining() ?? 0
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@objc func sendSleepTimerEnded() {
|
||||
self.notifyListeners("onSleepTimerEnded", data: [
|
||||
"value": PlayerHandler.getCurrentTime() ?? 0
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@objc func sendSleepTimerSet() {
|
||||
self.notifyListeners("onSleepTimerSet", data: [
|
||||
"value": PlayerHandler.getSleepTimeRemaining() ?? 0
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@objc func onLocalMediaProgressUpdate() {
|
||||
guard let localMediaProgressId = PlayerHandler.getPlaybackSession()?.localMediaProgressId else { return }
|
||||
guard let localMediaProgress = Database.shared.getLocalMediaProgress(localMediaProgressId: localMediaProgressId) else { return }
|
||||
@@ -224,7 +243,7 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
logger.log("Sending local progress back to the UI")
|
||||
self.notifyListeners("onLocalMediaProgressUpdate", data: progressUpdate)
|
||||
}
|
||||
|
||||
|
||||
@objc func onPlaybackFailed() {
|
||||
if (PlayerHandler.getPlayMethod() == PlayMethod.directplay.rawValue) {
|
||||
let session = PlayerHandler.getPlaybackSession()
|
||||
@@ -232,7 +251,7 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
let libraryItemId = session?.libraryItemId ?? ""
|
||||
let episodeId = session?.episodeId ?? nil
|
||||
logger.log("Forcing Transcode")
|
||||
|
||||
|
||||
// If direct playing then fallback to transcode
|
||||
ApiClient.startPlaybackSession(libraryItemId: libraryItemId, episodeId: episodeId, forceTranscode: true) { [weak self] session in
|
||||
do {
|
||||
@@ -251,19 +270,38 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
"value": "Playback Error"
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
@objc func sendPrepareMetadataEvent(itemId: String, playWhenReady: Bool) {
|
||||
self.notifyListeners("onPrepareMedia", data: [
|
||||
"audiobookId": itemId,
|
||||
"playWhenReady": playWhenReady,
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@objc func sendPlaybackSession(session: [String: Any]) {
|
||||
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 {
|
||||
|
||||
@@ -29,20 +29,20 @@ extension String {
|
||||
@objc(AbsDatabase)
|
||||
public class AbsDatabase: CAPPlugin {
|
||||
private let logger = AppLogger(category: "AbsDatabase")
|
||||
|
||||
|
||||
@objc func setCurrentServerConnectionConfig(_ call: CAPPluginCall) {
|
||||
var id = call.getString("id")
|
||||
let address = call.getString("address", "")
|
||||
let userId = call.getString("userId", "")
|
||||
let username = call.getString("username", "")
|
||||
let token = call.getString("token", "")
|
||||
|
||||
|
||||
let name = "\(address) (\(username))"
|
||||
|
||||
|
||||
if id == nil {
|
||||
id = "\(address)@\(username)".toBase64()
|
||||
}
|
||||
|
||||
|
||||
let config = ServerConnectionConfig()
|
||||
config.id = id ?? ""
|
||||
config.index = 0
|
||||
@@ -51,7 +51,7 @@ public class AbsDatabase: CAPPlugin {
|
||||
config.userId = userId
|
||||
config.username = username
|
||||
config.token = token
|
||||
|
||||
|
||||
Store.serverConfig = config
|
||||
let savedConfig = Store.serverConfig // Fetch the latest value
|
||||
call.resolve(convertServerConnectionConfigToJSON(config: savedConfig!))
|
||||
@@ -59,26 +59,26 @@ public class AbsDatabase: CAPPlugin {
|
||||
@objc func removeServerConnectionConfig(_ call: CAPPluginCall) {
|
||||
let id = call.getString("serverConnectionConfigId", "")
|
||||
Database.shared.deleteServerConnectionConfig(id: id)
|
||||
|
||||
|
||||
call.resolve()
|
||||
}
|
||||
@objc func logout(_ call: CAPPluginCall) {
|
||||
Store.serverConfig = nil
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
|
||||
@objc func getDeviceData(_ call: CAPPluginCall) {
|
||||
let configs = Database.shared.getServerConnectionConfigs()
|
||||
let index = Database.shared.getLastActiveConfigIndex()
|
||||
let settings = Database.shared.getDeviceSettings()
|
||||
|
||||
|
||||
call.resolve([
|
||||
"serverConnectionConfigs": configs.map { config in convertServerConnectionConfigToJSON(config: config) },
|
||||
"lastServerConnectionConfigId": configs.first { config in config.index == index }?.id as Any,
|
||||
"deviceSettings": deviceSettingsToJSON(settings: settings)
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@objc func getLocalLibraryItems(_ call: CAPPluginCall) {
|
||||
do {
|
||||
let items = Database.shared.getLocalLibraryItems()
|
||||
@@ -89,7 +89,7 @@ public class AbsDatabase: CAPPlugin {
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func getLocalLibraryItem(_ call: CAPPluginCall) {
|
||||
do {
|
||||
let item = Database.shared.getLocalLibraryItem(localLibraryItemId: call.getString("id") ?? "")
|
||||
@@ -105,7 +105,7 @@ public class AbsDatabase: CAPPlugin {
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func getLocalLibraryItemByLId(_ call: CAPPluginCall) {
|
||||
do {
|
||||
let item = Database.shared.getLocalLibraryItem(byServerLibraryItemId: call.getString("libraryItemId") ?? "")
|
||||
@@ -121,11 +121,11 @@ public class AbsDatabase: CAPPlugin {
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func getLocalLibraryItemsInFolder(_ call: CAPPluginCall) {
|
||||
call.resolve([ "value": [] ])
|
||||
}
|
||||
|
||||
|
||||
@objc func getAllLocalMediaProgress(_ call: CAPPluginCall) {
|
||||
do {
|
||||
call.resolve([ "value": try Database.shared.getAllLocalMediaProgress().asDictionaryArray() ])
|
||||
@@ -135,7 +135,7 @@ public class AbsDatabase: CAPPlugin {
|
||||
call.resolve(["value": []])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func removeLocalMediaProgress(_ call: CAPPluginCall) {
|
||||
let localMediaProgressId = call.getString("localMediaProgressId")
|
||||
guard let localMediaProgressId = localMediaProgressId else {
|
||||
@@ -145,26 +145,27 @@ public class AbsDatabase: CAPPlugin {
|
||||
try? Database.shared.removeLocalMediaProgress(localMediaProgressId: localMediaProgressId)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
|
||||
@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 {
|
||||
call.reject("syncLocalSessionsWithServer not connected to server")
|
||||
return call.resolve()
|
||||
}
|
||||
|
||||
|
||||
Task {
|
||||
await ApiClient.syncLocalSessionsWithServer()
|
||||
await ApiClient.syncLocalSessionsWithServer(isFirstSync: isFirstSync)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func syncServerMediaProgressWithLocalMediaProgress(_ call: CAPPluginCall) {
|
||||
let serverMediaProgress = call.getJson("mediaProgress", type: MediaProgress.self)
|
||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||
let localEpisodeId = call.getString("localEpisodeId")
|
||||
let localMediaProgressId = call.getString("localMediaProgressId")
|
||||
|
||||
|
||||
do {
|
||||
guard let serverMediaProgress = serverMediaProgress else {
|
||||
return call.reject("serverMediaProgress not specified")
|
||||
@@ -172,35 +173,35 @@ public class AbsDatabase: CAPPlugin {
|
||||
guard localLibraryItemId != nil || localMediaProgressId != nil else {
|
||||
return call.reject("localLibraryItemId or localMediaProgressId must be specified")
|
||||
}
|
||||
|
||||
|
||||
let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
|
||||
guard let localMediaProgress = localMediaProgress else {
|
||||
call.reject("Local media progress not found or created")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
logger.log("syncServerMediaProgressWithLocalMediaProgress: Saving local media progress")
|
||||
try localMediaProgress.updateFromServerMediaProgress(serverMediaProgress)
|
||||
|
||||
|
||||
call.resolve(try localMediaProgress.asDictionary())
|
||||
} catch {
|
||||
call.reject("Failed to sync media progress")
|
||||
debugPrint(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func updateLocalMediaProgressFinished(_ call: CAPPluginCall) {
|
||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||
let localEpisodeId = call.getString("localEpisodeId")
|
||||
let isFinished = call.getBool("isFinished", false)
|
||||
|
||||
|
||||
var localMediaProgressId = localLibraryItemId ?? ""
|
||||
if localEpisodeId != nil {
|
||||
localMediaProgressId += "-\(localEpisodeId ?? "")"
|
||||
}
|
||||
|
||||
|
||||
logger.log("updateLocalMediaProgressFinished \(localMediaProgressId) | Is Finished: \(isFinished)")
|
||||
|
||||
|
||||
do {
|
||||
let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localMediaProgressId, localLibraryItemId: localLibraryItemId, localEpisodeId: localEpisodeId)
|
||||
guard let localMediaProgress = localMediaProgress else {
|
||||
@@ -210,11 +211,11 @@ public class AbsDatabase: CAPPlugin {
|
||||
|
||||
// Update finished status
|
||||
try localMediaProgress.updateIsFinished(isFinished)
|
||||
|
||||
|
||||
// Build API response
|
||||
let progressDictionary = try? localMediaProgress.asDictionary()
|
||||
var response: [String: Any] = ["local": true, "server": false, "localMediaProgress": progressDictionary ?? ""]
|
||||
|
||||
|
||||
// Send update to the server if logged in
|
||||
let hasLinkedServer = localMediaProgress.serverConnectionConfigId != nil
|
||||
let loggedIntoServer = Store.serverConfig?.id == localMediaProgress.serverConnectionConfigId
|
||||
@@ -233,37 +234,45 @@ public class AbsDatabase: CAPPlugin {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@objc func updateDeviceSettings(_ call: CAPPluginCall) {
|
||||
let disableAutoRewind = call.getBool("disableAutoRewind") ?? false
|
||||
let enableAltView = call.getBool("enableAltView") ?? false
|
||||
let allowSeekingOnMediaControls = call.getBool("allowSeekingOnMediaControls") ?? false
|
||||
let jumpBackwardsTime = call.getInt("jumpBackwardsTime") ?? 10
|
||||
let jumpForwardTime = call.getInt("jumpForwardTime") ?? 10
|
||||
let lockOrientation = call.getString("lockOrientation") ?? "NONE"
|
||||
let hapticFeedback = call.getString("hapticFeedback") ?? "LIGHT"
|
||||
let languageCode = call.getString("languageCode") ?? "en-us"
|
||||
let downloadUsingCellular = call.getString("downloadUsingCellular") ?? "ALWAYS"
|
||||
let streamingUsingCellular = call.getString("streamingUsingCellular") ?? "ALWAYS"
|
||||
let settings = DeviceSettings()
|
||||
settings.disableAutoRewind = disableAutoRewind
|
||||
settings.enableAltView = enableAltView
|
||||
settings.allowSeekingOnMediaControls = allowSeekingOnMediaControls
|
||||
settings.jumpBackwardsTime = jumpBackwardsTime
|
||||
settings.jumpForwardTime = jumpForwardTime
|
||||
settings.lockOrientation = lockOrientation
|
||||
settings.hapticFeedback = hapticFeedback
|
||||
settings.languageCode = languageCode
|
||||
|
||||
settings.downloadUsingCellular = downloadUsingCellular
|
||||
settings.streamingUsingCellular = streamingUsingCellular
|
||||
|
||||
Database.shared.setDeviceSettings(deviceSettings: settings)
|
||||
|
||||
// call.resolve([ "value": [] ])
|
||||
|
||||
// Updates the media notification controls (for allowSeekingOnMediaControls setting)
|
||||
PlayerHandler.updateRemoteTransportControls()
|
||||
|
||||
getDeviceData(call)
|
||||
}
|
||||
|
||||
|
||||
@objc func updateLocalEbookProgress(_ call: CAPPluginCall) {
|
||||
let localLibraryItemId = call.getString("localLibraryItemId")
|
||||
let ebookLocation = call.getString("ebookLocation", "")
|
||||
let ebookProgress = call.getDouble("ebookProgress", 0.0)
|
||||
|
||||
|
||||
logger.log("updateLocalEbookProgress \(localLibraryItemId ?? "Unknown") | ebookLocation: \(ebookLocation) | ebookProgress: \(ebookProgress)")
|
||||
|
||||
|
||||
do {
|
||||
let localMediaProgress = try LocalMediaProgress.fetchOrCreateLocalMediaProgress(localMediaProgressId: localLibraryItemId, localLibraryItemId: localLibraryItemId, localEpisodeId: nil)
|
||||
guard let localMediaProgress = localMediaProgress else {
|
||||
@@ -273,7 +282,7 @@ public class AbsDatabase: CAPPlugin {
|
||||
|
||||
// Update finished status
|
||||
try localMediaProgress.updateEbookProgress(ebookLocation: ebookLocation, ebookProgress: ebookProgress)
|
||||
|
||||
|
||||
// Build API response
|
||||
let progressDictionary = try? localMediaProgress.asDictionary()
|
||||
let response: [String: Any] = ["localMediaProgress": progressDictionary ?? ""]
|
||||
|
||||
@@ -11,6 +11,7 @@ install! 'cocoapods', :disable_input_output_paths => true
|
||||
def capacitor_pods
|
||||
pod 'Capacitor', :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 'CapacitorBrowser', :path => '../../node_modules/@capacitor/browser'
|
||||
pod 'CapacitorClipboard', :path => '../../node_modules/@capacitor/clipboard'
|
||||
|
||||
+16
-10
@@ -1,5 +1,7 @@
|
||||
PODS:
|
||||
- Alamofire (5.6.4)
|
||||
- Alamofire (5.8.1)
|
||||
- ByteowlsCapacitorFilesharer (5.0.0):
|
||||
- Capacitor
|
||||
- Capacitor (5.4.0):
|
||||
- CapacitorCordova
|
||||
- CapacitorApp (5.0.6):
|
||||
@@ -21,14 +23,15 @@ PODS:
|
||||
- Capacitor
|
||||
- CordovaPlugins (5.4.0):
|
||||
- CapacitorCordova
|
||||
- Realm (10.36.0):
|
||||
- Realm/Headers (= 10.36.0)
|
||||
- Realm/Headers (10.36.0)
|
||||
- RealmSwift (10.36.0):
|
||||
- Realm (= 10.36.0)
|
||||
- Realm (10.47.0):
|
||||
- Realm/Headers (= 10.47.0)
|
||||
- Realm/Headers (10.47.0)
|
||||
- RealmSwift (10.47.0):
|
||||
- Realm (= 10.47.0)
|
||||
|
||||
DEPENDENCIES:
|
||||
- Alamofire (~> 5.5)
|
||||
- "ByteowlsCapacitorFilesharer (from `../../node_modules/@byteowls/capacitor-filesharer`)"
|
||||
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
|
||||
- "CapacitorApp (from `../../node_modules/@capacitor/app`)"
|
||||
- "CapacitorBrowser (from `../../node_modules/@capacitor/browser`)"
|
||||
@@ -49,6 +52,8 @@ SPEC REPOS:
|
||||
- RealmSwift
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
ByteowlsCapacitorFilesharer:
|
||||
:path: "../../node_modules/@byteowls/capacitor-filesharer"
|
||||
Capacitor:
|
||||
:path: "../../node_modules/@capacitor/ios"
|
||||
CapacitorApp:
|
||||
@@ -73,7 +78,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../capacitor-cordova-ios-plugins"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Alamofire: 4e95d97098eacb88856099c4fc79b526a299e48c
|
||||
Alamofire: 3ca42e259043ee0dc5c0cdd76c4bc568b8e42af7
|
||||
ByteowlsCapacitorFilesharer: f6a773825632d65d5404a34764c4a3fd857bb176
|
||||
Capacitor: a5cd803e02b471591c81165f400ace01f40b11d3
|
||||
CapacitorApp: 024e1b1bea5f883d79f6330d309bc441c88ad04a
|
||||
CapacitorBrowser: 7a0fb6a1011abfaaf2dfedfd8248f942a8eda3d6
|
||||
@@ -85,9 +91,9 @@ SPEC CHECKSUMS:
|
||||
CapacitorPreferences: f03954bcb0ff09c792909e46bff88e3183c16b10
|
||||
CapacitorStatusBar: 565c0a1ebd79bb40d797606a8992b4a105885309
|
||||
CordovaPlugins: a5db67e5ac1061b9869a0efd754f2c2f776aeccc
|
||||
Realm: 3fd136cb4c83a927482a7f1612496d37beed3cf5
|
||||
RealmSwift: 513d4dcbf5bfc4d573454088b592685fc48dd716
|
||||
Realm: e43fb540ae947497e3ea8a662443256920602060
|
||||
RealmSwift: 8b06ed06b5d16749ae0c4d91c0cba414a9e28189
|
||||
|
||||
PODFILE CHECKSUM: 7a8fc177ef0646dd60a1ee8aa387964975fcc1e3
|
||||
PODFILE CHECKSUM: 02e6ffe2f51a453ce222ee9af0e55e9448d8514c
|
||||
|
||||
COCOAPODS: 1.12.1
|
||||
|
||||
@@ -11,11 +11,14 @@ import RealmSwift
|
||||
class DeviceSettings: Object {
|
||||
@Persisted var disableAutoRewind: Bool = false
|
||||
@Persisted var enableAltView: Bool = true
|
||||
@Persisted var allowSeekingOnMediaControls: Bool = false
|
||||
@Persisted var jumpBackwardsTime: Int = 10
|
||||
@Persisted var jumpForwardTime: Int = 10
|
||||
@Persisted var lockOrientation: String = "NONE"
|
||||
@Persisted var hapticFeedback: String = "LIGHT"
|
||||
@Persisted var languageCode: String = "en-us"
|
||||
@Persisted var downloadUsingCellular: String = "ALWAYS"
|
||||
@Persisted var streamingUsingCellular: String = "ALWAYS"
|
||||
}
|
||||
|
||||
func getDefaultDeviceSettings() -> DeviceSettings {
|
||||
@@ -26,10 +29,13 @@ func deviceSettingsToJSON(settings: DeviceSettings) -> Dictionary<String, Any> {
|
||||
return [
|
||||
"disableAutoRewind": settings.disableAutoRewind,
|
||||
"enableAltView": settings.enableAltView,
|
||||
"allowSeekingOnMediaControls": settings.allowSeekingOnMediaControls,
|
||||
"jumpBackwardsTime": settings.jumpBackwardsTime,
|
||||
"jumpForwardTime": settings.jumpForwardTime,
|
||||
"lockOrientation": settings.lockOrientation,
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// 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 chapterTrack: Bool = true
|
||||
|
||||
|
||||
// Singleton pattern for Realm objects
|
||||
static func main() -> PlayerSettings {
|
||||
|
||||
@@ -39,3 +39,13 @@ class Chapter: EmbeddedObject, Codable {
|
||||
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")
|
||||
|
||||
private var status: PlayerStatus
|
||||
internal var rate: Float
|
||||
|
||||
private var tmpRate: Float = 1.0
|
||||
internal var rateManager: AudioPlayerRateManager
|
||||
|
||||
private var playerContext = 0
|
||||
private var playerItemContext = 0
|
||||
@@ -64,11 +62,18 @@ class AudioPlayer: NSObject {
|
||||
self.audioPlayer.automaticallyWaitsToMinimizeStalling = true
|
||||
self.sessionId = sessionId
|
||||
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()
|
||||
|
||||
self.rateManager.rateChangedCompletion = self.updateNowPlaying
|
||||
self.rateManager.defaultRateChangedCompletion = self.setupTimeObservers
|
||||
|
||||
initAudioSession()
|
||||
setupRemoteTransportControls()
|
||||
|
||||
@@ -81,7 +86,6 @@ class AudioPlayer: NSObject {
|
||||
|
||||
// Listen to player events
|
||||
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)
|
||||
|
||||
for track in playbackSession.audioTracks {
|
||||
@@ -150,7 +154,6 @@ class AudioPlayer: NSObject {
|
||||
}
|
||||
|
||||
// Remove observers
|
||||
self.audioPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.rate), context: &playerContext)
|
||||
self.audioPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), context: &playerContext)
|
||||
self.removeTimeObservers()
|
||||
|
||||
@@ -224,6 +227,8 @@ class AudioPlayer: NSObject {
|
||||
// Update the UI
|
||||
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)
|
||||
DispatchQueue.runOnMainQueue {
|
||||
self.audioPlayer.play()
|
||||
self.audioPlayer.rate = self.tmpRate
|
||||
self.rateManager.handlePlayEvent()
|
||||
}
|
||||
self.status = .playing
|
||||
|
||||
@@ -422,27 +427,14 @@ class AudioPlayer: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
public func setPlaybackRate(_ rate: Float, observed: Bool = false) {
|
||||
let playbackSpeedChanged = rate > 0.0 && rate != self.tmpRate && !(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.updateNowPlaying()
|
||||
|
||||
if playbackSpeedChanged {
|
||||
self.tmpRate = rate
|
||||
|
||||
// Setup the time observer again at the new rate
|
||||
self.setupTimeObservers()
|
||||
}
|
||||
public func setPlaybackRate(_ rate: Float) {
|
||||
self.rateManager.setPlaybackRate(rate)
|
||||
}
|
||||
|
||||
public func setChapterTrack() {
|
||||
self.updateNowPlaying()
|
||||
}
|
||||
|
||||
public func getCurrentTime() -> Double? {
|
||||
guard let playbackSession = self.getPlaybackSession() else { return nil }
|
||||
let currentTrackTime = self.audioPlayer.currentTime().seconds
|
||||
@@ -528,14 +520,24 @@ class AudioPlayer: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
// When interruption is from the app suspending then don't resume playback
|
||||
if #available(iOS 14.5, *) {
|
||||
let reasonValue = userInfo[AVAudioSessionInterruptionReasonKey] as? UInt ?? 0
|
||||
let reason = AVAudioSession.InterruptionReason(rawValue: reasonValue)
|
||||
if (reason == .appWasSuspended) {
|
||||
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
|
||||
private func setupRemoteTransportControls() {
|
||||
func setupRemoteTransportControls() {
|
||||
DispatchQueue.runOnMainQueue {
|
||||
UIApplication.shared.beginReceivingRemoteControlEvents()
|
||||
}
|
||||
@@ -582,17 +584,43 @@ class AudioPlayer: NSObject {
|
||||
let jumpBackwardsTime = deviceSettings.jumpBackwardsTime
|
||||
|
||||
commandCenter.playCommand.isEnabled = true
|
||||
commandCenter.playCommand.removeTarget(nil)
|
||||
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
|
||||
}
|
||||
|
||||
commandCenter.pauseCommand.isEnabled = true
|
||||
commandCenter.pauseCommand.removeTarget(nil)
|
||||
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
|
||||
}
|
||||
|
||||
commandCenter.skipForwardCommand.isEnabled = true
|
||||
commandCenter.skipForwardCommand.removeTarget(nil)
|
||||
commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: jumpForwardTime)]
|
||||
commandCenter.skipForwardCommand.addTarget { [weak self] event in
|
||||
guard let command = event.command as? MPSkipIntervalCommand else {
|
||||
@@ -605,6 +633,7 @@ class AudioPlayer: NSObject {
|
||||
return .success
|
||||
}
|
||||
commandCenter.skipBackwardCommand.isEnabled = true
|
||||
commandCenter.skipBackwardCommand.removeTarget(nil)
|
||||
commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: jumpBackwardsTime)]
|
||||
commandCenter.skipBackwardCommand.addTarget { [weak self] event in
|
||||
guard let command = event.command as? MPSkipIntervalCommand else {
|
||||
@@ -618,6 +647,7 @@ class AudioPlayer: NSObject {
|
||||
}
|
||||
|
||||
commandCenter.nextTrackCommand.isEnabled = true
|
||||
commandCenter.nextTrackCommand.removeTarget(nil)
|
||||
commandCenter.nextTrackCommand.addTarget { [weak self] _ in
|
||||
guard let currentTime = self?.getCurrentTime() else {
|
||||
return .commandFailed
|
||||
@@ -626,6 +656,7 @@ class AudioPlayer: NSObject {
|
||||
return .success
|
||||
}
|
||||
commandCenter.previousTrackCommand.isEnabled = true
|
||||
commandCenter.previousTrackCommand.removeTarget(nil)
|
||||
commandCenter.previousTrackCommand.addTarget { [weak self] _ in
|
||||
guard let currentTime = self?.getCurrentTime() else {
|
||||
return .commandFailed
|
||||
@@ -634,17 +665,26 @@ class AudioPlayer: NSObject {
|
||||
return .success
|
||||
}
|
||||
|
||||
commandCenter.changePlaybackPositionCommand.isEnabled = true
|
||||
commandCenter.changePlaybackPositionCommand.isEnabled = deviceSettings.allowSeekingOnMediaControls
|
||||
commandCenter.changePlaybackPositionCommand.removeTarget(nil)
|
||||
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
|
||||
guard let event = event as? MPChangePlaybackPositionCommandEvent else {
|
||||
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
|
||||
}
|
||||
|
||||
commandCenter.changePlaybackRateCommand.isEnabled = true
|
||||
commandCenter.changePlaybackRateCommand.removeTarget(nil)
|
||||
commandCenter.changePlaybackRateCommand.supportedPlaybackRates = [0.5, 0.75, 1.0, 1.25, 1.5, 2]
|
||||
commandCenter.changePlaybackRateCommand.addTarget { [weak self] event in
|
||||
guard let event = event as? MPChangePlaybackRateCommandEvent else {
|
||||
@@ -657,18 +697,25 @@ class AudioPlayer: NSObject {
|
||||
}
|
||||
private func updateNowPlaying() {
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
|
||||
if let duration = self.getDuration(), let currentTime = self.getCurrentTime() {
|
||||
NowPlayingInfo.shared.update(duration: duration, currentTime: currentTime, rate: rate)
|
||||
if let session = self.getPlaybackSession(), let currentChapter = session.getCurrentChapter(), PlayerSettings.main().chapterTrack {
|
||||
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
|
||||
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
||||
if context == &playerContext {
|
||||
if keyPath == #keyPath(AVPlayer.rate) {
|
||||
logger.log("playerContext observer player rate")
|
||||
self.setPlaybackRate(change?[.newKey] as? Float ?? 1.0, observed: true)
|
||||
} else if keyPath == #keyPath(AVPlayer.currentItem) {
|
||||
if keyPath == #keyPath(AVPlayer.currentItem) {
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
|
||||
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
|
||||
var sleepTimeRemaining: Double? = nil
|
||||
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() {
|
||||
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 {
|
||||
get {
|
||||
guard let player = player else { return true }
|
||||
return player.rate == 0.0
|
||||
return player.rateManager.rate == 0.0
|
||||
}
|
||||
set(paused) {
|
||||
if paused {
|
||||
@@ -61,6 +61,10 @@ class PlayerHandler {
|
||||
self.player?.setPlaybackRate(speed)
|
||||
}
|
||||
|
||||
public static func setChapterTrack() {
|
||||
self.player?.setChapterTrack()
|
||||
}
|
||||
|
||||
public static func getSleepTimeRemaining() -> Double? {
|
||||
return self.player?.getSleepTimeRemaining()
|
||||
}
|
||||
@@ -131,6 +135,10 @@ class PlayerHandler {
|
||||
)
|
||||
}
|
||||
|
||||
public static func updateRemoteTransportControls() {
|
||||
self.player?.setupRemoteTransportControls()
|
||||
}
|
||||
|
||||
// MARK: - Helper logic
|
||||
|
||||
private static func cleanupOldSessions(currentSessionId: String?) {
|
||||
|
||||
@@ -190,10 +190,10 @@ class ApiClient {
|
||||
}
|
||||
|
||||
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 {
|
||||
// Sync server progress with local media progress
|
||||
let localMediaProgressList = Database.shared.getAllLocalMediaProgress().filter {
|
||||
@@ -232,13 +232,15 @@ class ApiClient {
|
||||
let playbackSessions = Database.shared.getAllPlaybackSessions().filter {
|
||||
$0.serverConnectionConfigId == Store.serverConfig?.id
|
||||
}.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) {
|
||||
let success = await ApiClient.reportAllLocalPlaybackSessions(playbackSessions)
|
||||
if (success) {
|
||||
// Remove sessions from db
|
||||
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() {
|
||||
try session.delete()
|
||||
}
|
||||
@@ -327,6 +329,7 @@ struct LocalMediaProgressSyncResultsPayload: Codable {
|
||||
|
||||
struct LocalPlaybackSessionSyncAllPayload: Codable {
|
||||
var sessions: [PlaybackSession]
|
||||
var deviceInfo: [String: String?]?
|
||||
}
|
||||
|
||||
struct Connectivity {
|
||||
|
||||
@@ -53,15 +53,27 @@ class NowPlayingInfo {
|
||||
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
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
if let self = self {
|
||||
self.nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration
|
||||
self.nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -89,7 +101,7 @@ class NowPlayingInfo {
|
||||
|
||||
nowPlayingInfo[MPMediaItemPropertyTitle] = metadata!.title
|
||||
nowPlayingInfo[MPMediaItemPropertyArtist] = metadata!.author ?? "unknown"
|
||||
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = metadata!.series
|
||||
nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = metadata!.title
|
||||
}
|
||||
|
||||
private func shouldFetchCover(id: String) -> Bool {
|
||||
|
||||
+6
-9
@@ -45,7 +45,7 @@ export default {
|
||||
if (timeSinceDisconnect > 5000) {
|
||||
console.log('Time since disconnect was', timeSinceDisconnect, 'sync with server')
|
||||
setTimeout(() => {
|
||||
this.syncLocalSessions()
|
||||
this.syncLocalSessions(false)
|
||||
}, 4000)
|
||||
}
|
||||
}
|
||||
@@ -139,7 +139,7 @@ export default {
|
||||
|
||||
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)
|
||||
return false
|
||||
})
|
||||
@@ -195,14 +195,14 @@ export default {
|
||||
this.$eventBus.$emit('library-changed')
|
||||
this.inittingLibraries = false
|
||||
},
|
||||
async syncLocalSessions() {
|
||||
async syncLocalSessions(isFirstSync) {
|
||||
if (!this.user) {
|
||||
console.log('[default] No need to sync local sessions - not connected to server')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[default] Calling syncLocalSessions')
|
||||
const response = await this.$db.syncLocalSessionsWithServer()
|
||||
const response = await this.$db.syncLocalSessionsWithServer(isFirstSync)
|
||||
if (response?.error) {
|
||||
console.error('[default] Failed to sync local sessions', response.error)
|
||||
} else {
|
||||
@@ -212,8 +212,7 @@ export default {
|
||||
}
|
||||
},
|
||||
userUpdated(user) {
|
||||
// console.log('[default] userUpdated:', JSON.stringify(user))
|
||||
if (this.user && this.user.id == user.id) {
|
||||
if (this.user?.id == user.id) {
|
||||
this.$store.commit('user/setUser', user)
|
||||
}
|
||||
},
|
||||
@@ -326,8 +325,6 @@ export default {
|
||||
|
||||
await this.$store.dispatch('setupNetworkListener')
|
||||
|
||||
await this.$store.dispatch('globals/loadLocalMediaProgress')
|
||||
|
||||
if (this.$store.state.user.serverConnectionConfig) {
|
||||
await this.initLibraries()
|
||||
} else {
|
||||
@@ -335,7 +332,7 @@ export default {
|
||||
}
|
||||
|
||||
console.log(`[default] finished connection attempt or already connected ${!!this.user}`)
|
||||
await this.syncLocalSessions()
|
||||
await this.syncLocalSessions(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 {
|
||||
ssr: false,
|
||||
|
||||
target: 'static',
|
||||
telemetry: false,
|
||||
env: {
|
||||
PROD: '1',
|
||||
ANDROID_APP_URL: 'https://play.google.com/store/apps/details?id=com.audiobookshelf.app',
|
||||
@@ -35,6 +36,7 @@ export default {
|
||||
},
|
||||
|
||||
css: [
|
||||
'@/assets/tailwind.css',
|
||||
'@/assets/app.css'
|
||||
],
|
||||
|
||||
@@ -55,10 +57,6 @@ export default {
|
||||
|
||||
components: true,
|
||||
|
||||
buildModules: [
|
||||
'@nuxtjs/tailwindcss',
|
||||
],
|
||||
|
||||
modules: [
|
||||
'@nuxtjs/axios'
|
||||
],
|
||||
@@ -66,6 +64,12 @@ export default {
|
||||
axios: {},
|
||||
|
||||
build: {
|
||||
postcss: {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
},
|
||||
babel: {
|
||||
plugins: [['@babel/plugin-proposal-private-property-in-object', { loose: true }]],
|
||||
},
|
||||
|
||||
Generated
+2375
-17836
File diff suppressed because it is too large
Load Diff
+6
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.9.70-beta",
|
||||
"version": "0.9.76-beta",
|
||||
"author": "advplyr",
|
||||
"scripts": {
|
||||
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
|
||||
@@ -13,6 +13,7 @@
|
||||
"ionic:serve": "npm run start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@byteowls/capacitor-filesharer": "^5.0.0",
|
||||
"@capacitor/android": "^5.0.0",
|
||||
"@capacitor/app": "^5.0.6",
|
||||
"@capacitor/browser": "^5.1.0",
|
||||
@@ -43,8 +44,8 @@
|
||||
"@babel/core": "7.13.15",
|
||||
"@babel/preset-env": "7.13.15",
|
||||
"@capacitor/cli": "^5.0.0",
|
||||
"@nuxtjs/tailwindcss": "^4.2.1",
|
||||
"postcss": "^8.3.5",
|
||||
"tailwindcss": "^3.3.2"
|
||||
"autoprefixer": "^10.4.17",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div id="bookshelf" class="w-full h-full p-4 overflow-y-auto">
|
||||
<div class="flex flex-wrap justify-center">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -16,16 +16,21 @@ export default {
|
||||
return {
|
||||
loading: true,
|
||||
authors: [],
|
||||
loadedLibraryId: null
|
||||
loadedLibraryId: null,
|
||||
cardWidth: 200
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
cardHeight() {
|
||||
return this.cardWidth * 1.25
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async init() {
|
||||
this.cardWidth = (window.innerWidth - 64) / 2
|
||||
if (!this.currentLibraryId) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -236,7 +236,7 @@ export default {
|
||||
console.log('[categories] Local shelves set', this.shelves.length, this.lastLocalFetch)
|
||||
|
||||
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)
|
||||
return []
|
||||
})
|
||||
@@ -327,8 +327,13 @@ export default {
|
||||
this.$eventBus.$off('library-changed', this.libraryChanged)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
async mounted() {
|
||||
if (this.$route.query.error) {
|
||||
this.$toast.error(this.$route.query.error)
|
||||
}
|
||||
|
||||
this.initListeners()
|
||||
await this.$store.dispatch('globals/loadLocalMediaProgress')
|
||||
console.log(`[categories] mounted so fetching categories`)
|
||||
this.fetchCategories()
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<h1 class="text-xl mb-2 font-semibold">{{ $strings.HeaderLatestEpisodes }}</h1>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
@@ -17,7 +17,6 @@ export default {
|
||||
totalEpisodes: 0,
|
||||
currentPage: 0,
|
||||
localLibraryItems: [],
|
||||
isLocal: false,
|
||||
loadedLibraryId: null
|
||||
}
|
||||
},
|
||||
@@ -30,7 +29,10 @@ export default {
|
||||
const episodes = []
|
||||
this.localLibraryItems.forEach((li) => {
|
||||
if (li.media.episodes?.length) {
|
||||
episodes.push(...li.media.episodes)
|
||||
li.media.episodes.map((ep) => {
|
||||
ep.localLibraryItemId = li.id
|
||||
episodes.push(ep)
|
||||
})
|
||||
}
|
||||
})
|
||||
return episodes
|
||||
@@ -106,4 +108,4 @@ export default {
|
||||
this.$eventBus.$off('new-local-library-item', this.newLocalLibraryItem)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
+2
-6
@@ -34,11 +34,7 @@ export default {
|
||||
deviceData: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
networkConnected() {
|
||||
return this.$store.state.networkConnected
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
async init() {
|
||||
this.deviceData = await this.$db.getDeviceData()
|
||||
@@ -53,4 +49,4 @@ export default {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
</div>
|
||||
</template>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</ui-btn>
|
||||
</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 -->
|
||||
<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 { Dialog } from '@capacitor/dialog'
|
||||
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
|
||||
import cellularPermissionHelpers from '@/mixins/cellularPermissionHelpers'
|
||||
|
||||
export default {
|
||||
async asyncData({ store, params, redirect, app }) {
|
||||
@@ -115,7 +116,11 @@ export default {
|
||||
startingDownload: false
|
||||
}
|
||||
},
|
||||
mixins: [cellularPermissionHelpers],
|
||||
computed: {
|
||||
transformedDescription() {
|
||||
return this.parseDescription(this.description)
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
@@ -310,6 +315,24 @@ export default {
|
||||
}
|
||||
},
|
||||
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() {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
@@ -351,28 +374,56 @@ export default {
|
||||
} else {
|
||||
this.$store.commit('setPlayerIsStartingPlayback', this.episode.id)
|
||||
|
||||
if (this.localEpisodeId && this.localLibraryItemId && !this.isLocal) {
|
||||
console.log('Play local episode', this.localEpisodeId, this.localLibraryItemId)
|
||||
const playbackData = this.generatePlaybackData()
|
||||
this.emitPlayItemEvent(playbackData)
|
||||
}
|
||||
},
|
||||
async clickPlaybackTime(event) {
|
||||
const startTime = event.target.getAttribute('data-time')
|
||||
if (this.playerIsStartingPlayback) return
|
||||
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.localLibraryItemId,
|
||||
episodeId: this.localEpisodeId,
|
||||
serverLibraryItemId: this.serverLibraryItemId,
|
||||
serverEpisodeId: this.serverEpisodeId
|
||||
})
|
||||
} else {
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: this.episode.id,
|
||||
serverLibraryItemId: this.serverLibraryItemId,
|
||||
serverEpisodeId: this.serverEpisodeId
|
||||
})
|
||||
}
|
||||
await this.$hapticsImpact()
|
||||
|
||||
this.$store.commit('setPlayerIsStartingPlayback', this.episode.id)
|
||||
|
||||
const playbackData = this.generatePlaybackData(startTime)
|
||||
this.emitPlayItemEvent(playbackData)
|
||||
},
|
||||
generatePlaybackData(startTime) {
|
||||
const playbackData = {
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: this.episode.id,
|
||||
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() {
|
||||
if (this.downloadItem || this.startingDownload) return
|
||||
|
||||
const hasPermission = await this.checkCellularPermission('download')
|
||||
if (!hasPermission) return
|
||||
|
||||
this.startingDownload = true
|
||||
setTimeout(() => {
|
||||
this.startingDownload = false
|
||||
@@ -557,9 +608,13 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.$eventBus.$on('new-local-library-item', this.newLocalLibraryItem)
|
||||
this.bindTimeMarkerEvents()
|
||||
},
|
||||
beforeDestroy() {
|
||||
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>
|
||||
<div id="item-page" class="w-full h-full px-3 pb-4 overflow-y-auto overflow-x-hidden relative bg-bg">
|
||||
<div class="fixed top-0 left-0 w-full h-full pointer-events-none p-px z-10">
|
||||
<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="z-10 relative">
|
||||
<!-- cover -->
|
||||
<div class="w-full flex justify-center relative mb-4">
|
||||
<div style="width: 0; transform: translateX(-50vw); overflow: visible">
|
||||
<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 v-if="!libraryItem" class="w-full h-full relative flex items-center justify-center bg-bg">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
<div v-else id="item-page" class="w-full h-full overflow-y-auto overflow-x-hidden relative bg-bg">
|
||||
<!-- cover -->
|
||||
<div class="w-full flex justify-center relative">
|
||||
<div style="width: 0; transform: translateX(-50vw); overflow: visible">
|
||||
<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 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>
|
||||
|
||||
<!-- title -->
|
||||
<div class="text-center mb-2">
|
||||
<h1 class="text-xl font-semibold">{{ title }}</h1>
|
||||
<p v-if="subtitle" class="text-fg text-base">{{ subtitle }}</p>
|
||||
<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 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 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">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
@@ -154,45 +171,40 @@
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
|
||||
import { FastAverageColor } from 'fast-average-color'
|
||||
import cellularPermissionHelpers from '@/mixins/cellularPermissionHelpers'
|
||||
|
||||
export default {
|
||||
async asyncData({ store, params, redirect, app }) {
|
||||
async asyncData({ store, params, redirect, app, query }) {
|
||||
const libraryItemId = params.id
|
||||
let libraryItem = null
|
||||
|
||||
if (libraryItemId.startsWith('local')) {
|
||||
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 (libraryItem?.libraryItemId?.startsWith('li_')) {
|
||||
// Detect old library item id
|
||||
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) {
|
||||
let query = ''
|
||||
if (libraryItem.mediaType === 'podcast') query = '?episodefilter=downloaded' // Filter by downloaded when redirecting from the local copy
|
||||
return redirect(`/item/${libraryItem.libraryItemId}${query}`)
|
||||
}
|
||||
} else if (store.state.user.serverConnectionConfig) {
|
||||
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
|
||||
} else if (query.noredirect !== '1' && libraryItem?.libraryItemId && libraryItem?.serverAddress === store.getters['user/getServerAddress'] && store.state.networkConnected) {
|
||||
const queryParams = new URLSearchParams()
|
||||
queryParams.set('localLibraryItemId', libraryItemId)
|
||||
if (libraryItem.mediaType === 'podcast') {
|
||||
// Filter by downloaded when redirecting from the local copy
|
||||
queryParams.set('episodefilter', 'downloaded')
|
||||
}
|
||||
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 {
|
||||
libraryItem,
|
||||
rssFeed: libraryItem.rssFeed || null
|
||||
libraryItemId
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -201,7 +213,7 @@ export default {
|
||||
showSelectLocalFolder: false,
|
||||
showMoreMenu: false,
|
||||
showFullscreenCover: false,
|
||||
coverRgb: 'rgb(55, 56, 56)',
|
||||
coverRgb: null,
|
||||
coverBgIsLight: false,
|
||||
windowWidth: 0,
|
||||
descriptionClamped: false,
|
||||
@@ -210,6 +222,7 @@ export default {
|
||||
startingDownload: false
|
||||
}
|
||||
},
|
||||
mixins: [cellularPermissionHelpers],
|
||||
computed: {
|
||||
isIos() {
|
||||
return this.$platform === 'ios'
|
||||
@@ -285,8 +298,8 @@ export default {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
rssFeed() {
|
||||
return this.libraryItem?.rssFeed
|
||||
},
|
||||
mediaType() {
|
||||
return this.libraryItem.mediaType
|
||||
@@ -297,6 +310,9 @@ export default {
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
tags() {
|
||||
return this.media.tags || []
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
@@ -425,6 +441,9 @@ export default {
|
||||
isInvalid() {
|
||||
return this.libraryItem.isInvalid
|
||||
},
|
||||
isExplicit() {
|
||||
return !!this.mediaMetadata.explicit
|
||||
},
|
||||
showPlay() {
|
||||
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
|
||||
return width
|
||||
},
|
||||
coverHeight() {
|
||||
return this.coverWidth * this.bookCoverAspectRatio
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -587,9 +609,11 @@ export default {
|
||||
this.download(localFolder)
|
||||
},
|
||||
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
|
||||
setTimeout(() => {
|
||||
this.startingDownload = false
|
||||
@@ -705,27 +729,59 @@ export default {
|
||||
if (!this.libraryItem.libraryId) return
|
||||
await this.$store.dispatch('libraries/fetch', 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() {
|
||||
// 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 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 mounted() {
|
||||
if (!this.libraryItem) {
|
||||
await this.loadServerLibraryItem()
|
||||
}
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.windowResized)
|
||||
@@ -744,6 +800,15 @@ export default {
|
||||
</script>
|
||||
|
||||
<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 {
|
||||
width: calc(100% - 64px);
|
||||
max-width: calc(100% - 64px);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="w-full h-full py-6 px-2">
|
||||
<div v-if="localLibraryItem" class="w-full h-full">
|
||||
<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" />
|
||||
|
||||
<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 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">
|
||||
<transition-group type="transition" :name="!drag ? 'dragtrack' : null">
|
||||
@@ -46,7 +49,10 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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">
|
||||
@@ -83,22 +89,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="otherFiles.length" class="text-lg py-2">Other Files</p>
|
||||
<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 v-if="otherFiles.length">
|
||||
<div class="flex justify-between items-center py-2">
|
||||
<p class="text-lg">Other Files</p>
|
||||
<p class="text-xs text-fg-muted px-2">{{ $strings.LabelTotalSize }}: {{ $bytesPretty(totalOtherFilesSize) }}</p>
|
||||
</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 v-else class="px-2 w-full h-full">
|
||||
@@ -218,17 +233,27 @@ export default {
|
||||
},
|
||||
dialogItems() {
|
||||
if (this.selectedAudioTrack || this.selectedEpisode) {
|
||||
return [
|
||||
const items = [
|
||||
{
|
||||
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 {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.ButtonDeleteLocalItem,
|
||||
value: 'delete'
|
||||
value: 'delete',
|
||||
icon: 'delete'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -236,6 +261,18 @@ export default {
|
||||
playerIsStartingPlayback() {
|
||||
// Play has been pressed and waiting for native play response
|
||||
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: {
|
||||
@@ -291,6 +328,19 @@ export default {
|
||||
getCapImageSrc(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) {
|
||||
console.log('Dialog action', action)
|
||||
await this.$hapticsImpact()
|
||||
@@ -300,8 +350,10 @@ export default {
|
||||
} else if (action == 'track-delete') {
|
||||
if (this.isPodcast) this.deleteEpisode()
|
||||
else this.deleteTrack()
|
||||
} else if (action == 'play-episode') {
|
||||
this.playEpisode()
|
||||
}
|
||||
this.showDialog = false
|
||||
this.showDialog = false;
|
||||
},
|
||||
getLocalFileForTrack(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