mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-07-25 14:08:35 +02:00
Compare commits
285
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23e0a44e54 | ||
|
|
e788f8767a | ||
|
|
d09e88f138 | ||
|
|
6e1eee48f0 | ||
|
|
99c8949861 | ||
|
|
ce44ee8514 | ||
|
|
490b45d476 | ||
|
|
e403304e46 | ||
|
|
2bc00bb7f7 | ||
|
|
2785b1d09a | ||
|
|
1d703f8c87 | ||
|
|
93748a917f | ||
|
|
46d5e1d96c | ||
|
|
1e791f9601 | ||
|
|
ea2e8c7cc9 | ||
|
|
9ca160f1da | ||
|
|
1b0843d12e | ||
|
|
b1c4ceb40a | ||
|
|
ff626a3609 | ||
|
|
0862aecfc9 | ||
|
|
e119672336 | ||
|
|
71f6f53111 | ||
|
|
8cf757c080 | ||
|
|
6e0f67b19c | ||
|
|
fa2400871a | ||
|
|
7ef3f1895e | ||
|
|
637911e6b7 | ||
|
|
1b0d7c2463 | ||
|
|
dfb793247c | ||
|
|
a730ca38f2 | ||
|
|
e59a955e6b | ||
|
|
0fbe5f6c86 | ||
|
|
312b04acc9 | ||
|
|
8c20b7985b | ||
|
|
c10a82842e | ||
|
|
7a608bb2b2 | ||
|
|
a8493b2778 | ||
|
|
02a6dd71d4 | ||
|
|
bb5e1e6d71 | ||
|
|
bfbb04dc93 | ||
|
|
8f1b92add5 | ||
|
|
cab98eff3d | ||
|
|
4877d41ff2 | ||
|
|
01c5c03653 | ||
|
|
be31f265b8 | ||
|
|
6ef9721b7d | ||
|
|
13a50c990b | ||
|
|
67e2f2366d | ||
|
|
0947c2fe6f | ||
|
|
7c8d5658f3 | ||
|
|
66cf4639e6 | ||
|
|
8b370da5b7 | ||
|
|
a6d49fc926 | ||
|
|
8aedd1cd95 | ||
|
|
647bd8193b | ||
|
|
b6349fb3a7 | ||
|
|
675c10f7f7 | ||
|
|
5ab5609397 | ||
|
|
6de4bc14af | ||
|
|
8579536eff | ||
|
|
36ec19e25e | ||
|
|
b6b54e04b8 | ||
|
|
5e4ac06493 | ||
|
|
1f05e18ce4 | ||
|
|
cd059542c6 | ||
|
|
b207c18abe | ||
|
|
6b7d3775e4 | ||
|
|
7e1d942e0a | ||
|
|
5cb49daaba | ||
|
|
4e132dcc28 | ||
|
|
288ef8f368 | ||
|
|
66206adbe0 | ||
|
|
c6529ed8a3 | ||
|
|
6ddb44afb6 | ||
|
|
7f84bbe43c | ||
|
|
51b8eece7e | ||
|
|
c891c227a6 | ||
|
|
03457ef138 | ||
|
|
36fb117f89 | ||
|
|
605b52df0e | ||
|
|
45d3a15c68 | ||
|
|
78d7ba69df | ||
|
|
c72f7cddc8 | ||
|
|
e7c3242765 | ||
|
|
35d60ecaa0 | ||
|
|
bcf40c0b76 | ||
|
|
922601eab7 | ||
|
|
bc4267ea1d | ||
|
|
f65d3213fe | ||
|
|
bf2107c01b | ||
|
|
33c738873f | ||
|
|
5bf724b2a2 | ||
|
|
769ce0ade9 | ||
|
|
6f01eafd30 | ||
|
|
5ec27932b0 | ||
|
|
d79ff6e6d5 | ||
|
|
b0e7d3af2c | ||
|
|
f6966efff7 | ||
|
|
79e71a6978 | ||
|
|
5beed3e824 | ||
|
|
c6ea9daa3a | ||
|
|
95440951e5 | ||
|
|
7a45da2293 | ||
|
|
8ae8390339 | ||
|
|
27bc1f4a66 | ||
|
|
b887052d65 | ||
|
|
90bd2fc54c | ||
|
|
e433f7173f | ||
|
|
a1ce605719 | ||
|
|
665cbddcf5 | ||
|
|
6c0a39099f | ||
|
|
b2576d0b35 | ||
|
|
7107ea4aff | ||
|
|
2c43a71850 | ||
|
|
16b93bc490 | ||
|
|
d56a77acdf | ||
|
|
5da1dd1756 | ||
|
|
66e57e8d8f | ||
|
|
db1d7ae549 | ||
|
|
3d85c4085b | ||
|
|
da23b2cc51 | ||
|
|
38926a5b4f | ||
|
|
b39003d519 | ||
|
|
c79ea6ffd2 | ||
|
|
a7f1ead7fe | ||
|
|
3c56087432 | ||
|
|
1e98dea091 | ||
|
|
d239d06c3e | ||
|
|
339393e904 | ||
|
|
40cfb2593c | ||
|
|
346b418908 | ||
|
|
03c2850c38 | ||
|
|
1c29a08679 | ||
|
|
db89ac7743 | ||
|
|
412333cfc4 | ||
|
|
033b0b6ebf | ||
|
|
ab02ce5601 | ||
|
|
d46777595d | ||
|
|
669ced862e | ||
|
|
ba66cc02b7 | ||
|
|
1e840250b9 | ||
|
|
2b615a51fb | ||
|
|
f0c92be5f2 | ||
|
|
20206d6e14 | ||
|
|
7c6e098014 | ||
|
|
1e0f1f329f | ||
|
|
b2ebeafed5 | ||
|
|
9492975a74 | ||
|
|
e194df455b | ||
|
|
1e01139028 | ||
|
|
1141c6f7a5 | ||
|
|
5a1951b495 | ||
|
|
3b3f94124a | ||
|
|
bb56a55143 | ||
|
|
853a12b0dd | ||
|
|
840641681e | ||
|
|
022951f406 | ||
|
|
b6c9df62eb | ||
|
|
27a14a6e3b | ||
|
|
45e3620634 | ||
|
|
d770d6aac7 | ||
|
|
37d60252fd | ||
|
|
1cdcb48c1f | ||
|
|
f9920a0b6b | ||
|
|
00f524db16 | ||
|
|
51401e333f | ||
|
|
7692c29a81 | ||
|
|
d7c755235e | ||
|
|
8589efdf6e | ||
|
|
02b83f02d6 | ||
|
|
e4e7679414 | ||
|
|
abdf51d045 | ||
|
|
5fd21c8393 | ||
|
|
e7e03697d6 | ||
|
|
9e7a76bd97 | ||
|
|
d4090d15be | ||
|
|
973dca83a2 | ||
|
|
c7f51e815c | ||
|
|
1c302a7ac1 | ||
|
|
5d29efe1d5 | ||
|
|
b066df4efc | ||
|
|
fa6b71afae | ||
|
|
3d0c064d41 | ||
|
|
50755ead18 | ||
|
|
79f7fa32ab | ||
|
|
703ab710e9 | ||
|
|
bc782ba9ee | ||
|
|
f2eef64d84 | ||
|
|
abb3a7a3a9 | ||
|
|
ef61e020b6 | ||
|
|
66c87f37e6 | ||
|
|
56caab19f1 | ||
|
|
27eb5f72f7 | ||
|
|
23a80a60b9 | ||
|
|
0520cbd538 | ||
|
|
c79ecbb92e | ||
|
|
08651d28ef | ||
|
|
3060d186a1 | ||
|
|
0509d7105e | ||
|
|
b6ab7dc8a7 | ||
|
|
822ca65349 | ||
|
|
4b4a2b46c1 | ||
|
|
13b020732f | ||
|
|
fb9ca7f5f3 | ||
|
|
e21d37b20f | ||
|
|
161614f6c9 | ||
|
|
16472e1de8 | ||
|
|
d81e47204c | ||
|
|
7ec93cd91e | ||
|
|
af516991a9 | ||
|
|
2cac88cf7c | ||
|
|
1546a85195 | ||
|
|
9eba3e4dc4 | ||
|
|
b568e2ecf1 | ||
|
|
9dce119530 | ||
|
|
24f9b134cd | ||
|
|
c3e6bdd73d | ||
|
|
80e11c1492 | ||
|
|
bdebb4972e | ||
|
|
9b5f9e00d9 | ||
|
|
57167b8a10 | ||
|
|
98889a0baf | ||
|
|
0c535bb2d8 | ||
|
|
f4c34a3102 | ||
|
|
1f4b4d30ee | ||
|
|
1b1dc91c72 | ||
|
|
5a6a76bd63 | ||
|
|
e5263f7719 | ||
|
|
267229f929 | ||
|
|
79ee8b09d6 | ||
|
|
7824693254 | ||
|
|
0b2319fbaa | ||
|
|
dea3844090 | ||
|
|
be2c5759f2 | ||
|
|
c8b14d7822 | ||
|
|
1b54016035 | ||
|
|
50fe370585 | ||
|
|
e423ad168a | ||
|
|
1d7670830c | ||
|
|
d05dbfd5fb | ||
|
|
7e3f1f1ffa | ||
|
|
882ed1871a | ||
|
|
7c5c7d5632 | ||
|
|
240c0da577 | ||
|
|
7472ef01a8 | ||
|
|
9907095a2d | ||
|
|
658a527d93 | ||
|
|
3d25f3dd62 | ||
|
|
6290e983e9 | ||
|
|
10ae92ec41 | ||
|
|
2c25286155 | ||
|
|
656ede98b6 | ||
|
|
5fb0118f60 | ||
|
|
27e47b2b4b | ||
|
|
f02e3358aa | ||
|
|
7caae3171c | ||
|
|
0cded599b1 | ||
|
|
4e48240158 | ||
|
|
b488e24798 | ||
|
|
7e7d87c147 | ||
|
|
8b6389cf3a | ||
|
|
8a2725b48b | ||
|
|
a7e8fe12f4 | ||
|
|
67a5473e27 | ||
|
|
e16fba8eab | ||
|
|
cdfbafc789 | ||
|
|
c318f5d57b | ||
|
|
556ab85b0d | ||
|
|
20018aed27 | ||
|
|
e2facc87f0 | ||
|
|
07e951039c | ||
|
|
386d9ebc52 | ||
|
|
d8e058a416 | ||
|
|
f5d9c6076c | ||
|
|
e870855602 | ||
|
|
a3a6b4618b | ||
|
|
3ecae3e16b | ||
|
|
111e8d38dc | ||
|
|
d5fa36b11a | ||
|
|
a35c94cf03 | ||
|
|
b2eff46c38 | ||
|
|
07c0187423 | ||
|
|
0074078539 | ||
|
|
d1641ac0e8 | ||
|
|
13d3489cde |
@@ -12,6 +12,16 @@ body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: 'Join the [discord server](https://discord.gg/HQgCbd6E75) for questions or if you are not sure about a bug.'
|
||||
- type: dropdown
|
||||
id: confirm-check
|
||||
attributes:
|
||||
label: I have verified that the [bug is not already awaiting release](https://github.com/advplyr/audiobookshelf-app/issues?q=is%3Aissue%20label%3A%22awaiting%20release%22)
|
||||
multiple: false
|
||||
options:
|
||||
- 'Yes'
|
||||
- 'No'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
@@ -44,7 +54,7 @@ body:
|
||||
attributes:
|
||||
label: Phone Model
|
||||
description: What kind of phone are you using?
|
||||
placeholder: e.g. Pixel 6, iPhone 14, Samusung Galaxy s23, etc
|
||||
placeholder: e.g. Pixel 6, iPhone 14, Samsung Galaxy s23, etc
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
@@ -62,10 +72,10 @@ body:
|
||||
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
||||
multiple: true
|
||||
options:
|
||||
- Android App - 0.9.77
|
||||
- iOS App - 0.9.77
|
||||
- Android App - 0.9.76
|
||||
- iOS App - 0.9.76
|
||||
- 'Android App - 0.9.79'
|
||||
- 'iOS App - 0.9.79'
|
||||
- 'Android App - 0.9.78'
|
||||
- 'iOS App - 0.9.78'
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
@@ -74,10 +84,10 @@ body:
|
||||
label: Installation Source
|
||||
multiple: true
|
||||
options:
|
||||
- Google Play Store
|
||||
- Testflight
|
||||
- SideStore
|
||||
- Other (List in "Additional Notes")
|
||||
- 'Google Play Store'
|
||||
- 'Testflight'
|
||||
- 'SideStore'
|
||||
- 'Other (List in "Additional Notes")'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discord
|
||||
url: https://discord.gg/HQgCbd6E75
|
||||
about: Ask questions, get help troubleshooting, and join the Abs community here.
|
||||
+4
-4
@@ -43,10 +43,10 @@ body:
|
||||
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
||||
multiple: true
|
||||
options:
|
||||
- Android App - 0.9.77
|
||||
- iOS App - 0.9.77
|
||||
- Android App - 0.9.76
|
||||
- iOS App - 0.9.76
|
||||
- Android App - 0.9.79
|
||||
- iOS App - 0.9.79
|
||||
- Android App - 0.9.78
|
||||
- iOS App - 0.9.78
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
@@ -13,41 +13,41 @@ jobs:
|
||||
main:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout sources
|
||||
uses: actions/checkout@v3
|
||||
- name: checkout sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: use Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
- name: use Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: 17
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
|
||||
- name: install dependencies
|
||||
run: npm ci
|
||||
- name: install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: build Nuxt project
|
||||
run: npm run generate
|
||||
- name: build Nuxt project
|
||||
run: npm run generate
|
||||
|
||||
- name: copy to Android project
|
||||
run: npx cap sync
|
||||
- name: copy to Android project
|
||||
run: npx cap sync
|
||||
|
||||
- name: build Android app
|
||||
run: ./android/gradlew assembleDebug -p android --no-daemon
|
||||
- name: build Android app
|
||||
run: ./android/gradlew assembleDebug -p android --no-daemon
|
||||
|
||||
- name: rename apk
|
||||
working-directory: android/app/build/outputs/apk/debug/
|
||||
run: |
|
||||
build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
|
||||
name="audiobookshelf-${build}.apk"
|
||||
mv -v app-debug.apk "${name}"
|
||||
- name: rename apk
|
||||
working-directory: android/app/build/outputs/apk/debug/
|
||||
run: |
|
||||
build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
|
||||
name="audiobookshelf-${build}.apk"
|
||||
mv -v app-debug.apk "${name}"
|
||||
|
||||
- name: upload app
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: audiobookshelf-apk
|
||||
path: android/app/build/outputs/apk/debug/*.apk
|
||||
- name: upload app
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: audiobookshelf-apk
|
||||
path: android/app/build/outputs/apk/debug/*.apk
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Close Issues not using a template
|
||||
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
close_issue:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check issue headings
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const issueBody = context.payload.issue.body || "";
|
||||
|
||||
// Match Markdown headings (e.g., # Heading, ## Heading)
|
||||
const headingRegex = /^(#{1,6})\s.+/gm;
|
||||
const headings = [...issueBody.matchAll(headingRegex)];
|
||||
|
||||
if (headings.length < 3) {
|
||||
// Post a comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
body: "Thank you for opening an issue! To help us review your request efficiently, please use one of the provided issue templates. If you're seeking information or have a general question, consider opening a Discussion or joining the conversation on our Discord. Thanks!"
|
||||
});
|
||||
|
||||
// Close the issue
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.issue.number,
|
||||
state: "closed"
|
||||
});
|
||||
}
|
||||
@@ -12,57 +12,57 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout sources
|
||||
uses: actions/checkout@v3
|
||||
- name: checkout sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: use Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
- name: use Node.js 20.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: 17
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v2
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 17
|
||||
|
||||
- name: install dependencies
|
||||
run: npm ci
|
||||
- name: install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: build Nuxt project
|
||||
run: npm run generate
|
||||
- name: build Nuxt project
|
||||
run: npm run generate
|
||||
|
||||
- name: copy to Android project
|
||||
run: npx cap sync
|
||||
- name: copy to Android project
|
||||
run: npx cap sync
|
||||
|
||||
- name: build Android app
|
||||
run: ./android/gradlew assembleDebug -p android --no-daemon
|
||||
- name: build Android app
|
||||
run: ./android/gradlew assembleDebug -p android --no-daemon
|
||||
|
||||
- name: rename apk
|
||||
working-directory: android/app/build/outputs/apk/debug/
|
||||
run: |
|
||||
build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
|
||||
name="audiobookshelf-${build}.apk"
|
||||
mv -v app-debug.apk "${name}"
|
||||
- name: rename apk
|
||||
working-directory: android/app/build/outputs/apk/debug/
|
||||
run: |
|
||||
build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
|
||||
name="audiobookshelf-${build}.apk"
|
||||
mv -v app-debug.apk "${name}"
|
||||
|
||||
- name: prepare test page ressources
|
||||
run: |
|
||||
mkdir ghpages
|
||||
cp android/app/build/outputs/apk/debug/*apk ghpages/
|
||||
cp static/Logo.png ghpages/logo.png
|
||||
cp .github/testing-page-template.html ghpages/index.html
|
||||
- name: prepare test page ressources
|
||||
run: |
|
||||
mkdir ghpages
|
||||
cp android/app/build/outputs/apk/debug/*apk ghpages/
|
||||
cp static/Logo.png ghpages/logo.png
|
||||
cp .github/testing-page-template.html ghpages/index.html
|
||||
|
||||
- name: build test page
|
||||
working-directory: ghpages
|
||||
run: |
|
||||
sed -i "s/__DATE__/$(date)/g" index.html
|
||||
sed -i "s/__COMMIT__/$(git rev-parse --short HEAD)/g" index.html
|
||||
sed -i "s/__APK__/$(ls *apk)/g" index.html
|
||||
- name: build test page
|
||||
working-directory: ghpages
|
||||
run: |
|
||||
sed -i "s/__DATE__/$(date)/g" index.html
|
||||
sed -i "s/__COMMIT__/$(git rev-parse --short HEAD)/g" index.html
|
||||
sed -i "s/__APK__/$(ls *apk)/g" index.html
|
||||
|
||||
- name: upload test page artifact
|
||||
uses: actions/upload-pages-artifact@v1
|
||||
with:
|
||||
path: ./ghpages
|
||||
- name: upload test page artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: ./ghpages
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
@@ -79,4 +79,4 @@ jobs:
|
||||
steps:
|
||||
- name: deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v1
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
@@ -36,8 +36,8 @@ android {
|
||||
applicationId "com.audiobookshelf.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 108
|
||||
versionName "0.9.77-beta"
|
||||
versionCode 110
|
||||
versionName "0.9.79-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
manifestPlaceholders = [
|
||||
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
||||
@@ -86,7 +86,7 @@ dependencies {
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation project(':capacitor-android')
|
||||
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.2.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
|
||||
@@ -9,7 +9,7 @@ android {
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':byteowls-capacitor-filesharer')
|
||||
implementation project(':webnativellc-capacitor-filesharer')
|
||||
implementation project(':capacitor-community-keep-awake')
|
||||
implementation project(':capacitor-community-volume-buttons')
|
||||
implementation project(':capacitor-app')
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||
android:exported="true"
|
||||
android:label="@string/title_activity_main"
|
||||
android:launchMode="singleTask"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[
|
||||
{
|
||||
"pkg": "@byteowls/capacitor-filesharer",
|
||||
"pkg": "@webnativellc/capacitor-filesharer",
|
||||
"classpath": "com.byteowls.capacitor.filesharer.FileSharerPlugin"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioProbeStream(
|
||||
val index:Int,
|
||||
val codec_name:String,
|
||||
val codec_long_name:String,
|
||||
val channels:Int,
|
||||
val channel_layout:String,
|
||||
val duration:Double,
|
||||
val bit_rate:Double
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioProbeChapterTags(
|
||||
val title:String
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioProbeChapter(
|
||||
val id:Int,
|
||||
val start:Long,
|
||||
val end:Long,
|
||||
val tags:AudioProbeChapterTags?
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun getBookChapter():BookChapter {
|
||||
val startS = start / 1000.0
|
||||
val endS = end / 1000.0
|
||||
val title = tags?.title ?: "Chapter $id"
|
||||
return BookChapter(id, startS, endS, title)
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioProbeFormatTags(
|
||||
val artist:String?,
|
||||
val album:String?,
|
||||
val comment:String?,
|
||||
val date:String?,
|
||||
val genre:String?,
|
||||
val title:String?
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioProbeFormat(
|
||||
val filename:String,
|
||||
val format_name:String,
|
||||
val duration:Double,
|
||||
val size:Long,
|
||||
val bit_rate:Double,
|
||||
val tags:AudioProbeFormatTags?
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class AudioProbeResult (
|
||||
val streams:MutableList<AudioProbeStream>,
|
||||
val chapters:MutableList<AudioProbeChapter>,
|
||||
val format:AudioProbeFormat) {
|
||||
|
||||
val duration get() = format.duration
|
||||
val size get() = format.size
|
||||
val title get() = format.tags?.title ?: format.filename.split("/").last()
|
||||
val artist get() = format.tags?.artist ?: ""
|
||||
|
||||
@JsonIgnore
|
||||
fun getBookChapters(): List<BookChapter> {
|
||||
if (chapters.isEmpty()) return mutableListOf()
|
||||
return chapters.map { it.getBookChapter() }
|
||||
}
|
||||
}
|
||||
@@ -5,30 +5,33 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioTrack(
|
||||
var index:Int,
|
||||
var startOffset:Double,
|
||||
var duration:Double,
|
||||
var title:String,
|
||||
var contentUrl:String,
|
||||
var mimeType:String,
|
||||
var metadata:FileMetadata?,
|
||||
var isLocal:Boolean,
|
||||
var localFileId:String?,
|
||||
var audioProbeResult:AudioProbeResult?,
|
||||
var serverIndex:Int? // Need to know if server track index is different
|
||||
var index: Int,
|
||||
var startOffset: Double,
|
||||
var duration: Double,
|
||||
var title: String,
|
||||
var contentUrl: String,
|
||||
var mimeType: String,
|
||||
var metadata: FileMetadata?,
|
||||
var isLocal: Boolean,
|
||||
var localFileId: String?,
|
||||
var serverIndex: Int? // Need to know if server track index is different
|
||||
) {
|
||||
|
||||
@get:JsonIgnore
|
||||
val startOffsetMs get() = (startOffset * 1000L).toLong()
|
||||
val startOffsetMs
|
||||
get() = (startOffset * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val durationMs get() = (duration * 1000L).toLong()
|
||||
val durationMs
|
||||
get() = (duration * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val endOffsetMs get() = startOffsetMs + durationMs
|
||||
val endOffsetMs
|
||||
get() = startOffsetMs + durationMs
|
||||
@get:JsonIgnore
|
||||
val relPath get() = metadata?.relPath ?: ""
|
||||
val relPath
|
||||
get() = metadata?.relPath ?: ""
|
||||
|
||||
@JsonIgnore
|
||||
fun getBookChapter():BookChapter {
|
||||
return BookChapter(index + 1,startOffset, startOffset + duration, title)
|
||||
fun getBookChapter(): BookChapter {
|
||||
return BookChapter(index + 1, startOffset, startOffset + duration, title)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,70 +4,65 @@ import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
/*
|
||||
Used as a helper class to generate LocalLibraryItem from scan results
|
||||
*/
|
||||
Used as a helper class to generate LocalLibraryItem from scan results
|
||||
*/
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LocalMediaItem(
|
||||
var id:String,
|
||||
var name: String,
|
||||
var mediaType:String,
|
||||
var folderId:String,
|
||||
var contentUrl:String,
|
||||
var simplePath: String,
|
||||
var basePath:String,
|
||||
var absolutePath:String,
|
||||
var audioTracks:MutableList<AudioTrack>,
|
||||
var ebookFile:EBookFile?,
|
||||
var localFiles:MutableList<LocalFile>,
|
||||
var coverContentUrl:String?,
|
||||
var coverAbsolutePath:String?
|
||||
var id: String,
|
||||
var name: String,
|
||||
var mediaType: String,
|
||||
var folderId: String,
|
||||
var contentUrl: String,
|
||||
var simplePath: String,
|
||||
var basePath: String,
|
||||
var absolutePath: String,
|
||||
var audioTracks: MutableList<AudioTrack>,
|
||||
var ebookFile: EBookFile?,
|
||||
var localFiles: MutableList<LocalFile>,
|
||||
var coverContentUrl: String?,
|
||||
var coverAbsolutePath: String?
|
||||
) {
|
||||
|
||||
@JsonIgnore
|
||||
fun getDuration():Double {
|
||||
fun getDuration(): Double {
|
||||
var total = 0.0
|
||||
audioTracks.forEach{ total += it.duration }
|
||||
audioTracks.forEach { total += it.duration }
|
||||
return total
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getTotalSize():Long {
|
||||
fun getTotalSize(): Long {
|
||||
var total = 0L
|
||||
localFiles.forEach { total += it.size }
|
||||
return total
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getMediaMetadata():MediaTypeMetadata {
|
||||
fun getMediaMetadata(): MediaTypeMetadata {
|
||||
return if (mediaType == "book") {
|
||||
BookMetadata(name,null, mutableListOf(), mutableListOf(), mutableListOf(),null,null,null,null,null,null,null,false,null,null,null, null, null)
|
||||
BookMetadata(
|
||||
name,
|
||||
null,
|
||||
mutableListOf(),
|
||||
mutableListOf(),
|
||||
mutableListOf(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
} else {
|
||||
PodcastMetadata(name,null,null, mutableListOf(), false)
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getAudiobookChapters():List<BookChapter> {
|
||||
if (mediaType != "book" || audioTracks.isEmpty()) return mutableListOf()
|
||||
if (audioTracks.size == 1) { // Single track audiobook look for chapters from ffprobe
|
||||
return audioTracks[0].audioProbeResult?.getBookChapters() ?: mutableListOf()
|
||||
}
|
||||
// Multi-track make chapters from tracks
|
||||
return audioTracks.map { it.getBookChapter() }
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getLocalLibraryItem():LocalLibraryItem {
|
||||
val mediaMetadata = getMediaMetadata()
|
||||
if (mediaType == "book") {
|
||||
val chapters = getAudiobookChapters()
|
||||
val book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,ebookFile,getTotalSize(),getDuration(),audioTracks.size)
|
||||
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null)
|
||||
} else {
|
||||
val podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false, 0)
|
||||
podcast.setAudioTracks(audioTracks) // Builds episodes from audio tracks
|
||||
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false, mediaType, podcast,localFiles,coverContentUrl, coverAbsolutePath, true, null,null,null,null)
|
||||
PodcastMetadata(name, null, null, mutableListOf(), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,14 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class MediaItemHistory(
|
||||
var id: String, // media id
|
||||
var mediaDisplayTitle: String,
|
||||
var libraryItemId: String,
|
||||
var episodeId: String?,
|
||||
var isLocal:Boolean,
|
||||
var serverConnectionConfigId:String?,
|
||||
var serverAddress:String?,
|
||||
var serverUserId:String?,
|
||||
var createdAt: Long,
|
||||
var events:MutableList<MediaItemEvent>,
|
||||
var id: String, // media id
|
||||
var mediaDisplayTitle: String,
|
||||
var libraryItemId: String,
|
||||
var episodeId: String?,
|
||||
var isLocal: Boolean,
|
||||
var serverConnectionConfigId: String?,
|
||||
var serverAddress: String?,
|
||||
var serverUserId: String?,
|
||||
var createdAt: Long,
|
||||
var events: MutableList<MediaItemEvent>,
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.media.MediaProgressSyncData
|
||||
import com.audiobookshelf.app.player.*
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
@@ -19,61 +20,70 @@ import com.google.android.exoplayer2.MediaMetadata
|
||||
import com.google.android.gms.cast.MediaInfo
|
||||
import com.google.android.gms.cast.MediaQueueItem
|
||||
import com.google.android.gms.common.images.WebImage
|
||||
import com.audiobookshelf.app.player.*
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class PlaybackSession(
|
||||
var id:String,
|
||||
var userId:String?,
|
||||
var libraryItemId:String?,
|
||||
var episodeId:String?,
|
||||
var mediaType:String,
|
||||
var mediaMetadata:MediaTypeMetadata,
|
||||
var deviceInfo:DeviceInfo,
|
||||
var chapters:List<BookChapter>,
|
||||
var displayTitle: String?,
|
||||
var displayAuthor: String?,
|
||||
var coverPath:String?,
|
||||
var duration:Double,
|
||||
var playMethod:Int,
|
||||
var startedAt:Long,
|
||||
var updatedAt:Long,
|
||||
var timeListening:Long,
|
||||
var audioTracks:MutableList<AudioTrack>,
|
||||
var currentTime:Double,
|
||||
var libraryItem:LibraryItem?,
|
||||
var localLibraryItem:LocalLibraryItem?,
|
||||
var localEpisodeId:String?,
|
||||
var serverConnectionConfigId:String?,
|
||||
var serverAddress:String?,
|
||||
var mediaPlayer:String?
|
||||
var id: String,
|
||||
var userId: String?,
|
||||
var libraryItemId: String?,
|
||||
var episodeId: String?,
|
||||
var mediaType: String,
|
||||
var mediaMetadata: MediaTypeMetadata,
|
||||
var deviceInfo: DeviceInfo,
|
||||
var chapters: List<BookChapter>,
|
||||
var displayTitle: String?,
|
||||
var displayAuthor: String?,
|
||||
var coverPath: String?,
|
||||
var duration: Double,
|
||||
var playMethod: Int,
|
||||
var startedAt: Long,
|
||||
var updatedAt: Long,
|
||||
var timeListening: Long,
|
||||
var audioTracks: MutableList<AudioTrack>,
|
||||
var currentTime: Double,
|
||||
var libraryItem: LibraryItem?,
|
||||
var localLibraryItem: LocalLibraryItem?,
|
||||
var localEpisodeId: String?,
|
||||
var serverConnectionConfigId: String?,
|
||||
var serverAddress: String?,
|
||||
var mediaPlayer: String?
|
||||
) {
|
||||
|
||||
@get:JsonIgnore
|
||||
val isHLS get() = playMethod == PLAYMETHOD_TRANSCODE
|
||||
val isHLS
|
||||
get() = playMethod == PLAYMETHOD_TRANSCODE
|
||||
@get:JsonIgnore
|
||||
val isDirectPlay get() = playMethod == PLAYMETHOD_DIRECTPLAY
|
||||
val isDirectPlay
|
||||
get() = playMethod == PLAYMETHOD_DIRECTPLAY
|
||||
@get:JsonIgnore
|
||||
val isLocal get() = playMethod == PLAYMETHOD_LOCAL
|
||||
val isLocal
|
||||
get() = playMethod == PLAYMETHOD_LOCAL
|
||||
@get:JsonIgnore
|
||||
val isPodcastEpisode get() = mediaType == "podcast"
|
||||
val isPodcastEpisode
|
||||
get() = mediaType == "podcast"
|
||||
@get:JsonIgnore
|
||||
val currentTimeMs get() = (currentTime * 1000L).toLong()
|
||||
val currentTimeMs
|
||||
get() = (currentTime * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val totalDurationMs get() = (getTotalDuration() * 1000L).toLong()
|
||||
val totalDurationMs
|
||||
get() = (getTotalDuration() * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val localLibraryItemId get() = localLibraryItem?.id ?: ""
|
||||
val localLibraryItemId
|
||||
get() = localLibraryItem?.id ?: ""
|
||||
@get:JsonIgnore
|
||||
val localMediaProgressId get() = if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
||||
val localMediaProgressId
|
||||
get() =
|
||||
if (localEpisodeId.isNullOrEmpty()) localLibraryItemId
|
||||
else "$localLibraryItemId-$localEpisodeId"
|
||||
@get:JsonIgnore
|
||||
val progress get() = currentTime / getTotalDuration()
|
||||
val progress
|
||||
get() = currentTime / getTotalDuration()
|
||||
@get:JsonIgnore
|
||||
val isLocalLibraryItemOnly get() = localLibraryItemId != "" && libraryItemId == null
|
||||
@get:JsonIgnore
|
||||
val mediaItemId get() = if (isLocalLibraryItemOnly) localMediaProgressId else if (episodeId.isNullOrEmpty()) libraryItemId ?: "" else "$libraryItemId-$episodeId"
|
||||
val mediaItemId
|
||||
get() = if (episodeId.isNullOrEmpty()) libraryItemId ?: "" else "$libraryItemId-$episodeId"
|
||||
|
||||
@JsonIgnore
|
||||
fun getCurrentTrackIndex():Int {
|
||||
fun getCurrentTrackIndex(): Int {
|
||||
for (i in 0 until audioTracks.size) {
|
||||
val track = audioTracks[i]
|
||||
if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs > currentTimeMs)) {
|
||||
@@ -84,7 +94,7 @@ class PlaybackSession(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getNextTrackIndex():Int {
|
||||
fun getNextTrackIndex(): Int {
|
||||
for (i in 0 until audioTracks.size) {
|
||||
val track = audioTracks[i]
|
||||
if (currentTimeMs < track.startOffsetMs) {
|
||||
@@ -95,67 +105,74 @@ class PlaybackSession(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getChapterForTime(time:Long):BookChapter? {
|
||||
fun getChapterForTime(time: Long): BookChapter? {
|
||||
if (chapters.isEmpty()) return null
|
||||
return chapters.find { time >= it.startMs && it.endMs > time}
|
||||
return chapters.find { time >= it.startMs && it.endMs > time }
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getCurrentTrackEndTime():Long {
|
||||
fun getCurrentTrackEndTime(): Long {
|
||||
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
|
||||
return currentTrack.startOffsetMs + currentTrack.durationMs
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getNextChapterForTime(time:Long):BookChapter? {
|
||||
fun getNextChapterForTime(time: Long): BookChapter? {
|
||||
if (chapters.isEmpty()) return null
|
||||
return chapters.find { time < it.startMs } // First chapter where start time is > then time
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getNextTrackEndTime():Long {
|
||||
fun getNextTrackEndTime(): Long {
|
||||
val currentTrack = audioTracks[this.getNextTrackIndex()]
|
||||
return currentTrack.startOffsetMs + currentTrack.durationMs
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getCurrentTrackTimeMs():Long {
|
||||
fun getCurrentTrackTimeMs(): Long {
|
||||
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
|
||||
val time = currentTime - currentTrack.startOffset
|
||||
return (time * 1000L).toLong()
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getTrackStartOffsetMs(index:Int):Long {
|
||||
fun getTrackStartOffsetMs(index: Int): Long {
|
||||
if (index < 0 || index >= audioTracks.size) return 0L
|
||||
val currentTrack = audioTracks[index]
|
||||
return (currentTrack.startOffset * 1000L).toLong()
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getTotalDuration():Double {
|
||||
fun getTotalDuration(): Double {
|
||||
var total = 0.0
|
||||
audioTracks.forEach { total += it.duration }
|
||||
return total
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getCoverUri(ctx:Context): Uri {
|
||||
fun getCoverUri(ctx: Context): Uri {
|
||||
if (localLibraryItem?.coverContentUrl != null) {
|
||||
var coverUri = Uri.parse(localLibraryItem?.coverContentUrl.toString())
|
||||
if (coverUri.toString().startsWith("file:")) {
|
||||
coverUri = FileProvider.getUriForFile(ctx, "${BuildConfig.APPLICATION_ID}.fileprovider", coverUri.toFile())
|
||||
coverUri =
|
||||
FileProvider.getUriForFile(
|
||||
ctx,
|
||||
"${BuildConfig.APPLICATION_ID}.fileprovider",
|
||||
coverUri.toFile()
|
||||
)
|
||||
}
|
||||
|
||||
return coverUri ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
return coverUri
|
||||
?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
}
|
||||
|
||||
if (coverPath == null) return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
if (coverPath == null)
|
||||
return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getContentUri(audioTrack:AudioTrack): Uri {
|
||||
fun getContentUri(audioTrack: AudioTrack): Uri {
|
||||
if (isLocal) return Uri.parse(audioTrack.contentUrl) // Local content url
|
||||
return Uri.parse("$serverAddress${audioTrack.contentUrl}?token=${DeviceManager.token}")
|
||||
}
|
||||
@@ -164,28 +181,34 @@ class PlaybackSession(
|
||||
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
|
||||
val coverUri = getCoverUri(ctx)
|
||||
|
||||
val metadataBuilder = MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
|
||||
val metadataBuilder =
|
||||
MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
|
||||
.putString(
|
||||
MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI,
|
||||
coverUri.toString()
|
||||
)
|
||||
|
||||
// Local covers get bitmap
|
||||
if (localLibraryItem?.coverContentUrl != null) {
|
||||
val bitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||
} else {
|
||||
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
}
|
||||
val bitmap =
|
||||
if (Build.VERSION.SDK_INT < 28) {
|
||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||
} else {
|
||||
val source: ImageDecoder.Source =
|
||||
ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
}
|
||||
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
|
||||
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
|
||||
}
|
||||
@@ -194,26 +217,27 @@ class PlaybackSession(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getExoMediaMetadata(ctx:Context): MediaMetadata {
|
||||
fun getExoMediaMetadata(ctx: Context): MediaMetadata {
|
||||
val coverUri = getCoverUri(ctx)
|
||||
|
||||
val metadataBuilder = MediaMetadata.Builder()
|
||||
.setTitle(displayTitle)
|
||||
.setDisplayTitle(displayTitle)
|
||||
.setArtist(displayAuthor)
|
||||
.setAlbumArtist(displayAuthor)
|
||||
.setSubtitle(displayAuthor)
|
||||
.setAlbumTitle(displayAuthor)
|
||||
.setDescription(displayAuthor)
|
||||
.setArtworkUri(coverUri)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_AUDIO_BOOK)
|
||||
val metadataBuilder =
|
||||
MediaMetadata.Builder()
|
||||
.setTitle(displayTitle)
|
||||
.setDisplayTitle(displayTitle)
|
||||
.setArtist(displayAuthor)
|
||||
.setAlbumArtist(displayAuthor)
|
||||
.setSubtitle(displayAuthor)
|
||||
.setAlbumTitle(displayAuthor)
|
||||
.setDescription(displayAuthor)
|
||||
.setArtworkUri(coverUri)
|
||||
.setMediaType(MediaMetadata.MEDIA_TYPE_AUDIO_BOOK)
|
||||
|
||||
return metadataBuilder.build()
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getMediaItems(ctx:Context):List<MediaItem> {
|
||||
val mediaItems:MutableList<MediaItem> = mutableListOf()
|
||||
fun getMediaItems(ctx: Context): List<MediaItem> {
|
||||
val mediaItems: MutableList<MediaItem> = mutableListOf()
|
||||
|
||||
for (audioTrack in audioTracks) {
|
||||
val mediaMetadata = this.getExoMediaMetadata(ctx)
|
||||
@@ -221,50 +245,105 @@ class PlaybackSession(
|
||||
val mimeType = audioTrack.mimeType
|
||||
|
||||
val queueItem = getQueueItem(audioTrack) // Queue item used in exo player CastManager
|
||||
val mediaItem = MediaItem.Builder().setUri(mediaUri).setTag(queueItem).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build()
|
||||
val mediaItem =
|
||||
MediaItem.Builder()
|
||||
.setUri(mediaUri)
|
||||
.setTag(queueItem)
|
||||
.setMediaMetadata(mediaMetadata)
|
||||
.setMimeType(mimeType)
|
||||
.build()
|
||||
mediaItems.add(mediaItem)
|
||||
}
|
||||
return mediaItems
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getCastMediaMetadata(audioTrack:AudioTrack):com.google.android.gms.cast.MediaMetadata {
|
||||
val castMetadata = com.google.android.gms.cast.MediaMetadata(com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER)
|
||||
fun getCastMediaMetadata(audioTrack: AudioTrack): com.google.android.gms.cast.MediaMetadata {
|
||||
val castMetadata =
|
||||
com.google.android.gms.cast.MediaMetadata(
|
||||
com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER
|
||||
)
|
||||
|
||||
coverPath?.let {
|
||||
castMetadata.addImage(WebImage(Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")))
|
||||
castMetadata.addImage(
|
||||
WebImage(
|
||||
Uri.parse(
|
||||
"$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "")
|
||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ARTIST, displayAuthor ?: "")
|
||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ALBUM_TITLE, displayAuthor ?: "")
|
||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_CHAPTER_TITLE, audioTrack.title)
|
||||
castMetadata.putString(
|
||||
com.google.android.gms.cast.MediaMetadata.KEY_ARTIST,
|
||||
displayAuthor ?: ""
|
||||
)
|
||||
castMetadata.putString(
|
||||
com.google.android.gms.cast.MediaMetadata.KEY_ALBUM_TITLE,
|
||||
displayAuthor ?: ""
|
||||
)
|
||||
castMetadata.putString(
|
||||
com.google.android.gms.cast.MediaMetadata.KEY_CHAPTER_TITLE,
|
||||
audioTrack.title
|
||||
)
|
||||
|
||||
castMetadata.putInt(com.google.android.gms.cast.MediaMetadata.KEY_TRACK_NUMBER, audioTrack.index)
|
||||
castMetadata.putInt(
|
||||
com.google.android.gms.cast.MediaMetadata.KEY_TRACK_NUMBER,
|
||||
audioTrack.index
|
||||
)
|
||||
return castMetadata
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getQueueItem(audioTrack:AudioTrack):MediaQueueItem {
|
||||
fun getQueueItem(audioTrack: AudioTrack): MediaQueueItem {
|
||||
val castMetadata = getCastMediaMetadata(audioTrack)
|
||||
|
||||
val mediaUri = getContentUri(audioTrack)
|
||||
|
||||
val mediaInfo = MediaInfo.Builder(mediaUri.toString()).apply {
|
||||
setContentUrl(mediaUri.toString())
|
||||
setContentType(audioTrack.mimeType)
|
||||
setMetadata(castMetadata)
|
||||
setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
|
||||
}.build()
|
||||
val mediaInfo =
|
||||
MediaInfo.Builder(mediaUri.toString())
|
||||
.apply {
|
||||
setContentUrl(mediaUri.toString())
|
||||
setContentType(audioTrack.mimeType)
|
||||
setMetadata(castMetadata)
|
||||
setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
|
||||
}
|
||||
.build()
|
||||
|
||||
return MediaQueueItem.Builder(mediaInfo).apply {
|
||||
setPlaybackDuration(audioTrack.duration)
|
||||
}.build()
|
||||
return MediaQueueItem.Builder(mediaInfo)
|
||||
.apply { setPlaybackDuration(audioTrack.duration) }
|
||||
.build()
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun clone():PlaybackSession {
|
||||
return PlaybackSession(id,userId,libraryItemId,episodeId,mediaType,mediaMetadata,deviceInfo,chapters,displayTitle,displayAuthor,coverPath,duration,playMethod,startedAt,updatedAt,timeListening,audioTracks,currentTime,libraryItem,localLibraryItem,localEpisodeId,serverConnectionConfigId,serverAddress, mediaPlayer)
|
||||
fun clone(): PlaybackSession {
|
||||
return PlaybackSession(
|
||||
id,
|
||||
userId,
|
||||
libraryItemId,
|
||||
episodeId,
|
||||
mediaType,
|
||||
mediaMetadata,
|
||||
deviceInfo,
|
||||
chapters,
|
||||
displayTitle,
|
||||
displayAuthor,
|
||||
coverPath,
|
||||
duration,
|
||||
playMethod,
|
||||
startedAt,
|
||||
updatedAt,
|
||||
timeListening,
|
||||
audioTracks,
|
||||
currentTime,
|
||||
libraryItem,
|
||||
localLibraryItem,
|
||||
localEpisodeId,
|
||||
serverConnectionConfigId,
|
||||
serverAddress,
|
||||
mediaPlayer
|
||||
)
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
@@ -275,7 +354,25 @@ class PlaybackSession(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getNewLocalMediaProgress():LocalMediaProgress {
|
||||
return LocalMediaProgress(localMediaProgressId,localLibraryItemId,localEpisodeId,getTotalDuration(),progress,currentTime,false,null,null,updatedAt,startedAt,null,serverConnectionConfigId,serverAddress,userId,libraryItemId,episodeId)
|
||||
fun getNewLocalMediaProgress(): LocalMediaProgress {
|
||||
return LocalMediaProgress(
|
||||
localMediaProgressId,
|
||||
localLibraryItemId,
|
||||
localEpisodeId,
|
||||
getTotalDuration(),
|
||||
progress,
|
||||
currentTime,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
updatedAt,
|
||||
startedAt,
|
||||
null,
|
||||
serverConnectionConfigId,
|
||||
serverAddress,
|
||||
userId,
|
||||
libraryItemId,
|
||||
episodeId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,19 @@ import com.audiobookshelf.app.managers.DbManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.updateAppWidget
|
||||
|
||||
/** Interface for widget event handling. */
|
||||
interface WidgetEventEmitter {
|
||||
fun onPlayerChanged(pns:PlayerNotificationService)
|
||||
/**
|
||||
* Called when the player state changes.
|
||||
* @param pns The PlayerNotificationService instance.
|
||||
*/
|
||||
fun onPlayerChanged(pns: PlayerNotificationService)
|
||||
|
||||
/** Called when the player is closed. */
|
||||
fun onPlayerClosed()
|
||||
}
|
||||
|
||||
/** Singleton object for managing device-related operations. */
|
||||
object DeviceManager {
|
||||
const val tag = "DeviceManager"
|
||||
|
||||
@@ -24,21 +32,28 @@ object DeviceManager {
|
||||
var deviceData: DeviceData = dbManager.getDeviceData()
|
||||
var serverConnectionConfig: ServerConnectionConfig? = null
|
||||
|
||||
val serverConnectionConfigId get() = serverConnectionConfig?.id ?: ""
|
||||
val serverAddress get() = serverConnectionConfig?.address ?: ""
|
||||
val serverUserId get() = serverConnectionConfig?.userId ?: ""
|
||||
val token get() = serverConnectionConfig?.token ?: ""
|
||||
val isConnectedToServer get() = serverConnectionConfig != null
|
||||
val serverConnectionConfigId
|
||||
get() = serverConnectionConfig?.id ?: ""
|
||||
val serverAddress
|
||||
get() = serverConnectionConfig?.address ?: ""
|
||||
val serverUserId
|
||||
get() = serverConnectionConfig?.userId ?: ""
|
||||
val token
|
||||
get() = serverConnectionConfig?.token ?: ""
|
||||
val isConnectedToServer
|
||||
get() = serverConnectionConfig != null
|
||||
|
||||
var widgetUpdater:WidgetEventEmitter? = null
|
||||
var widgetUpdater: WidgetEventEmitter? = null
|
||||
|
||||
init {
|
||||
Log.d(tag, "Device Manager Singleton invoked")
|
||||
|
||||
// Initialize new sleep timer settings and shake sensitivity added in v0.9.61
|
||||
if (deviceData.deviceSettings?.autoSleepTimerStartTime == null || deviceData.deviceSettings?.autoSleepTimerEndTime == null) {
|
||||
if (deviceData.deviceSettings?.autoSleepTimerStartTime == null ||
|
||||
deviceData.deviceSettings?.autoSleepTimerEndTime == null
|
||||
) {
|
||||
deviceData.deviceSettings?.autoSleepTimerStartTime = "22:00"
|
||||
deviceData.deviceSettings?.autoSleepTimerStartTime = "06:00"
|
||||
deviceData.deviceSettings?.autoSleepTimerEndTime = "06:00"
|
||||
deviceData.deviceSettings?.sleepTimerLength = 900000L
|
||||
}
|
||||
if (deviceData.deviceSettings?.shakeSensitivity == null) {
|
||||
@@ -65,21 +80,40 @@ object DeviceManager {
|
||||
deviceData.deviceSettings?.androidAutoBrowseLimitForGrouping = 100
|
||||
}
|
||||
if (deviceData.deviceSettings?.androidAutoBrowseSeriesSequenceOrder == null) {
|
||||
deviceData.deviceSettings?.androidAutoBrowseSeriesSequenceOrder = AndroidAutoBrowseSeriesSequenceOrderSetting.ASC
|
||||
deviceData.deviceSettings?.androidAutoBrowseSeriesSequenceOrder =
|
||||
AndroidAutoBrowseSeriesSequenceOrderSetting.ASC
|
||||
}
|
||||
}
|
||||
|
||||
fun getBase64Id(id:String):String {
|
||||
return android.util.Base64.encodeToString(id.toByteArray(), android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP)
|
||||
/**
|
||||
* Encodes the given ID to a Base64 string.
|
||||
* @param id The ID to encode.
|
||||
* @return The Base64 encoded string.
|
||||
*/
|
||||
fun getBase64Id(id: String): String {
|
||||
return android.util.Base64.encodeToString(
|
||||
id.toByteArray(),
|
||||
android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP
|
||||
)
|
||||
}
|
||||
|
||||
fun getServerConnectionConfig(id:String?):ServerConnectionConfig? {
|
||||
if (id == null) return null
|
||||
return deviceData.serverConnectionConfigs.find { it.id == id }
|
||||
/**
|
||||
* Retrieves the server connection configuration for the given ID.
|
||||
* @param id The ID of the server connection configuration.
|
||||
* @return The ServerConnectionConfig instance or null if not found.
|
||||
*/
|
||||
fun getServerConnectionConfig(id: String?): ServerConnectionConfig? {
|
||||
return id?.let { deviceData.serverConnectionConfigs.find { it.id == id } }
|
||||
}
|
||||
|
||||
fun checkConnectivity(ctx:Context): Boolean {
|
||||
val connectivityManager = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
/**
|
||||
* Checks the network connectivity status.
|
||||
* @param ctx The context to use for checking connectivity.
|
||||
* @return True if connected to the internet, false otherwise.
|
||||
*/
|
||||
fun checkConnectivity(ctx: Context): Boolean {
|
||||
val connectivityManager =
|
||||
ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
|
||||
if (capabilities != null) {
|
||||
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
|
||||
@@ -96,35 +130,58 @@ object DeviceManager {
|
||||
return false
|
||||
}
|
||||
|
||||
fun setLastPlaybackSession(playbackSession:PlaybackSession) {
|
||||
/**
|
||||
* Sets the last playback session.
|
||||
* @param playbackSession The playback session to set.
|
||||
*/
|
||||
fun setLastPlaybackSession(playbackSession: PlaybackSession) {
|
||||
deviceData.lastPlaybackSession = playbackSession
|
||||
dbManager.saveDeviceData(deviceData)
|
||||
}
|
||||
|
||||
fun initializeWidgetUpdater(context:Context) {
|
||||
/**
|
||||
* Initializes the widget updater.
|
||||
* @param context The context to use for initializing the widget updater.
|
||||
*/
|
||||
fun initializeWidgetUpdater(context: Context) {
|
||||
Log.d(tag, "Initializing widget updater")
|
||||
widgetUpdater = (object : WidgetEventEmitter {
|
||||
override fun onPlayerChanged(pns: PlayerNotificationService) {
|
||||
widgetUpdater =
|
||||
(object : WidgetEventEmitter {
|
||||
override fun onPlayerChanged(pns: PlayerNotificationService) {
|
||||
val isPlaying = pns.currentPlayer.isPlaying
|
||||
|
||||
val isPlaying = pns.currentPlayer.isPlaying
|
||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||
val playbackSession = pns.getCurrentPlaybackSessionCopy()
|
||||
|
||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||
val playbackSession = pns.getCurrentPlaybackSessionCopy()
|
||||
for (widgetId in ids) {
|
||||
updateAppWidget(
|
||||
context,
|
||||
appWidgetManager,
|
||||
widgetId,
|
||||
playbackSession,
|
||||
isPlaying,
|
||||
PlayerNotificationService.isClosed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (widgetId in ids) {
|
||||
updateAppWidget(context, appWidgetManager, widgetId, playbackSession, isPlaying, PlayerNotificationService.isClosed)
|
||||
}
|
||||
}
|
||||
override fun onPlayerClosed() {
|
||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||
for (widgetId in ids) {
|
||||
updateAppWidget(context, appWidgetManager, widgetId, deviceData.lastPlaybackSession, false, PlayerNotificationService.isClosed)
|
||||
}
|
||||
}
|
||||
})
|
||||
override fun onPlayerClosed() {
|
||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||
for (widgetId in ids) {
|
||||
updateAppWidget(
|
||||
context,
|
||||
appWidgetManager,
|
||||
widgetId,
|
||||
deviceData.lastPlaybackSession,
|
||||
false,
|
||||
PlayerNotificationService.isClosed
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,83 +13,144 @@ import java.io.File
|
||||
|
||||
class FolderScanner(var ctx: Context) {
|
||||
private val tag = "FolderScanner"
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
private var jacksonMapper =
|
||||
jacksonObjectMapper()
|
||||
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
data class DownloadItemScanResult(val localLibraryItem:LocalLibraryItem, var localMediaProgress:LocalMediaProgress?)
|
||||
data class DownloadItemScanResult(
|
||||
val localLibraryItem: LocalLibraryItem,
|
||||
var localMediaProgress: LocalMediaProgress?
|
||||
)
|
||||
|
||||
private fun getLocalLibraryItemId(mediaItemId:String):String {
|
||||
private fun getLocalLibraryItemId(mediaItemId: String): String {
|
||||
return "local_" + DeviceManager.getBase64Id(mediaItemId)
|
||||
}
|
||||
|
||||
private fun scanInternalDownloadItem(downloadItem:DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
|
||||
private fun scanInternalDownloadItem(
|
||||
downloadItem: DownloadItem,
|
||||
cb: (DownloadItemScanResult?) -> Unit
|
||||
) {
|
||||
val localLibraryItemId = "local_${downloadItem.libraryItemId}"
|
||||
|
||||
var localEpisodeId:String? = null
|
||||
var localLibraryItem:LocalLibraryItem?
|
||||
var localEpisodeId: String? = null
|
||||
var localLibraryItem: LocalLibraryItem?
|
||||
if (downloadItem.mediaType == "book") {
|
||||
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, downloadItem.itemFolderPath, downloadItem.itemFolderPath, "", false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true, downloadItem.serverConnectionConfigId, downloadItem.serverAddress, downloadItem.serverUserId, downloadItem.libraryItemId)
|
||||
localLibraryItem =
|
||||
LocalLibraryItem(
|
||||
localLibraryItemId,
|
||||
downloadItem.localFolder.id,
|
||||
downloadItem.itemFolderPath,
|
||||
downloadItem.itemFolderPath,
|
||||
"",
|
||||
false,
|
||||
downloadItem.mediaType,
|
||||
downloadItem.media.getLocalCopy(),
|
||||
mutableListOf(),
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
downloadItem.serverConnectionConfigId,
|
||||
downloadItem.serverAddress,
|
||||
downloadItem.serverUserId,
|
||||
downloadItem.libraryItemId
|
||||
)
|
||||
} else {
|
||||
// Lookup or create podcast local library item
|
||||
localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
if (localLibraryItem == null) {
|
||||
Log.d(tag, "[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}")
|
||||
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, downloadItem.itemFolderPath, downloadItem.itemFolderPath, "", false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.libraryItemId)
|
||||
Log.d(
|
||||
tag,
|
||||
"[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}"
|
||||
)
|
||||
localLibraryItem =
|
||||
LocalLibraryItem(
|
||||
localLibraryItemId,
|
||||
downloadItem.localFolder.id,
|
||||
downloadItem.itemFolderPath,
|
||||
downloadItem.itemFolderPath,
|
||||
"",
|
||||
false,
|
||||
downloadItem.mediaType,
|
||||
downloadItem.media.getLocalCopy(),
|
||||
mutableListOf(),
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
downloadItem.serverConnectionConfigId,
|
||||
downloadItem.serverAddress,
|
||||
downloadItem.serverUserId,
|
||||
downloadItem.libraryItemId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val audioTracks:MutableList<AudioTrack> = mutableListOf()
|
||||
val audioTracks: MutableList<AudioTrack> = mutableListOf()
|
||||
var foundEBookFile = false
|
||||
|
||||
downloadItem.downloadItemParts.forEach { downloadItemPart ->
|
||||
Log.d(tag, "Scan internal storage item with finalDestinationUri=${downloadItemPart.finalDestinationUri}")
|
||||
Log.d(
|
||||
tag,
|
||||
"Scan internal storage item with finalDestinationUri=${downloadItemPart.finalDestinationUri}"
|
||||
)
|
||||
|
||||
val file = File(downloadItemPart.finalDestinationPath)
|
||||
Log.d(tag, "Scan internal storage item created file ${file.name}")
|
||||
|
||||
if (file == null) {
|
||||
Log.e(tag, "scanInternalDownloadItem: Null docFile for path ${downloadItemPart.finalDestinationPath}")
|
||||
Log.e(
|
||||
tag,
|
||||
"scanInternalDownloadItem: Null docFile for path ${downloadItemPart.finalDestinationPath}"
|
||||
)
|
||||
} else {
|
||||
if (downloadItemPart.audioTrack != null) {
|
||||
val audioTrackFromServer = downloadItemPart.audioTrack
|
||||
Log.d(
|
||||
tag,
|
||||
"scanInternalDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}"
|
||||
tag,
|
||||
"scanInternalDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}"
|
||||
)
|
||||
|
||||
val localFileId = DeviceManager.getBase64Id(file.name)
|
||||
Log.d(tag, "Scan internal file localFileId=$localFileId")
|
||||
val localFile = LocalFile(
|
||||
localFileId,
|
||||
file.name,
|
||||
downloadItemPart.finalDestinationUri.toString(),
|
||||
file.getBasePath(ctx),
|
||||
file.absolutePath,
|
||||
file.getSimplePath(ctx),
|
||||
file.mimeType,
|
||||
file.length()
|
||||
)
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
file.name,
|
||||
downloadItemPart.finalDestinationUri.toString(),
|
||||
file.getBasePath(ctx),
|
||||
file.absolutePath,
|
||||
file.getSimplePath(ctx),
|
||||
file.mimeType,
|
||||
file.length()
|
||||
)
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
val trackFileMetadata = FileMetadata(file.name, file.extension, file.absolutePath, file.getBasePath(ctx), file.length())
|
||||
val trackFileMetadata =
|
||||
FileMetadata(
|
||||
file.name,
|
||||
file.extension,
|
||||
file.absolutePath,
|
||||
file.getBasePath(ctx),
|
||||
file.length()
|
||||
)
|
||||
// Create new audio track
|
||||
val track = AudioTrack(
|
||||
audioTrackFromServer.index,
|
||||
audioTrackFromServer.startOffset,
|
||||
audioTrackFromServer.duration,
|
||||
localFile.filename ?: "",
|
||||
localFile.contentUrl,
|
||||
localFile.mimeType ?: "",
|
||||
trackFileMetadata,
|
||||
true,
|
||||
localFileId,
|
||||
null,
|
||||
audioTrackFromServer.index
|
||||
)
|
||||
val track =
|
||||
AudioTrack(
|
||||
audioTrackFromServer.index,
|
||||
audioTrackFromServer.startOffset,
|
||||
audioTrackFromServer.duration,
|
||||
localFile.filename ?: "",
|
||||
localFile.contentUrl,
|
||||
localFile.mimeType ?: "",
|
||||
trackFileMetadata,
|
||||
true,
|
||||
localFileId,
|
||||
audioTrackFromServer.index
|
||||
)
|
||||
audioTracks.add(track)
|
||||
|
||||
Log.d(
|
||||
tag,
|
||||
"scanInternalDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}"
|
||||
tag,
|
||||
"scanInternalDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}"
|
||||
)
|
||||
|
||||
// Add podcast episodes to library
|
||||
@@ -98,40 +159,51 @@ class FolderScanner(var ctx: Context) {
|
||||
val newEpisode = podcast.addEpisode(track, podcastEpisode)
|
||||
localEpisodeId = newEpisode.id
|
||||
Log.d(
|
||||
tag,
|
||||
"scanInternalDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}"
|
||||
tag,
|
||||
"scanInternalDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}"
|
||||
)
|
||||
}
|
||||
|
||||
} else if (downloadItemPart.ebookFile != null) {
|
||||
foundEBookFile = true
|
||||
Log.d(tag, "scanInternalDownloadItem: Ebook file found with mimetype=${file.mimeType}")
|
||||
val localFileId = DeviceManager.getBase64Id(file.name)
|
||||
val localFile = LocalFile(
|
||||
localFileId,
|
||||
file.name,
|
||||
Uri.fromFile(file).toString(),
|
||||
file.getBasePath(ctx),
|
||||
file.absolutePath,
|
||||
file.getSimplePath(ctx),
|
||||
file.mimeType,
|
||||
file.length()
|
||||
)
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
file.name,
|
||||
Uri.fromFile(file).toString(),
|
||||
file.getBasePath(ctx),
|
||||
file.absolutePath,
|
||||
file.getSimplePath(ctx),
|
||||
file.mimeType,
|
||||
file.length()
|
||||
)
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
val ebookFile = EBookFile(
|
||||
downloadItemPart.ebookFile.ino,
|
||||
downloadItemPart.ebookFile.metadata,
|
||||
downloadItemPart.ebookFile.ebookFormat,
|
||||
true,
|
||||
localFileId,
|
||||
localFile.contentUrl
|
||||
)
|
||||
val ebookFile =
|
||||
EBookFile(
|
||||
downloadItemPart.ebookFile.ino,
|
||||
downloadItemPart.ebookFile.metadata,
|
||||
downloadItemPart.ebookFile.ebookFormat,
|
||||
true,
|
||||
localFileId,
|
||||
localFile.contentUrl
|
||||
)
|
||||
(localLibraryItem.media as Book).ebookFile = ebookFile
|
||||
Log.d(tag, "scanInternalDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
|
||||
} else {
|
||||
val localFileId = DeviceManager.getBase64Id(file.name)
|
||||
val localFile = LocalFile(localFileId,file.name,Uri.fromFile(file).toString(),file.getBasePath(ctx),file.absolutePath,file.getSimplePath(ctx),file.mimeType,file.length())
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
file.name,
|
||||
Uri.fromFile(file).toString(),
|
||||
file.getBasePath(ctx),
|
||||
file.absolutePath,
|
||||
file.getSimplePath(ctx),
|
||||
file.mimeType,
|
||||
file.length()
|
||||
)
|
||||
|
||||
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
||||
localLibraryItem.coverContentUrl = localFile.contentUrl
|
||||
@@ -141,7 +213,10 @@ class FolderScanner(var ctx: Context) {
|
||||
}
|
||||
|
||||
if (audioTracks.isEmpty() && !foundEBookFile) {
|
||||
Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}")
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}"
|
||||
)
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
@@ -163,30 +238,37 @@ class FolderScanner(var ctx: Context) {
|
||||
localLibraryItem.media.setAudioTracks(audioTracks)
|
||||
}
|
||||
|
||||
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem,null)
|
||||
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem, null)
|
||||
|
||||
// If library item had media progress then make local media progress and save
|
||||
downloadItem.userMediaProgress?.let { mediaProgress ->
|
||||
val localMediaProgressId = if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
||||
val newLocalMediaProgress = LocalMediaProgress(
|
||||
id = localMediaProgressId,
|
||||
localLibraryItemId = localLibraryItemId,
|
||||
localEpisodeId = localEpisodeId,
|
||||
duration = mediaProgress.duration,
|
||||
progress = mediaProgress.progress,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
isFinished = mediaProgress.isFinished,
|
||||
ebookLocation = mediaProgress.ebookLocation,
|
||||
ebookProgress = mediaProgress.ebookProgress,
|
||||
lastUpdate = mediaProgress.lastUpdate,
|
||||
startedAt = mediaProgress.startedAt,
|
||||
finishedAt = mediaProgress.finishedAt,
|
||||
serverConnectionConfigId = downloadItem.serverConnectionConfigId,
|
||||
serverAddress = downloadItem.serverAddress,
|
||||
serverUserId = downloadItem.serverUserId,
|
||||
libraryItemId = downloadItem.libraryItemId,
|
||||
episodeId = downloadItem.episodeId)
|
||||
Log.d(tag, "scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}")
|
||||
val localMediaProgressId =
|
||||
if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId
|
||||
else "$localLibraryItemId-$localEpisodeId"
|
||||
val newLocalMediaProgress =
|
||||
LocalMediaProgress(
|
||||
id = localMediaProgressId,
|
||||
localLibraryItemId = localLibraryItemId,
|
||||
localEpisodeId = localEpisodeId,
|
||||
duration = mediaProgress.duration,
|
||||
progress = mediaProgress.progress,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
isFinished = mediaProgress.isFinished,
|
||||
ebookLocation = mediaProgress.ebookLocation,
|
||||
ebookProgress = mediaProgress.ebookProgress,
|
||||
lastUpdate = mediaProgress.lastUpdate,
|
||||
startedAt = mediaProgress.startedAt,
|
||||
finishedAt = mediaProgress.finishedAt,
|
||||
serverConnectionConfigId = downloadItem.serverConnectionConfigId,
|
||||
serverAddress = downloadItem.serverAddress,
|
||||
serverUserId = downloadItem.serverUserId,
|
||||
libraryItemId = downloadItem.libraryItemId,
|
||||
episodeId = downloadItem.episodeId
|
||||
)
|
||||
Log.d(
|
||||
tag,
|
||||
"scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}"
|
||||
)
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
|
||||
|
||||
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
|
||||
@@ -206,7 +288,7 @@ class FolderScanner(var ctx: Context) {
|
||||
}
|
||||
|
||||
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
|
||||
val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
|
||||
val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
|
||||
|
||||
var itemFolderId = ""
|
||||
var itemFolderUrl = ""
|
||||
@@ -235,72 +317,188 @@ class FolderScanner(var ctx: Context) {
|
||||
}
|
||||
|
||||
val localLibraryItemId = getLocalLibraryItemId(itemFolderId)
|
||||
Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId")
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId"
|
||||
)
|
||||
|
||||
// Search for files in media item folder
|
||||
// m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154
|
||||
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/*"))
|
||||
val filesFound =
|
||||
df.search(
|
||||
false,
|
||||
DocumentFileType.FILE,
|
||||
arrayOf("audio/*", "image/*", "video/mp4", "application/*")
|
||||
)
|
||||
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
|
||||
|
||||
var localEpisodeId:String? = null
|
||||
var localLibraryItem:LocalLibraryItem?
|
||||
var localEpisodeId: String? = null
|
||||
var localLibraryItem: LocalLibraryItem?
|
||||
if (downloadItem.mediaType == "book") {
|
||||
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true, downloadItem.serverConnectionConfigId, downloadItem.serverAddress, downloadItem.serverUserId, downloadItem.libraryItemId)
|
||||
localLibraryItem =
|
||||
LocalLibraryItem(
|
||||
localLibraryItemId,
|
||||
downloadItem.localFolder.id,
|
||||
itemFolderBasePath,
|
||||
itemFolderAbsolutePath,
|
||||
itemFolderUrl,
|
||||
false,
|
||||
downloadItem.mediaType,
|
||||
downloadItem.media.getLocalCopy(),
|
||||
mutableListOf(),
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
downloadItem.serverConnectionConfigId,
|
||||
downloadItem.serverAddress,
|
||||
downloadItem.serverUserId,
|
||||
downloadItem.libraryItemId
|
||||
)
|
||||
} else {
|
||||
// Lookup or create podcast local library item
|
||||
localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
if (localLibraryItem == null) {
|
||||
Log.d(tag, "[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}")
|
||||
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.libraryItemId)
|
||||
Log.d(
|
||||
tag,
|
||||
"[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}"
|
||||
)
|
||||
localLibraryItem =
|
||||
LocalLibraryItem(
|
||||
localLibraryItemId,
|
||||
downloadItem.localFolder.id,
|
||||
itemFolderBasePath,
|
||||
itemFolderAbsolutePath,
|
||||
itemFolderUrl,
|
||||
false,
|
||||
downloadItem.mediaType,
|
||||
downloadItem.media.getLocalCopy(),
|
||||
mutableListOf(),
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
downloadItem.serverConnectionConfigId,
|
||||
downloadItem.serverAddress,
|
||||
downloadItem.serverUserId,
|
||||
downloadItem.libraryItemId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val audioTracks:MutableList<AudioTrack> = mutableListOf()
|
||||
val audioTracks: MutableList<AudioTrack> = mutableListOf()
|
||||
var foundEBookFile = false
|
||||
|
||||
filesFound.forEach { docFile ->
|
||||
val itemPart = downloadItem.downloadItemParts.find { itemPart ->
|
||||
itemPart.filename == docFile.name
|
||||
}
|
||||
val itemPart =
|
||||
downloadItem.downloadItemParts.find { itemPart -> itemPart.filename == docFile.name }
|
||||
if (itemPart == null) {
|
||||
if (downloadItem.mediaType == "book") { // for books every download item should be a file found
|
||||
Log.e(tag, "scanDownloadItem: Item part not found for doc file ${docFile.name} | ${docFile.getAbsolutePath(ctx)} | ${docFile.uri}")
|
||||
if (downloadItem.mediaType == "book"
|
||||
) { // for books every download item should be a file found
|
||||
Log.e(
|
||||
tag,
|
||||
"scanDownloadItem: Item part not found for doc file ${docFile.name} | ${docFile.getAbsolutePath(ctx)} | ${docFile.uri}"
|
||||
)
|
||||
}
|
||||
} else if (itemPart.audioTrack != null) { // Is audio track
|
||||
val audioTrackFromServer = itemPart.audioTrack
|
||||
Log.d(tag, "scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}")
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}"
|
||||
)
|
||||
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
docFile.name,
|
||||
docFile.uri.toString(),
|
||||
docFile.getBasePath(ctx),
|
||||
docFile.getAbsolutePath(ctx),
|
||||
docFile.getSimplePath(ctx),
|
||||
docFile.mimeType,
|
||||
docFile.length()
|
||||
)
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
// Create new audio track
|
||||
val trackFileMetadata = FileMetadata(docFile.name ?: "", docFile.extension ?: "", docFile.getAbsolutePath(ctx), docFile.getBasePath(ctx), docFile.length())
|
||||
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioTrackFromServer.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", trackFileMetadata, true, localFileId, null, audioTrackFromServer.index)
|
||||
val trackFileMetadata =
|
||||
FileMetadata(
|
||||
docFile.name ?: "",
|
||||
docFile.extension ?: "",
|
||||
docFile.getAbsolutePath(ctx),
|
||||
docFile.getBasePath(ctx),
|
||||
docFile.length()
|
||||
)
|
||||
val track =
|
||||
AudioTrack(
|
||||
audioTrackFromServer.index,
|
||||
audioTrackFromServer.startOffset,
|
||||
audioTrackFromServer.duration,
|
||||
localFile.filename ?: "",
|
||||
localFile.contentUrl,
|
||||
localFile.mimeType ?: "",
|
||||
trackFileMetadata,
|
||||
true,
|
||||
localFileId,
|
||||
audioTrackFromServer.index
|
||||
)
|
||||
audioTracks.add(track)
|
||||
|
||||
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}"
|
||||
)
|
||||
|
||||
// Add podcast episodes to library
|
||||
itemPart.episode?.let { podcastEpisode ->
|
||||
val podcast = localLibraryItem.media as Podcast
|
||||
val newEpisode = podcast.addEpisode(track, podcastEpisode)
|
||||
localEpisodeId = newEpisode.id
|
||||
Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}")
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}"
|
||||
)
|
||||
}
|
||||
} else if (itemPart.ebookFile != null) { // Ebook
|
||||
foundEBookFile = true
|
||||
Log.d(tag, "scanDownloadItem: Ebook file found with mimetype=${docFile.mimeType}")
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
docFile.name,
|
||||
docFile.uri.toString(),
|
||||
docFile.getBasePath(ctx),
|
||||
docFile.getAbsolutePath(ctx),
|
||||
docFile.getSimplePath(ctx),
|
||||
docFile.mimeType,
|
||||
docFile.length()
|
||||
)
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
val ebookFile = EBookFile(itemPart.ebookFile.ino, itemPart.ebookFile.metadata, itemPart.ebookFile.ebookFormat, true, localFileId, localFile.contentUrl)
|
||||
val ebookFile =
|
||||
EBookFile(
|
||||
itemPart.ebookFile.ino,
|
||||
itemPart.ebookFile.metadata,
|
||||
itemPart.ebookFile.ebookFormat,
|
||||
true,
|
||||
localFileId,
|
||||
localFile.contentUrl
|
||||
)
|
||||
(localLibraryItem.media as Book).ebookFile = ebookFile
|
||||
Log.d(tag, "scanDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
|
||||
} else { // Cover image
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
docFile.name,
|
||||
docFile.uri.toString(),
|
||||
docFile.getBasePath(ctx),
|
||||
docFile.getAbsolutePath(ctx),
|
||||
docFile.getSimplePath(ctx),
|
||||
docFile.mimeType,
|
||||
docFile.length()
|
||||
)
|
||||
|
||||
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
||||
localLibraryItem.coverContentUrl = localFile.contentUrl
|
||||
@@ -309,7 +507,10 @@ class FolderScanner(var ctx: Context) {
|
||||
}
|
||||
|
||||
if (audioTracks.isEmpty() && !foundEBookFile) {
|
||||
Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}")
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}"
|
||||
)
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
@@ -331,30 +532,37 @@ class FolderScanner(var ctx: Context) {
|
||||
localLibraryItem.media.setAudioTracks(audioTracks)
|
||||
}
|
||||
|
||||
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem,null)
|
||||
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem, null)
|
||||
|
||||
// If library item had media progress then make local media progress and save
|
||||
downloadItem.userMediaProgress?.let { mediaProgress ->
|
||||
val localMediaProgressId = if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
||||
val newLocalMediaProgress = LocalMediaProgress(
|
||||
id = localMediaProgressId,
|
||||
localLibraryItemId = localLibraryItemId,
|
||||
localEpisodeId = localEpisodeId,
|
||||
duration = mediaProgress.duration,
|
||||
progress = mediaProgress.progress,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
isFinished = mediaProgress.isFinished,
|
||||
ebookLocation = mediaProgress.ebookLocation,
|
||||
ebookProgress = mediaProgress.ebookProgress,
|
||||
lastUpdate = mediaProgress.lastUpdate,
|
||||
startedAt = mediaProgress.startedAt,
|
||||
finishedAt = mediaProgress.finishedAt,
|
||||
serverConnectionConfigId = downloadItem.serverConnectionConfigId,
|
||||
serverAddress = downloadItem.serverAddress,
|
||||
serverUserId = downloadItem.serverUserId,
|
||||
libraryItemId = downloadItem.libraryItemId,
|
||||
episodeId = downloadItem.episodeId)
|
||||
Log.d(tag, "scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}")
|
||||
val localMediaProgressId =
|
||||
if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId
|
||||
else "$localLibraryItemId-$localEpisodeId"
|
||||
val newLocalMediaProgress =
|
||||
LocalMediaProgress(
|
||||
id = localMediaProgressId,
|
||||
localLibraryItemId = localLibraryItemId,
|
||||
localEpisodeId = localEpisodeId,
|
||||
duration = mediaProgress.duration,
|
||||
progress = mediaProgress.progress,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
isFinished = mediaProgress.isFinished,
|
||||
ebookLocation = mediaProgress.ebookLocation,
|
||||
ebookProgress = mediaProgress.ebookProgress,
|
||||
lastUpdate = mediaProgress.lastUpdate,
|
||||
startedAt = mediaProgress.startedAt,
|
||||
finishedAt = mediaProgress.finishedAt,
|
||||
serverConnectionConfigId = downloadItem.serverConnectionConfigId,
|
||||
serverAddress = downloadItem.serverAddress,
|
||||
serverUserId = downloadItem.serverUserId,
|
||||
libraryItemId = downloadItem.libraryItemId,
|
||||
episodeId = downloadItem.episodeId
|
||||
)
|
||||
Log.d(
|
||||
tag,
|
||||
"scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}"
|
||||
)
|
||||
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
|
||||
|
||||
|
||||
@@ -22,45 +22,47 @@ class DbManager {
|
||||
}
|
||||
|
||||
fun getDeviceData(): DeviceData {
|
||||
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, DeviceSettings.default(), null)
|
||||
return Paper.book("device").read("data")
|
||||
?: DeviceData(mutableListOf(), null, DeviceSettings.default(), null)
|
||||
}
|
||||
fun saveDeviceData(deviceData: DeviceData) {
|
||||
Paper.book("device").write("data", deviceData)
|
||||
}
|
||||
|
||||
fun getLocalLibraryItems(mediaType:String? = null):MutableList<LocalLibraryItem> {
|
||||
val localLibraryItems:MutableList<LocalLibraryItem> = mutableListOf()
|
||||
fun getLocalLibraryItems(mediaType: String? = null): MutableList<LocalLibraryItem> {
|
||||
val localLibraryItems: MutableList<LocalLibraryItem> = mutableListOf()
|
||||
Paper.book("localLibraryItems").allKeys.forEach {
|
||||
val localLibraryItem: LocalLibraryItem? = Paper.book("localLibraryItems").read(it)
|
||||
if (localLibraryItem != null && (mediaType.isNullOrEmpty() || mediaType == localLibraryItem.mediaType)) {
|
||||
if (localLibraryItem != null &&
|
||||
(mediaType.isNullOrEmpty() || mediaType == localLibraryItem.mediaType)
|
||||
) {
|
||||
localLibraryItems.add(localLibraryItem)
|
||||
}
|
||||
}
|
||||
return localLibraryItems
|
||||
}
|
||||
|
||||
fun getLocalLibraryItemsInFolder(folderId:String):List<LocalLibraryItem> {
|
||||
fun getLocalLibraryItemsInFolder(folderId: String): List<LocalLibraryItem> {
|
||||
val localLibraryItems = getLocalLibraryItems()
|
||||
return localLibraryItems.filter {
|
||||
it.folderId == folderId
|
||||
}
|
||||
return localLibraryItems.filter { it.folderId == folderId }
|
||||
}
|
||||
|
||||
fun getLocalLibraryItemByLId(libraryItemId:String): LocalLibraryItem? {
|
||||
fun getLocalLibraryItemByLId(libraryItemId: String): LocalLibraryItem? {
|
||||
return getLocalLibraryItems().find { it.libraryItemId == libraryItemId }
|
||||
}
|
||||
|
||||
fun getLocalLibraryItem(localLibraryItemId:String): LocalLibraryItem? {
|
||||
fun getLocalLibraryItem(localLibraryItemId: String): LocalLibraryItem? {
|
||||
return Paper.book("localLibraryItems").read(localLibraryItemId)
|
||||
}
|
||||
|
||||
fun getLocalLibraryItemWithEpisode(podcastEpisodeId:String): LibraryItemWithEpisode? {
|
||||
fun getLocalLibraryItemWithEpisode(podcastEpisodeId: String): LibraryItemWithEpisode? {
|
||||
var podcastEpisode: PodcastEpisode? = null
|
||||
val localLibraryItem = getLocalLibraryItems("podcast").find { localLibraryItem ->
|
||||
val podcast = localLibraryItem.media as Podcast
|
||||
podcastEpisode = podcast.episodes?.find { it.id == podcastEpisodeId }
|
||||
podcastEpisode != null
|
||||
}
|
||||
val localLibraryItem =
|
||||
getLocalLibraryItems("podcast").find { localLibraryItem ->
|
||||
val podcast = localLibraryItem.media as Podcast
|
||||
podcastEpisode = podcast.episodes?.find { it.id == podcastEpisodeId }
|
||||
podcastEpisode != null
|
||||
}
|
||||
return if (localLibraryItem != null) {
|
||||
LibraryItemWithEpisode(localLibraryItem, podcastEpisode!!)
|
||||
} else {
|
||||
@@ -68,14 +70,12 @@ class DbManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun removeLocalLibraryItem(localLibraryItemId:String) {
|
||||
fun removeLocalLibraryItem(localLibraryItemId: String) {
|
||||
Paper.book("localLibraryItems").delete(localLibraryItemId)
|
||||
}
|
||||
|
||||
fun saveLocalLibraryItems(localLibraryItems:List<LocalLibraryItem>) {
|
||||
localLibraryItems.map {
|
||||
Paper.book("localLibraryItems").write(it.id, it)
|
||||
}
|
||||
fun saveLocalLibraryItems(localLibraryItems: List<LocalLibraryItem>) {
|
||||
localLibraryItems.map { Paper.book("localLibraryItems").write(it.id, it) }
|
||||
}
|
||||
|
||||
fun saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) {
|
||||
@@ -83,28 +83,24 @@ class DbManager {
|
||||
}
|
||||
|
||||
fun saveLocalFolder(localFolder: LocalFolder) {
|
||||
Paper.book("localFolders").write(localFolder.id,localFolder)
|
||||
Paper.book("localFolders").write(localFolder.id, localFolder)
|
||||
}
|
||||
|
||||
fun getLocalFolder(folderId:String): LocalFolder? {
|
||||
fun getLocalFolder(folderId: String): LocalFolder? {
|
||||
return Paper.book("localFolders").read(folderId)
|
||||
}
|
||||
|
||||
fun getAllLocalFolders():List<LocalFolder> {
|
||||
val localFolders:MutableList<LocalFolder> = mutableListOf()
|
||||
fun getAllLocalFolders(): List<LocalFolder> {
|
||||
val localFolders: MutableList<LocalFolder> = mutableListOf()
|
||||
Paper.book("localFolders").allKeys.forEach { localFolderId ->
|
||||
Paper.book("localFolders").read<LocalFolder>(localFolderId)?.let {
|
||||
localFolders.add(it)
|
||||
}
|
||||
Paper.book("localFolders").read<LocalFolder>(localFolderId)?.let { localFolders.add(it) }
|
||||
}
|
||||
return localFolders
|
||||
}
|
||||
|
||||
fun removeLocalFolder(folderId:String) {
|
||||
fun removeLocalFolder(folderId: String) {
|
||||
val localLibraryItems = getLocalLibraryItemsInFolder(folderId)
|
||||
localLibraryItems.forEach {
|
||||
Paper.book("localLibraryItems").delete(it.id)
|
||||
}
|
||||
localLibraryItems.forEach { Paper.book("localLibraryItems").delete(it.id) }
|
||||
Paper.book("localFolders").delete(folderId)
|
||||
}
|
||||
|
||||
@@ -112,29 +108,28 @@ class DbManager {
|
||||
Paper.book("downloadItems").write(downloadItem.id, downloadItem)
|
||||
}
|
||||
|
||||
fun removeDownloadItem(downloadItemId:String) {
|
||||
fun removeDownloadItem(downloadItemId: String) {
|
||||
Paper.book("downloadItems").delete(downloadItemId)
|
||||
}
|
||||
|
||||
fun getDownloadItems():List<DownloadItem> {
|
||||
val downloadItems:MutableList<DownloadItem> = mutableListOf()
|
||||
fun getDownloadItems(): List<DownloadItem> {
|
||||
val downloadItems: MutableList<DownloadItem> = mutableListOf()
|
||||
Paper.book("downloadItems").allKeys.forEach { downloadItemId ->
|
||||
Paper.book("downloadItems").read<DownloadItem>(downloadItemId)?.let {
|
||||
downloadItems.add(it)
|
||||
}
|
||||
Paper.book("downloadItems").read<DownloadItem>(downloadItemId)?.let { downloadItems.add(it) }
|
||||
}
|
||||
return downloadItems
|
||||
}
|
||||
|
||||
fun saveLocalMediaProgress(mediaProgress: LocalMediaProgress) {
|
||||
Paper.book("localMediaProgress").write(mediaProgress.id,mediaProgress)
|
||||
Paper.book("localMediaProgress").write(mediaProgress.id, mediaProgress)
|
||||
}
|
||||
// For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}"
|
||||
fun getLocalMediaProgress(localMediaProgressId:String): LocalMediaProgress? {
|
||||
// For books this will just be the localLibraryItemId for podcast episodes this will be
|
||||
// "{localLibraryItemId}-{episodeId}"
|
||||
fun getLocalMediaProgress(localMediaProgressId: String): LocalMediaProgress? {
|
||||
return Paper.book("localMediaProgress").read(localMediaProgressId)
|
||||
}
|
||||
fun getAllLocalMediaProgress():List<LocalMediaProgress> {
|
||||
val mediaProgress:MutableList<LocalMediaProgress> = mutableListOf()
|
||||
fun getAllLocalMediaProgress(): List<LocalMediaProgress> {
|
||||
val mediaProgress: MutableList<LocalMediaProgress> = mutableListOf()
|
||||
Paper.book("localMediaProgress").allKeys.forEach { localMediaProgressId ->
|
||||
Paper.book("localMediaProgress").read<LocalMediaProgress>(localMediaProgressId)?.let {
|
||||
mediaProgress.add(it)
|
||||
@@ -142,7 +137,7 @@ class DbManager {
|
||||
}
|
||||
return mediaProgress
|
||||
}
|
||||
fun removeLocalMediaProgress(localMediaProgressId:String) {
|
||||
fun removeLocalMediaProgress(localMediaProgressId: String) {
|
||||
Paper.book("localMediaProgress").delete(localMediaProgressId)
|
||||
}
|
||||
|
||||
@@ -158,35 +153,50 @@ class DbManager {
|
||||
var hasUpdates = false
|
||||
|
||||
// Check local files
|
||||
lli.localFiles = lli.localFiles.filter { localFile ->
|
||||
|
||||
val file = File(localFile.absolutePath)
|
||||
if (!file.exists()) {
|
||||
Log.d(tag, "cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}")
|
||||
hasUpdates = true
|
||||
}
|
||||
file.exists()
|
||||
} as MutableList<LocalFile>
|
||||
lli.localFiles =
|
||||
lli.localFiles.filter { localFile ->
|
||||
val file = File(localFile.absolutePath)
|
||||
if (!file.exists()) {
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}"
|
||||
)
|
||||
hasUpdates = true
|
||||
}
|
||||
file.exists()
|
||||
} as
|
||||
MutableList<LocalFile>
|
||||
|
||||
// Check audio tracks and episodes
|
||||
if (lli.isPodcast) {
|
||||
val podcast = lli.media as Podcast
|
||||
podcast.episodes = podcast.episodes?.filter { ep ->
|
||||
if (lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } == null) {
|
||||
Log.d(tag, "cleanLocalLibraryItems: Podcast episode ${ep.title} was removed from library item ${lli.media.metadata.title}")
|
||||
hasUpdates = true
|
||||
}
|
||||
ep.audioTrack != null && lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } != null
|
||||
} as MutableList<PodcastEpisode>
|
||||
podcast.episodes =
|
||||
podcast.episodes?.filter { ep ->
|
||||
if (lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } == null) {
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalLibraryItems: Podcast episode ${ep.title} was removed from library item ${lli.media.metadata.title}"
|
||||
)
|
||||
hasUpdates = true
|
||||
}
|
||||
ep.audioTrack != null &&
|
||||
lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } != null
|
||||
} as
|
||||
MutableList<PodcastEpisode>
|
||||
} else {
|
||||
val book = lli.media as Book
|
||||
book.tracks = book.tracks?.filter { track ->
|
||||
if (lli.localFiles.find { lf -> lf.id == track.localFileId } == null) {
|
||||
Log.d(tag, "cleanLocalLibraryItems: Audio track ${track.title} was removed from library item ${lli.media.metadata.title}")
|
||||
hasUpdates = true
|
||||
}
|
||||
lli.localFiles.find { lf -> lf.id == track.localFileId } != null
|
||||
} as MutableList<AudioTrack>
|
||||
book.tracks =
|
||||
book.tracks?.filter { track ->
|
||||
if (lli.localFiles.find { lf -> lf.id == track.localFileId } == null) {
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalLibraryItems: Audio track ${track.title} was removed from library item ${lli.media.metadata.title}"
|
||||
)
|
||||
hasUpdates = true
|
||||
}
|
||||
lli.localFiles.find { lf -> lf.id == track.localFileId } != null
|
||||
} as
|
||||
MutableList<AudioTrack>
|
||||
}
|
||||
|
||||
// Check cover still there
|
||||
@@ -194,14 +204,22 @@ class DbManager {
|
||||
val coverFile = File(it)
|
||||
|
||||
if (!coverFile.exists()) {
|
||||
Log.d(tag, "cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}")
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}"
|
||||
)
|
||||
lli.coverAbsolutePath = null
|
||||
lli.coverContentUrl = null
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
if (lli.serverConnectionConfigId == null) {
|
||||
// Local-only item support was removed in app version 0.9.67, remove any remaining local
|
||||
// only items beginning in 0.9.80
|
||||
Log.d(tag, "cleanLocalLibraryItems: Local only item ${lli.id} - removing from ABS")
|
||||
Paper.book("localLibraryItems").delete(lli.id)
|
||||
} else if (hasUpdates) {
|
||||
Log.d(tag, "cleanLocalLibraryItems: Saving local library item ${lli.id}")
|
||||
Paper.book("localLibraryItems").write(lli.id, lli)
|
||||
}
|
||||
@@ -215,11 +233,18 @@ class DbManager {
|
||||
localMediaProgress.forEach {
|
||||
val matchingLLI = localLibraryItems.find { lli -> lli.id == it.localLibraryItemId }
|
||||
if (!it.id.startsWith("local")) {
|
||||
// A bug on the server when syncing local media progress was replacing the media progress id causing duplicate progress. Remove them.
|
||||
Log.d(tag, "cleanLocalMediaProgress: Invalid local media progress does not start with 'local' (fixed on server 2.0.24)")
|
||||
// A bug on the server when syncing local media progress was replacing the media progress id
|
||||
// causing duplicate progress. Remove them.
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalMediaProgress: Invalid local media progress does not start with 'local' (fixed on server 2.0.24)"
|
||||
)
|
||||
Paper.book("localMediaProgress").delete(it.id)
|
||||
} else if (matchingLLI == null) {
|
||||
Log.d(tag, "cleanLocalMediaProgress: No matching local library item for local media progress ${it.id} - removing")
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalMediaProgress: No matching local library item for local media progress ${it.id} - removing"
|
||||
)
|
||||
Paper.book("localMediaProgress").delete(it.id)
|
||||
} else if (matchingLLI.isPodcast) {
|
||||
if (it.localEpisodeId.isNullOrEmpty()) {
|
||||
@@ -229,7 +254,10 @@ class DbManager {
|
||||
val podcast = matchingLLI.media as Podcast
|
||||
val matchingLEp = podcast.episodes?.find { ep -> ep.id == it.localEpisodeId }
|
||||
if (matchingLEp == null) {
|
||||
Log.d(tag, "cleanLocalMediaProgress: Podcast media progress for episode ${it.localEpisodeId} not found - removing")
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalMediaProgress: Podcast media progress for episode ${it.localEpisodeId} not found - removing"
|
||||
)
|
||||
Paper.book("localMediaProgress").delete(it.id)
|
||||
}
|
||||
}
|
||||
@@ -238,20 +266,20 @@ class DbManager {
|
||||
}
|
||||
|
||||
fun saveMediaItemHistory(mediaItemHistory: MediaItemHistory) {
|
||||
Paper.book("mediaItemHistory").write(mediaItemHistory.id,mediaItemHistory)
|
||||
Paper.book("mediaItemHistory").write(mediaItemHistory.id, mediaItemHistory)
|
||||
}
|
||||
fun getMediaItemHistory(id:String): MediaItemHistory? {
|
||||
fun getMediaItemHistory(id: String): MediaItemHistory? {
|
||||
return Paper.book("mediaItemHistory").read(id)
|
||||
}
|
||||
|
||||
fun savePlaybackSession(playbackSession: PlaybackSession) {
|
||||
Paper.book("playbackSession").write(playbackSession.id,playbackSession)
|
||||
Paper.book("playbackSession").write(playbackSession.id, playbackSession)
|
||||
}
|
||||
fun removePlaybackSession(playbackSessionId:String) {
|
||||
fun removePlaybackSession(playbackSessionId: String) {
|
||||
Paper.book("playbackSession").delete(playbackSessionId)
|
||||
}
|
||||
fun getPlaybackSessions():List<PlaybackSession> {
|
||||
val sessions:MutableList<PlaybackSession> = mutableListOf()
|
||||
fun getPlaybackSessions(): List<PlaybackSession> {
|
||||
val sessions: MutableList<PlaybackSession> = mutableListOf()
|
||||
Paper.book("playbackSession").allKeys.forEach { playbackSessionId ->
|
||||
Paper.book("playbackSession").read<PlaybackSession>(playbackSessionId)?.let {
|
||||
sessions.add(it)
|
||||
|
||||
+220
-128
@@ -18,18 +18,26 @@ import com.audiobookshelf.app.models.DownloadItemPart
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.getcapacitor.JSObject
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.*
|
||||
|
||||
class DownloadItemManager(var downloadManager:DownloadManager, private var folderScanner: FolderScanner, var mainActivity: MainActivity, private var clientEventEmitter:DownloadEventEmitter) {
|
||||
/** Manages download items and their parts. */
|
||||
class DownloadItemManager(
|
||||
var downloadManager: DownloadManager,
|
||||
private var folderScanner: FolderScanner,
|
||||
var mainActivity: MainActivity,
|
||||
private var clientEventEmitter: DownloadEventEmitter
|
||||
) {
|
||||
val tag = "DownloadItemManager"
|
||||
private val maxSimultaneousDownloads = 3
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
private var jacksonMapper =
|
||||
jacksonObjectMapper()
|
||||
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
enum class DownloadCheckStatus {
|
||||
InProgress,
|
||||
@@ -37,25 +45,28 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
Failed
|
||||
}
|
||||
|
||||
var downloadItemQueue: MutableList<DownloadItem> = mutableListOf() // All pending and downloading items
|
||||
var currentDownloadItemParts: MutableList<DownloadItemPart> = mutableListOf() // Item parts currently being downloaded
|
||||
var downloadItemQueue: MutableList<DownloadItem> =
|
||||
mutableListOf() // All pending and downloading items
|
||||
var currentDownloadItemParts: MutableList<DownloadItemPart> =
|
||||
mutableListOf() // Item parts currently being downloaded
|
||||
|
||||
interface DownloadEventEmitter {
|
||||
fun onDownloadItem(downloadItem:DownloadItem)
|
||||
fun onDownloadItemPartUpdate(downloadItemPart:DownloadItemPart)
|
||||
fun onDownloadItemComplete(jsobj:JSObject)
|
||||
fun onDownloadItem(downloadItem: DownloadItem)
|
||||
fun onDownloadItemPartUpdate(downloadItemPart: DownloadItemPart)
|
||||
fun onDownloadItemComplete(jsobj: JSObject)
|
||||
}
|
||||
|
||||
interface InternalProgressCallback {
|
||||
fun onProgress(totalBytesWritten:Long, progress: Long)
|
||||
fun onProgress(totalBytesWritten: Long, progress: Long)
|
||||
fun onComplete(failed: Boolean)
|
||||
}
|
||||
|
||||
companion object {
|
||||
var isDownloading:Boolean = false
|
||||
var isDownloading: Boolean = false
|
||||
}
|
||||
|
||||
fun addDownloadItem(downloadItem:DownloadItem) {
|
||||
/** Adds a download item to the queue and starts processing the queue. */
|
||||
fun addDownloadItem(downloadItem: DownloadItem) {
|
||||
DeviceManager.dbManager.saveDownloadItem(downloadItem)
|
||||
Log.i(tag, "Add download item ${downloadItem.media.metadata.title}")
|
||||
|
||||
@@ -64,42 +75,18 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
checkUpdateDownloadQueue()
|
||||
}
|
||||
|
||||
/** Checks and updates the download queue. */
|
||||
private fun checkUpdateDownloadQueue() {
|
||||
for (downloadItem in downloadItemQueue) {
|
||||
val numPartsToGet = maxSimultaneousDownloads - currentDownloadItemParts.size
|
||||
val nextDownloadItemParts = downloadItem.getNextDownloadItemParts(numPartsToGet)
|
||||
Log.d(tag, "checkUpdateDownloadQueue: numPartsToGet=$numPartsToGet, nextDownloadItemParts=${nextDownloadItemParts.size}")
|
||||
Log.d(
|
||||
tag,
|
||||
"checkUpdateDownloadQueue: numPartsToGet=$numPartsToGet, nextDownloadItemParts=${nextDownloadItemParts.size}"
|
||||
)
|
||||
|
||||
if (nextDownloadItemParts.size > 0) {
|
||||
nextDownloadItemParts.forEach {
|
||||
if (it.isInternalStorage) {
|
||||
val file = File(it.finalDestinationPath)
|
||||
file.parentFile?.mkdirs()
|
||||
|
||||
val fileOutputStream = FileOutputStream(it.finalDestinationPath)
|
||||
val internalProgressCallback = (object : InternalProgressCallback {
|
||||
override fun onProgress(totalBytesWritten:Long, progress: Long) {
|
||||
it.bytesDownloaded = totalBytesWritten
|
||||
it.progress = progress
|
||||
}
|
||||
override fun onComplete(failed:Boolean) {
|
||||
it.failed = failed
|
||||
it.completed = true
|
||||
}
|
||||
})
|
||||
|
||||
Log.d(tag, "Start internal download to destination path ${it.finalDestinationPath} from ${it.serverUrl}")
|
||||
InternalDownloadManager(fileOutputStream, internalProgressCallback).download(it.serverUrl)
|
||||
it.downloadId = 1
|
||||
currentDownloadItemParts.add(it)
|
||||
} else {
|
||||
val dlRequest = it.getDownloadRequest()
|
||||
val downloadId = downloadManager.enqueue(dlRequest)
|
||||
it.downloadId = downloadId
|
||||
Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId")
|
||||
currentDownloadItemParts.add(it)
|
||||
}
|
||||
}
|
||||
if (nextDownloadItemParts.isNotEmpty()) {
|
||||
processDownloadItemParts(nextDownloadItemParts)
|
||||
}
|
||||
|
||||
if (currentDownloadItemParts.size >= maxSimultaneousDownloads) {
|
||||
@@ -107,9 +94,59 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
}
|
||||
}
|
||||
|
||||
if (currentDownloadItemParts.size > 0) startWatchingDownloads()
|
||||
if (currentDownloadItemParts.isNotEmpty()) startWatchingDownloads()
|
||||
}
|
||||
|
||||
/** Processes the download item parts. */
|
||||
private fun processDownloadItemParts(nextDownloadItemParts: List<DownloadItemPart>) {
|
||||
nextDownloadItemParts.forEach {
|
||||
if (it.isInternalStorage) {
|
||||
startInternalDownload(it)
|
||||
} else {
|
||||
startExternalDownload(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts an internal download. */
|
||||
private fun startInternalDownload(downloadItemPart: DownloadItemPart) {
|
||||
val file = File(downloadItemPart.finalDestinationPath)
|
||||
file.parentFile?.mkdirs()
|
||||
|
||||
val fileOutputStream = FileOutputStream(downloadItemPart.finalDestinationPath)
|
||||
val internalProgressCallback =
|
||||
object : InternalProgressCallback {
|
||||
override fun onProgress(totalBytesWritten: Long, progress: Long) {
|
||||
downloadItemPart.bytesDownloaded = totalBytesWritten
|
||||
downloadItemPart.progress = progress
|
||||
}
|
||||
|
||||
override fun onComplete(failed: Boolean) {
|
||||
downloadItemPart.failed = failed
|
||||
downloadItemPart.completed = true
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(
|
||||
tag,
|
||||
"Start internal download to destination path ${downloadItemPart.finalDestinationPath} from ${downloadItemPart.serverUrl}"
|
||||
)
|
||||
InternalDownloadManager(fileOutputStream, internalProgressCallback)
|
||||
.download(downloadItemPart.serverUrl)
|
||||
downloadItemPart.downloadId = 1
|
||||
currentDownloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
/** Starts an external download. */
|
||||
private fun startExternalDownload(downloadItemPart: DownloadItemPart) {
|
||||
val dlRequest = downloadItemPart.getDownloadRequest()
|
||||
val downloadId = downloadManager.enqueue(dlRequest)
|
||||
downloadItemPart.downloadId = downloadId
|
||||
Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId")
|
||||
currentDownloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
/** Starts watching the downloads. */
|
||||
private fun startWatchingDownloads() {
|
||||
if (isDownloading) return // Already watching
|
||||
|
||||
@@ -117,25 +154,13 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
Log.d(tag, "Starting watching downloads")
|
||||
isDownloading = true
|
||||
|
||||
while (currentDownloadItemParts.size > 0) {
|
||||
val itemParts = currentDownloadItemParts.filter { !it.isMoving }.map { it }
|
||||
while (currentDownloadItemParts.isNotEmpty()) {
|
||||
val itemParts = currentDownloadItemParts.filter { !it.isMoving }
|
||||
for (downloadItemPart in itemParts) {
|
||||
if (downloadItemPart.isInternalStorage) {
|
||||
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||
|
||||
if (downloadItemPart.completed) {
|
||||
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
|
||||
downloadItem?.let {
|
||||
checkDownloadItemFinished(it)
|
||||
}
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
handleInternalDownloadPart(downloadItemPart)
|
||||
} else {
|
||||
val downloadCheckStatus = checkDownloadItemPart(downloadItemPart)
|
||||
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||
|
||||
// Will move to final destination, remove current item parts, and check if download item is finished
|
||||
handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart)
|
||||
handleExternalDownloadPart(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +176,29 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkDownloadItemPart(downloadItemPart:DownloadItemPart):DownloadCheckStatus {
|
||||
/** Handles an internal download part. */
|
||||
private fun handleInternalDownloadPart(downloadItemPart: DownloadItemPart) {
|
||||
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||
|
||||
if (downloadItemPart.completed) {
|
||||
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
|
||||
downloadItem?.let { checkDownloadItemFinished(it) }
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles an external download part. */
|
||||
private fun handleExternalDownloadPart(downloadItemPart: DownloadItemPart) {
|
||||
val downloadCheckStatus = checkDownloadItemPart(downloadItemPart)
|
||||
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||
|
||||
// Will move to final destination, remove current item parts, and check if download item is
|
||||
// finished
|
||||
handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart)
|
||||
}
|
||||
|
||||
/** Checks the status of a download item part. */
|
||||
private fun checkDownloadItemPart(downloadItemPart: DownloadItemPart): DownloadCheckStatus {
|
||||
val downloadId = downloadItemPart.downloadId ?: return DownloadCheckStatus.Failed
|
||||
|
||||
val query = DownloadManager.Query().setFilterById(downloadId)
|
||||
@@ -159,12 +206,17 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
if (it.moveToFirst()) {
|
||||
val bytesColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
|
||||
val statusColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
||||
val bytesDownloadedColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
|
||||
val bytesDownloadedColumnIndex =
|
||||
it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
|
||||
|
||||
val totalBytes = if (bytesColumnIndex >= 0) it.getInt(bytesColumnIndex) else 0
|
||||
val downloadStatus = if (statusColumnIndex >= 0) it.getInt(statusColumnIndex) else 0
|
||||
val bytesDownloadedSoFar = if (bytesDownloadedColumnIndex >= 0) it.getLong(bytesDownloadedColumnIndex) else 0
|
||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} bytes $totalBytes | bytes dled $bytesDownloadedSoFar | downloadStatus $downloadStatus")
|
||||
val bytesDownloadedSoFar =
|
||||
if (bytesDownloadedColumnIndex >= 0) it.getLong(bytesDownloadedColumnIndex) else 0
|
||||
Log.d(
|
||||
tag,
|
||||
"checkDownloads Download ${downloadItemPart.filename} bytes $totalBytes | bytes dled $bytesDownloadedSoFar | downloadStatus $downloadStatus"
|
||||
)
|
||||
|
||||
return when (downloadStatus) {
|
||||
DownloadManager.STATUS_SUCCESSFUL -> {
|
||||
@@ -183,8 +235,12 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
DownloadCheckStatus.Failed
|
||||
}
|
||||
else -> {
|
||||
val percentProgress = if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0
|
||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Progress = $percentProgress%")
|
||||
val percentProgress =
|
||||
if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0
|
||||
Log.d(
|
||||
tag,
|
||||
"checkDownloads Download ${downloadItemPart.filename} Progress = $percentProgress%"
|
||||
)
|
||||
downloadItemPart.progress = percentProgress
|
||||
downloadItemPart.bytesDownloaded = bytesDownloadedSoFar
|
||||
|
||||
@@ -200,84 +256,120 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDownloadItemPartCheck(downloadCheckStatus:DownloadCheckStatus, downloadItemPart:DownloadItemPart) {
|
||||
/** Handles the result of a download item part check. */
|
||||
private fun handleDownloadItemPartCheck(
|
||||
downloadCheckStatus: DownloadCheckStatus,
|
||||
downloadItemPart: DownloadItemPart
|
||||
) {
|
||||
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
|
||||
if (downloadItem == null) {
|
||||
Log.e(tag, "Download item part finished but download item not found ${downloadItemPart.filename}")
|
||||
Log.e(
|
||||
tag,
|
||||
"Download item part finished but download item not found ${downloadItemPart.filename}"
|
||||
)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
} else if (downloadCheckStatus == DownloadCheckStatus.Successful) {
|
||||
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
|
||||
Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}")
|
||||
|
||||
val fcb = object : FileCallback() {
|
||||
override fun onPrepare() {
|
||||
Log.d(tag, "DOWNLOAD: PREPARING MOVE FILE")
|
||||
}
|
||||
override fun onFailed(errorCode: ErrorCode) {
|
||||
Log.e(tag, "DOWNLOAD: FAILED TO MOVE FILE $errorCode")
|
||||
downloadItemPart.failed = true
|
||||
downloadItemPart.isMoving = false
|
||||
file?.delete()
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
override fun onCompleted(result:Any) {
|
||||
Log.d(tag, "DOWNLOAD: FILE MOVE COMPLETED")
|
||||
val resultDocFile = result as DocumentFile
|
||||
Log.d(tag, "DOWNLOAD: COMPLETED FILE INFO (name=${resultDocFile.name}) ${resultDocFile.getAbsolutePath(mainActivity)}")
|
||||
|
||||
// Rename to fix appended .mp3 on m4b/m4a files
|
||||
// REF: https://github.com/anggrayudi/SimpleStorage/issues/94
|
||||
val docNameLowerCase = resultDocFile.name?.lowercase(Locale.getDefault()) ?: ""
|
||||
if (docNameLowerCase.endsWith(".m4b.mp3")|| docNameLowerCase.endsWith(".m4a.mp3")) {
|
||||
resultDocFile.renameTo(downloadItemPart.filename)
|
||||
}
|
||||
|
||||
downloadItemPart.moved = true
|
||||
downloadItemPart.isMoving = false
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
val localFolderFile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(downloadItemPart.localFolderUrl))
|
||||
if (localFolderFile == null) {
|
||||
// fAILED
|
||||
downloadItemPart.failed = true
|
||||
Log.e(tag, "Local Folder File from uri is null")
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
} else {
|
||||
downloadItemPart.isMoving = true
|
||||
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
|
||||
val fileDescription = FileDescription(downloadItemPart.filename, downloadItemPart.finalDestinationSubfolder, mimetype)
|
||||
file?.moveFileTo(mainActivity, localFolderFile, fileDescription, fcb)
|
||||
}
|
||||
|
||||
moveDownloadedFile(downloadItem, downloadItemPart)
|
||||
} else if (downloadCheckStatus != DownloadCheckStatus.InProgress) {
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkDownloadItemFinished(downloadItem:DownloadItem) {
|
||||
/** Moves the downloaded file to its final destination. */
|
||||
private fun moveDownloadedFile(downloadItem: DownloadItem, downloadItemPart: DownloadItemPart) {
|
||||
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
|
||||
Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}")
|
||||
|
||||
val fcb =
|
||||
object : FileCallback() {
|
||||
override fun onPrepare() {
|
||||
Log.d(tag, "DOWNLOAD: PREPARING MOVE FILE")
|
||||
}
|
||||
|
||||
override fun onFailed(errorCode: ErrorCode) {
|
||||
Log.e(tag, "DOWNLOAD: FAILED TO MOVE FILE $errorCode")
|
||||
downloadItemPart.failed = true
|
||||
downloadItemPart.isMoving = false
|
||||
file?.delete()
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
|
||||
override fun onCompleted(result: Any) {
|
||||
Log.d(tag, "DOWNLOAD: FILE MOVE COMPLETED")
|
||||
val resultDocFile = result as DocumentFile
|
||||
Log.d(
|
||||
tag,
|
||||
"DOWNLOAD: COMPLETED FILE INFO (name=${resultDocFile.name}) ${resultDocFile.getAbsolutePath(mainActivity)}"
|
||||
)
|
||||
|
||||
// Rename to fix appended .mp3 on m4b/m4a files
|
||||
// REF: https://github.com/anggrayudi/SimpleStorage/issues/94
|
||||
val docNameLowerCase = resultDocFile.name?.lowercase(Locale.getDefault()) ?: ""
|
||||
if (docNameLowerCase.endsWith(".m4b.mp3") || docNameLowerCase.endsWith(".m4a.mp3")
|
||||
) {
|
||||
resultDocFile.renameTo(downloadItemPart.filename)
|
||||
}
|
||||
|
||||
downloadItemPart.moved = true
|
||||
downloadItemPart.isMoving = false
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
val localFolderFile =
|
||||
DocumentFileCompat.fromUri(mainActivity, Uri.parse(downloadItemPart.localFolderUrl))
|
||||
if (localFolderFile == null) {
|
||||
// Failed
|
||||
downloadItemPart.failed = true
|
||||
Log.e(tag, "Local Folder File from uri is null")
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
} else {
|
||||
downloadItemPart.isMoving = true
|
||||
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
|
||||
val fileDescription =
|
||||
FileDescription(
|
||||
downloadItemPart.filename,
|
||||
downloadItemPart.finalDestinationSubfolder,
|
||||
mimetype
|
||||
)
|
||||
file?.moveFileTo(mainActivity, localFolderFile, fileDescription, fcb)
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks if a download item is finished and processes it. */
|
||||
private fun checkDownloadItemFinished(downloadItem: DownloadItem) {
|
||||
if (downloadItem.isDownloadFinished) {
|
||||
Log.i(tag, "Download Item finished ${downloadItem.media.metadata.title}")
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
folderScanner.scanDownloadItem(downloadItem) { downloadItemScanResult ->
|
||||
Log.d(tag, "Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id}")
|
||||
Log.d(
|
||||
tag,
|
||||
"Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id}"
|
||||
)
|
||||
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("libraryItemId", downloadItem.id)
|
||||
jsobj.put("localFolderId", downloadItem.localFolder.id)
|
||||
val jsobj =
|
||||
JSObject().apply {
|
||||
put("libraryItemId", downloadItem.id)
|
||||
put("localFolderId", downloadItem.localFolder.id)
|
||||
|
||||
downloadItemScanResult?.localLibraryItem?.let { localLibraryItem ->
|
||||
jsobj.put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
|
||||
}
|
||||
downloadItemScanResult?.localMediaProgress?.let { localMediaProgress ->
|
||||
jsobj.put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
||||
}
|
||||
downloadItemScanResult?.localLibraryItem?.let { localLibraryItem ->
|
||||
put(
|
||||
"localLibraryItem",
|
||||
JSObject(jacksonMapper.writeValueAsString(localLibraryItem))
|
||||
)
|
||||
}
|
||||
downloadItemScanResult?.localMediaProgress?.let { localMediaProgress ->
|
||||
put(
|
||||
"localMediaProgress",
|
||||
JSObject(jacksonMapper.writeValueAsString(localMediaProgress))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
clientEventEmitter.onDownloadItemComplete(jsobj)
|
||||
|
||||
+73
-33
@@ -1,60 +1,95 @@
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.util.Log
|
||||
import com.google.common.net.HttpHeaders.CONTENT_LENGTH
|
||||
import okhttp3.*
|
||||
import java.io.*
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import okhttp3.*
|
||||
|
||||
/**
|
||||
* Manages the internal download process.
|
||||
*
|
||||
* @property outputStream The output stream to write the downloaded data.
|
||||
* @property progressCallback The callback to report download progress.
|
||||
*/
|
||||
class InternalDownloadManager(
|
||||
private val outputStream: FileOutputStream,
|
||||
private val progressCallback: DownloadItemManager.InternalProgressCallback
|
||||
) : AutoCloseable {
|
||||
|
||||
class InternalDownloadManager(outputStream:FileOutputStream, private val progressCallback: DownloadItemManager.InternalProgressCallback) : AutoCloseable {
|
||||
private val tag = "InternalDownloadManager"
|
||||
|
||||
private val client: OkHttpClient = OkHttpClient()
|
||||
private val client: OkHttpClient =
|
||||
OkHttpClient.Builder().connectTimeout(30, TimeUnit.SECONDS).build()
|
||||
private val writer = BinaryFileWriter(outputStream, progressCallback)
|
||||
|
||||
/**
|
||||
* Downloads a file from the given URL.
|
||||
*
|
||||
* @param url The URL to download the file from.
|
||||
* @throws IOException If an I/O error occurs.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun download(url:String) {
|
||||
fun download(url: String) {
|
||||
val request: Request = Request.Builder().url(url).build()
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(tag, "download URL $url FAILED")
|
||||
progressCallback.onComplete(true)
|
||||
}
|
||||
client.newCall(request)
|
||||
.enqueue(
|
||||
object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(tag, "Download URL $url FAILED", e)
|
||||
progressCallback.onComplete(true)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
val responseBody: ResponseBody = response.body
|
||||
?: throw IllegalStateException("Response doesn't contain a file")
|
||||
|
||||
val length: Long = (response.header(CONTENT_LENGTH, "1") ?: "0").toLong()
|
||||
writer.write(responseBody.byteStream(), length)
|
||||
}
|
||||
})
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.body?.let { responseBody ->
|
||||
val length: Long = response.header("Content-Length")?.toLongOrNull() ?: 0L
|
||||
writer.write(responseBody.byteStream(), length)
|
||||
}
|
||||
?: run {
|
||||
Log.e(tag, "Response doesn't contain a file")
|
||||
progressCallback.onComplete(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the download manager and releases resources.
|
||||
*
|
||||
* @throws Exception If an error occurs during closing.
|
||||
*/
|
||||
@Throws(Exception::class)
|
||||
override fun close() {
|
||||
writer.close()
|
||||
}
|
||||
}
|
||||
|
||||
class BinaryFileWriter(outputStream: OutputStream, progressCallback: DownloadItemManager.InternalProgressCallback) :
|
||||
AutoCloseable {
|
||||
private val outputStream: OutputStream
|
||||
private val progressCallback: DownloadItemManager.InternalProgressCallback
|
||||
|
||||
init {
|
||||
this.outputStream = outputStream
|
||||
this.progressCallback = progressCallback
|
||||
}
|
||||
/**
|
||||
* Writes binary data to an output stream.
|
||||
*
|
||||
* @property outputStream The output stream to write the data to.
|
||||
* @property progressCallback The callback to report write progress.
|
||||
*/
|
||||
class BinaryFileWriter(
|
||||
private val outputStream: OutputStream,
|
||||
private val progressCallback: DownloadItemManager.InternalProgressCallback
|
||||
) : AutoCloseable {
|
||||
|
||||
/**
|
||||
* Writes data from the input stream to the output stream.
|
||||
*
|
||||
* @param inputStream The input stream to read the data from.
|
||||
* @param length The total length of the data to be written.
|
||||
* @return The total number of bytes written.
|
||||
* @throws IOException If an I/O error occurs.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun write(inputStream: InputStream?, length: Long): Long {
|
||||
fun write(inputStream: InputStream, length: Long): Long {
|
||||
BufferedInputStream(inputStream).use { input ->
|
||||
val dataBuffer = ByteArray(CHUNK_SIZE)
|
||||
var readBytes: Int
|
||||
var totalBytes: Long = 0
|
||||
var readBytes: Int
|
||||
while (input.read(dataBuffer).also { readBytes = it } != -1) {
|
||||
totalBytes += readBytes.toLong()
|
||||
totalBytes += readBytes
|
||||
outputStream.write(dataBuffer, 0, readBytes)
|
||||
progressCallback.onProgress(totalBytes, (totalBytes * 100L) / length)
|
||||
}
|
||||
@@ -63,12 +98,17 @@ class BinaryFileWriter(outputStream: OutputStream, progressCallback: DownloadIte
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the writer and releases resources.
|
||||
*
|
||||
* @throws IOException If an error occurs during closing.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
override fun close() {
|
||||
outputStream.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHUNK_SIZE = 1024
|
||||
private const val CHUNK_SIZE = 8192 // Increased chunk size for better performance
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,133 +1,212 @@
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.content.Context
|
||||
import android.media.metrics.PlaybackSession
|
||||
import android.os.*
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.player.SLEEP_TIMER_WAKE_UP_EXPIRATION
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class SleepTimerManager constructor(private val playerNotificationService: PlayerNotificationService) {
|
||||
class SleepTimerManager
|
||||
constructor(private val playerNotificationService: PlayerNotificationService) {
|
||||
private val tag = "SleepTimerManager"
|
||||
|
||||
private var sleepTimerTask:TimerTask? = null
|
||||
private var sleepTimerRunning:Boolean = false
|
||||
private var sleepTimerEndTime:Long = 0L
|
||||
private var sleepTimerLength:Long = 0L
|
||||
private var sleepTimerElapsed:Long = 0L
|
||||
private var sleepTimerFinishedAt:Long = 0L
|
||||
private var isAutoSleepTimer:Boolean = false // When timer was auto-set
|
||||
private var isFirstAutoSleepTimer: Boolean = true
|
||||
private var sleepTimerSessionId:String = ""
|
||||
private var sleepTimerTask: TimerTask? = null
|
||||
private var sleepTimerRunning: Boolean = false
|
||||
private var sleepTimerEndTime: Long = 0L
|
||||
private var sleepTimerLength: Long = 0L
|
||||
private var sleepTimerElapsed: Long = 0L
|
||||
private var sleepTimerFinishedAt: Long = 0L
|
||||
private var isAutoSleepTimer: Boolean = false // When timer was auto-set
|
||||
private var autoTimerDisabled: Boolean = false // Disable until out of auto timer period
|
||||
private var sleepTimerSessionId: String = ""
|
||||
|
||||
private fun getCurrentTime():Long {
|
||||
/**
|
||||
* Gets the current time from the player notification service.
|
||||
* @return Long - the current time in milliseconds.
|
||||
*/
|
||||
private fun getCurrentTime(): Long {
|
||||
return playerNotificationService.getCurrentTime()
|
||||
}
|
||||
|
||||
private fun getDuration():Long {
|
||||
/**
|
||||
* Gets the duration of the current playback.
|
||||
* @return Long - the duration in milliseconds.
|
||||
*/
|
||||
private fun getDuration(): Long {
|
||||
return playerNotificationService.getDuration()
|
||||
}
|
||||
|
||||
private fun getIsPlaying():Boolean {
|
||||
/**
|
||||
* Checks if the player is currently playing.
|
||||
* @return Boolean - true if the player is playing, false otherwise.
|
||||
*/
|
||||
private fun getIsPlaying(): Boolean {
|
||||
return playerNotificationService.currentPlayer.isPlaying
|
||||
}
|
||||
|
||||
private fun setVolume(volume:Float) {
|
||||
/**
|
||||
* Gets the playback speed of the player.
|
||||
* @return Float - the playback speed.
|
||||
*/
|
||||
private fun getPlaybackSpeed(): Float {
|
||||
return playerNotificationService.currentPlayer.playbackParameters.speed
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the volume of the player.
|
||||
* @param volume Float - the volume level to set.
|
||||
*/
|
||||
private fun setVolume(volume: Float) {
|
||||
playerNotificationService.currentPlayer.volume = volume
|
||||
}
|
||||
|
||||
/** Pauses the player. */
|
||||
private fun pause() {
|
||||
playerNotificationService.currentPlayer.pause()
|
||||
}
|
||||
|
||||
/** Plays the player. */
|
||||
private fun play() {
|
||||
playerNotificationService.currentPlayer.play()
|
||||
}
|
||||
|
||||
private fun getSleepTimerTimeRemainingSeconds():Int {
|
||||
/**
|
||||
* Gets the remaining time of the sleep timer in seconds.
|
||||
* @param speed Float - the playback speed of the player, default value is 1.
|
||||
* @return Int - the remaining time in seconds.
|
||||
*/
|
||||
private fun getSleepTimerTimeRemainingSeconds(speed: Float = 1f): Int {
|
||||
if (sleepTimerEndTime == 0L && sleepTimerLength > 0) { // For regular timer
|
||||
return ((sleepTimerLength - sleepTimerElapsed) / 1000).toDouble().roundToInt()
|
||||
}
|
||||
// For chapter end timer
|
||||
if (sleepTimerEndTime <= 0) return 0
|
||||
return (((sleepTimerEndTime - getCurrentTime()) / 1000).toDouble()).roundToInt()
|
||||
return (((sleepTimerEndTime - getCurrentTime()) / 1000).toDouble() / speed).roundToInt()
|
||||
}
|
||||
|
||||
private fun setSleepTimer(time: Long, isChapterTime: Boolean) : Boolean {
|
||||
Log.d(tag, "Setting Sleep Timer for $time is chapter time $isChapterTime")
|
||||
/**
|
||||
* Sets the sleep timer.
|
||||
* @param time Long - the time to set the sleep timer for. When 0L, use end of chapter/track time.
|
||||
* @return Boolean - true if the sleep timer was set successfully, false otherwise.
|
||||
*/
|
||||
private fun setSleepTimer(time: Long): Boolean {
|
||||
Log.d(tag, "Setting Sleep Timer for $time")
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerRunning = true
|
||||
sleepTimerFinishedAt = 0L
|
||||
sleepTimerElapsed = 0L
|
||||
setVolume(1f)
|
||||
|
||||
// Register shake sensor
|
||||
playerNotificationService.registerSensor()
|
||||
if (time == 0L) {
|
||||
// Get the current chapter time and set the sleep timer to the end of the chapter
|
||||
val chapterEndTime = this.getChapterEndTime()
|
||||
|
||||
val currentTime = getCurrentTime()
|
||||
if (isChapterTime) {
|
||||
if (currentTime > time) {
|
||||
Log.d(tag, "Invalid sleep timer - current time is already passed chapter time $time")
|
||||
if (chapterEndTime == null) {
|
||||
Log.e(tag, "Setting sleep timer to end of chapter/track but there is no current session")
|
||||
return false
|
||||
}
|
||||
sleepTimerEndTime = time
|
||||
sleepTimerLength = 0
|
||||
|
||||
val currentTime = getCurrentTime()
|
||||
if (currentTime > chapterEndTime) {
|
||||
Log.d(tag, "Invalid sleep timer - time is already past chapter time $chapterEndTime")
|
||||
return false
|
||||
}
|
||||
|
||||
sleepTimerEndTime = chapterEndTime
|
||||
|
||||
if (sleepTimerEndTime > getDuration()) {
|
||||
sleepTimerEndTime = getDuration()
|
||||
}
|
||||
} else {
|
||||
sleepTimerLength = time
|
||||
sleepTimerEndTime = 0L
|
||||
}
|
||||
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
||||
// Set sleep timer length. Will be 0L if using chapter end time
|
||||
sleepTimerLength = time
|
||||
|
||||
sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
if (getIsPlaying()) {
|
||||
sleepTimerElapsed += 1000L
|
||||
// Register shake sensor
|
||||
playerNotificationService.registerSensor()
|
||||
|
||||
val sleepTimeSecondsRemaining = getSleepTimerTimeRemainingSeconds()
|
||||
Log.d(tag, "Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s")
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(
|
||||
getSleepTimerTimeRemainingSeconds(getPlaybackSpeed()),
|
||||
isAutoSleepTimer
|
||||
)
|
||||
|
||||
if (sleepTimeSecondsRemaining > 0) {
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(sleepTimeSecondsRemaining, isAutoSleepTimer)
|
||||
}
|
||||
sleepTimerTask =
|
||||
Timer("SleepTimer", false).schedule(0L, 1000L) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
if (getIsPlaying()) {
|
||||
sleepTimerElapsed += 1000L
|
||||
|
||||
if (sleepTimeSecondsRemaining <= 0) {
|
||||
Log.d(tag, "Sleep Timer Pausing Player on Chapter")
|
||||
pause()
|
||||
val sleepTimeSecondsRemaining =
|
||||
getSleepTimerTimeRemainingSeconds(getPlaybackSpeed())
|
||||
Log.d(
|
||||
tag,
|
||||
"Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s"
|
||||
)
|
||||
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime())
|
||||
clearSleepTimer()
|
||||
sleepTimerFinishedAt = System.currentTimeMillis()
|
||||
} else if (sleepTimeSecondsRemaining <= 60 && DeviceManager.deviceData.deviceSettings?.disableSleepTimerFadeOut != true) {
|
||||
// Start fading out audio down to 10% volume
|
||||
val percentToReduce = 1 - (sleepTimeSecondsRemaining / 60F)
|
||||
val volume = 1f - (percentToReduce * 0.9f)
|
||||
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
|
||||
setVolume(volume)
|
||||
} else {
|
||||
setVolume(1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sleepTimeSecondsRemaining > 0) {
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(
|
||||
sleepTimeSecondsRemaining,
|
||||
isAutoSleepTimer
|
||||
)
|
||||
}
|
||||
|
||||
if (sleepTimeSecondsRemaining <= 0) {
|
||||
Log.d(tag, "Sleep Timer Pausing Player on Chapter")
|
||||
pause()
|
||||
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(
|
||||
getCurrentTime()
|
||||
)
|
||||
clearSleepTimer()
|
||||
sleepTimerFinishedAt = System.currentTimeMillis()
|
||||
} else if (sleepTimeSecondsRemaining <= 60 &&
|
||||
DeviceManager.deviceData
|
||||
.deviceSettings
|
||||
?.disableSleepTimerFadeOut != true
|
||||
) {
|
||||
// Start fading out audio down to 10% volume
|
||||
val percentToReduce = 1 - (sleepTimeSecondsRemaining / 60F)
|
||||
val volume = 1f - (percentToReduce * 0.9f)
|
||||
Log.d(
|
||||
tag,
|
||||
"SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining"
|
||||
)
|
||||
setVolume(volume)
|
||||
} else {
|
||||
setVolume(1f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun setManualSleepTimer(playbackSessionId:String, time: Long, isChapterTime:Boolean):Boolean {
|
||||
/**
|
||||
* Sets a manual sleep timer.
|
||||
* @param playbackSessionId String - the playback session ID.
|
||||
* @param time Long - the time to set the sleep timer for.
|
||||
* @param isChapterTime Boolean - true if the time is for the end of a chapter, false otherwise.
|
||||
* @return Boolean - true if the sleep timer was set successfully, false otherwise.
|
||||
*/
|
||||
fun setManualSleepTimer(playbackSessionId: String, time: Long, isChapterTime: Boolean): Boolean {
|
||||
sleepTimerSessionId = playbackSessionId
|
||||
isAutoSleepTimer = false
|
||||
return setSleepTimer(time, isChapterTime)
|
||||
if (isChapterTime) {
|
||||
Log.d(tag, "Setting manual sleep timer for end of chapter")
|
||||
return setSleepTimer(0L)
|
||||
} else {
|
||||
Log.d(tag, "Setting manual sleep timer for $time")
|
||||
return setSleepTimer(time)
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears the sleep timer. */
|
||||
private fun clearSleepTimer() {
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerTask = null
|
||||
@@ -138,32 +217,36 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
setVolume(1f)
|
||||
}
|
||||
|
||||
fun getSleepTimerTime():Long {
|
||||
/**
|
||||
* Gets the sleep timer end time.
|
||||
* @return Long - the sleep timer end time in milliseconds.
|
||||
*/
|
||||
fun getSleepTimerTime(): Long {
|
||||
return sleepTimerEndTime
|
||||
}
|
||||
|
||||
/** Cancels the sleep timer. */
|
||||
fun cancelSleepTimer() {
|
||||
Log.d(tag, "Canceling Sleep Timer")
|
||||
|
||||
if (isAutoSleepTimer) {
|
||||
Log.i(tag, "Disabling auto sleep timer")
|
||||
DeviceManager.deviceData.deviceSettings?.autoSleepTimer = false
|
||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
Log.i(tag, "Disabling auto sleep timer for this time period")
|
||||
autoTimerDisabled = true
|
||||
}
|
||||
|
||||
clearSleepTimer()
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0, false)
|
||||
}
|
||||
|
||||
// Vibrate when resetting sleep timer
|
||||
/** Provides vibration feedback when resetting the sleep timer. */
|
||||
private fun vibrateFeedback() {
|
||||
if (DeviceManager.deviceData.deviceSettings?.disableSleepTimerResetFeedback == true) return
|
||||
|
||||
val context = playerNotificationService.getContext()
|
||||
val vibrator:Vibrator
|
||||
val vibrator: Vibrator
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val vibratorManager =
|
||||
context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
||||
context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
||||
vibrator = vibratorManager.defaultVibrator
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -172,18 +255,20 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
|
||||
vibrator.let {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val vibrationEffect = VibrationEffect.createWaveform(longArrayOf(0, 150, 150, 150),-1)
|
||||
val vibrationEffect = VibrationEffect.createWaveform(longArrayOf(0, 150, 150, 150), -1)
|
||||
it.vibrate(vibrationEffect)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
it.vibrate(10)
|
||||
@Suppress("DEPRECATION") it.vibrate(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get the chapter end time for use in End of Chapter timers
|
||||
// if less than 2s remain in chapter then use the next chapter
|
||||
private fun getChapterEndTime():Long? {
|
||||
/**
|
||||
* Gets the chapter end time for use in End of Chapter timers. If less than 10 seconds remain in
|
||||
* the chapter, then use the next chapter.
|
||||
* @return Long? - the chapter end time in milliseconds, or null if there is no current session.
|
||||
*/
|
||||
private fun getChapterEndTime(): Long? {
|
||||
val currentChapterEndTimeMs = playerNotificationService.getEndTimeOfChapterOrTrack()
|
||||
if (currentChapterEndTimeMs == null) {
|
||||
Log.e(tag, "Getting chapter sleep timer end of chapter/track but there is no current session")
|
||||
@@ -191,11 +276,17 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
}
|
||||
|
||||
val timeLeftInChapter = currentChapterEndTimeMs - getCurrentTime()
|
||||
return if (timeLeftInChapter < 2000L) {
|
||||
Log.i(tag, "Getting chapter sleep timer time and current chapter has less than 2s remaining")
|
||||
// If less than 10 seconds remain in the chapter, set the timer to the next chapter or track
|
||||
// This handles the auto-rewind from not playing media for a little bit to select the next
|
||||
// chapter
|
||||
return if (timeLeftInChapter < 10000L) {
|
||||
Log.i(tag, "Getting chapter sleep timer time and current chapter has less than 10s remaining")
|
||||
val nextChapterEndTimeMs = playerNotificationService.getEndTimeOfNextChapterOrTrack()
|
||||
if (nextChapterEndTimeMs == null || currentChapterEndTimeMs == nextChapterEndTimeMs) {
|
||||
Log.e(tag, "Invalid next chapter time. No current session or equal to current chapter. $nextChapterEndTimeMs")
|
||||
Log.e(
|
||||
tag,
|
||||
"Invalid next chapter time. No current session or equal to current chapter. $nextChapterEndTimeMs"
|
||||
)
|
||||
null
|
||||
} else {
|
||||
nextChapterEndTimeMs
|
||||
@@ -205,17 +296,35 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetChapterTimer() {
|
||||
this.getChapterEndTime()?.let { chapterEndTime ->
|
||||
Log.d(tag, "Resetting stopped sleep timer to end of chapter $chapterEndTime")
|
||||
vibrateFeedback()
|
||||
setSleepTimer(chapterEndTime, true)
|
||||
play()
|
||||
/**
|
||||
* Rewind auto sleep timer if setting enabled. To ensure the first rewind of the time period does
|
||||
* not take place, make sure to set `isAutoSleepTimer` after calling this function.
|
||||
*/
|
||||
private fun tryRewindAutoSleepTimer() {
|
||||
DeviceManager.deviceData.deviceSettings?.let { deviceSettings ->
|
||||
if (isAutoSleepTimer && deviceSettings.autoSleepTimerAutoRewind) {
|
||||
Log.i(
|
||||
tag,
|
||||
"Auto sleep timer auto rewind seeking back ${deviceSettings.autoSleepTimerAutoRewindTime}ms"
|
||||
)
|
||||
playerNotificationService.seekBackward(deviceSettings.autoSleepTimerAutoRewindTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks if the sleep timer should be reset. */
|
||||
private fun checkShouldResetSleepTimer() {
|
||||
if (!sleepTimerRunning) {
|
||||
if (sleepTimerRunning) {
|
||||
// Reset the sleep timer if it has been running for at least 3 seconds or it is an end of
|
||||
// chapter/track timer
|
||||
if (sleepTimerLength == 0L || sleepTimerElapsed > 3000L) {
|
||||
Log.d(tag, "Resetting running sleep timer")
|
||||
vibrateFeedback()
|
||||
setSleepTimer(sleepTimerLength)
|
||||
play()
|
||||
}
|
||||
} else {
|
||||
|
||||
if (sleepTimerFinishedAt <= 0L) return
|
||||
|
||||
val finishedAtDistance = System.currentTimeMillis() - sleepTimerFinishedAt
|
||||
@@ -226,51 +335,31 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
return
|
||||
}
|
||||
|
||||
// Automatically Rewind in the book if settings is enabled
|
||||
if (isAutoSleepTimer) {
|
||||
DeviceManager.deviceData.deviceSettings?.let { deviceSettings ->
|
||||
if (deviceSettings.autoSleepTimerAutoRewind && !isFirstAutoSleepTimer) {
|
||||
Log.i(tag, "Auto sleep timer auto rewind seeking back ${deviceSettings.autoSleepTimerAutoRewindTime}ms")
|
||||
playerNotificationService.seekBackward(deviceSettings.autoSleepTimerAutoRewindTime)
|
||||
}
|
||||
isFirstAutoSleepTimer = false
|
||||
}
|
||||
// If timer was cleared by going negative on time, clear the sleep timer length so pressing
|
||||
// play allows playback to continue without the sleep timer continuously setting for 1 second.
|
||||
if (sleepTimerLength == 1000L) {
|
||||
Log.d(tag, "Sleep timer cleared by manually subtracting time, clearing sleep timer")
|
||||
sleepTimerFinishedAt = 0L
|
||||
return
|
||||
}
|
||||
|
||||
// Automatically rewind in the book if settings are enabled
|
||||
tryRewindAutoSleepTimer()
|
||||
|
||||
// Set sleep timer
|
||||
// When sleepTimerLength is 0 then use end of chapter/track time
|
||||
if (sleepTimerLength == 0L) {
|
||||
Log.d(tag, "Resetting stopped chapter sleep timer")
|
||||
resetChapterTimer()
|
||||
} else {
|
||||
Log.d(tag, "Resetting stopped sleep timer to length $sleepTimerLength")
|
||||
vibrateFeedback()
|
||||
setSleepTimer(sleepTimerLength, false)
|
||||
play()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Does not apply to chapter sleep timers and timer must be running for at least 3 seconds
|
||||
if (sleepTimerLength > 0L && sleepTimerElapsed > 3000L) {
|
||||
Log.d(tag, "Resetting running sleep timer to length $sleepTimerLength")
|
||||
Log.d(tag, "Resetting stopped sleep timer")
|
||||
vibrateFeedback()
|
||||
setSleepTimer(sleepTimerLength, false)
|
||||
} else if (sleepTimerLength == 0L) {
|
||||
// When navigating to previous chapters make sure this is still the end of the current chapter
|
||||
this.getChapterEndTime()?.let { chapterEndTime ->
|
||||
if (chapterEndTime != sleepTimerEndTime) {
|
||||
Log.d(tag, "Resetting chapter sleep timer to end of chapter $chapterEndTime from $sleepTimerEndTime")
|
||||
vibrateFeedback()
|
||||
setSleepTimer(chapterEndTime, true)
|
||||
play()
|
||||
}
|
||||
}
|
||||
setSleepTimer(sleepTimerLength)
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the shake event to reset the sleep timer. Shaking to reset only works during the 2
|
||||
* minute grace period after the timer ends or while media is playing.
|
||||
*/
|
||||
fun handleShake() {
|
||||
if (sleepTimerRunning || sleepTimerFinishedAt > 0L) {
|
||||
if ((sleepTimerRunning && getIsPlaying()) || sleepTimerFinishedAt > 0L) {
|
||||
if (DeviceManager.deviceData.deviceSettings?.disableShakeToResetSleepTimer == true) {
|
||||
Log.d(tag, "Shake to reset sleep timer is disabled")
|
||||
return
|
||||
@@ -279,48 +368,63 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increases the sleep timer time.
|
||||
* @param time Long - the time to increase the sleep timer by.
|
||||
*/
|
||||
fun increaseSleepTime(time: Long) {
|
||||
Log.d(tag, "Increase Sleep time $time")
|
||||
if (!sleepTimerRunning) return
|
||||
|
||||
// Increase the sleep timer time (if using fixed length) or end time (if using chapter end time)
|
||||
// and ensure it doesn't go over the duration of the current playback item
|
||||
if (sleepTimerEndTime == 0L) {
|
||||
// Fixed length
|
||||
sleepTimerLength += time
|
||||
if (sleepTimerLength + getCurrentTime() > getDuration()) sleepTimerLength = getDuration() - getCurrentTime()
|
||||
sleepTimerLength = minOf(sleepTimerLength, getDuration() - getCurrentTime())
|
||||
} else {
|
||||
val newSleepEndTime = sleepTimerEndTime + time
|
||||
sleepTimerEndTime = if (newSleepEndTime >= getDuration()) {
|
||||
getDuration()
|
||||
} else {
|
||||
newSleepEndTime
|
||||
}
|
||||
// Chapter end time
|
||||
sleepTimerEndTime =
|
||||
minOf(sleepTimerEndTime + (time * getPlaybackSpeed()).roundToInt(), getDuration())
|
||||
}
|
||||
|
||||
setVolume(1F)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(
|
||||
getSleepTimerTimeRemainingSeconds(getPlaybackSpeed()),
|
||||
isAutoSleepTimer
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decreases the sleep timer time.
|
||||
* @param time Long - the time to decrease the sleep timer by.
|
||||
*/
|
||||
fun decreaseSleepTime(time: Long) {
|
||||
Log.d(tag, "Decrease Sleep time $time")
|
||||
if (!sleepTimerRunning) return
|
||||
|
||||
|
||||
// Decrease the sleep timer time (if using fixed length) or end time (if using chapter end time)
|
||||
// and ensure it doesn't go below 1 second
|
||||
if (sleepTimerEndTime == 0L) {
|
||||
sleepTimerLength -= time
|
||||
if (sleepTimerLength <= 0) sleepTimerLength = 1000L
|
||||
// Fixed length
|
||||
sleepTimerLength = maxOf(sleepTimerLength - time, 1000L)
|
||||
} else {
|
||||
val newSleepEndTime = sleepTimerEndTime - time
|
||||
sleepTimerEndTime = if (newSleepEndTime <= 1000) {
|
||||
// End sleep timer in 1 second
|
||||
getCurrentTime() + 1000
|
||||
} else {
|
||||
newSleepEndTime
|
||||
}
|
||||
// Chapter end time
|
||||
sleepTimerEndTime =
|
||||
maxOf(
|
||||
sleepTimerEndTime - (time * getPlaybackSpeed()).roundToInt(),
|
||||
getCurrentTime() + 1000
|
||||
)
|
||||
}
|
||||
|
||||
setVolume(1F)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(
|
||||
getSleepTimerTimeRemainingSeconds(getPlaybackSpeed()),
|
||||
isAutoSleepTimer
|
||||
)
|
||||
}
|
||||
|
||||
/** Checks whether the auto sleep timer should be set, and set up auto sleep timer if so. */
|
||||
fun checkAutoSleepTimer() {
|
||||
if (sleepTimerRunning) { // Sleep timer already running
|
||||
return
|
||||
@@ -337,10 +441,13 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
|
||||
val currentCalendar = Calendar.getInstance()
|
||||
|
||||
// In cases where end time is before start time then we shift the time window forward or backward based on the current time.
|
||||
// In cases where end time is before start time then we shift the time window forward or
|
||||
// backward based on the current time.
|
||||
// e.g. start time 22:00 and end time 06:00.
|
||||
// If current time is less than start time (e.g. 00:30) then start time will be the previous day.
|
||||
// If current time is greater than start time (e.g. 23:00) then end time will be the next day.
|
||||
// If current time is less than start time (e.g. 00:30) then start time will be the
|
||||
// previous day.
|
||||
// If current time is greater than start time (e.g. 23:00) then end time will be the
|
||||
// next day.
|
||||
if (endCalendar.before(startCalendar)) {
|
||||
if (currentCalendar.before(startCalendar)) { // Shift start back a day
|
||||
startCalendar.add(Calendar.DAY_OF_MONTH, -1)
|
||||
@@ -349,47 +456,52 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
}
|
||||
}
|
||||
|
||||
val currentHour = SimpleDateFormat("HH:mm", Locale.getDefault()).format(currentCalendar.time)
|
||||
if (currentCalendar.after(startCalendar) && currentCalendar.before(endCalendar)) {
|
||||
Log.i(tag, "Current hour $currentHour is between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime} - starting sleep timer")
|
||||
val isDuringAutoTime =
|
||||
currentCalendar.after(startCalendar) && currentCalendar.before(endCalendar)
|
||||
|
||||
// Automatically Rewind in the book if settings is enabled
|
||||
if (deviceSettings.autoSleepTimerAutoRewind && !isFirstAutoSleepTimer) {
|
||||
Log.i(tag, "Auto sleep timer auto rewind seeking back ${deviceSettings.autoSleepTimerAutoRewindTime}ms")
|
||||
playerNotificationService.seekBackward(deviceSettings.autoSleepTimerAutoRewindTime)
|
||||
}
|
||||
isFirstAutoSleepTimer = false
|
||||
|
||||
// Set sleep timer
|
||||
// When sleepTimerLength is 0 then use end of chapter/track time
|
||||
if (deviceSettings.sleepTimerLength == 0L) {
|
||||
val chapterEndTimeMs = this.getChapterEndTime()
|
||||
if (chapterEndTimeMs == null) {
|
||||
Log.e(tag, "Setting auto sleep timer to end of chapter/track but there is no current session")
|
||||
} else {
|
||||
isAutoSleepTimer = true
|
||||
setSleepTimer(chapterEndTimeMs, true)
|
||||
}
|
||||
// Determine whether to set the auto sleep timer or not
|
||||
if (autoTimerDisabled) {
|
||||
if (!isDuringAutoTime) {
|
||||
// Check if sleep timer was disabled during the previous period and enable again
|
||||
Log.i(tag, "Leaving disabled auto sleep time period, enabling for next time period")
|
||||
autoTimerDisabled = false
|
||||
} else {
|
||||
isAutoSleepTimer = true
|
||||
setSleepTimer(deviceSettings.sleepTimerLength, false)
|
||||
// Auto time is disabled, do not set sleep timer
|
||||
Log.i(tag, "Auto sleep timer is disabled for this time period")
|
||||
}
|
||||
} else {
|
||||
isFirstAutoSleepTimer = true
|
||||
Log.d(tag, "Current hour $currentHour is NOT between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime}")
|
||||
if (isDuringAutoTime) {
|
||||
// Start an auto sleep timer
|
||||
val currentHour = currentCalendar.get(Calendar.HOUR_OF_DAY)
|
||||
val currentMin = currentCalendar.get(Calendar.MINUTE)
|
||||
Log.i(tag, "Starting auto sleep timer at $currentHour:$currentMin")
|
||||
|
||||
// Automatically rewind in the book if settings is enabled
|
||||
tryRewindAutoSleepTimer()
|
||||
|
||||
// Set `isAutoSleepTimer` to true to indicate that the timer was set automatically
|
||||
// and to not cause the timer to rewind
|
||||
isAutoSleepTimer = true
|
||||
setSleepTimer(deviceSettings.sleepTimerLength)
|
||||
} else {
|
||||
Log.d(tag, "Not in auto sleep time period")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleMediaPlayEvent(playbackSessionId:String) {
|
||||
/**
|
||||
* Handles the media play event and checks if the sleep timer should be reset or set.
|
||||
* @param playbackSessionId String - the playback session ID.
|
||||
*/
|
||||
fun handleMediaPlayEvent(playbackSessionId: String) {
|
||||
// Check if the playback session has changed
|
||||
// If it hasn't changed OR the sleep timer is running then check reset the timer
|
||||
// e.g. You set a manual sleep timer for 10 mins, then decide to change books, the sleep timer will stay on and reset to 10 mins
|
||||
// e.g. You set a manual sleep timer for 10 mins, then decide to change books, the sleep timer
|
||||
// will stay on and reset to 10 mins
|
||||
if (sleepTimerSessionId == playbackSessionId || sleepTimerRunning) {
|
||||
checkShouldResetSleepTimer()
|
||||
} else {
|
||||
isFirstAutoSleepTimer = true
|
||||
}
|
||||
} else {}
|
||||
sleepTimerSessionId = playbackSessionId
|
||||
|
||||
checkAutoSleepTimer()
|
||||
|
||||
@@ -36,76 +36,97 @@ object MediaEventManager {
|
||||
}
|
||||
|
||||
fun seekEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
|
||||
Log.i(tag, "Seek Event for media \"${playbackSession.displayTitle}\", currentTime=${playbackSession.currentTime}")
|
||||
Log.i(
|
||||
tag,
|
||||
"Seek Event for media \"${playbackSession.displayTitle}\", currentTime=${playbackSession.currentTime}"
|
||||
)
|
||||
addPlaybackEvent("Seek", playbackSession, syncResult)
|
||||
}
|
||||
|
||||
fun syncEvent(mediaProgress: MediaProgressWrapper, description: String) {
|
||||
Log.i(tag, "Sync Event for media item id \"${mediaProgress.mediaItemId}\", currentTime=${mediaProgress.currentTime}")
|
||||
Log.i(
|
||||
tag,
|
||||
"Sync Event for media item id \"${mediaProgress.mediaItemId}\", currentTime=${mediaProgress.currentTime}"
|
||||
)
|
||||
addSyncEvent("Sync", mediaProgress, description)
|
||||
}
|
||||
|
||||
private fun addSyncEvent(eventName:String, mediaProgress:MediaProgressWrapper, description: String) {
|
||||
private fun addSyncEvent(
|
||||
eventName: String,
|
||||
mediaProgress: MediaProgressWrapper,
|
||||
description: String
|
||||
) {
|
||||
val mediaItemHistory = getMediaItemHistoryMediaItem(mediaProgress.mediaItemId)
|
||||
if (mediaItemHistory == null) {
|
||||
Log.w(tag, "addSyncEvent: Media Item History not created yet for media item id ${mediaProgress.mediaItemId}")
|
||||
Log.w(
|
||||
tag,
|
||||
"addSyncEvent: Media Item History not created yet for media item id ${mediaProgress.mediaItemId}"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val mediaItemEvent = MediaItemEvent(
|
||||
name = eventName,
|
||||
type = "Sync",
|
||||
description = description,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
serverSyncAttempted = false,
|
||||
serverSyncSuccess = null,
|
||||
serverSyncMessage = null,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
val mediaItemEvent =
|
||||
MediaItemEvent(
|
||||
name = eventName,
|
||||
type = "Sync",
|
||||
description = description,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
serverSyncAttempted = false,
|
||||
serverSyncSuccess = null,
|
||||
serverSyncMessage = null,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
mediaItemHistory.events.add(mediaItemEvent)
|
||||
DeviceManager.dbManager.saveMediaItemHistory(mediaItemHistory)
|
||||
|
||||
clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory)
|
||||
}
|
||||
|
||||
private fun addPlaybackEvent(eventName:String, playbackSession:PlaybackSession, syncResult: SyncResult?) {
|
||||
val mediaItemHistory = getMediaItemHistoryMediaItem(playbackSession.mediaItemId) ?: createMediaItemHistoryForSession(playbackSession)
|
||||
private fun addPlaybackEvent(
|
||||
eventName: String,
|
||||
playbackSession: PlaybackSession,
|
||||
syncResult: SyncResult?
|
||||
) {
|
||||
val mediaItemHistory =
|
||||
getMediaItemHistoryMediaItem(playbackSession.mediaItemId)
|
||||
?: createMediaItemHistoryForSession(playbackSession)
|
||||
|
||||
val mediaItemEvent = MediaItemEvent(
|
||||
name = eventName,
|
||||
type = "Playback",
|
||||
description = "",
|
||||
currentTime = playbackSession.currentTime,
|
||||
serverSyncAttempted = syncResult?.serverSyncAttempted ?: false,
|
||||
serverSyncSuccess = syncResult?.serverSyncSuccess,
|
||||
serverSyncMessage = syncResult?.serverSyncMessage,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
val mediaItemEvent =
|
||||
MediaItemEvent(
|
||||
name = eventName,
|
||||
type = "Playback",
|
||||
description = "",
|
||||
currentTime = playbackSession.currentTime,
|
||||
serverSyncAttempted = syncResult?.serverSyncAttempted ?: false,
|
||||
serverSyncSuccess = syncResult?.serverSyncSuccess,
|
||||
serverSyncMessage = syncResult?.serverSyncMessage,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
mediaItemHistory.events.add(mediaItemEvent)
|
||||
DeviceManager.dbManager.saveMediaItemHistory(mediaItemHistory)
|
||||
|
||||
clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory)
|
||||
}
|
||||
|
||||
private fun getMediaItemHistoryMediaItem(mediaItemId: String) : MediaItemHistory? {
|
||||
private fun getMediaItemHistoryMediaItem(mediaItemId: String): MediaItemHistory? {
|
||||
return DeviceManager.dbManager.getMediaItemHistory(mediaItemId)
|
||||
}
|
||||
|
||||
private fun createMediaItemHistoryForSession(playbackSession: PlaybackSession):MediaItemHistory {
|
||||
private fun createMediaItemHistoryForSession(playbackSession: PlaybackSession): MediaItemHistory {
|
||||
Log.i(tag, "Creating new media item history for media \"${playbackSession.displayTitle}\"")
|
||||
val isLocalOnly = playbackSession.isLocalLibraryItemOnly
|
||||
val libraryItemId = if (isLocalOnly) playbackSession.localLibraryItemId else playbackSession.libraryItemId ?: ""
|
||||
val episodeId:String? = if (isLocalOnly && playbackSession.localEpisodeId != null) playbackSession.localEpisodeId else playbackSession.episodeId
|
||||
val libraryItemId = playbackSession.libraryItemId ?: ""
|
||||
val episodeId: String? = playbackSession.episodeId
|
||||
return MediaItemHistory(
|
||||
id = playbackSession.mediaItemId,
|
||||
mediaDisplayTitle = playbackSession.displayTitle ?: "Unset",
|
||||
libraryItemId,
|
||||
episodeId,
|
||||
isLocalOnly,
|
||||
playbackSession.serverConnectionConfigId,
|
||||
playbackSession.serverAddress,
|
||||
playbackSession.userId,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
events = mutableListOf())
|
||||
id = playbackSession.mediaItemId,
|
||||
mediaDisplayTitle = playbackSession.displayTitle ?: "Unset",
|
||||
libraryItemId,
|
||||
episodeId,
|
||||
false, // local-only items are not supported
|
||||
playbackSession.serverConnectionConfigId,
|
||||
playbackSession.serverAddress,
|
||||
playbackSession.userId,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
events = mutableListOf()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,36 +13,43 @@ import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
|
||||
data class MediaProgressSyncData(
|
||||
var timeListened:Long, // seconds
|
||||
var duration:Double, // seconds
|
||||
var currentTime:Double // seconds
|
||||
var timeListened: Long, // seconds
|
||||
var duration: Double, // seconds
|
||||
var currentTime: Double // seconds
|
||||
)
|
||||
|
||||
data class SyncResult(
|
||||
var serverSyncAttempted:Boolean,
|
||||
var serverSyncSuccess:Boolean?,
|
||||
var serverSyncMessage:String?
|
||||
var serverSyncAttempted: Boolean,
|
||||
var serverSyncSuccess: Boolean?,
|
||||
var serverSyncMessage: String?
|
||||
)
|
||||
|
||||
class MediaProgressSyncer(val playerNotificationService: PlayerNotificationService, private val apiHandler: ApiHandler) {
|
||||
class MediaProgressSyncer(
|
||||
val playerNotificationService: PlayerNotificationService,
|
||||
private val apiHandler: ApiHandler
|
||||
) {
|
||||
private val tag = "MediaProgressSync"
|
||||
private val METERED_CONNECTION_SYNC_INTERVAL = 60000
|
||||
|
||||
private var listeningTimerTask: TimerTask? = null
|
||||
var listeningTimerRunning:Boolean = false
|
||||
var listeningTimerRunning: Boolean = false
|
||||
|
||||
private var lastSyncTime:Long = 0
|
||||
private var failedSyncs:Int = 0
|
||||
private var lastSyncTime: Long = 0
|
||||
private var failedSyncs: Int = 0
|
||||
|
||||
var currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing
|
||||
var currentLocalMediaProgress: LocalMediaProgress? = null
|
||||
|
||||
private val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
|
||||
val currentIsLocal get() = currentPlaybackSession?.isLocal == true
|
||||
val currentSessionId get() = currentPlaybackSession?.id ?: ""
|
||||
private val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
|
||||
private val currentDisplayTitle
|
||||
get() = currentPlaybackSession?.displayTitle ?: "Unset"
|
||||
val currentIsLocal
|
||||
get() = currentPlaybackSession?.isLocal == true
|
||||
val currentSessionId
|
||||
get() = currentPlaybackSession?.id ?: ""
|
||||
private val currentPlaybackDuration
|
||||
get() = currentPlaybackSession?.duration ?: 0.0
|
||||
|
||||
fun start(playbackSession:PlaybackSession) {
|
||||
fun start(playbackSession: PlaybackSession) {
|
||||
if (listeningTimerRunning) {
|
||||
Log.d(tag, "start: Timer already running for $currentDisplayTitle")
|
||||
if (playbackSession.id != currentSessionId) {
|
||||
@@ -62,40 +69,48 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
||||
listeningTimerRunning = true
|
||||
lastSyncTime = System.currentTimeMillis()
|
||||
currentPlaybackSession = playbackSession.clone()
|
||||
Log.d(tag, "start: init last sync time $lastSyncTime with playback session id=${currentPlaybackSession?.id}")
|
||||
Log.d(
|
||||
tag,
|
||||
"start: init last sync time $lastSyncTime with playback session id=${currentPlaybackSession?.id}"
|
||||
)
|
||||
|
||||
listeningTimerTask = Timer("ListeningTimer", false).schedule(15000L, 15000L) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
if (playerNotificationService.currentPlayer.isPlaying) {
|
||||
// Set auto sleep timer if enabled and within start/end time
|
||||
playerNotificationService.sleepTimerManager.checkAutoSleepTimer()
|
||||
listeningTimerTask =
|
||||
Timer("ListeningTimer", false).schedule(15000L, 15000L) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
if (playerNotificationService.currentPlayer.isPlaying) {
|
||||
// Set auto sleep timer if enabled and within start/end time
|
||||
playerNotificationService.sleepTimerManager.checkAutoSleepTimer()
|
||||
|
||||
// Only sync with server on unmetered connection every 15s OR sync with server if last sync time is >= 60s
|
||||
val shouldSyncServer = PlayerNotificationService.isUnmeteredNetwork || System.currentTimeMillis() - lastSyncTime >= METERED_CONNECTION_SYNC_INTERVAL
|
||||
// Only sync with server on unmetered connection every 15s OR sync with server if
|
||||
// last sync time is >= 60s
|
||||
val shouldSyncServer =
|
||||
PlayerNotificationService.isUnmeteredNetwork ||
|
||||
System.currentTimeMillis() - lastSyncTime >=
|
||||
METERED_CONNECTION_SYNC_INTERVAL
|
||||
|
||||
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||
if (currentTime > 0) {
|
||||
sync(shouldSyncServer, currentTime) { syncResult ->
|
||||
Log.d(tag, "Sync complete")
|
||||
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||
if (currentTime > 0) {
|
||||
sync(shouldSyncServer, currentTime) { syncResult ->
|
||||
Log.d(tag, "Sync complete")
|
||||
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
MediaEventManager.saveEvent(playbackSession, syncResult)
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
MediaEventManager.saveEvent(playbackSession, syncResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun play(playbackSession:PlaybackSession) {
|
||||
fun play(playbackSession: PlaybackSession) {
|
||||
Log.d(tag, "play ${playbackSession.displayTitle}")
|
||||
MediaEventManager.playEvent(playbackSession)
|
||||
|
||||
start(playbackSession)
|
||||
}
|
||||
|
||||
fun stop(shouldSync:Boolean? = true, cb: () -> Unit) {
|
||||
fun stop(shouldSync: Boolean? = true, cb: () -> Unit) {
|
||||
if (!listeningTimerRunning) {
|
||||
reset()
|
||||
return cb()
|
||||
@@ -106,7 +121,8 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
||||
listeningTimerRunning = false
|
||||
Log.d(tag, "stop: Stopping listening for $currentDisplayTitle")
|
||||
|
||||
val currentTime = if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0
|
||||
val currentTime =
|
||||
if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0
|
||||
if (currentTime > 0) { // Current time should always be > 0 on stop
|
||||
sync(true, currentTime) { syncResult ->
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
@@ -197,12 +213,15 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
||||
it.updatedAt = mediaProgress.lastUpdate
|
||||
it.currentTime = mediaProgress.currentTime
|
||||
|
||||
MediaEventManager.syncEvent(mediaProgress, "Received from server get media progress request while playback session open")
|
||||
MediaEventManager.syncEvent(
|
||||
mediaProgress,
|
||||
"Received from server get media progress request while playback session open"
|
||||
)
|
||||
saveLocalProgress(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun sync(shouldSyncServer:Boolean, currentTime:Double, cb: (SyncResult?) -> Unit) {
|
||||
fun sync(shouldSyncServer: Boolean, currentTime: Double, cb: (SyncResult?) -> Unit) {
|
||||
if (lastSyncTime <= 0) {
|
||||
Log.e(tag, "Last sync time is not set $lastSyncTime")
|
||||
return cb(null)
|
||||
@@ -214,11 +233,14 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
||||
}
|
||||
val listeningTimeToAdd = diffSinceLastSync / 1000L
|
||||
|
||||
val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
|
||||
val syncData = MediaProgressSyncData(listeningTimeToAdd, currentPlaybackDuration, currentTime)
|
||||
currentPlaybackSession?.syncData(syncData)
|
||||
|
||||
if (currentPlaybackSession?.progress?.isNaN() == true) {
|
||||
Log.e(tag, "Current Playback Session invalid progress ${currentPlaybackSession?.progress} | Current Time: ${currentPlaybackSession?.currentTime} | Duration: ${currentPlaybackSession?.getTotalDuration()}")
|
||||
Log.e(
|
||||
tag,
|
||||
"Current Playback Session invalid progress ${currentPlaybackSession?.progress} | Current Time: ${currentPlaybackSession?.currentTime} | Duration: ${currentPlaybackSession?.getTotalDuration()}"
|
||||
)
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
@@ -226,11 +248,7 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
||||
|
||||
// Save playback session to db (server linked sessions only)
|
||||
// Sessions are removed once successfully synced with the server
|
||||
currentPlaybackSession?.let {
|
||||
if (!it.isLocalLibraryItemOnly) {
|
||||
DeviceManager.dbManager.savePlaybackSession(it)
|
||||
}
|
||||
}
|
||||
currentPlaybackSession?.let { DeviceManager.dbManager.savePlaybackSession(it) }
|
||||
|
||||
if (currentIsLocal) {
|
||||
// Save local progress sync
|
||||
@@ -238,11 +256,20 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
||||
saveLocalProgress(it)
|
||||
lastSyncTime = System.currentTimeMillis()
|
||||
|
||||
Log.d(tag, "Sync local device current serverConnectionConfigId=${DeviceManager.serverConnectionConfig?.id}")
|
||||
Log.d(
|
||||
tag,
|
||||
"Sync local device current serverConnectionConfigId=${DeviceManager.serverConnectionConfig?.id}"
|
||||
)
|
||||
|
||||
// Local library item is linked to a server library item
|
||||
// Send sync to server also if connected to this server and local item belongs to this server
|
||||
if (hasNetworkConnection && shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
|
||||
// Send sync to server also if connected to this server and local item belongs to this
|
||||
// server
|
||||
if (hasNetworkConnection &&
|
||||
shouldSyncServer &&
|
||||
!it.libraryItemId.isNullOrEmpty() &&
|
||||
it.serverConnectionConfigId != null &&
|
||||
DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId
|
||||
) {
|
||||
apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg ->
|
||||
if (syncSuccess) {
|
||||
failedSyncs = 0
|
||||
@@ -254,7 +281,10 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
||||
playerNotificationService.alertSyncFailing() // Show alert in client
|
||||
failedSyncs = 0
|
||||
}
|
||||
Log.e(tag, "Local Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime with session id=${it.id}")
|
||||
Log.e(
|
||||
tag,
|
||||
"Local Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime with session id=${it.id}"
|
||||
)
|
||||
}
|
||||
|
||||
cb(SyncResult(true, syncSuccess, errorMsg))
|
||||
@@ -278,7 +308,10 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
||||
playerNotificationService.alertSyncFailing() // Show alert in client
|
||||
failedSyncs = 0
|
||||
}
|
||||
Log.e(tag, "Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime with session id=${currentSessionId}")
|
||||
Log.e(
|
||||
tag,
|
||||
"Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime with session id=${currentSessionId}"
|
||||
)
|
||||
}
|
||||
cb(SyncResult(true, syncSuccess, errorMsg))
|
||||
}
|
||||
@@ -287,9 +320,10 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveLocalProgress(playbackSession:PlaybackSession) {
|
||||
private fun saveLocalProgress(playbackSession: PlaybackSession) {
|
||||
if (currentLocalMediaProgress == null) {
|
||||
val mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(playbackSession.localMediaProgressId)
|
||||
val mediaProgress =
|
||||
DeviceManager.dbManager.getLocalMediaProgress(playbackSession.localMediaProgressId)
|
||||
if (mediaProgress == null) {
|
||||
currentLocalMediaProgress = playbackSession.getNewLocalMediaProgress()
|
||||
} else {
|
||||
@@ -306,12 +340,14 @@ class MediaProgressSyncer(val playerNotificationService: PlayerNotificationServi
|
||||
} else {
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(it)
|
||||
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it)
|
||||
Log.d(tag, "Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${it.progressPercent}%")
|
||||
Log.d(
|
||||
tag,
|
||||
"Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${it.progressPercent}%"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun reset() {
|
||||
currentPlaybackSession = null
|
||||
currentLocalMediaProgress = null
|
||||
|
||||
@@ -42,7 +42,10 @@ data class DownloadItemPart(
|
||||
val finalDestinationUri = Uri.fromFile(finalDestinationFile)
|
||||
|
||||
var downloadUrl = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
|
||||
if (serverPath.endsWith("/cover")) downloadUrl += "&format=jpeg&raw=1" // For cover images force to jpeg
|
||||
if (serverPath.endsWith("/cover")) {
|
||||
downloadUrl += "&raw=1" // Download raw cover image
|
||||
}
|
||||
|
||||
val downloadUri = Uri.parse(downloadUrl)
|
||||
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Download URI $downloadUri")
|
||||
return DownloadItemPart(
|
||||
@@ -77,7 +80,7 @@ data class DownloadItemPart(
|
||||
val isInternalStorage get() = localFolderId.startsWith("internal-")
|
||||
|
||||
@get:JsonIgnore
|
||||
val serverUrl get() = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
|
||||
val serverUrl get() = uri.toString()
|
||||
|
||||
@JsonIgnore
|
||||
fun getDownloadRequest(): DownloadManager.Request {
|
||||
|
||||
+939
-613
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ buildscript {
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.google.gms:google-services:4.4.2'
|
||||
classpath 'com.android.tools.build:gradle:8.8.0'
|
||||
classpath 'com.android.tools.build:gradle:8.8.2'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':byteowls-capacitor-filesharer'
|
||||
project(':byteowls-capacitor-filesharer').projectDir = new File('../node_modules/@byteowls/capacitor-filesharer/android')
|
||||
include ':webnativellc-capacitor-filesharer'
|
||||
project(':webnativellc-capacitor-filesharer').projectDir = new File('../node_modules/@webnativellc/capacitor-filesharer/android')
|
||||
|
||||
include ':capacitor-community-keep-awake'
|
||||
project(':capacitor-community-keep-awake').projectDir = new File('../node_modules/@capacitor-community/keep-awake/android')
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||
networkTimeout=10000
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 35
|
||||
targetSdkVersion = 34
|
||||
androidxActivityVersion = '1.8.0'
|
||||
targetSdkVersion = 35
|
||||
androidxActivityVersion = '1.9.2'
|
||||
androidxAppCompatVersion = '1.7.0'
|
||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||
androidxCoreVersion = '1.12.0'
|
||||
androidxFragmentVersion = '1.6.2'
|
||||
androidxCoordinatorLayoutVersion = '1.3.0'
|
||||
androidxCoreVersion = '1.15.0'
|
||||
androidxFragmentVersion = '1.8.4'
|
||||
junitVersion = '4.13.2'
|
||||
androidxJunitVersion = '1.1.5'
|
||||
androidxEspressoCoreVersion = '3.5.1'
|
||||
androidxJunitVersion = '1.2.1'
|
||||
androidxEspressoCoreVersion = '3.6.1'
|
||||
cordovaAndroidVersion = '10.1.1'
|
||||
androidx_core_ktx_version = '1.15.0'
|
||||
androidx_core_ktx_version = '1.16.0'
|
||||
androidx_media_version = '1.7.0'
|
||||
exoplayer_version = '2.18.7'
|
||||
glide_version = '4.11.0'
|
||||
@@ -20,5 +20,5 @@ ext {
|
||||
kotlin_coroutines_version = '1.10.1'
|
||||
test_runner_version = '1.1.0'
|
||||
coreSplashScreenVersion = '1.0.1'
|
||||
androidxWebkitVersion = '1.9.0'
|
||||
androidxWebkitVersion = '1.12.1'
|
||||
}
|
||||
|
||||
@@ -52,4 +52,16 @@
|
||||
text-indent: 0px !important;
|
||||
text-align: start !important;
|
||||
text-align-last: start !important;
|
||||
}
|
||||
}
|
||||
|
||||
.default-style.less-spacing p {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
.default-style.less-spacing ul {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
.default-style.less-spacing ol {
|
||||
margin-block-start: 0;
|
||||
}
|
||||
|
||||
+9
-33
@@ -1,19 +1,12 @@
|
||||
@font-face {
|
||||
font-family: 'Material Icons';
|
||||
font-family: 'Material Symbols Rounded';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(/fonts/MaterialIcons-Regular.ttf) format('truetype');
|
||||
src: url(/fonts/MaterialSymbolsRounded.woff2) format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Material Icons Outlined';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(/fonts/MaterialIconsOutlined-Regular.otf) format('opentype');
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-family: 'Material Icons';
|
||||
.material-symbols {
|
||||
font-family: 'Material Symbols Rounded';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
@@ -24,31 +17,14 @@
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.material-icons:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
|
||||
font-size: 1.5rem;
|
||||
.material-symbols.fill {
|
||||
font-variation-settings:
|
||||
'FILL' 1
|
||||
}
|
||||
|
||||
.material-icons-outlined {
|
||||
font-family: 'Material Icons Outlined';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.material-icons-outlined:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
@@ -317,4 +293,4 @@
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<img src="/Logo.png" class="h-10 w-10" />
|
||||
</nuxt-link>
|
||||
<a v-if="showBack" @click="back" class="rounded-full h-10 w-10 flex items-center justify-center mr-2 cursor-pointer">
|
||||
<span class="material-icons text-3xl text-fg">arrow_back</span>
|
||||
<span class="material-symbols text-3xl text-fg">arrow_back</span>
|
||||
</a>
|
||||
<div v-if="user && currentLibrary && socketConnected">
|
||||
<div class="pl-1.5 pr-2.5 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
|
||||
@@ -21,16 +21,18 @@
|
||||
<widgets-download-progress-indicator />
|
||||
|
||||
<!-- Must be connected to a server to cast, only supports media items on server -->
|
||||
<div v-show="isCastAvailable && user" class="mx-2 cursor-pointer flex items-center pt-0.5" @click="castClick">
|
||||
<span class="material-icons" :class="isCasting ? 'text-success' : ''">cast</span>
|
||||
<div v-show="isCastAvailable && user" class="mx-2 cursor-pointer flex items-center" @click="castClick">
|
||||
<span class="material-symbols text-2xl leading-none">
|
||||
{{ isCasting ? 'cast_connected' : 'cast' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nuxt-link v-if="user" class="h-7 mx-1.5" style="padding-top: 3px" to="/search">
|
||||
<span class="material-icons">search</span>
|
||||
<nuxt-link v-if="user" class="mx-1.5 flex items-center h-10" to="/search">
|
||||
<span class="material-symbols text-2xl leading-none">search</span>
|
||||
</nuxt-link>
|
||||
|
||||
<div class="h-7 mx-1.5">
|
||||
<span class="material-icons" style="font-size: 1.75rem" @click="clickShowSideDrawer">menu</span>
|
||||
<span class="material-symbols" style="font-size: 1.75rem" @click="clickShowSideDrawer">menu</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
<div class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-audio-player)" />
|
||||
|
||||
<div class="top-4 left-4 absolute cursor-pointer">
|
||||
<span class="material-icons text-5xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="collapseFullscreen">expand_more</span>
|
||||
<span class="material-symbols text-5xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="collapseFullscreen">keyboard_arrow_down</span>
|
||||
</div>
|
||||
<div v-show="showCastBtn" class="top-6 right-16 absolute cursor-pointer">
|
||||
<span class="material-icons text-3xl" :class="isCasting ? (coverBgIsLight ? 'text-successDark' : 'text-success') : coverBgIsLight ? 'text-black' : ''" @click="castClick">cast</span>
|
||||
<span class="material-symbols text-3xl" :class="coverBgIsLight ? 'text-black' : ''" @click="castClick">{{ isCasting ? 'cast_connected' : 'cast' }}</span>
|
||||
</div>
|
||||
<div class="top-6 right-4 absolute cursor-pointer">
|
||||
<span class="material-icons text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="showMoreMenuDialog = true">more_vert</span>
|
||||
<span class="material-symbols text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="showMoreMenuDialog = true">more_vert</span>
|
||||
</div>
|
||||
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" :class="{ 'text-black text-opacity-75': coverBgIsLight }" style="font-size: 10px">{{ isDirectPlayMethod ? $strings.LabelPlaybackDirect : isLocalPlayMethod ? $strings.LabelPlaybackLocal : $strings.LabelPlaybackTranscode }}</p>
|
||||
</div>
|
||||
@@ -35,8 +35,8 @@
|
||||
<covers-book-cover v-if="libraryItem || localLibraryItemCoverSrc" ref="cover" :library-item="libraryItem" :download-cover="localLibraryItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" raw @imageLoaded="coverImageLoaded" />
|
||||
</div>
|
||||
|
||||
<div v-if="syncStatus === $constants.SyncStatus.FAILED" class="absolute top-0 left-0 w-full h-full flex items-center justify-center z-30">
|
||||
<span class="material-icons text-error text-3xl">error</span>
|
||||
<div v-if="syncStatus === $constants.SyncStatus.FAILED" class="absolute top-0 left-0 w-full h-full flex items-center justify-center z-30" @click.stop="showSyncsFailedDialog">
|
||||
<span class="material-symbols text-error text-3xl">error</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,9 +50,9 @@
|
||||
<div id="playerContent" class="playerContainer w-full z-20 absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" :style="{ backgroundColor: showFullscreen ? '' : coverRgb }" @click="clickContainer">
|
||||
<div v-if="showFullscreen" class="absolute bottom-4 left-0 right-0 w-full pb-4 pt-2 mx-auto px-6" style="max-width: 414px">
|
||||
<div class="flex items-center justify-between pointer-events-auto">
|
||||
<span v-if="!isPodcast && serverLibraryItemId && socketConnected" class="material-icons text-3xl text-fg-muted cursor-pointer" @click="$emit('showBookmarks')">{{ bookmarks.length ? 'bookmark' : 'bookmark_border' }}</span>
|
||||
<span v-if="!isPodcast && serverLibraryItemId && socketConnected" class="material-symbols text-3xl text-fg-muted cursor-pointer" :class="{ fill: bookmarks.length }" @click="$emit('showBookmarks')">bookmark</span>
|
||||
<!-- hidden for podcasts but still using this as a placeholder -->
|
||||
<span v-else class="material-icons text-3xl text-white text-opacity-0">bookmark</span>
|
||||
<span v-else class="material-symbols text-3xl text-white text-opacity-0">bookmark</span>
|
||||
|
||||
<span class="font-mono text-fg-muted cursor-pointer" style="font-size: 1.35rem" @click="$emit('selectPlaybackSpeed')">{{ currentPlaybackRate }}x</span>
|
||||
<svg v-if="!sleepTimerRunning" xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 text-fg-muted cursor-pointer" fill="none" viewBox="0 0 24 24" stroke="currentColor" @click.stop="$emit('showSleepTimer')">
|
||||
@@ -62,23 +62,23 @@
|
||||
<p class="text-xl font-mono text-success">{{ sleepTimeRemainingPretty }}</p>
|
||||
</div>
|
||||
|
||||
<span class="material-icons text-3xl text-fg cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="clickChaptersBtn">format_list_bulleted</span>
|
||||
<span class="material-symbols text-3xl text-fg cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="clickChaptersBtn">format_list_bulleted</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-minimized-audio-player)" />
|
||||
|
||||
<div id="playerControls" class="absolute right-0 bottom-0 mx-auto" style="max-width: 414px">
|
||||
<div class="flex items-center max-w-full" :class="playerSettings.lockUi ? 'justify-center' : 'justify-between'">
|
||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-icons next-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||
<span v-show="!playerSettings.lockUi" class="material-icons jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||
<span v-show="!playerSettings.lockUi" class="material-symbols jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
||||
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4 relative overflow-hidden" :style="{ backgroundColor: coverRgb }" :class="{ 'animate-spin': seekLoading }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
|
||||
<div v-if="!coverBgIsLight" class="absolute top-0 left-0 w-full h-full bg-white bg-opacity-20 pointer-events-none" />
|
||||
|
||||
<span v-if="!isLoading" class="material-icons" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
||||
<span v-if="!isLoading" class="material-symbols fill" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
||||
<widgets-spinner-icon v-else class="h-8 w-8" />
|
||||
</div>
|
||||
<span v-show="!playerSettings.lockUi" class="material-icons jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
|
||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-icons next-icon text-fg cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||
<span v-show="!playerSettings.lockUi" class="material-symbols jump-icon text-fg cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
|
||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
<script>
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
import { FastAverageColor } from 'fast-average-color'
|
||||
import WrappingMarquee from '@/assets/WrappingMarquee.js'
|
||||
|
||||
@@ -390,6 +391,13 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showSyncsFailedDialog() {
|
||||
Dialog.alert({
|
||||
title: this.$strings.HeaderProgressSyncFailed,
|
||||
message: this.$strings.MessageProgressSyncFailed,
|
||||
cancelText: this.$strings.ButtonOk
|
||||
})
|
||||
},
|
||||
clickChaptersBtn() {
|
||||
if (!this.chapters.length) return
|
||||
this.showChapterModal = true
|
||||
@@ -1098,7 +1106,7 @@ export default {
|
||||
min-height: 40px;
|
||||
margin: 0px 7px;
|
||||
}
|
||||
#playerControls .play-btn .material-icons {
|
||||
#playerControls .play-btn .material-symbols {
|
||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
transition-property: font-size;
|
||||
|
||||
@@ -1134,7 +1142,7 @@ export default {
|
||||
min-width: 65px;
|
||||
min-height: 65px;
|
||||
}
|
||||
.fullscreen #playerControls .play-btn .material-icons {
|
||||
.fullscreen #playerControls .play-btn .material-symbols {
|
||||
font-size: 2.1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
<div class="w-full overflow-y-auto">
|
||||
<template v-for="item in navItems">
|
||||
<button v-if="item.action" :key="item.text" class="w-full hover:bg-bg/60 flex items-center py-3 px-6 text-fg-muted" @click="clickAction(item.action)">
|
||||
<span class="text-lg" :class="item.iconOutlined ? 'material-icons-outlined' : 'material-icons'">{{ item.icon }}</span>
|
||||
<span class="material-symbols fill text-lg">{{ item.icon }}</span>
|
||||
<p class="pl-4">{{ item.text }}</p>
|
||||
</button>
|
||||
<nuxt-link v-else :to="item.to" :key="item.text" class="w-full hover:bg-bg/60 flex items-center py-3 px-6 text-fg" :class="currentRoutePath.startsWith(item.to) ? 'bg-bg-hover/50' : 'text-fg-muted'">
|
||||
<span class="text-lg" :class="item.iconOutlined ? 'material-icons-outlined' : 'material-icons'">{{ item.icon }}</span>
|
||||
<span class="material-symbols fill text-lg">{{ item.icon }}</span>
|
||||
<p class="pl-4">{{ item.text }}</p>
|
||||
</nuxt-link>
|
||||
</template>
|
||||
@@ -30,7 +30,7 @@
|
||||
<div class="flex-grow" />
|
||||
<div v-if="user" class="flex items-center" @click="disconnect">
|
||||
<p class="text-xs pr-2">{{ $strings.ButtonDisconnect }}</p>
|
||||
<i class="material-icons text-sm -mb-0.5">cloud_off</i>
|
||||
<i class="material-symbols text-sm -mb-0.5">cloud_off</i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -105,8 +105,18 @@ export default {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
bookWidth() {
|
||||
var coverSize = 100
|
||||
if (window.innerWidth <= 375) coverSize = 90
|
||||
const availableWidth = window.innerWidth - 16
|
||||
let coverSize = 100
|
||||
|
||||
// Smaller screens fill width with 2 items per row
|
||||
if (availableWidth <= 400) {
|
||||
coverSize = Math.floor(availableWidth / 2 - 24)
|
||||
if (coverSize < 120) {
|
||||
// Fallback to 1 item per row
|
||||
coverSize = Math.min(availableWidth - 24, 200)
|
||||
}
|
||||
if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') coverSize /= 1.6
|
||||
}
|
||||
|
||||
if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return coverSize * 1.6
|
||||
return coverSize
|
||||
|
||||
@@ -42,9 +42,6 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
_author() {
|
||||
return this.author || {}
|
||||
},
|
||||
|
||||
@@ -43,14 +43,14 @@
|
||||
|
||||
<div v-if="showPlayButton" class="absolute -bottom-16 -right-16 rotate-45 w-32 h-32 p-2 bg-gradient-to-r from-transparent to-black to-40% inline-flex justify-start items-center">
|
||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto -rotate-45" @click.stop.prevent="play">
|
||||
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">{{ streamIsPlaying ? 'pause_circle' : 'play_circle_filled' }}</span>
|
||||
<span class="material-symbols fill">{{ streamIsPlaying ? 'pause_circle' : 'play_circle' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Play/pause button for podcast episode -->
|
||||
<div v-if="recentEpisode" class="absolute z-10 top-0 left-0 bottom-0 right-0 m-auto flex items-center justify-center w-12 h-12 rounded-full" :class="{ 'bg-white/70': !playerIsStartingForThisMedia }" @click.stop="playEpisode">
|
||||
<span v-if="!playerIsStartingForThisMedia" class="material-icons text-6xl text-black/80">{{ streamIsPlaying ? 'pause_circle' : 'play_circle_filled' }}</span>
|
||||
<span v-if="!playerIsStartingForThisMedia" class="material-symbols fill text-6xl text-black/80">{{ streamIsPlaying ? 'pause_circle' : 'play_circle' }}</span>
|
||||
<div v-else class="text-fg absolute top-0 left-0 w-full h-full flex items-center justify-center bg-black/80 rounded-full overflow-hidden">
|
||||
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
@@ -63,17 +63,17 @@
|
||||
|
||||
<!-- Downloaded icon -->
|
||||
<div v-if="showHasLocalDownload" class="absolute right-0 top-0 z-20" :style="{ top: (isPodcast || (seriesSequence && showSequence) ? 1.75 : 0.375) * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
|
||||
<span class="material-symbols text-2xl text-success">download_done</span>
|
||||
</div>
|
||||
|
||||
<!-- Error widget -->
|
||||
<div v-if="showError" :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
|
||||
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
|
||||
<div v-if="showError" :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300 absolute bottom-4 left-0 z-10">
|
||||
<span class="material-symbols text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
|
||||
</div>
|
||||
|
||||
<!-- rss feed icon -->
|
||||
<div v-if="rssFeed" class="absolute text-success top-0 left-0 z-10" :style="{ padding: 0.375 * sizeMultiplier + 'rem' }">
|
||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.5 + 'rem' }">rss_feed</span>
|
||||
<span class="material-symbols" :style="{ fontSize: sizeMultiplier * 1.5 + 'rem' }">rss_feed</span>
|
||||
</div>
|
||||
|
||||
<!-- Series sequence -->
|
||||
@@ -94,8 +94,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Podcast Num Episodes Incomplete -->
|
||||
<div v-else-if="numEpisodesIncomplete && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
||||
<p class="text-white" :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodesIncomplete }}</p>
|
||||
<div v-else-if="numEpisodesIncomplete && !isSelectionMode" class="absolute rounded-full bg-yellow-400 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
||||
<p class="text-black" :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodesIncomplete }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -153,10 +153,6 @@ export default {
|
||||
isLocal() {
|
||||
return !!this._libraryItem.isLocal
|
||||
},
|
||||
isLocalOnly() {
|
||||
// Local item with no server match
|
||||
return this.isLocal && !this._libraryItem.libraryItemId
|
||||
},
|
||||
media() {
|
||||
return this._libraryItem.media || {}
|
||||
},
|
||||
|
||||
@@ -21,11 +21,17 @@
|
||||
<p class="truncate text-fg-muted" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-fg-muted" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
<p v-if="duration" class="truncate text-fg-muted" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ $elapsedPretty(duration) }}</p>
|
||||
<p v-if="episodes" class="truncate text-fg-muted" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ episodes }}</p>
|
||||
|
||||
<p v-if="numEpisodesIncomplete" class="truncate text-fg-muted" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">
|
||||
{{ $getString('LabelNumEpisodesIncomplete', [numEpisodes, numEpisodesIncomplete]) }}
|
||||
</p>
|
||||
<p v-else-if="numEpisodes" class="truncate text-fg-muted" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">
|
||||
{{ $getString('LabelNumEpisodes', [numEpisodes]) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="localLibraryItem || isLocal" class="absolute top-0 right-0 z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
|
||||
<span class="material-symbols text-2xl text-success">download_done</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,10 +93,6 @@ export default {
|
||||
isLocal() {
|
||||
return !!this._libraryItem.isLocal
|
||||
},
|
||||
isLocalOnly() {
|
||||
// Local item with no server match
|
||||
return this.isLocal && !this._libraryItem.libraryItemId
|
||||
},
|
||||
media() {
|
||||
return this._libraryItem.media || {}
|
||||
},
|
||||
@@ -106,12 +108,13 @@ export default {
|
||||
isPodcast() {
|
||||
return this.mediaType === 'podcast'
|
||||
},
|
||||
episodes() {
|
||||
if (this.isPodcast) {
|
||||
return this.$getString('LabelNumEpisodes', [this.media.numEpisodes])
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
numEpisodes() {
|
||||
if (this.isLocal && this.isPodcast && this.media.episodes) return this.media.episodes.length
|
||||
return this.media.numEpisodes
|
||||
},
|
||||
numEpisodesIncomplete() {
|
||||
if (this.isLocal) return 0
|
||||
return this._libraryItem.numEpisodesIncomplete || 0
|
||||
},
|
||||
placeholderUrl() {
|
||||
return '/book_placeholder.jpg'
|
||||
@@ -356,4 +359,4 @@ export default {
|
||||
min-width: var(--list-card-cover-width);
|
||||
max-width: var(--list-card-cover-width);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<div class="w-10 h-10 flex items-center justify-center">
|
||||
<span class="material-icons text-2xl text-gray-200">record_voice_over</span>
|
||||
<span class="material-symbols text-2xl text-gray-200">record_voice_over</span>
|
||||
</div>
|
||||
<div class="flex-grow px-2 narratorSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ narrator }}</p>
|
||||
@@ -31,4 +31,4 @@ export default {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<div class="w-10 h-10 flex items-center justify-center">
|
||||
<span class="material-icons text-2xl text-fg">local_offer</span>
|
||||
<span class="material-symbols fill text-2xl text-fg">sell</span>
|
||||
</div>
|
||||
<div class="flex-grow px-2 tagSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ tag }}</p>
|
||||
@@ -31,4 +31,4 @@ export default {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -3,12 +3,19 @@
|
||||
<div v-show="!loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg px-4 py-6 w-full">
|
||||
<!-- list of server connection configs -->
|
||||
<template v-if="!showForm">
|
||||
<div v-for="config in serverConnectionConfigs" :key="config.id" class="flex items-center py-4 my-1 border-b border-fg/10 relative" @click="connectToServer(config)">
|
||||
<span class="material-icons-outlined text-xl text-fg-muted">dns</span>
|
||||
<p class="pl-3 pr-6 text-base text-fg">{{ config.name }}</p>
|
||||
<div v-for="config in serverConnectionConfigs" :key="config.id" class="border-b border-fg/10 py-4">
|
||||
<div class="flex items-center my-1 relative" @click="connectToServer(config)">
|
||||
<span class="material-symbols text-xl text-fg-muted">dns</span>
|
||||
<p class="pl-3 pr-6 text-base text-fg">{{ config.name }}</p>
|
||||
|
||||
<div class="absolute top-0 right-0 h-full px-4 flex items-center" @click.stop="editServerConfig(config)">
|
||||
<span class="material-icons text-lg text-fg-muted">more_vert</span>
|
||||
<div class="absolute top-0 right-0 h-full px-4 flex items-center" @click.stop="editServerConfig(config)">
|
||||
<span class="material-symbols text-2xl text-fg-muted">more_vert</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- warning message if server connection config is using an old user id -->
|
||||
<div v-if="!checkIdUuid(config.userId)" class="flex flex-nowrap justify-between items-center space-x-4 pt-4">
|
||||
<p class="text-xs text-warning">{{ $strings.MessageOldServerConnectionWarning }}</p>
|
||||
<ui-btn class="text-xs whitespace-nowrap" :padding-x="2" :padding-y="1" @click="showOldUserIdWarningDialog">{{ $strings.LabelMoreInfo }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-1 py-4 w-full">
|
||||
@@ -20,7 +27,7 @@
|
||||
<!-- server address input -->
|
||||
<form v-if="!showAuth" @submit.prevent="submit" novalidate class="w-full">
|
||||
<div v-if="serverConnectionConfigs.length" class="flex items-center mb-4" @click="showServerList">
|
||||
<span class="material-icons text-fg-muted">arrow_back</span>
|
||||
<span class="material-symbols text-fg-muted">arrow_back</span>
|
||||
</div>
|
||||
<h2 class="text-lg leading-7 mb-2">{{ $strings.LabelServerAddress }}</h2>
|
||||
<ui-text-input v-model="serverConfig.address" :disabled="processing || !networkConnected || !!serverConfig.id" placeholder="http://55.55.55.55:13378" type="url" class="w-full h-10" />
|
||||
@@ -31,13 +38,13 @@
|
||||
<!-- username/password and auth methods -->
|
||||
<template v-else>
|
||||
<div v-if="serverConfig.id" class="flex items-center mb-4" @click="showServerList">
|
||||
<span class="material-icons text-fg-muted">arrow_back</span>
|
||||
<span class="material-symbols text-fg-muted">arrow_back</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<p class="text-fg-muted">{{ serverConfig.address }}</p>
|
||||
<div class="flex-grow" />
|
||||
<span v-if="!serverConfig.id" class="material-icons" style="font-size: 1.1rem" @click="editServerAddress">edit</span>
|
||||
<span v-if="!serverConfig.id" class="material-symbols" style="font-size: 1.1rem" @click="editServerAddress">edit</span>
|
||||
</div>
|
||||
<div class="w-full h-px bg-fg/10 my-2" />
|
||||
<form v-if="isLocalAuthEnabled" @submit.prevent="submitAuth" class="pt-3">
|
||||
@@ -57,7 +64,7 @@
|
||||
|
||||
<!-- auth error message -->
|
||||
<div v-show="error" class="w-full rounded-lg bg-red-600 bg-opacity-10 border border-error border-opacity-50 py-3 px-2 flex items-center mt-4">
|
||||
<span class="material-icons mr-2 text-error" style="font-size: 1.1rem">warning</span>
|
||||
<span class="material-symbols mr-2 text-error" style="font-size: 1.1rem">warning</span>
|
||||
<p class="text-error">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,6 +148,16 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
showOldUserIdWarningDialog() {
|
||||
Dialog.alert({
|
||||
title: 'Old Server Connection Warning',
|
||||
message: this.$strings.MessageOldServerConnectionWarningHelp,
|
||||
cancelText: this.$strings.ButtonOk
|
||||
})
|
||||
},
|
||||
checkIdUuid(userId) {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(userId)
|
||||
},
|
||||
/**
|
||||
* Initiates the login process using OpenID via OAuth2.0.
|
||||
* 1. Verifying the server's address
|
||||
|
||||
@@ -39,9 +39,6 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
_author() {
|
||||
return this.author || {}
|
||||
},
|
||||
@@ -59,11 +56,15 @@ export default {
|
||||
},
|
||||
imgSrc() {
|
||||
if (!this.imagePath || !this.serverAddress) return null
|
||||
const urlQuery = new URLSearchParams({ ts: this.updatedAt })
|
||||
if (this.$store.getters.getDoesServerImagesRequireToken) {
|
||||
urlQuery.append('token', this.$store.getters['user/getToken'])
|
||||
}
|
||||
if (process.env.NODE_ENV !== 'production' && this.serverAddress.startsWith('http://192.168')) {
|
||||
// Testing
|
||||
return `http://localhost:3333/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
|
||||
return `http://localhost:3333/api/authors/${this.authorId}/image?${urlQuery.toString()}`
|
||||
}
|
||||
return `${this.serverAddress}/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
|
||||
return `${this.serverAddress}/api/authors/${this.authorId}/image?${urlQuery.toString()}`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -141,9 +141,6 @@ export default {
|
||||
},
|
||||
authorBottom() {
|
||||
return 0.75 * this.sizeMultiplier
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
|
||||
|
||||
<a v-if="!imageFailed && showOpenNewTab && isHovering" :href="cover" @click.stop target="_blank" class="absolute bg-primary flex items-center justify-center shadow-sm rounded-full hover:scale-110 transform duration-100" :style="{ top: sizeMultiplier * 0.5 + 'rem', right: sizeMultiplier * 0.5 + 'rem', width: 2.5 * sizeMultiplier + 'rem', height: 2.5 * sizeMultiplier + 'rem' }">
|
||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.75 + 'rem' }">open_in_new</span>
|
||||
<span class="material-symbols" :style="{ fontSize: sizeMultiplier * 1.75 + 'rem' }">open_in_new</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -100,4 +100,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -62,7 +62,7 @@ export default {
|
||||
items.push({
|
||||
to: '/bookshelf/add-podcast',
|
||||
routeName: 'bookshelf-add-podcast',
|
||||
iconPack: 'material-icons',
|
||||
iconPack: 'material-symbols',
|
||||
icon: 'podcasts',
|
||||
iconClass: 'text-xl',
|
||||
text: this.$strings.ButtonAdd
|
||||
@@ -97,7 +97,7 @@ export default {
|
||||
{
|
||||
to: '/bookshelf/collections',
|
||||
routeName: 'bookshelf-collections',
|
||||
iconPack: 'material-icons-outlined',
|
||||
iconPack: 'material-symbols',
|
||||
icon: 'collections_bookmark',
|
||||
iconClass: 'text-xl',
|
||||
text: this.$strings.ButtonCollections
|
||||
@@ -117,8 +117,9 @@ export default {
|
||||
items.push({
|
||||
to: '/bookshelf/playlists',
|
||||
routeName: 'bookshelf-playlists',
|
||||
iconPack: 'material-icons',
|
||||
iconPack: 'material-symbols',
|
||||
icon: 'queue_music',
|
||||
iconClass: 'text-2xl',
|
||||
text: this.$strings.ButtonPlaylists
|
||||
})
|
||||
}
|
||||
@@ -149,4 +150,4 @@ export default {
|
||||
#bookshelf-navbar a {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
<div class="w-full h-9 bg-bg relative z-20">
|
||||
<div id="bookshelf-toolbar" class="absolute top-0 left-0 w-full h-full z-20 flex items-center px-2">
|
||||
<div class="flex items-center w-full text-sm">
|
||||
<p v-show="!selectedSeriesName" class="pt-1">{{ totalEntities }} {{ entityTitle }}</p>
|
||||
<p v-show="selectedSeriesName" class="ml-2 pt-1">{{ selectedSeriesName }} ({{ totalEntities }})</p>
|
||||
<p v-show="!selectedSeriesName" class="pt-1">{{ $formatNumber(totalEntities) }} {{ entityTitle }}</p>
|
||||
<p v-show="selectedSeriesName" class="ml-2 pt-1">{{ selectedSeriesName }} ({{ $formatNumber(totalEntities) }})</p>
|
||||
<div class="flex-grow" />
|
||||
<span v-if="page == 'library' || seriesBookPage" class="material-icons px-2" @click="changeView">{{ !bookshelfListView ? 'view_list' : 'grid_view' }}</span>
|
||||
<span v-if="page == 'library' || seriesBookPage" class="material-symbols text-2xl px-2" @click="changeView">{{ !bookshelfListView ? 'view_list' : 'grid_view' }}</span>
|
||||
<template v-if="page === 'library'">
|
||||
<div class="relative flex items-center px-2">
|
||||
<span class="material-icons" @click="showFilterModal = true">filter_alt</span>
|
||||
<span class="material-symbols text-2xl" @click="showFilterModal = true">filter_alt</span>
|
||||
<div v-show="hasFilters" class="absolute top-0 right-2 w-2 h-2 rounded-full bg-success border border-green-300 shadow-sm z-10 pointer-events-none" />
|
||||
</div>
|
||||
<span class="material-icons px-2" @click="showSortModal = true">sort</span>
|
||||
<span class="material-symbols text-2xl px-2" @click="showSortModal = true">sort</span>
|
||||
</template>
|
||||
<span v-if="seriesBookPage" class="material-icons px-2" @click="downloadSeries">download</span>
|
||||
<span v-if="(page == 'library' && isBookLibrary) || seriesBookPage" class="material-icons px-2" @click="showMoreMenuDialog = true">more_vert</span>
|
||||
<span v-if="seriesBookPage" class="material-symbols text-2xl px-2" @click="downloadSeries">download</span>
|
||||
<span v-if="(page == 'library' && isBookLibrary) || seriesBookPage" class="material-symbols text-2xl px-2" @click="showMoreMenuDialog = true">more_vert</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,22 +6,25 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="
|
||||
<div
|
||||
class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center"
|
||||
@click="
|
||||
show = false
|
||||
manualTimerModal = false
|
||||
">
|
||||
"
|
||||
>
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<div v-if="manualTimerModal" class="p-4">
|
||||
<div class="flex mb-4" @click="manualTimerModal = false">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
<span class="material-symbols text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<div class="flex my-2 justify-between">
|
||||
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px">
|
||||
<span class="material-icons">remove</span>
|
||||
<span class="material-symbols">remove</span>
|
||||
</ui-btn>
|
||||
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
|
||||
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px">
|
||||
<span class="material-icons">add</span>
|
||||
<span class="material-symbols">add</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">{{ $strings.ButtonSetTimer }}</ui-btn>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="w-full h-full p-4" v-if="showBookmarkTitleInput">
|
||||
<div class="flex mb-4 items-center">
|
||||
<div class="w-9 h-9 flex items-center justify-center rounded-full hover:bg-white hover:bg-opacity-10 cursor-pointer" @click.stop="showBookmarkTitleInput = false">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
<span class="material-symbols text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<p class="text-xl pl-2">{{ selectedBookmark ? 'Edit Bookmark' : 'New Bookmark' }}</p>
|
||||
<div class="flex-grow" />
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="canCreateBookmark && !showBookmarkTitleInput" class="flex px-4 py-2 items-center text-center justify-between border-b border-fg/10 bg-success cursor-pointer text-white text-opacity-80 sticky bottom-0 left-0 w-full" @click.stop="createBookmark">
|
||||
<span class="material-icons">add</span>
|
||||
<span class="material-symbols">add</span>
|
||||
<p class="text-base pl-2">{{ $strings.ButtonCreateBookmark }}</p>
|
||||
<p class="text-sm font-mono">{{ this.$secondsToTimestamp(currentTime / _playbackRate) }}</p>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<p class="text-sm text-gray-400">{{ value }}</p>
|
||||
|
||||
<div class="absolute top-0 bottom-0 right-0 h-full p-4 flex items-center justify-center text-error">
|
||||
<button @click="removeHeader(key)"><span class="material-icons text-lg">delete</span></button>
|
||||
<button @click="removeHeader(key)"><span class="material-symbols text-lg">delete</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<slot :name="item.value" :item="item" :selected="item.value === selected">
|
||||
<li :key="item.value" :ref="`item-${item.value}`" class="text-fg select-none relative cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" :style="{ paddingTop: itemPaddingY, paddingBottom: itemPaddingY }" role="option" @click="clickedOption(item.value)">
|
||||
<div class="relative flex items-center px-3">
|
||||
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-fg text-opacity-80">{{ item.icon }}</span>
|
||||
<span v-if="item.icon" class="material-symbols text-xl mr-2 text-fg text-opacity-80">{{ item.icon }}</span>
|
||||
<p class="font-normal block truncate text-base text-fg text-opacity-80">{{ item.text }}</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<span class="font-normal ml-3 block truncate text-lg">{{ item.text }}</span>
|
||||
</div>
|
||||
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
|
||||
<span class="material-icons text-2xl">arrow_right</span>
|
||||
<span class="material-symbols text-2xl">arrow_right</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
@@ -22,7 +22,7 @@
|
||||
<ul v-show="sublist" class="h-full w-full rounded-lg" role="listbox" aria-labelledby="listbox-label">
|
||||
<li class="text-fg select-none relative py-3 pl-9 cursor-pointer" role="option" @click="sublist = null">
|
||||
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
|
||||
<span class="material-icons text-2xl">arrow_left</span>
|
||||
<span class="material-symbols text-2xl">arrow_left</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-normal ml-3 block truncate text-lg">{{ $strings.ButtonBack }}</span>
|
||||
|
||||
@@ -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] min-h-[400px]' : '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="threeQuartersScreen ? 'h-[75vh] 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>
|
||||
@@ -11,7 +11,7 @@ export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
processing: Boolean,
|
||||
halfScreen: Boolean
|
||||
threeQuartersScreen: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="absolute top-0 left-0 w-full h-40 bg-gradient-to-b from-black to-transparent opacity-90 pointer-events-none" />
|
||||
|
||||
<div class="absolute z-40 top-11 right-4 h-10 w-10 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
|
||||
<span class="material-icons text-4xl">close</span>
|
||||
<span class="material-symbols text-4xl">close</span>
|
||||
</div>
|
||||
<slot name="outer" />
|
||||
<div ref="content" style="min-height: 200px" class="relative text-fg max-h-screen" :style="{ height: modalHeight, width: modalWidth, maxWidth: maxWidth }" v-click-outside="clickBg">
|
||||
@@ -110,4 +110,4 @@ export default {
|
||||
this.$eventBus.$off('close-modal', this.closeModalEvt)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<span class="font-normal ml-3 block truncate text-lg">{{ item.text }}</span>
|
||||
</div>
|
||||
<span v-if="item.value === selected" class="text-yellow-300 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-icons text-3xl">{{ descending ? 'south' : 'north' }}</span>
|
||||
<span class="material-symbols text-3xl">{{ descending ? 'south' : 'north' }}</span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
@@ -86,6 +86,10 @@ export default {
|
||||
text: this.$strings.LabelSize,
|
||||
value: 'size'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelNumberOfEpisodes,
|
||||
value: 'media.numTracks'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelFileBirthtime,
|
||||
value: 'birthtimeMs'
|
||||
@@ -115,6 +119,10 @@ export default {
|
||||
{
|
||||
text: this.$strings.LabelEpisode,
|
||||
value: 'episode'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelFilename,
|
||||
value: 'audioFile.metadata.filename'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -19,13 +19,13 @@
|
||||
</ul>
|
||||
<div class="flex items-center justify-center py-3 border-t border-fg/10">
|
||||
<button :disabled="!canDecrement" @click="decrement" class="icon-num-btn w-8 h-8 text-fg-muted rounded border border-border flex items-center justify-center">
|
||||
<span class="material-icons">remove</span>
|
||||
<span class="material-symbols">remove</span>
|
||||
</button>
|
||||
<div class="w-24 text-center">
|
||||
<p class="text-xl">{{ playbackRate }}<span class="text-lg">⨯</span></p>
|
||||
</div>
|
||||
<button :disabled="!canIncrement" @click="increment" class="icon-num-btn w-8 h-8 text-fg-muted rounded border border-border flex items-center justify-center">
|
||||
<span class="material-icons">add</span>
|
||||
<span class="material-symbols">add</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,4 +118,4 @@ button.icon-num-btn:disabled::before {
|
||||
button.icon-num-btn:disabled span {
|
||||
color: #777;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<template v-for="(episode, index) in episodes">
|
||||
<div :key="index" class="relative" :class="itemEpisodeMap[episode.enclosure.url] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'bg-success bg-opacity-10' : index % 2 == 0 ? 'bg-primary bg-opacity-25' : 'bg-primary bg-opacity-5'" @click="selectEpisode(episode, index)">
|
||||
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
||||
<span v-if="itemEpisodeMap[episode.enclosure.url]" class="material-icons text-success text-xl">download_done</span>
|
||||
<span v-if="itemEpisodeMap[episode.enclosure.url]" class="material-symbols text-success text-xl">download_done</span>
|
||||
<ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
|
||||
</div>
|
||||
<div class="pl-9 pr-2 py-2 border-b border-white border-opacity-10">
|
||||
@@ -134,4 +134,4 @@ export default {
|
||||
max-height: calc(100vh - 150px);
|
||||
margin-top: 5px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
</template>
|
||||
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-fg/20" style="max-height: 75%" @click.stop>
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="folder in localFolders">
|
||||
<li :key="folder.id" :id="`folder-${folder.id}`" class="text-gray-50 select-none relative py-5" role="option" @click="clickedOption(folder)">
|
||||
<li :key="folder.id" :id="`folder-${folder.id}`" class="text-fg select-none relative py-5" role="option" @click="clickedOption(folder)">
|
||||
<div class="relative flex items-center pl-3" style="padding-right: 4.5rem">
|
||||
<span class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">folder</span>
|
||||
<p class="font-normal block truncate text-sm text-white text-opacity-80">{{ folder.name }}</p>
|
||||
<span class="material-symbols text-xl mr-2 text-fg text-opacity-80">folder</span>
|
||||
<p class="font-normal block truncate text-sm text-fg text-opacity-80">{{ folder.name }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@@ -16,12 +16,12 @@
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<div v-if="manualTimerModal" class="p-4">
|
||||
<div class="flex mb-4" @click="manualTimerModal = false">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
<span class="material-symbols text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<div class="flex my-2 justify-between">
|
||||
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
|
||||
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-symbols">remove</span></ui-btn>
|
||||
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
|
||||
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
|
||||
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-symbols">add</span></ui-btn>
|
||||
</div>
|
||||
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">{{ $strings.ButtonSetTimer }}</ui-btn>
|
||||
</div>
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-border" style="max-height: 75%" @click.stop>
|
||||
<div v-if="manualTimerModal" class="p-4">
|
||||
<div class="flex mb-4" @click="manualTimerModal = false">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
<span class="material-symbols text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<div class="flex my-2 justify-between">
|
||||
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
|
||||
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-symbols text-lg">remove</span></ui-btn>
|
||||
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
|
||||
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
|
||||
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-symbols text-lg">add</span></ui-btn>
|
||||
</div>
|
||||
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">{{ $strings.ButtonSetTimer }}</ui-btn>
|
||||
</div>
|
||||
@@ -40,9 +40,9 @@
|
||||
</ul>
|
||||
<div v-else class="p-4">
|
||||
<div class="flex my-2 justify-between">
|
||||
<ui-btn @click="decreaseSleepTime" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
|
||||
<ui-btn @click="decreaseSleepTime" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-symbols text-lg">remove</span></ui-btn>
|
||||
<p class="text-2xl font-mono text-center">{{ timeRemainingPretty }}</p>
|
||||
<ui-btn @click="increaseSleepTime" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
|
||||
<ui-btn @click="increaseSleepTime" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-symbols text-lg">add</span></ui-btn>
|
||||
</div>
|
||||
|
||||
<ui-btn @click="cancelSleepTimer" class="w-full">{{ isAuto ? $strings.ButtonDisableAutoTimer : $strings.ButtonCancelTimer }}</ui-btn>
|
||||
@@ -85,7 +85,11 @@ export default {
|
||||
return [5, 10, 15, 30, 45, 60, 90]
|
||||
},
|
||||
timeRemainingPretty() {
|
||||
if (this.currentTime <= 0) return '0:00'
|
||||
return this.$secondsToTimestamp(this.currentTime)
|
||||
},
|
||||
isIos() {
|
||||
return this.$platform === 'ios'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -105,7 +109,7 @@ export default {
|
||||
if (this.isAuto) {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: 'Are you sure you want to disable the auto sleep timer? You will need to enable this again in settings.'
|
||||
message: this.$strings.MessageConfirmDisableAutoTimer
|
||||
})
|
||||
if (!value) return
|
||||
}
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
<div :key="bookmark.time" :id="`bookmark-row-${bookmark.time}`" class="flex items-center px-1 py-4 justify-start relative" :class="highlight ? 'bg-bg bg-opacity-60' : ' bg-opacity-20'" @click="click">
|
||||
<div class="flex-grow overflow-hidden px-2">
|
||||
<div class="flex items-center mb-0.5">
|
||||
<i class="material-icons text-lg pr-1 -mb-1" :class="highlight ? 'text-success' : 'text-fg-muted'">{{ highlight ? 'bookmark' : 'bookmark_border' }}</i>
|
||||
<i class="material-symbols text-lg pr-1 -mb-1" :class="{ 'text-success fill': highlight, 'text-fg-muted': !highlight }">bookmark</i>
|
||||
<p class="truncate text-sm">
|
||||
{{ bookmark.title }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm font-mono text-fg-muted flex items-center"><span class="material-icons text-base pl-px pr-1">schedule</span>{{ $secondsToTimestamp(bookmark.time / playbackRate) }}</p>
|
||||
<p class="text-sm font-mono text-fg-muted flex items-center"><span class="material-symbols text-base pl-px pr-1">schedule</span>{{ $secondsToTimestamp(bookmark.time / playbackRate) }}</p>
|
||||
</div>
|
||||
<div class="h-full flex items-center justify-end transform w-16 pr-2" @click.stop>
|
||||
<span class="material-icons text-2xl mr-2 text-fg hover:text-yellow-400" @click.stop="editClick">edit</span>
|
||||
<span class="material-icons text-2xl text-fg hover:text-error" @click.stop="deleteClick">delete</span>
|
||||
<span class="material-symbols text-2xl mr-2 text-fg hover:text-yellow-400" @click.stop="editClick">edit</span>
|
||||
<span class="material-symbols text-2xl text-fg hover:text-error" @click.stop="deleteClick">delete</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -42,4 +42,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div v-if="download.isIncomplete || download.isMissing" class="shadow-sm text-warning flex items-center justify-center rounded-full mr-4">
|
||||
<span class="material-icons">error_outline</span>
|
||||
<span class="material-symbols">error_outline</span>
|
||||
</div>
|
||||
<button v-if="!isMissing" class="shadow-sm text-accent flex items-center justify-center rounded-full" @click.stop="playDownload">
|
||||
<span class="material-icons" style="font-size: 2rem">play_arrow</span>
|
||||
<span class="material-symbols fill" style="font-size: 2rem">play_arrow</span>
|
||||
</button>
|
||||
<div class="shadow-sm text-error flex items-center justify-center rounded-ful ml-4" @click.stop="clickDelete">
|
||||
<span class="material-icons" style="font-size: 1.2rem">delete</span>
|
||||
<span class="material-symbols" style="font-size: 1.2rem">delete</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -49,4 +49,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="flex items-end justify-between h-24 px-4 pb-2">
|
||||
<h1 class="text-lg">{{ $strings.LabelAddToPlaylist }}</h1>
|
||||
<button class="flex" @click="show = false">
|
||||
<span class="material-icons">close</span>
|
||||
<span class="material-symbols">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<div class="w-full px-4">
|
||||
<div class="flex mb-4 items-center">
|
||||
<div class="w-9 h-9 flex items-center justify-center rounded-full cursor-pointer" @click.stop="showPlaylistNameInput = false">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
<span class="material-symbols text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<p class="text-xl pl-2 leading-none">{{ $strings.HeaderNewPlaylist }}</p>
|
||||
<div class="flex-grow" />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="flex items-end justify-end h-24 pr-4 pb-2">
|
||||
<!-- <h1 class="text-lg">RSS Feed</h1> -->
|
||||
<button class="flex" @click="show = false">
|
||||
<span class="material-icons">close</span>
|
||||
<span class="material-symbols">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<ui-text-input v-model="currentFeed.feedUrl" class="text-sm" readonly />
|
||||
|
||||
<span class="material-icons absolute right-2 bottom-2 p-0.5 text-base" :class="linkCopied ? 'text-success' : 'text-fg-muted'" @click="copyToClipboard(currentFeed.feedUrl)">{{ linkCopied ? 'done' : 'content_copy' }}</span>
|
||||
<span class="material-symbols absolute right-2 bottom-2 p-0.5 text-base" :class="linkCopied ? 'text-success' : 'text-fg-muted'" @click="copyToClipboard(currentFeed.feedUrl)">{{ linkCopied ? 'check' : 'content_copy' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="currentFeed.meta" class="mt-5">
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fixed left-0 h-8 w-full bg-bg px-4 flex items-center text-fg-muted" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
|
||||
<div class="fixed left-0 h-8 w-full bg-bg px-4 flex items-center text-fg-muted transition-transform" :class="showingToolbar ? 'translate-y-0' : isPlayerOpen ? 'translate-y-[120px]' : 'translate-y-32'" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
|
||||
<div class="flex-grow" />
|
||||
<p class="text-xs">{{ page }} / {{ numPages }}</p>
|
||||
</div>
|
||||
@@ -49,7 +49,8 @@ export default {
|
||||
default: () => {}
|
||||
},
|
||||
isLocal: Boolean,
|
||||
keepProgress: Boolean
|
||||
keepProgress: Boolean,
|
||||
showingToolbar: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -372,14 +373,12 @@ export default {
|
||||
|
||||
<style scoped>
|
||||
#comic-reader {
|
||||
height: calc(100% - 36px);
|
||||
max-height: calc(100% - 36px);
|
||||
padding-top: 36px;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
.reader-player-open #comic-reader {
|
||||
height: calc(100% - 156px);
|
||||
max-height: calc(100% - 156px);
|
||||
padding-top: 36px;
|
||||
height: calc(100% - 120px);
|
||||
max-height: calc(100% - 120px);
|
||||
}
|
||||
|
||||
.comicimg {
|
||||
@@ -390,4 +389,4 @@ export default {
|
||||
width: 100vw;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div id="epub-frame" class="w-full">
|
||||
<div id="viewer" class="h-full w-full"></div>
|
||||
|
||||
<div class="fixed left-0 h-8 w-full px-4 flex items-center" :class="isLightTheme ? 'bg-white text-black' : 'bg-[#232323] text-white/80'" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
|
||||
<div class="fixed left-0 h-8 w-full px-4 flex items-center" :class="isLightTheme ? 'bg-white text-black' : isDarkTheme ? 'bg-[#232323] text-white/80' : 'bg-black text-white/80'" :style="{ bottom: isPlayerOpen ? '120px' : '0px' }">
|
||||
<p v-if="totalLocations" class="text-xs text-slate-600">Location {{ currentLocationNum }} of {{ totalLocations }}</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="text-xs">{{ progress }}%</p>
|
||||
@@ -105,17 +105,21 @@ export default {
|
||||
isLightTheme() {
|
||||
return this.ereaderSettings.theme === 'light'
|
||||
},
|
||||
isDarkTheme() {
|
||||
return this.ereaderSettings.theme === 'dark'
|
||||
},
|
||||
themeRules() {
|
||||
const isDark = this.ereaderSettings.theme === 'dark'
|
||||
const fontColor = isDark ? '#fff' : '#000'
|
||||
const backgroundColor = isDark ? 'rgb(35 35 35)' : 'rgb(255, 255, 255)'
|
||||
const isBlack = this.ereaderSettings.theme === 'black'
|
||||
const fontColor = isDark ? '#fff' : isBlack ? '#fff' : '#000'
|
||||
const backgroundColor = isDark ? 'rgb(35 35 35)' : isBlack ? 'rgb(0 0 0)' : 'rgb(255, 255, 255)'
|
||||
|
||||
return {
|
||||
'*': {
|
||||
color: `${fontColor}!important`,
|
||||
'background-color': `${backgroundColor}!important`,
|
||||
'line-height': this.ereaderSettings.lineSpacing + '%!important',
|
||||
'-webkit-text-stroke': this.ereaderSettings.textStroke/100 + 'px ' + fontColor + '!important'
|
||||
'-webkit-text-stroke': this.ereaderSettings.textStroke / 100 + 'px ' + fontColor + '!important'
|
||||
},
|
||||
a: {
|
||||
color: `${fontColor}!important`
|
||||
@@ -427,7 +431,7 @@ export default {
|
||||
document.removeEventListener('orientationchange', this.screenOrientationChange)
|
||||
}
|
||||
window.removeEventListener('resize', this.screenOrientationChange)
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
<div class="w-full h-full relative">
|
||||
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
|
||||
<div class="flex items-center justify-center h-full w-1/2">
|
||||
<span class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
|
||||
<span class="material-symbols text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
|
||||
<div class="flex items-center justify-center h-full w-1/2 ml-auto">
|
||||
<span class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
|
||||
<span class="material-symbols text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<div v-if="show" :data-theme="ereaderTheme" class="group fixed top-0 left-0 right-0 layout-wrapper w-full z-40 pt-8 data-[theme=dark]:bg-[#232323] data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': isPlayerOpen }">
|
||||
<div v-if="show" :data-theme="ereaderTheme" class="group fixed top-0 left-0 right-0 layout-wrapper w-full z-40 pt-8 data-[theme=black]:bg-black data-[theme=black]:text-white data-[theme=dark]:bg-[#232323] data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': isPlayerOpen }">
|
||||
<!-- toolbar -->
|
||||
<div class="h-32 pt-10 w-full px-2 fixed top-0 left-0 z-30 transition-transform bg-bg text-fg" :class="showingToolbar ? 'translate-y-0' : '-translate-y-32'" :style="{ boxShadow: showingToolbar ? '0px 8px 8px #11111155' : '' }" @touchstart.stop @mousedown.stop @touchend.stop @mouseup.stop>
|
||||
<div class="flex items-center mb-2">
|
||||
<button type="button" class="inline-flex mx-2" @click.stop="show = false">
|
||||
<span class="material-icons-outlined text-3xl text-fg">chevron_left</span>
|
||||
<span class="material-symbols text-3xl text-fg">chevron_left</span>
|
||||
</button>
|
||||
<div class="flex-grow" />
|
||||
<button v-if="isComic || isEpub" type="button" class="inline-flex mx-2" @click.stop="clickTOCBtn">
|
||||
<span class="material-icons-outlined text-2xl text-fg">format_list_bulleted</span>
|
||||
<span class="material-symbols text-2xl text-fg">format_list_bulleted</span>
|
||||
</button>
|
||||
<button v-if="isEpub" type="button" class="inline-flex mx-2" @click.stop="clickSettingsBtn">
|
||||
<span class="material-icons text-2xl text-fg">settings</span>
|
||||
<span class="material-symbols text-2xl text-fg">settings</span>
|
||||
</button>
|
||||
<button v-if="comicHasMetadata" type="button" class="inline-flex mx-2" @click.stop="clickMetadataBtn">
|
||||
<span class="material-icons text-2xl text-fg">more</span>
|
||||
<span class="material-symbols text-2xl text-fg">more-vert</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -22,14 +22,14 @@
|
||||
</div>
|
||||
|
||||
<!-- ereader -->
|
||||
<component v-if="readerComponentName" ref="readerComponent" :is="readerComponentName" :url="ebookUrl" :library-item="selectedLibraryItem" :is-local="isLocal" :keep-progress="keepProgress" @touchstart="touchstart" @touchend="touchend" @loaded="readerLoaded" @hook:mounted="readerMounted" />
|
||||
<component v-if="readerComponentName" ref="readerComponent" :is="readerComponentName" :url="ebookUrl" :library-item="selectedLibraryItem" :is-local="isLocal" :keep-progress="keepProgress" :showing-toolbar="showingToolbar" @touchstart="touchstart" @touchend="touchend" @loaded="readerLoaded" @hook:mounted="readerMounted" />
|
||||
|
||||
<!-- table of contents modal -->
|
||||
<modals-fullscreen-modal v-model="showTOCModal" :theme="ereaderTheme">
|
||||
<div class="flex items-end justify-between h-20 px-4 pb-2">
|
||||
<h1 class="text-lg">{{ $strings.HeaderTableOfContents }}</h1>
|
||||
<button class="flex" @click.stop="showTOCModal = false">
|
||||
<span class="material-icons">close</span>
|
||||
<span class="material-symbols">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -54,15 +54,15 @@
|
||||
</modals-fullscreen-modal>
|
||||
|
||||
<!-- ereader settings modal -->
|
||||
<modals-fullscreen-modal v-model="showSettingsModal" :theme="ereaderTheme">
|
||||
<modals-fullscreen-modal v-model="showSettingsModal" :theme="ereaderTheme" threeQuartersScreen>
|
||||
<div style="box-shadow: 0px -8px 8px #11111155">
|
||||
<div class="flex items-end justify-between h-20 px-4 pb-2 mb-6">
|
||||
<div class="flex items-end justify-between h-14 px-4 pb-2 mb-6">
|
||||
<h1 class="text-lg">{{ $strings.HeaderEreaderSettings }}</h1>
|
||||
<button class="flex" @click="showSettingsModal = false">
|
||||
<span class="material-icons">close</span>
|
||||
<span class="material-symbols">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(100vh-85px)]">
|
||||
<div class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(75vh-85px)]">
|
||||
<div class="w-full h-full px-4">
|
||||
<div class="flex items-center mb-6">
|
||||
<div class="w-32">
|
||||
@@ -106,7 +106,7 @@
|
||||
</div>
|
||||
<ui-toggle-btns v-model="ereaderSettings.navigateWithVolumeWhilePlaying" name="navigate-volume-playing" :items="onOffToggleButtonItems" @input="settingsUpdated" />
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center mb-6">
|
||||
<div class="w-32">
|
||||
<p class="text-sm">{{ $strings.LabelKeepScreenAwake }}</p>
|
||||
</div>
|
||||
@@ -238,6 +238,10 @@ export default {
|
||||
},
|
||||
themeItems() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelThemeBlack,
|
||||
value: 'black'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelThemeDark,
|
||||
value: 'dark'
|
||||
|
||||
@@ -33,22 +33,22 @@
|
||||
<div class="flex justify-between pt-12">
|
||||
<div>
|
||||
<p class="text-sm text-center">{{ $strings.LabelStatsWeekListening }}</p>
|
||||
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ totalMinutesListeningThisWeek }}</p>
|
||||
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ $formatNumber(totalMinutesListeningThisWeek) }}</p>
|
||||
<p class="text-sm text-center">{{ $strings.LabelStatsMinutes }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-center">{{ $strings.LabelStatsDailyAverage }}</p>
|
||||
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ averageMinutesPerDay }}</p>
|
||||
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ $formatNumber(averageMinutesPerDay) }}</p>
|
||||
<p class="text-sm text-center">{{ $strings.LabelStatsMinutes }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-center">{{ $strings.LabelStatsBestDay }}</p>
|
||||
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ mostListenedDay }}</p>
|
||||
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ $formatNumber(mostListenedDay) }}</p>
|
||||
<p class="text-sm text-center">{{ $strings.LabelStatsMinutes }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-center">{{ $strings.LabelStatsDays }}</p>
|
||||
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ daysInARow }}</p>
|
||||
<p class="text-5xl font-semibold text-center" style="line-height: 0.85">{{ $formatNumber(daysInARow) }}</p>
|
||||
<p class="text-sm text-center">{{ $strings.LabelStatsInARow }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { FileSharer } from '@byteowls/capacitor-filesharer'
|
||||
import { FileSharer } from '@webnativellc/capacitor-filesharer'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@@ -75,7 +75,7 @@ export default {
|
||||
|
||||
const addIcon = (icon, color, fontSize, x, y) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `${fontSize} Material Icons Outlined`
|
||||
ctx.font = `${fontSize} Material Symbols Rounded`
|
||||
ctx.fillText(icon, x, y)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-2 sm:p-4 mb-4">
|
||||
<!-- hack to get icon fonts loaded on init -->
|
||||
<div class="h-0 w-0 overflow-hidden opacity-0">
|
||||
<span class="material-icons-outlined">close</span>
|
||||
<span class="material-symbols">close</span>
|
||||
<span class="abs-icons icon-audiobookshelf" />
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<div class="flex items-center justify-center mb-2 max-w-[800px] mx-auto">
|
||||
<!-- previous button -->
|
||||
<ui-btn small :disabled="!yearInReviewVariant || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant--">
|
||||
<span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||
<span class="material-symbols text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||
<span class="hidden sm:inline-block pr-2">Previous</span>
|
||||
</ui-btn>
|
||||
<!-- share button -->
|
||||
@@ -33,12 +33,12 @@
|
||||
<!-- refresh button -->
|
||||
<ui-btn small :disabled="processingYearInReview" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReview">
|
||||
<span class="hidden sm:inline-block">Refresh</span>
|
||||
<span class="material-icons sm:!hidden text-lg py-px">refresh</span>
|
||||
<span class="material-symbols sm:!hidden text-lg py-px">refresh</span>
|
||||
</ui-btn>
|
||||
<!-- next button -->
|
||||
<ui-btn small :disabled="yearInReviewVariant >= 2 || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant++">
|
||||
<span class="hidden sm:inline-block pl-2">Next</span>
|
||||
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||
<span class="material-symbols text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
<stats-year-in-review ref="yearInReview" :variant="yearInReviewVariant" :year="yearInReviewYear" :processing.sync="processingYearInReview" />
|
||||
@@ -55,7 +55,7 @@
|
||||
<div class="flex items-center justify-center mb-2">
|
||||
<!-- previous button -->
|
||||
<ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant--">
|
||||
<span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||
<span class="material-symbols text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||
<span class="hidden sm:inline-block pr-2">Previous</span>
|
||||
</ui-btn>
|
||||
<!-- share button -->
|
||||
@@ -69,12 +69,12 @@
|
||||
<!-- refresh button -->
|
||||
<ui-btn small :disabled="processingYearInReviewServer" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReviewServer">
|
||||
<span class="hidden sm:inline-block">Refresh</span>
|
||||
<span class="material-icons sm:!hidden text-lg py-px">refresh</span>
|
||||
<span class="material-symbols sm:!hidden text-lg py-px">refresh</span>
|
||||
</ui-btn>
|
||||
<!-- next button -->
|
||||
<ui-btn small :disabled="yearInReviewServerVariant >= 2 || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant++">
|
||||
<span class="hidden sm:inline-block pl-2">Next</span>
|
||||
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||
<span class="material-symbols text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,4 +131,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { FileSharer } from '@byteowls/capacitor-filesharer'
|
||||
import { FileSharer } from '@webnativellc/capacitor-filesharer'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { FileSharer } from '@byteowls/capacitor-filesharer'
|
||||
import { FileSharer } from '@webnativellc/capacitor-filesharer'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@@ -66,7 +66,7 @@ export default {
|
||||
|
||||
const addIcon = (icon, color, fontSize, x, y) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `${fontSize} Material Icons Outlined`
|
||||
ctx.font = `${fontSize} Material Symbols Rounded`
|
||||
ctx.fillText(icon, x, y)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div class="h-10 w-10 rounded-full flex justify-center items-center duration-500" :class="expanded ? 'transform rotate-180' : ''">
|
||||
<span class="material-icons text-3xl">expand_more</span>
|
||||
<span class="material-symbols text-3xl">arrow_drop_down</span>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="slide">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div class="h-10 w-10 rounded-full flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''">
|
||||
<span class="material-icons text-3xl">expand_more</span>
|
||||
<span class="material-symbols text-3xl">arrow_drop_down</span>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="slide">
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
</div>
|
||||
<div class="book-table-content h-full px-2 flex items-center">
|
||||
<div class="max-w-full">
|
||||
<p class="truncate block text-sm">{{ bookTitle }}</p>
|
||||
<p class="truncate block text-sm">{{ bookTitle }} <span v-if="localLibraryItem" class="material-symbols text-success text-base align-text-bottom">download_done</span></p>
|
||||
<p class="truncate block text-fg-muted text-xs">{{ bookAuthor }}</p>
|
||||
<p v-if="media.duration" class="text-xxs text-fg-muted">{{ bookDuration }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-8 min-w-8 flex justify-center">
|
||||
<button v-if="showPlayBtn" class="w-8 h-8 rounded-full border border-white border-opacity-20 flex items-center justify-center" @click.stop.prevent="playClick">
|
||||
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||
<span class="material-symbols text-2xl fill" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
@@ -39,6 +39,9 @@ export default {
|
||||
libraryItemId() {
|
||||
return this.book.id
|
||||
},
|
||||
localLibraryItem() {
|
||||
return this.book.localLibraryItem
|
||||
},
|
||||
media() {
|
||||
return this.book.media || {}
|
||||
},
|
||||
@@ -73,18 +76,34 @@ export default {
|
||||
showPlayBtn() {
|
||||
return !this.isMissing && !this.isInvalid && this.tracks.length
|
||||
},
|
||||
isStreaming() {
|
||||
playerIsStartingPlayback() {
|
||||
// Play has been pressed and waiting for native play response
|
||||
return this.$store.state.playerIsStartingPlayback
|
||||
},
|
||||
isOpenInPlayer() {
|
||||
if (this.localLibraryItem && this.$store.getters['getIsMediaStreaming'](this.localLibraryItem.id)) return true
|
||||
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId)
|
||||
},
|
||||
streamIsPlaying() {
|
||||
return this.$store.state.playerIsPlaying && this.isStreaming
|
||||
return this.$store.state.playerIsPlaying && this.isOpenInPlayer
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async playClick() {
|
||||
if (this.playerIsStartingPlayback) return
|
||||
await this.$hapticsImpact()
|
||||
|
||||
if (this.streamIsPlaying) {
|
||||
this.$eventBus.$emit('pause-item')
|
||||
return
|
||||
}
|
||||
|
||||
this.$store.commit('setPlayerIsStartingPlayback', this.libraryItemId)
|
||||
if (this.localLibraryItem) {
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.localLibraryItem.id,
|
||||
serverLibraryItemId: this.libraryItemId
|
||||
})
|
||||
} else {
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.libraryItemId
|
||||
@@ -101,4 +120,4 @@ export default {
|
||||
width: calc(100% - 82px);
|
||||
max-width: calc(100% - 82px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div class="h-10 w-10 rounded-full flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
|
||||
<span class="material-icons text-3xl">expand_more</span>
|
||||
<span class="material-symbols text-3xl">arrow_drop_down</span>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="slide">
|
||||
@@ -50,9 +50,6 @@ export default {
|
||||
libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
ebookFiles() {
|
||||
return (this.libraryItem.libraryFiles || []).filter((lf) => lf.fileType === 'ebook')
|
||||
},
|
||||
@@ -99,4 +96,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<tr>
|
||||
<td class="px-4">{{ file.metadata.filename }} <span v-if="isPrimary" class="material-icons-outlined text-success align-text-bottom text-base">check_circle</span></td>
|
||||
<td class="px-4">{{ file.metadata.filename }} <span v-if="isPrimary" class="material-symbols text-success align-text-bottom text-base">check_circle</span></td>
|
||||
<td class="text-xs w-16">
|
||||
<ui-icon-btn icon="auto_stories" outlined borderless icon-font-size="1.125rem" :size="8" @click="readEbook" />
|
||||
</td>
|
||||
@@ -24,9 +24,6 @@ export default {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
@@ -60,4 +57,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
</div>
|
||||
<div class="item-table-content h-full px-2 flex items-center">
|
||||
<div class="max-w-full">
|
||||
<p class="truncate block text-sm">{{ itemTitle }} <span v-if="localLibraryItem" class="material-icons text-success text-base align-text-bottom">download_done</span></p>
|
||||
<p class="truncate block text-sm">{{ itemTitle }} <span v-if="localLibraryItem" class="material-symbols text-success text-base align-text-bottom">download_done</span></p>
|
||||
<p v-if="authorName" class="truncate block text-fg-muted text-xs">{{ authorName }}</p>
|
||||
<p class="text-xxs text-fg-muted">{{ itemDuration }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-8 min-w-8 flex justify-center">
|
||||
<button v-if="showPlayBtn" class="w-8 h-8 rounded-full border border-white/20 flex items-center justify-center" @click.stop.prevent="playClick">
|
||||
<span v-if="!playerIsStartingForThisMedia" class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||
<span v-if="!playerIsStartingForThisMedia" class="material-symbols text-2xl fill" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||
<svg v-else class="animate-spin" style="width: 18px; height: 18px" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
</svg>
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
<div class="w-8 min-w-8 flex justify-center">
|
||||
<button class="w-8 h-8 rounded-full flex items-center justify-center" @click.stop.prevent="showMore">
|
||||
<span class="material-icons">more_vert</span>
|
||||
<span class="material-symbols text-2xl">more_vert</span>
|
||||
</button>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
@@ -113,12 +113,12 @@ export default {
|
||||
showPlayBtn() {
|
||||
return !this.isMissing && !this.isInvalid && (this.tracks.length || this.episode)
|
||||
},
|
||||
isStreaming() {
|
||||
isOpenInPlayer() {
|
||||
if (this.localLibraryItem && this.localEpisode && this.$store.getters['getIsMediaStreaming'](this.localLibraryItem.id, this.localEpisode.id)) return true
|
||||
return this.$store.getters['getIsMediaStreaming'](this.libraryItem.id, this.episodeId)
|
||||
},
|
||||
streamIsPlaying() {
|
||||
return this.$store.state.playerIsPlaying && this.isStreaming
|
||||
return this.$store.state.playerIsPlaying && this.isOpenInPlayer
|
||||
},
|
||||
playerIsStartingPlayback() {
|
||||
// Play has been pressed and waiting for native play response
|
||||
@@ -188,4 +188,4 @@ export default {
|
||||
width: calc(100% - 114px);
|
||||
max-width: calc(100% - 114px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
|
||||
<p class="text-sm text-fg episode-subtitle mt-1.5 mb-0.5" v-html="subtitle" />
|
||||
|
||||
<p v-if="sortKey === 'audioFile.metadata.filename'" class="text-xs text-fg-muted truncate mt-2 mb-0.5">
|
||||
<span class="font-semibold">{{ $getString('LabelFilename') }}</span
|
||||
>: <span class="font-light">{{ episode.audioFile.metadata.filename }}</span>
|
||||
</p>
|
||||
|
||||
<div v-if="episodeNumber || season || episodeType" class="flex py-2 items-center -mx-0.5">
|
||||
<div v-if="episodeNumber" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-fg">Episode #{{ episodeNumber }}</div>
|
||||
<div v-if="season" class="px-2 pt-px pb-0.5 mx-0.5 bg-primary bg-opacity-50 rounded-full text-xs font-light text-fg">Season #{{ season }}</div>
|
||||
@@ -24,26 +29,35 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center pt-2">
|
||||
<div class="h-8 px-4 border border-border rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick">
|
||||
<span v-if="!playerIsStartingForThisMedia" class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||
<svg v-else class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||
<!-- Play/Pause Button -->
|
||||
<div class="h-10 px-4 border border-border rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick">
|
||||
<span v-if="!playerIsStartingForThisMedia" class="material-symbols text-2xl fill leading-none" :class="streamIsPlaying ? '' : 'text-success'">
|
||||
{{ streamIsPlaying ? 'pause' : 'play_arrow' }}
|
||||
</span>
|
||||
<svg v-else class="animate-spin" style="width: 28px; height: 28px" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
</svg>
|
||||
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
|
||||
</div>
|
||||
|
||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||
<!-- Read Status Button -->
|
||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1" @click="toggleFinished" />
|
||||
|
||||
<button v-if="!isLocal" class="mx-1.5 mt-1.5" @click.stop="addToPlaylist">
|
||||
<span class="material-icons text-2xl">playlist_add</span>
|
||||
<!-- Add to Playlist Button -->
|
||||
<button v-if="!isLocal" class="mx-1.5" @click.stop="addToPlaylist">
|
||||
<span class="material-symbols text-2xl leading-none">playlist_add</span>
|
||||
</button>
|
||||
|
||||
<div v-if="userCanDownload">
|
||||
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
|
||||
<span v-else-if="!localEpisode" class="material-icons mx-1.5 mt-2 text-xl" :class="downloadItem || startingDownload ? 'animate-bounce text-warning text-opacity-75' : ''" @click.stop="downloadClick">{{ downloadItem || startingDownload ? 'downloading' : 'download' }}</span>
|
||||
<span v-else class="material-icons px-2 text-success text-xl">download_done</span>
|
||||
<!-- Download Section -->
|
||||
<div v-if="userCanDownload" class="flex items-center">
|
||||
<span v-if="isLocal" class="material-symbols px-2 text-success text-2xl leading-none">audio_file</span>
|
||||
<span v-else-if="!localEpisode" class="material-symbols mx-1.5 text-2xl leading-none" :class="downloadItem || startingDownload ? 'animate-bounce text-warning text-opacity-75' : ''" @click.stop="downloadClick">
|
||||
{{ downloadItem || startingDownload ? 'downloading' : 'download' }}
|
||||
</span>
|
||||
<span v-else class="material-symbols px-2 text-success text-2xl leading-none">download_done</span>
|
||||
</div>
|
||||
|
||||
<!-- Spacer to push elements left -->
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,7 +86,8 @@ export default {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
isLocal: Boolean
|
||||
isLocal: Boolean,
|
||||
sortKey: String
|
||||
},
|
||||
mixins: [cellularPermissionHelpers],
|
||||
data() {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="flex items-center">
|
||||
<p class="text-sm py-1">{{ $getString('MessageEpisodesQueuedForDownload', [episodeDownloadsQueued.length]) }}</p>
|
||||
<div class="flex-grow" />
|
||||
<span v-if="isAdminOrUp" class="material-icons text-xl ml-3 cursor-pointer" @click="clearDownloadQueue">close</span>
|
||||
<span v-if="isAdminOrUp" class="material-symbols text-xl ml-3 cursor-pointer" @click="clearDownloadQueue">close</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,23 +23,23 @@
|
||||
<div class="flex-grow" />
|
||||
|
||||
<button v-if="isAdminOrUp && !fetchingRSSFeed" class="outline:none mx-1 pt-0.5 relative" @click="searchEpisodes">
|
||||
<span class="material-icons text-xl text-fg">search</span>
|
||||
<span class="material-symbols text-xl text-fg">search</span>
|
||||
</button>
|
||||
<widgets-loading-spinner v-else-if="fetchingRSSFeed" class="mx-1" />
|
||||
|
||||
<button class="outline:none mx-3 pt-0.5 relative" @click="showFilters">
|
||||
<span class="material-icons text-xl text-fg">filter_alt</span>
|
||||
<span class="material-symbols text-xl text-fg">filter_alt</span>
|
||||
<div v-show="filterKey !== 'all' && episodesAreFiltered" class="absolute top-0 right-0 w-1.5 h-1.5 rounded-full bg-success border border-green-300 shadow-sm z-10 pointer-events-none" />
|
||||
</button>
|
||||
|
||||
<div class="flex items-center border border-white border-opacity-25 rounded px-2" @click="clickSort">
|
||||
<p class="text-sm text-fg">{{ sortText }}</p>
|
||||
<span class="material-icons ml-1 text-fg">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
<span class="material-symbols ml-1 text-fg">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-for="episode in episodesSorted">
|
||||
<tables-podcast-episode-row :episode="episode" :local-episode="localEpisodeMap[episode.id]" :library-item-id="libraryItemId" :local-library-item-id="localLibraryItemId" :is-local="isLocal" :key="episode.id" @addToPlaylist="addEpisodeToPlaylist" />
|
||||
<tables-podcast-episode-row :episode="episode" :local-episode="localEpisodeMap[episode.id]" :library-item-id="libraryItemId" :local-library-item-id="localLibraryItemId" :is-local="isLocal" :sort-key="sortKey" :key="episode.id" @addToPlaylist="addEpisodeToPlaylist" />
|
||||
</template>
|
||||
|
||||
<!-- Huhhh?
|
||||
@@ -133,6 +133,10 @@ export default {
|
||||
{
|
||||
text: this.$strings.LabelEpisode,
|
||||
value: 'episode'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelFilename,
|
||||
value: 'audioFile.metadata.filename'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -181,8 +185,17 @@ export default {
|
||||
},
|
||||
episodesSorted() {
|
||||
return this.episodesFiltered.sort((a, b) => {
|
||||
let aValue = a[this.sortKey]
|
||||
let bValue = b[this.sortKey]
|
||||
let aValue
|
||||
let bValue
|
||||
|
||||
if (this.sortKey.includes('.')) {
|
||||
const getNestedValue = (ob, s) => s.split('.').reduce((o, k) => o?.[k], ob)
|
||||
aValue = getNestedValue(a, this.sortKey)
|
||||
bValue = getNestedValue(b, this.sortKey)
|
||||
} else {
|
||||
aValue = a[this.sortKey]
|
||||
bValue = b[this.sortKey]
|
||||
}
|
||||
|
||||
// Sort episodes with no pub date as the oldest
|
||||
if (this.sortKey === 'publishedAt') {
|
||||
@@ -278,6 +291,7 @@ export default {
|
||||
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, episode.id)
|
||||
},
|
||||
init() {
|
||||
this.sortDesc = this.mediaMetadata.type === 'episodic'
|
||||
this.episodesCopy = this.episodes.map((ep) => {
|
||||
return { ...ep }
|
||||
})
|
||||
|
||||
@@ -27,26 +27,35 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center pt-2">
|
||||
<div class="h-8 px-4 border border-border hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-fg text-opacity-40' : ''" @click.stop="playClick">
|
||||
<span v-if="!playerIsStartingForThisMedia" class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||
<svg v-else class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||
<!-- Play/Pause Button -->
|
||||
<div class="h-9 px-4 border border-border hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-fg text-opacity-40' : ''" @click.stop="playClick">
|
||||
<span v-if="!playerIsStartingForThisMedia" class="material-symbols text-2xl fill leading-none" :class="streamIsPlaying ? '' : 'text-success'">
|
||||
{{ streamIsPlaying ? 'pause' : 'play_arrow' }}
|
||||
</span>
|
||||
<svg v-else class="animate-spin" style="width: 22px; height: 22px" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
</svg>
|
||||
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
|
||||
</div>
|
||||
|
||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||
<!-- Read Status Button -->
|
||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1" @click="toggleFinished" />
|
||||
|
||||
<button v-if="!isLocal" class="mx-1.5 mt-1.5" @click.stop="addToPlaylist">
|
||||
<span class="material-icons text-2xl">playlist_add</span>
|
||||
<!-- Add to Playlist Button -->
|
||||
<button v-if="!isLocal" class="mx-1.5" @click.stop="addToPlaylist">
|
||||
<span class="material-symbols text-2xl leading-none">playlist_add</span>
|
||||
</button>
|
||||
|
||||
<div v-if="userCanDownload">
|
||||
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
|
||||
<span v-else-if="!localEpisode" class="material-icons mx-1.5 mt-2 text-xl" :class="downloadItem || pendingDownload ? 'animate-bounce text-warning text-opacity-75' : ''" @click.stop="downloadClick">{{ downloadItem || pendingDownload ? 'downloading' : 'download' }}</span>
|
||||
<span v-else class="material-icons px-2 text-success text-xl">download_done</span>
|
||||
<!-- Download Section -->
|
||||
<div v-if="userCanDownload" class="flex items-center">
|
||||
<span v-if="isLocal" class="material-symbols px-2 text-success text-2xl leading-none">audio_file</span>
|
||||
<span v-else-if="!localEpisode" class="material-symbols mx-1.5 text-2xl leading-none" :class="downloadItem || pendingDownload ? 'animate-bounce text-warning text-opacity-75' : ''" @click.stop="downloadClick">
|
||||
{{ downloadItem || pendingDownload ? 'downloading' : 'download' }}
|
||||
</span>
|
||||
<span v-else class="material-symbols px-2 text-success text-2xl leading-none">download_done</span>
|
||||
</div>
|
||||
|
||||
<!-- Spacer to push elements left -->
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText || placeholder || '' }}</span>
|
||||
</span>
|
||||
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<span class="material-icons">expand_more</span>
|
||||
<span class="material-symbols">arrow_drop_down</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -86,4 +86,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
<nuxt-link :key="item.value" v-if="item.to" :to="item.to">
|
||||
<li :key="item.value" class="text-fg select-none relative py-2" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center px-2">
|
||||
<span v-if="item.icon" class="material-icons-outlined text-lg mr-2" :class="item.iconClass ? item.iconClass : ''">{{ item.icon }}</span>
|
||||
<span v-if="item.icon" class="material-symbols text-lg mr-2" :class="item.iconClass ? item.iconClass : ''">{{ item.icon }}</span>
|
||||
<span class="font-normal block truncate font-sans text-center">{{ item.text }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</nuxt-link>
|
||||
<li v-else :key="item.value" class="text-fg select-none relative py-2" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center px-2">
|
||||
<span v-if="item.icon" class="material-icons-outlined text-lg mr-2" :class="item.iconClass ? item.iconClass : ''">{{ item.icon }}</span>
|
||||
<span v-if="item.icon" class="material-symbols text-lg mr-2" :class="item.iconClass ? item.iconClass : ''">{{ item.icon }}</span>
|
||||
<span class="font-normal block truncate font-sans text-center">{{ item.text }}</span>
|
||||
</div>
|
||||
</li>
|
||||
@@ -102,4 +102,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span v-else :class="outlined ? 'material-icons-outlined' : 'material-icons'" :style="{ fontSize }">{{ icon }}</span>
|
||||
<span v-else class="material-symbols text-2xl" :class="{ fill: !outlined }" :style="{ fontSize }">{{ icon }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<span class="block truncate">{{ label }}</span>
|
||||
</span>
|
||||
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<span class="material-icons text-fg">person</span>
|
||||
<span class="material-symbols text-fg">person</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -58,4 +58,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-0.5 my-0.5 text-xs bg-bg flex flex-nowrap break-all items-center relative">
|
||||
<div v-if="!disabled" class="w-full h-full rounded-full absolute top-0 left-0 px-1 bg-bg bg-opacity-75 flex items-center justify-end opacity-0 hover:opacity-100">
|
||||
<span v-if="showEdit" class="material-icons text-white hover:text-warning cursor-pointer" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</span>
|
||||
<span class="material-icons text-white hover:text-error cursor-pointer" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
|
||||
<span v-if="showEdit" class="material-symbols text-white hover:text-warning cursor-pointer" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</span>
|
||||
<span class="material-symbols text-white hover:text-error cursor-pointer" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
|
||||
</div>
|
||||
{{ item }}
|
||||
</div>
|
||||
@@ -22,7 +22,7 @@
|
||||
<span class="font-normal ml-3 block truncate">{{ item }}</span>
|
||||
</div>
|
||||
<span v-if="selected.includes(item)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-icons text-xl">checkmark</span>
|
||||
<span class="material-symbols text-xl">checkmark</span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
@@ -258,4 +258,4 @@ input:read-only {
|
||||
color: #aaa;
|
||||
background-color: #444;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
<div class="relative">
|
||||
<input v-model="input" ref="input" autofocus :type="type" :disabled="disabled" :readonly="readonly" autocorrect="off" autocapitalize="none" autocomplete="off" :placeholder="placeholder" class="py-2 w-full outline-none bg-primary disabled:text-fg-muted" :class="inputClass" @keyup="keyup" />
|
||||
<div v-if="prependIcon" class="absolute top-0 left-0 h-full px-2 flex items-center justify-center">
|
||||
<span class="material-icons text-lg">{{ prependIcon }}</span>
|
||||
<span class="material-symbols text-lg">{{ prependIcon }}</span>
|
||||
</div>
|
||||
<div v-if="clearable && input" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center" @click.stop="clear">
|
||||
<span class="material-icons text-lg">close</span>
|
||||
<span class="material-symbols text-lg">close</span>
|
||||
</div>
|
||||
<div v-else-if="!clearable && appendIcon" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
|
||||
<span class="material-icons text-lg">{{ appendIcon }}</span>
|
||||
<span class="material-symbols text-lg">{{ appendIcon }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -91,4 +91,4 @@ input[type='time']::-webkit-calendar-picker-indicator {
|
||||
html[data-theme='light'] input[type='time']::-webkit-calendar-picker-indicator {
|
||||
filter: unset;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div v-if="icon" class="flex h-full items-center px-2">
|
||||
<span class="material-icons-outlined text-lg" :class="iconClass" @click="showAlertDialog">{{ icon }}</span>
|
||||
<span class="material-symbols text-lg" :class="iconClass" @click="showAlertDialog">{{ icon }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -79,4 +79,4 @@ export default {
|
||||
mounted() {},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -744,12 +744,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 34;
|
||||
CURRENT_PROJECT_VERSION = 36;
|
||||
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.77;
|
||||
MARKETING_VERSION = 0.9.79;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -768,12 +768,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 34;
|
||||
CURRENT_PROJECT_VERSION = 36;
|
||||
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.77;
|
||||
MARKETING_VERSION = 0.9.79;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
@@ -14,7 +14,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
// Override point for customization after application launch.
|
||||
|
||||
let configuration = Realm.Configuration(
|
||||
schemaVersion: 18,
|
||||
schemaVersion: 19,
|
||||
migrationBlock: { [weak self] migration, oldSchemaVersion in
|
||||
if (oldSchemaVersion < 1) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)")
|
||||
@@ -61,6 +61,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
newObject?["streamingUsingCellular"] = "ALWAYS"
|
||||
}
|
||||
}
|
||||
if (oldSchemaVersion < 18) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding disableSleepTimerFadeOut settings")
|
||||
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
|
||||
newObject?["disableSleepTimerFadeOut"] = false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
)
|
||||
|
||||
@@ -246,6 +246,7 @@ public class AbsDatabase: CAPPlugin {
|
||||
let languageCode = call.getString("languageCode") ?? "en-us"
|
||||
let downloadUsingCellular = call.getString("downloadUsingCellular") ?? "ALWAYS"
|
||||
let streamingUsingCellular = call.getString("streamingUsingCellular") ?? "ALWAYS"
|
||||
let disableSleepTimerFadeOut = call.getBool("disableSleepTimerFadeOut") ?? false
|
||||
let settings = DeviceSettings()
|
||||
settings.disableAutoRewind = disableAutoRewind
|
||||
settings.enableAltView = enableAltView
|
||||
@@ -257,6 +258,7 @@ public class AbsDatabase: CAPPlugin {
|
||||
settings.languageCode = languageCode
|
||||
settings.downloadUsingCellular = downloadUsingCellular
|
||||
settings.streamingUsingCellular = streamingUsingCellular
|
||||
settings.disableSleepTimerFadeOut = disableSleepTimerFadeOut
|
||||
|
||||
Database.shared.setDeviceSettings(deviceSettings: settings)
|
||||
|
||||
|
||||
@@ -12,9 +12,36 @@ final class PlayerTimeUtilsTests: XCTestCase {
|
||||
|
||||
func testCalcSeekBackTime() {
|
||||
let currentTime: Double = 1000
|
||||
let threeSecondsAgo = Date(timeIntervalSinceNow: -3)
|
||||
let lastPlayedMs = threeSecondsAgo.timeIntervalSince1970 * 1000
|
||||
XCTAssertEqual(PlayerTimeUtils.calcSeekBackTime(currentTime: currentTime, lastPlayedMs: lastPlayedMs), 998)
|
||||
|
||||
// 1. Nil lastPlayedMs → should seek back 5s
|
||||
XCTAssertEqual(PlayerTimeUtils.calcSeekBackTime(currentTime: currentTime, lastPlayedMs: nil), 1000)
|
||||
|
||||
// 2. Played ~2s ago (<6s) → should seek back 2s
|
||||
let played2sAgo = Date(timeIntervalSinceNow: -2).timeIntervalSince1970 * 1000
|
||||
XCTAssertEqual(PlayerTimeUtils.calcSeekBackTime(currentTime: currentTime, lastPlayedMs: played2sAgo), 1000)
|
||||
|
||||
// 3. Played ~12s ago (6-12s range) → should seek back 10s
|
||||
let played12sAgo = Date(timeIntervalSinceNow: -12).timeIntervalSince1970 * 1000
|
||||
XCTAssertEqual(PlayerTimeUtils.calcSeekBackTime(currentTime: currentTime, lastPlayedMs: played12sAgo), 997)
|
||||
|
||||
// 4. Played ~62s ago (12-30s range) → should seek back 15s
|
||||
let played62sAgo = Date(timeIntervalSinceNow: -62).timeIntervalSince1970 * 1000
|
||||
XCTAssertEqual(PlayerTimeUtils.calcSeekBackTime(currentTime: currentTime, lastPlayedMs: played62sAgo), 990)
|
||||
|
||||
// 5. Played ~302s ago (30-180s range) → should seek back 20s
|
||||
let played302sAgo = Date(timeIntervalSinceNow: -302).timeIntervalSince1970 * 1000
|
||||
XCTAssertEqual(PlayerTimeUtils.calcSeekBackTime(currentTime: currentTime, lastPlayedMs: played302sAgo), 980)
|
||||
|
||||
// 6. Played ~1802s ago (180-3600s range) → should seek back 25s
|
||||
let played1802sAgo = Date(timeIntervalSinceNow: -1802).timeIntervalSince1970 * 1000
|
||||
XCTAssertEqual(PlayerTimeUtils.calcSeekBackTime(currentTime: currentTime, lastPlayedMs: played1802sAgo), 970)
|
||||
|
||||
// 8. Edge case where currentTime is small and would go negative
|
||||
XCTAssertEqual(PlayerTimeUtils.calcSeekBackTime(currentTime: 1, lastPlayedMs: played12sAgo), 0)
|
||||
|
||||
// 9. Edge case: negative lastPlayedMs (should be treated as an old timestamp)
|
||||
XCTAssertEqual(PlayerTimeUtils.calcSeekBackTime(currentTime: currentTime, lastPlayedMs: -5000), 970)
|
||||
|
||||
}
|
||||
|
||||
func testCalcSeekBackTimeWithZeroCurrentTime() {
|
||||
@@ -27,18 +54,18 @@ final class PlayerTimeUtilsTests: XCTestCase {
|
||||
func testTimeSinceLastPlayed() throws {
|
||||
let fiveSecondsAgo = Date(timeIntervalSinceNow: -5)
|
||||
let lastPlayedMs = fiveSecondsAgo.timeIntervalSince1970 * 1000
|
||||
XCTAssertEqual(PlayerTimeUtils.timeSinceLastPlayed(lastPlayedMs)!, -5, accuracy: 1.0)
|
||||
XCTAssertEqual(PlayerTimeUtils.timeSinceLastPlayed(lastPlayedMs)!, 5, accuracy: 1.0)
|
||||
XCTAssertNil(PlayerTimeUtils.timeSinceLastPlayed(nil))
|
||||
}
|
||||
|
||||
func testTimeToSeekBackForSinceLastPlayed() throws {
|
||||
XCTAssertEqual(PlayerTimeUtils.timeToSeekBackForSinceLastPlayed(nil), 5, "Seeks back 5 seconds for nil")
|
||||
XCTAssertEqual(PlayerTimeUtils.timeToSeekBackForSinceLastPlayed(5), 2, "Seeks back 2 seconds for less than 6 seconds")
|
||||
XCTAssertEqual(PlayerTimeUtils.timeToSeekBackForSinceLastPlayed(11), 10, "Seeks back 10 seconds for less than 12 seconds")
|
||||
XCTAssertEqual(PlayerTimeUtils.timeToSeekBackForSinceLastPlayed(29), 15, "Seeks back 15 seconds for less than 30 seconds")
|
||||
XCTAssertEqual(PlayerTimeUtils.timeToSeekBackForSinceLastPlayed(179), 20, "Seeks back 20 seconds for less than 2 minutes")
|
||||
XCTAssertEqual(PlayerTimeUtils.timeToSeekBackForSinceLastPlayed(3599), 25, "Seeks back 25 seconds for less than 59 minutes")
|
||||
XCTAssertEqual(PlayerTimeUtils.timeToSeekBackForSinceLastPlayed(60000), 29, "Seeks back 29 seconds for anything over 59 minuts")
|
||||
XCTAssertEqual(PlayerTimeUtils.timeToSeekBackForSinceLastPlayed(nil), 0, "Seeks back 0 seconds for nil")
|
||||
XCTAssertEqual(PlayerTimeUtils.timeToSeekBackForSinceLastPlayed(5), 0, "Seeks back 0 seconds for less than 10 seconds")
|
||||
XCTAssertEqual(PlayerTimeUtils.timeToSeekBackForSinceLastPlayed(11), 3, "Seeks back 3 seconds for less than 1 minute")
|
||||
XCTAssertEqual(PlayerTimeUtils.timeToSeekBackForSinceLastPlayed(298), 10, "Seeks back 10 seconds for less than 5 minutes")
|
||||
XCTAssertEqual(PlayerTimeUtils.timeToSeekBackForSinceLastPlayed(1798), 20, "Seeks back 20 seconds for less than 30 minutes")
|
||||
XCTAssertEqual(PlayerTimeUtils.timeToSeekBackForSinceLastPlayed(3599), 30, "Seeks back 30 seconds for greater than 30 minutes")
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
|
||||
|
||||
platform :ios, '13.0'
|
||||
platform :ios, '14.0'
|
||||
use_frameworks!
|
||||
|
||||
# workaround to avoid Xcode caching of Pods that requires
|
||||
@@ -11,7 +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 'WebnativellcCapacitorFilesharer', :path => '../../node_modules/@webnativellc/capacitor-filesharer'
|
||||
pod 'CapacitorCommunityKeepAwake', :path => '../../node_modules/@capacitor-community/keep-awake'
|
||||
pod 'CapacitorCommunityVolumeButtons', :path => '../../node_modules/@capacitor-community/volume-buttons'
|
||||
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
|
||||
|
||||
+33
-33
@@ -1,41 +1,40 @@
|
||||
PODS:
|
||||
- Alamofire (5.8.1)
|
||||
- ByteowlsCapacitorFilesharer (6.0.0):
|
||||
- Capacitor
|
||||
- Capacitor (6.2.0):
|
||||
- Capacitor (7.2.0):
|
||||
- CapacitorCordova
|
||||
- CapacitorApp (6.0.2):
|
||||
- CapacitorApp (7.0.1):
|
||||
- Capacitor
|
||||
- CapacitorBrowser (6.0.4):
|
||||
- CapacitorBrowser (7.0.1):
|
||||
- Capacitor
|
||||
- CapacitorClipboard (6.0.2):
|
||||
- CapacitorClipboard (7.0.1):
|
||||
- Capacitor
|
||||
- CapacitorCommunityKeepAwake (5.0.1):
|
||||
- CapacitorCommunityKeepAwake (7.0.0):
|
||||
- Capacitor
|
||||
- CapacitorCommunityVolumeButtons (6.0.1):
|
||||
- CapacitorCommunityVolumeButtons (7.0.0):
|
||||
- Capacitor
|
||||
- CapacitorCordova (6.2.0)
|
||||
- CapacitorDialog (6.0.2):
|
||||
- CapacitorCordova (7.2.0)
|
||||
- CapacitorDialog (7.0.1):
|
||||
- Capacitor
|
||||
- CapacitorHaptics (6.0.2):
|
||||
- CapacitorHaptics (7.0.1):
|
||||
- Capacitor
|
||||
- CapacitorNetwork (6.0.3):
|
||||
- CapacitorNetwork (7.0.1):
|
||||
- Capacitor
|
||||
- CapacitorPreferences (6.0.3):
|
||||
- CapacitorPreferences (7.0.1):
|
||||
- Capacitor
|
||||
- CapacitorStatusBar (6.0.2):
|
||||
- CapacitorStatusBar (7.0.1):
|
||||
- Capacitor
|
||||
- CordovaPlugins (6.2.0):
|
||||
- CordovaPlugins (6.2.1):
|
||||
- CapacitorCordova
|
||||
- Realm (10.47.0):
|
||||
- Realm/Headers (= 10.47.0)
|
||||
- Realm/Headers (10.47.0)
|
||||
- RealmSwift (10.47.0):
|
||||
- Realm (= 10.47.0)
|
||||
- WebnativellcCapacitorFilesharer (7.0.4):
|
||||
- Capacitor
|
||||
|
||||
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`)"
|
||||
@@ -50,6 +49,7 @@ DEPENDENCIES:
|
||||
- "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)"
|
||||
- CordovaPlugins (from `../capacitor-cordova-ios-plugins`)
|
||||
- RealmSwift (~> 10)
|
||||
- "WebnativellcCapacitorFilesharer (from `../../node_modules/@webnativellc/capacitor-filesharer`)"
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
@@ -58,8 +58,6 @@ SPEC REPOS:
|
||||
- RealmSwift
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
ByteowlsCapacitorFilesharer:
|
||||
:path: "../../node_modules/@byteowls/capacitor-filesharer"
|
||||
Capacitor:
|
||||
:path: "../../node_modules/@capacitor/ios"
|
||||
CapacitorApp:
|
||||
@@ -86,26 +84,28 @@ EXTERNAL SOURCES:
|
||||
:path: "../../node_modules/@capacitor/status-bar"
|
||||
CordovaPlugins:
|
||||
:path: "../capacitor-cordova-ios-plugins"
|
||||
WebnativellcCapacitorFilesharer:
|
||||
:path: "../../node_modules/@webnativellc/capacitor-filesharer"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Alamofire: 3ca42e259043ee0dc5c0cdd76c4bc568b8e42af7
|
||||
ByteowlsCapacitorFilesharer: ea14537059851aed44df8c52f0984f61cd34f53b
|
||||
Capacitor: 1f3c7b9802d958cd8c4eb63895fff85dff2e1eea
|
||||
CapacitorApp: 2a8c3a0b0814322e5e6e15fe595f02c3808f0f8b
|
||||
CapacitorBrowser: ef0529d16cd8839281050c350e7bbee4f5c6d65f
|
||||
CapacitorClipboard: 55e0a514f1e97b1409d533266c119dcbff3e78c3
|
||||
CapacitorCommunityKeepAwake: e2ddd50812e3407f8dc3a2c28e97d66e9b59b2f5
|
||||
CapacitorCommunityVolumeButtons: a7612c5996f1c66320ef7a567522346d84a57e22
|
||||
CapacitorCordova: b33e7f4aa4ed105dd43283acdd940964374a87d9
|
||||
CapacitorDialog: e966e2261e1c74a8a502e610dd66b4f97ec6fcca
|
||||
CapacitorHaptics: b53409aaca1203f79c6d0eb3ed5de40556339518
|
||||
CapacitorNetwork: da96b5fff8d05b67f4658503aabb22f65bda2c0f
|
||||
CapacitorPreferences: 9f9935bce493977183511362131a93f7b260191a
|
||||
CapacitorStatusBar: 3b9ac7d0684770522c532d1158a1434512ab1477
|
||||
CordovaPlugins: 08731213c63ccf137c576da2926bf4b3a8c40f17
|
||||
Capacitor: 106e7a4205f4618d582b886a975657c61179138d
|
||||
CapacitorApp: d63334c052278caf5d81585d80b21905c6f93f39
|
||||
CapacitorBrowser: 081852cf532acf77b9d2953f3a88fe5b9711fb06
|
||||
CapacitorClipboard: b98aead5dc7ec595547fc2c5d75bacd2ae3338bc
|
||||
CapacitorCommunityKeepAwake: 00dfd8fa3cca0df003c9a3e2cd7bee678aeec68b
|
||||
CapacitorCommunityVolumeButtons: 8a0443a202ed659688d85f4d44d66f42f62f2b56
|
||||
CapacitorCordova: 5967b9ba03915ef1d585469d6e31f31dc49be96f
|
||||
CapacitorDialog: 9b934329026b2b0ffa56939bb06df3c67541a2ab
|
||||
CapacitorHaptics: 70e47470fa1a6bd6338cd102552e3846b7f9a1b3
|
||||
CapacitorNetwork: 07ec4c69c1bb696f41c23e00d31bda1bbb221bba
|
||||
CapacitorPreferences: cbf154e5e5519b7f5ab33817a334dda1e98387f9
|
||||
CapacitorStatusBar: 275cbf2f4dfc00388f519ef80c7ec22edda342c9
|
||||
CordovaPlugins: 5a72a85b45469e68556bb172409f1b6d57b27236
|
||||
Realm: e43fb540ae947497e3ea8a662443256920602060
|
||||
RealmSwift: 8b06ed06b5d16749ae0c4d91c0cba414a9e28189
|
||||
WebnativellcCapacitorFilesharer: 10b111373d4dc49608935600dcbcc14605258c73
|
||||
|
||||
PODFILE CHECKSUM: 96d7bd74a37a613766883c65fc082490b5dbd7e9
|
||||
PODFILE CHECKSUM: 498821c0cfa2508609567fa95d7244c01cbef538
|
||||
|
||||
COCOAPODS: 1.12.1
|
||||
|
||||
@@ -19,6 +19,7 @@ class DeviceSettings: Object {
|
||||
@Persisted var languageCode: String = "en-us"
|
||||
@Persisted var downloadUsingCellular: String = "ALWAYS"
|
||||
@Persisted var streamingUsingCellular: String = "ALWAYS"
|
||||
@Persisted var disableSleepTimerFadeOut: Bool = false
|
||||
}
|
||||
|
||||
func getDefaultDeviceSettings() -> DeviceSettings {
|
||||
@@ -36,6 +37,7 @@ func deviceSettingsToJSON(settings: DeviceSettings) -> Dictionary<String, Any> {
|
||||
"hapticFeedback": settings.hapticFeedback,
|
||||
"languageCode": settings.languageCode,
|
||||
"downloadUsingCellular": settings.downloadUsingCellular,
|
||||
"streamingUsingCellular": settings.streamingUsingCellular
|
||||
"streamingUsingCellular": settings.streamingUsingCellular,
|
||||
"disableSleepTimerFadeOut": settings.disableSleepTimerFadeOut
|
||||
]
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ class AudioPlayer: NSObject {
|
||||
private var queueObserver:NSKeyValueObservation?
|
||||
private var queueItemStatusObserver:NSKeyValueObservation?
|
||||
|
||||
private var isRebuildingQueue = false
|
||||
|
||||
// Sleep timer values
|
||||
internal var sleepTimeChapterStopAt: Double?
|
||||
internal var sleepTimeChapterToken: Any?
|
||||
@@ -365,7 +367,57 @@ class AudioPlayer: NSObject {
|
||||
self.status = .paused
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
|
||||
public func startFadeOut() {
|
||||
guard self.isInitialized() else { return }
|
||||
guard let currentTime = self.getCurrentTime() else { return }
|
||||
logger.log("fadeOut: Fading out playback")
|
||||
|
||||
// Define fade parameters.
|
||||
let fadeDuration: Float = 60.0 // total fade duration in seconds
|
||||
let interval: Float = 1.0 // timer interval in seconds
|
||||
|
||||
// Get the current volume.
|
||||
let initialVolume = self.audioPlayer.volume
|
||||
let targetVolume: Float = 0.0
|
||||
|
||||
// If the current volume is already at or below zero, just pause.
|
||||
if initialVolume <= targetVolume {
|
||||
self.pause()
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate the volume change per timer tick.
|
||||
// (targetVolume - initialVolume) is negative since target < initial.
|
||||
let step = (targetVolume - initialVolume) * interval / fadeDuration
|
||||
|
||||
// Schedule a timer on the main queue to adjust the volume.
|
||||
DispatchQueue.runOnMainQueue { [weak self] in
|
||||
var timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(interval), repeats: true) { t in
|
||||
guard let self = self else {
|
||||
t.invalidate()
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate the new volume.
|
||||
let newVolume = self.audioPlayer.volume + step
|
||||
|
||||
// Check if the next step would go below zero.
|
||||
if newVolume > targetVolume {
|
||||
self.audioPlayer.volume = newVolume
|
||||
} else {
|
||||
// Ensure volume is exactly zero and end fade.
|
||||
self.audioPlayer.volume = targetVolume
|
||||
t.invalidate()
|
||||
self.logger.log("Fadeout: Fade complete, pausing playback")
|
||||
self.pause()
|
||||
self.audioPlayer.volume = initialVolume
|
||||
self.seek(currentTime, from: "fadeOut")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func seek(_ to: Double, from: String) {
|
||||
logger.log("SEEK: Seek to \(to) from \(from)")
|
||||
|
||||
@@ -374,6 +426,31 @@ class AudioPlayer: NSObject {
|
||||
let indexOfSeek = getItemIndexForTime(time: to)
|
||||
logger.log("SEEK: Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)")
|
||||
|
||||
if self.audioPlayer.currentItem == nil {
|
||||
self.currentTrackIndex = indexOfSeek
|
||||
|
||||
try? playbackSession.update {
|
||||
playbackSession.currentTime = to
|
||||
}
|
||||
|
||||
let playerItems = self.allPlayerItems[indexOfSeek..<self.allPlayerItems.count]
|
||||
|
||||
DispatchQueue.runOnMainQueue {
|
||||
// Let observers know we're rebuilding the queue
|
||||
// prevents race conditions on stopping sessions if queue is empty.
|
||||
self.isRebuildingQueue = true
|
||||
self.audioPlayer.removeAllItems()
|
||||
for item in Array(playerItems) {
|
||||
self.audioPlayer.insert(item, after:self.audioPlayer.items().last)
|
||||
}
|
||||
self.isRebuildingQueue = false
|
||||
}
|
||||
|
||||
seekInCurrentTrack(to: to, playbackSession: playbackSession)
|
||||
setupQueueItemStatusObserver()
|
||||
return
|
||||
}
|
||||
|
||||
// Reconstruct queue if seeking to a different track
|
||||
if (self.currentTrackIndex != indexOfSeek) {
|
||||
// When we seek to a different track, we need to make sure to seek the old track to 0
|
||||
@@ -389,10 +466,14 @@ class AudioPlayer: NSObject {
|
||||
let playerItems = self.allPlayerItems[indexOfSeek..<self.allPlayerItems.count]
|
||||
|
||||
DispatchQueue.runOnMainQueue {
|
||||
// Let observers know we're rebuilding the queue
|
||||
// prevents race conditions on stopping sessions if queue is empty.
|
||||
self.isRebuildingQueue = true
|
||||
self.audioPlayer.removeAllItems()
|
||||
for item in Array(playerItems) {
|
||||
self.audioPlayer.insert(item, after:self.audioPlayer.items().last)
|
||||
}
|
||||
self.isRebuildingQueue = false
|
||||
}
|
||||
|
||||
seekInCurrentTrack(to: to, playbackSession: playbackSession)
|
||||
@@ -437,9 +518,14 @@ class AudioPlayer: NSObject {
|
||||
|
||||
public func getCurrentTime() -> Double? {
|
||||
guard let playbackSession = self.getPlaybackSession() else { return nil }
|
||||
let currentTrackTime = self.audioPlayer.currentTime().seconds
|
||||
let audioTrack = playbackSession.audioTracks[currentTrackIndex]
|
||||
let startOffset = audioTrack.startOffset ?? 0.0
|
||||
|
||||
// if the currentTrackTime isNan, then fall back on session.
|
||||
let currentTrackTime = self.audioPlayer.currentTime().seconds
|
||||
if currentTrackTime.isNaN {
|
||||
return playbackSession.currentTime
|
||||
}
|
||||
return startOffset + currentTrackTime
|
||||
}
|
||||
|
||||
@@ -718,6 +804,15 @@ class AudioPlayer: NSObject {
|
||||
if keyPath == #keyPath(AVPlayer.currentItem) {
|
||||
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil)
|
||||
logger.log("WARNING: Item ended")
|
||||
|
||||
if audioPlayer.currentItem == nil {
|
||||
// if the queue is rebuilding, we expect the current item may be nil
|
||||
if self.isRebuildingQueue {
|
||||
return
|
||||
}
|
||||
logger.log("Player ended or next item is nil, marking ended")
|
||||
self.markAudioSessionAs(active: false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
|
||||
|
||||
@@ -125,7 +125,11 @@ extension AudioPlayer {
|
||||
if var sleepTimeRemaining = self.sleepTimeRemaining {
|
||||
sleepTimeRemaining -= 1
|
||||
self.sleepTimeRemaining = sleepTimeRemaining
|
||||
|
||||
|
||||
if sleepTimeRemaining == 60 && self.isSleepTimerFadeOutEnabled() {
|
||||
self.startFadeOut()
|
||||
}
|
||||
|
||||
// Handle the sleep if the timer has expired
|
||||
if sleepTimeRemaining <= 0 {
|
||||
self.handleSleepEnd()
|
||||
@@ -154,5 +158,9 @@ extension AudioPlayer {
|
||||
private func isChapterSleepTimerSet() -> Bool {
|
||||
return self.sleepTimeChapterStopAt != nil
|
||||
}
|
||||
|
||||
|
||||
private func isSleepTimerFadeOutEnabled() -> Bool {
|
||||
let deviceSettings = Database.shared.getDeviceSettings()
|
||||
return !deviceSettings.disableSleepTimerFadeOut
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,27 +21,27 @@ class PlayerTimeUtils {
|
||||
static internal func timeSinceLastPlayed(_ lastPlayedMs: Double?) -> TimeInterval? {
|
||||
guard let lastPlayedMs = lastPlayedMs else { return nil }
|
||||
let lastPlayed = Date(timeIntervalSince1970: lastPlayedMs / 1000)
|
||||
return lastPlayed.timeIntervalSinceNow
|
||||
return Date().timeIntervalSince(lastPlayed)
|
||||
}
|
||||
|
||||
static internal func timeToSeekBackForSinceLastPlayed(_ sinceLastPlayed: TimeInterval?) -> TimeInterval {
|
||||
if isAutoRewindDisabled(){
|
||||
return 0
|
||||
}
|
||||
if let sinceLastPlayed = sinceLastPlayed {
|
||||
if sinceLastPlayed < 6 {
|
||||
return 2
|
||||
} else if sinceLastPlayed < 12 {
|
||||
return 10
|
||||
} else if sinceLastPlayed < 30 {
|
||||
return 15
|
||||
} else if sinceLastPlayed < 180 {
|
||||
return 20
|
||||
} else if sinceLastPlayed < 3600 {
|
||||
return 25
|
||||
} else {
|
||||
return 29
|
||||
}
|
||||
if sinceLastPlayed < 10 { return 0 } // 10s or less = no seekback
|
||||
else if sinceLastPlayed < 60 { return 3 } // 10s to 1m = jump back 3s
|
||||
else if sinceLastPlayed < 300 { return 10 } // 1m to 5m = jump back 10s
|
||||
else if sinceLastPlayed < 1800 { return 20 } // 5m to 30m = jump back 20s
|
||||
else { return 30 } // 30m and up = jump back 30s
|
||||
} else {
|
||||
return 5
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
static internal func isAutoRewindDisabled() -> Bool {
|
||||
let deviceSettings = Database.shared.getDeviceSettings()
|
||||
return deviceSettings.disableAutoRewind
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Generated
+310
-3136
File diff suppressed because it is too large
Load Diff
+15
-15
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.9.77-beta",
|
||||
"version": "0.9.79-beta",
|
||||
"author": "advplyr",
|
||||
"scripts": {
|
||||
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
|
||||
@@ -14,20 +14,20 @@
|
||||
"devlive": "ionic cap run -l --external --port=1337"
|
||||
},
|
||||
"dependencies": {
|
||||
"@byteowls/capacitor-filesharer": "^6.0.0",
|
||||
"@capacitor-community/keep-awake": "^5.0.1",
|
||||
"@capacitor-community/volume-buttons": "^6.0.1",
|
||||
"@capacitor/android": "^6.0.0",
|
||||
"@capacitor/app": "^6.0.0",
|
||||
"@capacitor/browser": "^6.0.0",
|
||||
"@capacitor/clipboard": "^6.0.0",
|
||||
"@capacitor/core": "^6.0.0",
|
||||
"@capacitor/dialog": "^6.0.0",
|
||||
"@capacitor/haptics": "^6.0.0",
|
||||
"@capacitor/ios": "^6.0.0",
|
||||
"@capacitor/network": "^6.0.0",
|
||||
"@capacitor/preferences": "^6.0.0",
|
||||
"@capacitor/status-bar": "^6.0.0",
|
||||
"@webnativellc/capacitor-filesharer": "7.0.4",
|
||||
"@capacitor-community/keep-awake": "^7.0.0",
|
||||
"@capacitor-community/volume-buttons": "^7.0.0",
|
||||
"@capacitor/android": "^7.0.0",
|
||||
"@capacitor/app": "^7.0.0",
|
||||
"@capacitor/browser": "^7.0.0",
|
||||
"@capacitor/clipboard": "^7.0.0",
|
||||
"@capacitor/core": "^7.0.0",
|
||||
"@capacitor/dialog": "^7.0.0",
|
||||
"@capacitor/haptics": "^7.0.0",
|
||||
"@capacitor/ios": "^7.0.0",
|
||||
"@capacitor/network": "^7.0.0",
|
||||
"@capacitor/preferences": "^7.0.0",
|
||||
"@capacitor/status-bar": "^7.0.0",
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
"@teckel/vue-pdf": "^4.3.5",
|
||||
"buffer": "^6.0.3",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user