Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7014e04e6f | ||
|
|
a00032beda | ||
|
|
02acaf1159 | ||
|
|
6f923ef1d8 | ||
|
|
679ebb16f8 | ||
|
|
ed356a8d99 | ||
|
|
33e187678d | ||
|
|
5fc952a9e5 | ||
|
|
a408c55cff | ||
|
|
a3d482e3f7 | ||
|
|
2f8c1731e9 | ||
|
|
b5269f52c4 | ||
|
|
9ebc7da984 | ||
|
|
c8c4b8b059 | ||
|
|
c1f3e236f8 | ||
|
|
128e23fabf | ||
|
|
989cf66715 | ||
|
|
ce6883331e | ||
|
|
108f34bfb7 | ||
|
|
869873acfd | ||
|
|
96a44bcce7 | ||
|
|
ca2c023486 | ||
|
|
46850159de | ||
|
|
bd4c3be210 | ||
|
|
da4745217c | ||
|
|
3048237a4b | ||
|
|
e473155646 | ||
|
|
643603c073 | ||
|
|
2ac4de4d58 | ||
|
|
68931febf6 | ||
|
|
8561ada1d2 | ||
|
|
06f4ba2e82 | ||
|
|
353e2f02df | ||
|
|
2927ab92ae | ||
|
|
94db1f72a7 | ||
|
|
bf92d18b48 | ||
|
|
c4aed2587f | ||
|
|
7a5e4f559c | ||
|
|
fe14ec0f45 | ||
|
|
607e5fc670 | ||
|
|
a34a1bb2b9 | ||
|
|
e48f2f0da2 | ||
|
|
32a8212653 | ||
|
|
9a2f0c3e4a | ||
|
|
7292e367d2 | ||
|
|
fe8f5de891 | ||
|
|
c6d0f0e8b5 | ||
|
|
14885467a4 | ||
|
|
87f1c83cbf | ||
|
|
b706337852 | ||
|
|
34f2165ef6 | ||
|
|
88ab456ce5 | ||
|
|
022a9c5875 | ||
|
|
4cb4183a12 | ||
|
|
62e80cb469 | ||
|
|
37b19e9a7b | ||
|
|
961e66b863 | ||
|
|
6c56003c26 | ||
|
|
d44dbd770a | ||
|
|
ca04bb7a3e | ||
|
|
c832c86ca4 | ||
|
|
64cbb41b0b | ||
|
|
d11d9860b6 | ||
|
|
c1a0b96ae4 | ||
|
|
a09326b5b2 | ||
|
|
1a290b4d07 | ||
|
|
8d8147ffea | ||
|
|
17fcec4401 | ||
|
|
79a925088f | ||
|
|
2fc8735643 | ||
|
|
0b1dfa4c34 | ||
|
|
30c4fa7e16 | ||
|
|
71bd354bc7 | ||
|
|
e06ce4a868 | ||
|
|
90f8d99287 | ||
|
|
1cdf0a59b1 | ||
|
|
6560038495 | ||
|
|
4b8abe7deb | ||
|
|
d230b16d5e | ||
|
|
0e71eb0308 | ||
|
|
4a1cf0ea4a | ||
|
|
d2196ce035 | ||
|
|
a100226a85 | ||
|
|
ba66869502 | ||
|
|
02e3f3541f | ||
|
|
f25ff6344e | ||
|
|
c800ed727a | ||
|
|
4eb848ae5f | ||
|
|
9dfa68ea8b | ||
|
|
7989607313 | ||
|
|
61c314a531 | ||
|
|
a709e994a0 | ||
|
|
96fc9adbbf | ||
|
|
12025ab59a | ||
|
|
f66840e8e4 | ||
|
|
4c3e098785 | ||
|
|
441086e773 | ||
|
|
1408a4c3ff | ||
|
|
fe67a221ac | ||
|
|
f087cbed6f | ||
|
|
9820db0ef6 | ||
|
|
9664f79b1f | ||
|
|
533396b855 | ||
|
|
005a051cd0 | ||
|
|
f06168fdc2 | ||
|
|
26c6b9149b | ||
|
|
8b70d02332 | ||
|
|
4370b19e12 | ||
|
|
f708e4dc07 | ||
|
|
4bb809c1b9 | ||
|
|
ccf57bb016 | ||
|
|
65b2a1a9a4 | ||
|
|
8e6ef8e718 | ||
|
|
cbea6229b5 | ||
|
|
05205eb646 | ||
|
|
32fc866d8a | ||
|
|
da089338dc | ||
|
|
e19f47b291 | ||
|
|
e7ed54439b | ||
|
|
3db3bbd756 | ||
|
|
24ab13e27e | ||
|
|
195d1d1ef4 | ||
|
|
5383e91249 | ||
|
|
c02e45b703 | ||
|
|
991dd997ee | ||
|
|
daddd5c3c2 | ||
|
|
8b77fae668 | ||
|
|
bba15dacf8 | ||
|
|
bfaeb6ea42 | ||
|
|
5f66f65d6f | ||
|
|
8b9098c877 | ||
|
|
7044925791 | ||
|
|
bcc292b33a | ||
|
|
ae08206c3e | ||
|
|
91315dd9b5 | ||
|
|
8a253e7679 | ||
|
|
04e9c8a329 | ||
|
|
1d163df773 | ||
|
|
4cfa22004c | ||
|
|
e0747e3f19 | ||
|
|
b14e7b7357 | ||
|
|
0123df99d2 | ||
|
|
4a48cb0a9f | ||
|
|
425ef4c3b6 | ||
|
|
e5ef38295d | ||
|
|
b084e54dcc | ||
|
|
d3e55cabf4 | ||
|
|
2cc9ff5a41 | ||
|
|
9562e8f252 | ||
|
|
7726192008 | ||
|
|
f8424c03fd | ||
|
|
58fbfca645 | ||
|
|
6282fd1f95 | ||
|
|
245df5d934 | ||
|
|
dc6184f41d | ||
|
|
b3c3950c55 | ||
|
|
034a134319 | ||
|
|
8142d626c0 | ||
|
|
8b20730b6f | ||
|
|
d27f431f6f | ||
|
|
0df0a206e7 | ||
|
|
6aeb31b591 | ||
|
|
eb2483d039 | ||
|
|
67fbeeeb6c | ||
|
|
5099e39f7b | ||
|
|
34710dbddc | ||
|
|
4798644bea | ||
|
|
bd0cbf8bf0 | ||
|
|
f562a1a5dc | ||
|
|
185cba16eb | ||
|
|
547d086228 | ||
|
|
3a2d5647d4 | ||
|
|
0a67c9b802 | ||
|
|
0254afd5d8 | ||
|
|
853546f3d2 | ||
|
|
b8ac073247 | ||
|
|
56cb4df31a | ||
|
|
7285b0bf26 | ||
|
|
3fdc2e6dcf | ||
|
|
ed2f9d2e45 | ||
|
|
68dafbba75 | ||
|
|
7d17367c1e | ||
|
|
8be08702c1 | ||
|
|
91bc56e1e1 | ||
|
|
2c2e77f6cd | ||
|
|
12d02645fd | ||
|
|
4578339594 | ||
|
|
b81f78755d | ||
|
|
82664d6c81 | ||
|
|
9e06db58d2 | ||
|
|
de3461af52 | ||
|
|
61f676d106 | ||
|
|
82627b85a5 | ||
|
|
21bc6cc8b6 | ||
|
|
2295b1071e | ||
|
|
c75e376533 | ||
|
|
3a5e2af9e4 | ||
|
|
30f1414ea5 | ||
|
|
737364aa3a | ||
|
|
fd988afad1 | ||
|
|
56dc7f856c | ||
|
|
5e4a41b0b5 | ||
|
|
66f426f6bb | ||
|
|
03efa352f5 | ||
|
|
5c147955b7 | ||
|
|
0a55573bd0 | ||
|
|
de3812ac33 | ||
|
|
b6de52eb91 | ||
|
|
65ec1ec5d4 | ||
|
|
614118d1d3 | ||
|
|
e3999360f0 | ||
|
|
50d1b62b95 | ||
|
|
d4e7d0abf9 | ||
|
|
1571bcd9f3 | ||
|
|
5531ced129 | ||
|
|
2b258de5b8 | ||
|
|
4e3d3f7752 | ||
|
|
63863a8800 | ||
|
|
d594fcb08b | ||
|
|
85aab8df06 | ||
|
|
ec1585f7c1 | ||
|
|
3b3bda117c | ||
|
|
c37f2b71d4 | ||
|
|
04de5960f1 | ||
|
|
dad7ac4efd | ||
|
|
d2830c1603 | ||
|
|
6091fd6f79 | ||
|
|
c9935eca2b | ||
|
|
38eacdd7b4 | ||
|
|
1bde917b09 | ||
|
|
0859e219fa | ||
|
|
dac9279320 | ||
|
|
4fb9f6b92d | ||
|
|
fabd579247 | ||
|
|
b4ee3b80a8 | ||
|
|
a75bdce9b4 | ||
|
|
d48ac952a5 | ||
|
|
d83b2e032b | ||
|
|
46c9c3f006 | ||
|
|
4199add608 | ||
|
|
8311643e73 | ||
|
|
7775d5216f | ||
|
|
6f2fc6b5c1 | ||
|
|
235cac7ab4 | ||
|
|
93b4acfe13 | ||
|
|
bd7c73c48d | ||
|
|
45ecca7ce4 | ||
|
|
0a8c67cf13 | ||
|
|
316d3d8324 | ||
|
|
182d45b07c | ||
|
|
58d8afca38 | ||
|
|
136709d7ae | ||
|
|
b0d02aa769 | ||
|
|
ca66a45ba4 | ||
|
|
d7d4114ee6 | ||
|
|
e589c0a7ea | ||
|
|
eb2625a2df | ||
|
|
aacbacf557 | ||
|
|
0a7b129db2 | ||
|
|
c927264b62 | ||
|
|
808225351e | ||
|
|
40ee8d5221 | ||
|
|
8cc80e8378 | ||
|
|
db5f249c14 | ||
|
|
e86028e577 | ||
|
|
54bed2bee1 | ||
|
|
1de78165d0 | ||
|
|
4c9ed5e2bf | ||
|
|
a036a8bda6 | ||
|
|
5a482e603e | ||
|
|
d5eb8a341e | ||
|
|
9c74c47448 | ||
|
|
8584bebe6c | ||
|
|
39cd6c65f2 | ||
|
|
1056541dd2 | ||
|
|
f9a3a4a218 | ||
|
|
80bafd9281 | ||
|
|
c06c17d3ae | ||
|
|
2a7969fe68 | ||
|
|
decda748ba | ||
|
|
427f858be5 | ||
|
|
1043fb3f3e | ||
|
|
7c5e8cb046 | ||
|
|
5fb8baa186 | ||
|
|
f63b04451b | ||
|
|
a80722ff79 | ||
|
|
9d5cbd37c8 | ||
|
|
fada8fccb1 | ||
|
|
9d650019cd | ||
|
|
915f3c88bc | ||
|
|
140bc7fbc5 | ||
|
|
2a12c1e811 | ||
|
|
5250adb36e | ||
|
|
f548163689 | ||
|
|
589ffcecca | ||
|
|
d9c591b0ea | ||
|
|
462021225f | ||
|
|
3f0bc74c21 | ||
|
|
b2c2b625e3 | ||
|
|
d3bd3427e3 | ||
|
|
a040a73174 | ||
|
|
332d9f203e | ||
|
|
2d97329836 | ||
|
|
730d030623 | ||
|
|
2e29daab1d | ||
|
|
d7b48b4f61 | ||
|
|
24b98f10e3 | ||
|
|
5fd6f5e4cc | ||
|
|
c21af649c3 | ||
|
|
1d8a23922e | ||
|
|
68893ab1f4 | ||
|
|
dcf172bd98 | ||
|
|
6dd466a9ce | ||
|
|
ca222d4110 | ||
|
|
c9af9582e3 | ||
|
|
a3028127ce | ||
|
|
23133194b2 | ||
|
|
b84555db6c | ||
|
|
229298df1b | ||
|
|
fb270cb50b | ||
|
|
a861c880ba | ||
|
|
9712d3f527 | ||
|
|
51a7ce41de | ||
|
|
4c657c66d0 | ||
|
|
61c3fafd94 | ||
|
|
bab95530c8 | ||
|
|
68afba45bc | ||
|
|
1a52f19b32 | ||
|
|
aaef0a7e8d | ||
|
|
02d9023f9d | ||
|
|
59a27cdd4d | ||
|
|
9440782027 | ||
|
|
f19ec05780 | ||
|
|
d95b75e317 | ||
|
|
a6dec31461 | ||
|
|
b12427daa6 | ||
|
|
892686b213 | ||
|
|
81e71cd7a2 | ||
|
|
38a5e3637d | ||
|
|
eb16e769fe | ||
|
|
9417f0dd3d | ||
|
|
68d82f7a3a | ||
|
|
b8fa041dcb | ||
|
|
1df07dc58d | ||
|
|
e63358f776 | ||
|
|
9518189898 | ||
|
|
70a9cab6f2 | ||
|
|
495ec80ebd | ||
|
|
34454fee10 | ||
|
|
fbac4be4a5 | ||
|
|
a150e6109e | ||
|
|
f392075836 | ||
|
|
5d9dd394ea | ||
|
|
162c189d4f | ||
|
|
01902f8a79 | ||
|
|
399a01a937 | ||
|
|
dcaa19071f | ||
|
|
a6280805e6 | ||
|
|
3d8843109a | ||
|
|
16eaccc8e3 | ||
|
|
9df996a507 | ||
|
|
fa0b9ed6ab | ||
|
|
9b8536a581 | ||
|
|
9158f4f8f1 | ||
|
|
072f19e157 | ||
|
|
44198fa17a | ||
|
|
83ccb398c5 | ||
|
|
824e335dec | ||
|
|
e28e9d71ae | ||
|
|
46acc76f07 | ||
|
|
356fa4eb7c | ||
|
|
09a6c631cb | ||
|
|
cf19816e06 | ||
|
|
2c4b9f5a50 | ||
|
|
9e29d45fc3 | ||
|
|
b4e350a308 | ||
|
|
8274072b6a | ||
|
|
1ddd146558 | ||
|
|
2d76fc6e8c | ||
|
|
e19e541a70 | ||
|
|
3a56c82501 | ||
|
|
2f0e385ca2 | ||
|
|
9241a4729c | ||
|
|
ea331bd4ef | ||
|
|
c2b598d98e | ||
|
|
2fde05412c | ||
|
|
d8436d3273 | ||
|
|
e662dae396 | ||
|
|
e74c5d8609 | ||
|
|
3f05a8408d | ||
|
|
61f0ba4643 | ||
|
|
b773ec7690 | ||
|
|
df294730a0 | ||
|
|
ee1cd38fa2 | ||
|
|
a012328f4c | ||
|
|
1df9656191 | ||
|
|
fde2d0887e | ||
|
|
356e439715 | ||
|
|
941e823374 | ||
|
|
f74a1ceb47 |
No files matched your search
@@ -6,3 +6,7 @@ indent_size = 2
|
||||
charset = utf-8
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.swift]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
@@ -72,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.10.0'
|
||||
- 'iOS App - 0.10.0'
|
||||
- 'Android App - 0.9.81'
|
||||
- 'iOS App - 0.9.81'
|
||||
- 'Android App - 0.14.1'
|
||||
- 'iOS App - 0.14.1'
|
||||
- 'Android App - 0.14.0'
|
||||
- 'iOS App - 0.14.0'
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -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.10.0'
|
||||
- 'iOS App - 0.10.0'
|
||||
- 'Android App - 0.9.81'
|
||||
- 'iOS App - 0.9.81'
|
||||
- 'Android App - 0.14.1'
|
||||
- 'iOS App - 0.14.1'
|
||||
- 'Android App - 0.14.0'
|
||||
- 'iOS App - 0.14.0'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
java-version: 21
|
||||
|
||||
- name: install dependencies
|
||||
run: npm ci
|
||||
run: npm ci --omit=optional
|
||||
|
||||
- name: build Nuxt project
|
||||
run: npm run generate
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
name: Build iOS
|
||||
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- 'android/**'
|
||||
- '.github/workflows/build-apk.yml'
|
||||
- '.github/workflows/deploy-apk.yml'
|
||||
- 'readme.md'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- 'android/**'
|
||||
- '.github/workflows/build-apk.yml'
|
||||
- '.github/workflows/deploy-apk.yml'
|
||||
- 'readme.md'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build iOS simulator app
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Check out sources
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Install JavaScript dependencies
|
||||
run: npm ci --omit=optional
|
||||
|
||||
- name: Build shared web app
|
||||
run: npm run generate
|
||||
|
||||
- name: Sync Capacitor iOS project
|
||||
run: npx cap sync ios
|
||||
|
||||
- name: Install CocoaPods dependencies
|
||||
working-directory: ios/App
|
||||
run: pod install --deployment
|
||||
|
||||
- name: Build iOS simulator app
|
||||
working-directory: ios/App
|
||||
run: |
|
||||
xcodebuild -quiet \
|
||||
-workspace App.xcworkspace \
|
||||
-scheme App \
|
||||
-configuration Debug \
|
||||
-sdk iphonesimulator \
|
||||
-destination 'generic/platform=iOS Simulator' \
|
||||
-derivedDataPath build \
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME=IconsDebug \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
build
|
||||
|
||||
echo "iOS simulator build succeeded."
|
||||
|
||||
- name: Upload iOS simulator app
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: audiobookshelf-ios-simulator
|
||||
path: ios/App/build/Build/Products/Debug-iphonesimulator/Audiobookshelf.app
|
||||
if-no-files-found: error
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
java-version: 21
|
||||
|
||||
- name: install dependencies
|
||||
run: npm ci
|
||||
run: npm ci --omit=optional
|
||||
|
||||
- name: build Nuxt project
|
||||
run: npm run generate
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Prepare translation credits
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release-notes:
|
||||
if: github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, 'weblate')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- id: translator-credits
|
||||
uses: audiobookshelf/get-translator-credits@v1.1.0
|
||||
|
||||
- name: Show translator credits
|
||||
run: |
|
||||
printf '%s\n' "${{ steps.translator-credits.outputs.credits }}"
|
||||
@@ -36,8 +36,8 @@ android {
|
||||
applicationId "com.audiobookshelf.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 114
|
||||
versionName "0.10.1-beta"
|
||||
versionCode 121
|
||||
versionName "0.14.2-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
manifestPlaceholders = [
|
||||
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
||||
@@ -92,6 +92,7 @@ dependencies {
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
|
||||
implementation "androidx.core:core-ktx:$androidx_core_ktx_version"
|
||||
implementation "androidx.work:work-runtime-ktx:2.9.1"
|
||||
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
|
||||
<!-- Permissions -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -36,6 +38,10 @@
|
||||
<meta-data
|
||||
android:name="android.appwidget.provider"
|
||||
android:resource="@xml/media_player_widget_info" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.samsung.android.appwidget.provider"
|
||||
android:resource="@xml/samsung_cover_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- Used by Android Auto -->
|
||||
@@ -94,6 +100,12 @@
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".services.DownloadService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
||||
@@ -40,9 +40,6 @@ class MainActivity : BridgeActivity() {
|
||||
val storage = SimpleStorage(this)
|
||||
|
||||
val REQUEST_PERMISSIONS = 1
|
||||
var PERMISSIONS_ALL = arrayOf(
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
)
|
||||
|
||||
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||
DbManager.initialize(applicationContext)
|
||||
@@ -98,11 +95,20 @@ class MainActivity : BridgeActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
if (permission != PackageManager.PERMISSION_GRANTED) {
|
||||
ActivityCompat.requestPermissions(this,
|
||||
PERMISSIONS_ALL,
|
||||
REQUEST_PERMISSIONS)
|
||||
requestNeededPermissions()
|
||||
}
|
||||
|
||||
private fun requestNeededPermissions() {
|
||||
val needed = mutableListOf<String>()
|
||||
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
needed.add(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
needed.add(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
if (needed.isNotEmpty()) {
|
||||
ActivityCompat.requestPermissions(this, needed.toTypedArray(), REQUEST_PERMISSIONS)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ data class AudioTrack(
|
||||
var duration: Double,
|
||||
var title: String,
|
||||
var contentUrl: String,
|
||||
var mimeType: String,
|
||||
var mimeType: String?,
|
||||
var metadata: FileMetadata?,
|
||||
var isLocal: Boolean,
|
||||
var localFileId: String?,
|
||||
|
||||
@@ -1,74 +1,102 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.util.Log
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo
|
||||
import java.io.File
|
||||
|
||||
enum class LockOrientationSetting {
|
||||
NONE, PORTRAIT, LANDSCAPE
|
||||
NONE,
|
||||
PORTRAIT,
|
||||
LANDSCAPE
|
||||
}
|
||||
|
||||
enum class HapticFeedbackSetting {
|
||||
OFF, LIGHT, MEDIUM, HEAVY
|
||||
OFF,
|
||||
LIGHT,
|
||||
MEDIUM,
|
||||
HEAVY
|
||||
}
|
||||
|
||||
enum class ShakeSensitivitySetting {
|
||||
VERY_LOW, LOW, MEDIUM, HIGH, VERY_HIGH
|
||||
VERY_LOW,
|
||||
LOW,
|
||||
MEDIUM,
|
||||
HIGH,
|
||||
VERY_HIGH
|
||||
}
|
||||
|
||||
enum class DownloadUsingCellularSetting {
|
||||
ASK, ALWAYS, NEVER
|
||||
ASK,
|
||||
ALWAYS,
|
||||
NEVER
|
||||
}
|
||||
|
||||
enum class StreamingUsingCellularSetting {
|
||||
ASK, ALWAYS, NEVER
|
||||
ASK,
|
||||
ALWAYS,
|
||||
NEVER
|
||||
}
|
||||
|
||||
enum class AndroidAutoBrowseSeriesSequenceOrderSetting {
|
||||
ASC, DESC
|
||||
ASC,
|
||||
DESC
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class ServerConnectionConfig(
|
||||
var id:String,
|
||||
var index:Int,
|
||||
var name:String,
|
||||
var address:String,
|
||||
// version added after 0.9.81-beta
|
||||
var version:String?,
|
||||
var userId:String,
|
||||
var username:String,
|
||||
var token:String,
|
||||
var customHeaders:Map<String, String>?
|
||||
var id: String,
|
||||
var index: Int,
|
||||
var name: String,
|
||||
var address: String,
|
||||
// version added after 0.9.81-beta
|
||||
var version: String?,
|
||||
var userId: String,
|
||||
var username: String,
|
||||
var token: String,
|
||||
var customHeaders: Map<String, String>?
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LocalFile(
|
||||
var id:String,
|
||||
var filename:String?,
|
||||
var contentUrl:String,
|
||||
var basePath:String,
|
||||
var absolutePath:String,
|
||||
var simplePath:String,
|
||||
var mimeType:String?,
|
||||
var size:Long
|
||||
var id: String,
|
||||
var filename: String?,
|
||||
var contentUrl: String,
|
||||
var basePath: String,
|
||||
var absolutePath: String,
|
||||
var mimeType: String?,
|
||||
var size: Long
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun isAudioFile():Boolean {
|
||||
fun exists(ctx: Context): Boolean {
|
||||
if (contentUrl.startsWith("content:")) {
|
||||
return try {
|
||||
ctx.contentResolver.openFileDescriptor(Uri.parse(contentUrl), "r")?.use { true } ?: false
|
||||
} catch (e: Exception) {
|
||||
Log.w("LocalFile", "Cannot access SAF file $contentUrl", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
return File(absolutePath).exists()
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun isAudioFile(): Boolean {
|
||||
if (mimeType == "application/octet-stream") return true
|
||||
if (mimeType == "video/mp4") return true
|
||||
return mimeType?.startsWith("audio") == true
|
||||
}
|
||||
@JsonIgnore
|
||||
fun isEBookFile():Boolean {
|
||||
fun isEBookFile(): Boolean {
|
||||
return getEBookFormat() != null
|
||||
}
|
||||
@JsonIgnore
|
||||
fun getEBookFormat():String? {
|
||||
fun getEBookFormat(): String? {
|
||||
if (mimeType == "application/epub+zip") return "epub"
|
||||
if (mimeType == "application/pdf") return "pdf"
|
||||
if (mimeType == "application/x-mobipocket-ebook") return "mobi"
|
||||
@@ -81,118 +109,124 @@ data class LocalFile(
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LocalFolder(
|
||||
var id:String,
|
||||
var name:String,
|
||||
var contentUrl:String,
|
||||
var basePath:String,
|
||||
var absolutePath:String,
|
||||
var simplePath:String,
|
||||
var storageType:String,
|
||||
var mediaType:String
|
||||
var id: String,
|
||||
var name: String,
|
||||
var contentUrl: String,
|
||||
var basePath: String,
|
||||
var absolutePath: String,
|
||||
var storageType: String,
|
||||
var mediaType: String
|
||||
)
|
||||
|
||||
@JsonTypeInfo(use= JsonTypeInfo.Id.DEDUCTION)
|
||||
@JsonSubTypes(
|
||||
JsonSubTypes.Type(LibraryItem::class),
|
||||
JsonSubTypes.Type(LocalLibraryItem::class)
|
||||
)
|
||||
open class LibraryItemWrapper(var id:String) {
|
||||
@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)
|
||||
@JsonSubTypes(JsonSubTypes.Type(LibraryItem::class), JsonSubTypes.Type(LocalLibraryItem::class))
|
||||
open class LibraryItemWrapper(var id: String) {
|
||||
@JsonIgnore
|
||||
open fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
|
||||
open fun getMediaDescription(
|
||||
progress: MediaProgressWrapper?,
|
||||
ctx: Context
|
||||
): MediaDescriptionCompat {
|
||||
return MediaDescriptionCompat.Builder().build()
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class DeviceInfo(
|
||||
var deviceId:String,
|
||||
var manufacturer:String,
|
||||
var model:String,
|
||||
var sdkVersion:Int,
|
||||
var clientVersion: String
|
||||
var deviceId: String,
|
||||
var manufacturer: String,
|
||||
var model: String,
|
||||
var sdkVersion: Int,
|
||||
var clientVersion: String
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class PlayItemRequestPayload(
|
||||
var mediaPlayer:String,
|
||||
var forceDirectPlay:Boolean,
|
||||
var forceTranscode:Boolean,
|
||||
var deviceInfo:DeviceInfo
|
||||
var mediaPlayer: String,
|
||||
var forceDirectPlay: Boolean,
|
||||
var forceTranscode: Boolean,
|
||||
var deviceInfo: DeviceInfo
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class DeviceSettings(
|
||||
var disableAutoRewind:Boolean,
|
||||
var enableAltView:Boolean,
|
||||
var allowSeekingOnMediaControls:Boolean,
|
||||
var jumpBackwardsTime:Int,
|
||||
var jumpForwardTime:Int,
|
||||
var enableMp3IndexSeeking:Boolean,
|
||||
var disableShakeToResetSleepTimer:Boolean,
|
||||
var shakeSensitivity: ShakeSensitivitySetting,
|
||||
var lockOrientation: LockOrientationSetting,
|
||||
var hapticFeedback: HapticFeedbackSetting,
|
||||
var autoSleepTimer: Boolean,
|
||||
var autoSleepTimerStartTime: String,
|
||||
var autoSleepTimerEndTime: String,
|
||||
var autoSleepTimerAutoRewind: Boolean,
|
||||
var autoSleepTimerAutoRewindTime: Long, //Time in milliseconds
|
||||
var sleepTimerLength: Long, // Time in milliseconds
|
||||
var disableSleepTimerFadeOut: Boolean,
|
||||
var disableSleepTimerResetFeedback: Boolean,
|
||||
var enableSleepTimerAlmostDoneChime: Boolean,
|
||||
var languageCode: String,
|
||||
var downloadUsingCellular: DownloadUsingCellularSetting,
|
||||
var streamingUsingCellular: StreamingUsingCellularSetting,
|
||||
var androidAutoBrowseLimitForGrouping: Int,
|
||||
var androidAutoBrowseSeriesSequenceOrder: AndroidAutoBrowseSeriesSequenceOrderSetting
|
||||
var disableAutoRewind: Boolean,
|
||||
var enableAltView: Boolean,
|
||||
var allowSeekingOnMediaControls: Boolean,
|
||||
var jumpBackwardsTime: Int,
|
||||
var jumpForwardTime: Int,
|
||||
var enableMp3IndexSeeking: Boolean,
|
||||
var disableShakeToResetSleepTimer: Boolean,
|
||||
var shakeSensitivity: ShakeSensitivitySetting,
|
||||
var lockOrientation: LockOrientationSetting,
|
||||
var hapticFeedback: HapticFeedbackSetting,
|
||||
var autoSleepTimer: Boolean,
|
||||
var autoSleepTimerStartTime: String,
|
||||
var autoSleepTimerEndTime: String,
|
||||
var autoSleepTimerAutoRewind: Boolean,
|
||||
var autoSleepTimerAutoRewindTime: Long, // Time in milliseconds
|
||||
var sleepTimerLength: Long, // Time in milliseconds
|
||||
var disableSleepTimerFadeOut: Boolean,
|
||||
var disableSleepTimerResetFeedback: Boolean,
|
||||
var enableSleepTimerAlmostDoneChime: Boolean,
|
||||
var languageCode: String,
|
||||
var downloadUsingCellular: DownloadUsingCellularSetting,
|
||||
var streamingUsingCellular: StreamingUsingCellularSetting,
|
||||
var androidAutoBrowseLimitForGrouping: Int,
|
||||
var androidAutoBrowseSeriesSequenceOrder: AndroidAutoBrowseSeriesSequenceOrderSetting
|
||||
) {
|
||||
companion object {
|
||||
// Static method to get default device settings
|
||||
fun default():DeviceSettings {
|
||||
fun default(): DeviceSettings {
|
||||
return DeviceSettings(
|
||||
disableAutoRewind = false,
|
||||
enableAltView = true,
|
||||
allowSeekingOnMediaControls = false,
|
||||
jumpBackwardsTime = 10,
|
||||
jumpForwardTime = 10,
|
||||
enableMp3IndexSeeking = false,
|
||||
disableShakeToResetSleepTimer = false,
|
||||
shakeSensitivity = ShakeSensitivitySetting.MEDIUM,
|
||||
lockOrientation = LockOrientationSetting.NONE,
|
||||
hapticFeedback = HapticFeedbackSetting.LIGHT,
|
||||
autoSleepTimer = false,
|
||||
autoSleepTimerStartTime = "22:00",
|
||||
autoSleepTimerEndTime = "06:00",
|
||||
sleepTimerLength = 900000L, // 15 minutes
|
||||
autoSleepTimerAutoRewind = false,
|
||||
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
|
||||
disableSleepTimerFadeOut = false,
|
||||
disableSleepTimerResetFeedback = false,
|
||||
enableSleepTimerAlmostDoneChime = false,
|
||||
languageCode = "en-us",
|
||||
downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS,
|
||||
streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS,
|
||||
androidAutoBrowseLimitForGrouping = 100,
|
||||
androidAutoBrowseSeriesSequenceOrder = AndroidAutoBrowseSeriesSequenceOrderSetting.ASC
|
||||
disableAutoRewind = false,
|
||||
enableAltView = true,
|
||||
allowSeekingOnMediaControls = false,
|
||||
jumpBackwardsTime = 10,
|
||||
jumpForwardTime = 10,
|
||||
enableMp3IndexSeeking = false,
|
||||
disableShakeToResetSleepTimer = false,
|
||||
shakeSensitivity = ShakeSensitivitySetting.MEDIUM,
|
||||
lockOrientation = LockOrientationSetting.NONE,
|
||||
hapticFeedback = HapticFeedbackSetting.LIGHT,
|
||||
autoSleepTimer = false,
|
||||
autoSleepTimerStartTime = "22:00",
|
||||
autoSleepTimerEndTime = "06:00",
|
||||
sleepTimerLength = 900000L, // 15 minutes
|
||||
autoSleepTimerAutoRewind = false,
|
||||
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
|
||||
disableSleepTimerFadeOut = false,
|
||||
disableSleepTimerResetFeedback = false,
|
||||
enableSleepTimerAlmostDoneChime = false,
|
||||
languageCode = "en-us",
|
||||
downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS,
|
||||
streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS,
|
||||
androidAutoBrowseLimitForGrouping = 100,
|
||||
androidAutoBrowseSeriesSequenceOrder = AndroidAutoBrowseSeriesSequenceOrderSetting.ASC
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@get:JsonIgnore
|
||||
val jumpBackwardsTimeMs get() = jumpBackwardsTime * 1000L
|
||||
val jumpBackwardsTimeMs
|
||||
get() = jumpBackwardsTime * 1000L
|
||||
@get:JsonIgnore
|
||||
val jumpForwardTimeMs get() = jumpForwardTime * 1000L
|
||||
val jumpForwardTimeMs
|
||||
get() = jumpForwardTime * 1000L
|
||||
@get:JsonIgnore
|
||||
val autoSleepTimerStartHour get() = autoSleepTimerStartTime.split(":")[0].toInt()
|
||||
val autoSleepTimerStartHour
|
||||
get() = autoSleepTimerStartTime.split(":")[0].toInt()
|
||||
@get:JsonIgnore
|
||||
val autoSleepTimerStartMinute get() = autoSleepTimerStartTime.split(":")[1].toInt()
|
||||
val autoSleepTimerStartMinute
|
||||
get() = autoSleepTimerStartTime.split(":")[1].toInt()
|
||||
@get:JsonIgnore
|
||||
val autoSleepTimerEndHour get() = autoSleepTimerEndTime.split(":")[0].toInt()
|
||||
val autoSleepTimerEndHour
|
||||
get() = autoSleepTimerEndTime.split(":")[0].toInt()
|
||||
@get:JsonIgnore
|
||||
val autoSleepTimerEndMinute get() = autoSleepTimerEndTime.split(":")[1].toInt()
|
||||
|
||||
val autoSleepTimerEndMinute
|
||||
get() = autoSleepTimerEndTime.split(":")[1].toInt()
|
||||
|
||||
@JsonIgnore
|
||||
fun getShakeThresholdGravity() : Float { // Used in ShakeDetector
|
||||
fun getShakeThresholdGravity(): Float { // Used in ShakeDetector
|
||||
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.1f
|
||||
else if (shakeSensitivity == ShakeSensitivitySetting.HIGH) 1.3f
|
||||
else if (shakeSensitivity == ShakeSensitivitySetting.MEDIUM) 1.5f
|
||||
@@ -206,10 +240,10 @@ data class DeviceSettings(
|
||||
}
|
||||
|
||||
data class DeviceData(
|
||||
var serverConnectionConfigs:MutableList<ServerConnectionConfig>,
|
||||
var lastServerConnectionConfigId:String?,
|
||||
var deviceSettings: DeviceSettings?,
|
||||
var lastPlaybackSession: PlaybackSession?
|
||||
var serverConnectionConfigs: MutableList<ServerConnectionConfig>,
|
||||
var lastServerConnectionConfigId: String?,
|
||||
var deviceSettings: DeviceSettings?,
|
||||
var lastPlaybackSession: PlaybackSession?
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun getLastServerConnectionConfig(): ServerConnectionConfig? {
|
||||
@@ -218,4 +252,3 @@ data class DeviceData(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
data class FolderScanResult(
|
||||
var itemsAdded:Int,
|
||||
var itemsUpdated:Int,
|
||||
var itemsRemoved:Int,
|
||||
var itemsUpToDate:Int,
|
||||
val localFolder:LocalFolder,
|
||||
val localLibraryItems:List<LocalLibraryItem>,
|
||||
)
|
||||
|
||||
data class LocalLibraryItemScanResult(
|
||||
val updated:Boolean,
|
||||
val localLibraryItem:LocalLibraryItem,
|
||||
)
|
||||
@@ -80,7 +80,7 @@ class LocalLibraryItem(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun hasTracks(episode:PodcastEpisode?): Boolean {
|
||||
fun hasTracks(ctx: Context, episode:PodcastEpisode?): Boolean {
|
||||
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
|
||||
if (episode != null) { // Get podcast episode audio track
|
||||
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
|
||||
@@ -91,15 +91,18 @@ class LocalLibraryItem(
|
||||
if (it.metadata === null) {
|
||||
return false
|
||||
}
|
||||
// Check that file exists
|
||||
val file = File(it.metadata!!.path)
|
||||
if (!file.exists()) {
|
||||
if (!trackExists(ctx, it.contentUrl, it.metadata!!.path)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
private fun trackExists(ctx: Context, contentUrl: String?, path: String): Boolean {
|
||||
return LocalFile("", null, contentUrl ?: "", "", path, null, 0).exists(ctx)
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getPlaybackSession(episode:PodcastEpisode?, deviceInfo:DeviceInfo):PlaybackSession {
|
||||
val localEpisodeId = episode?.id
|
||||
@@ -108,7 +111,7 @@ class LocalLibraryItem(
|
||||
// Get current progress for local media
|
||||
val mediaProgressId = if (localEpisodeId.isNullOrEmpty()) id else "$id-$localEpisodeId"
|
||||
val mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(mediaProgressId)
|
||||
val currentTime = mediaProgress?.currentTime ?: 0.0
|
||||
val currentTime = if (mediaProgress?.isFinished == true) 0.0 else mediaProgress?.currentTime ?: 0.0
|
||||
|
||||
|
||||
val mediaMetadata = media.metadata
|
||||
|
||||
@@ -14,7 +14,6 @@ data class LocalMediaItem(
|
||||
var mediaType: String,
|
||||
var folderId: String,
|
||||
var contentUrl: String,
|
||||
var simplePath: String,
|
||||
var basePath: String,
|
||||
var absolutePath: String,
|
||||
var audioTracks: MutableList<AudioTrack>,
|
||||
|
||||
@@ -46,6 +46,10 @@ class LocalMediaProgress(
|
||||
progress = if (finished) 1.0 else 0.0
|
||||
}
|
||||
|
||||
if (finished) {
|
||||
currentTime = 0.0
|
||||
}
|
||||
|
||||
isFinished = finished
|
||||
lastUpdate = System.currentTimeMillis()
|
||||
finishedAt = if (isFinished) lastUpdate else null
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.ImageDecoder
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
@@ -20,6 +21,9 @@ 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 kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class PlaybackSession(
|
||||
@@ -202,6 +206,13 @@ class PlaybackSession(
|
||||
return Uri.parse("$serverAddress${audioTrack.contentUrl}?token=${DeviceManager.token}")
|
||||
}
|
||||
|
||||
/** Bitmap for the cover art, once resolved */
|
||||
@JsonIgnore
|
||||
private var resolvedCoverBitmap: Bitmap? = null
|
||||
|
||||
/**
|
||||
* Builds the current session metadata, including the cover art bitmap if it has already been resolved.
|
||||
*/
|
||||
@JsonIgnore
|
||||
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
|
||||
val coverUri = getCoverUri(ctx)
|
||||
@@ -217,16 +228,42 @@ class PlaybackSession(
|
||||
.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())
|
||||
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, totalDurationMs)
|
||||
.putString(
|
||||
MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI,
|
||||
coverUri.toString()
|
||||
)
|
||||
|
||||
// Local covers get bitmap
|
||||
if (resolvedCoverBitmap != null) {
|
||||
metadataBuilder
|
||||
.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, resolvedCoverBitmap)
|
||||
.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, resolvedCoverBitmap)
|
||||
} else {
|
||||
metadataBuilder
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
|
||||
}
|
||||
|
||||
return metadataBuilder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the cover art bitmap (local covers are decoded synchronously, server-side covers
|
||||
* are fetched asynchronously) and calls `onArtResolved` once it's available
|
||||
*
|
||||
* Returns the Job for the async fetch, or `null` if the bitmap was resolved synchronously.
|
||||
*/
|
||||
@JsonIgnore
|
||||
fun resolveCoverBitmapAsync(
|
||||
ctx: Context,
|
||||
coroutineScope: CoroutineScope,
|
||||
onArtResolved: () -> Unit
|
||||
): Job? {
|
||||
val coverUri = getCoverUri(ctx)
|
||||
|
||||
// Local covers get bitmap synchronously, no async fetch needed
|
||||
if (localLibraryItem?.coverContentUrl != null) {
|
||||
val bitmap =
|
||||
resolvedCoverBitmap =
|
||||
if (Build.VERSION.SDK_INT < 28) {
|
||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||
} else {
|
||||
@@ -234,11 +271,18 @@ class PlaybackSession(
|
||||
ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
}
|
||||
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
|
||||
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
|
||||
onArtResolved()
|
||||
return null
|
||||
}
|
||||
|
||||
return metadataBuilder.build()
|
||||
// Server-side cover: resolve the art bitmap async
|
||||
return coroutineScope.launch {
|
||||
val bitmap = resolveUriAsBitmap(ctx, coverUri)
|
||||
bitmap?.let {
|
||||
resolvedCoverBitmap = it
|
||||
onArtResolved()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
@@ -332,7 +376,7 @@ class PlaybackSession(
|
||||
MediaInfo.Builder(mediaUri.toString())
|
||||
.apply {
|
||||
setContentUrl(mediaUri.toString())
|
||||
setContentType(audioTrack.mimeType)
|
||||
setContentType(audioTrack.mimeType ?: "")
|
||||
setMetadata(castMetadata)
|
||||
setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
|
||||
}
|
||||
|
||||
@@ -7,570 +7,306 @@ import androidx.documentfile.provider.DocumentFile
|
||||
import com.anggrayudi.storage.file.*
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.audiobookshelf.app.models.DownloadItemPart
|
||||
import java.io.File
|
||||
|
||||
class FolderScanner(var ctx: Context) {
|
||||
/** Creates local-library records from a completed download manifest. */
|
||||
class FolderScanner(private val ctx: Context) {
|
||||
private val tag = "FolderScanner"
|
||||
private var jacksonMapper =
|
||||
jacksonObjectMapper()
|
||||
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
data class DownloadItemScanResult(
|
||||
val localLibraryItem: LocalLibraryItem,
|
||||
var localMediaProgress: LocalMediaProgress?
|
||||
)
|
||||
|
||||
private fun getLocalLibraryItemId(mediaItemId: String): String {
|
||||
return "local_" + DeviceManager.getBase64Id(mediaItemId)
|
||||
}
|
||||
private fun localLibraryItemId(mediaItemId: String) =
|
||||
"local_${DeviceManager.getBase64Id(mediaItemId)}"
|
||||
|
||||
private fun scanInternalDownloadItem(
|
||||
downloadItem: DownloadItem,
|
||||
cb: (DownloadItemScanResult?) -> Unit
|
||||
) {
|
||||
val localLibraryItemId = "local_${downloadItem.libraryItemId}"
|
||||
|
||||
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
|
||||
)
|
||||
} 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
|
||||
)
|
||||
}
|
||||
private fun createLocalFile(
|
||||
part: DownloadItemPart,
|
||||
externalFile: DocumentFile? = null
|
||||
): LocalFile? {
|
||||
if (part.isInternalStorage) {
|
||||
val file = File(part.finalDestinationPath)
|
||||
if (!file.exists()) return null
|
||||
return LocalFile(
|
||||
DeviceManager.getBase64Id(file.name),
|
||||
file.name,
|
||||
Uri.fromFile(file).toString(),
|
||||
file.getBasePath(ctx),
|
||||
file.absolutePath,
|
||||
file.mimeType,
|
||||
file.length()
|
||||
)
|
||||
}
|
||||
|
||||
val audioTracks: MutableList<AudioTrack> = mutableListOf()
|
||||
var foundEBookFile = false
|
||||
|
||||
downloadItem.downloadItemParts.forEach { downloadItemPart ->
|
||||
Log.d(
|
||||
tag,
|
||||
"Scan internal storage item with finalDestinationUri=${downloadItemPart.finalDestinationUri}"
|
||||
part.completedDestinationUri?.let { contentUrl ->
|
||||
val uri = Uri.parse(contentUrl)
|
||||
val size =
|
||||
try {
|
||||
ctx.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
|
||||
descriptor.statSize.coerceAtLeast(0L)
|
||||
}
|
||||
?: 0L
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Could not open completed SAF file: $contentUrl", e)
|
||||
return null
|
||||
}
|
||||
// Android 10 DownloadsProvider may not reconstruct a DocumentFile for an audio URI even
|
||||
// though the URI remains readable. Keep the URI as the authoritative local-file location.
|
||||
return LocalFile(
|
||||
DeviceManager.getBase64Id(contentUrl),
|
||||
part.filename,
|
||||
contentUrl,
|
||||
part.localFolderName,
|
||||
part.finalDestinationPath,
|
||||
mimeTypeFor(part),
|
||||
size
|
||||
)
|
||||
}
|
||||
|
||||
val file = File(downloadItemPart.finalDestinationPath)
|
||||
Log.d(tag, "Scan internal storage item created file ${file.name}")
|
||||
// Do not reconstruct a DocumentFile from an absolute path: on Android 10 that becomes a
|
||||
// file:// URI, which DocumentsContract rejects. The caller resolves this from the persisted
|
||||
// SAF tree grant instead.
|
||||
val document = externalFile
|
||||
if (document == null || !document.exists()) {
|
||||
Log.e(tag, "Could not resolve downloaded SAF file: ${part.finalDestinationPath}")
|
||||
return null
|
||||
}
|
||||
return LocalFile(
|
||||
DeviceManager.getBase64Id(document.id),
|
||||
document.name,
|
||||
document.uri.toString(),
|
||||
document.getBasePath(ctx),
|
||||
document.getAbsolutePath(ctx),
|
||||
document.mimeType,
|
||||
document.length()
|
||||
)
|
||||
}
|
||||
|
||||
if (file == null) {
|
||||
Log.e(
|
||||
tag,
|
||||
"scanInternalDownloadItem: Null docFile for path ${downloadItemPart.finalDestinationPath}"
|
||||
)
|
||||
} else {
|
||||
if (downloadItemPart.audioTrack != null) {
|
||||
val audioTrackFromServer = downloadItemPart.audioTrack
|
||||
Log.d(
|
||||
tag,
|
||||
"scanInternalDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}"
|
||||
private fun newLocalLibraryItem(
|
||||
id: String,
|
||||
downloadItem: DownloadItem,
|
||||
basePath: String,
|
||||
absolutePath: String,
|
||||
contentUrl: String
|
||||
) =
|
||||
LocalLibraryItem(
|
||||
id,
|
||||
downloadItem.localFolder.id,
|
||||
basePath,
|
||||
absolutePath,
|
||||
contentUrl,
|
||||
false,
|
||||
downloadItem.mediaType,
|
||||
downloadItem.media.getLocalCopy(),
|
||||
mutableListOf(),
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
downloadItem.serverConnectionConfigId,
|
||||
downloadItem.serverAddress,
|
||||
downloadItem.serverUserId,
|
||||
downloadItem.libraryItemId
|
||||
)
|
||||
|
||||
val localFileId = DeviceManager.getBase64Id(file.name)
|
||||
Log.d(tag, "Scan internal file localFileId=$localFileId")
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
file.name,
|
||||
downloadItemPart.finalDestinationUri.toString(),
|
||||
file.getBasePath(ctx),
|
||||
file.absolutePath,
|
||||
file.getSimplePath(ctx),
|
||||
file.mimeType,
|
||||
file.length()
|
||||
)
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
private fun scanParts(
|
||||
item: DownloadItem,
|
||||
localItem: LocalLibraryItem,
|
||||
externalFolder: DocumentFile? = null,
|
||||
callback: (DownloadItemScanResult?) -> Unit
|
||||
) {
|
||||
val tracks = mutableListOf<AudioTrack>()
|
||||
var foundEbook = false
|
||||
var localEpisodeId: String? = null
|
||||
|
||||
val trackFileMetadata =
|
||||
item.downloadItemParts.forEach { part ->
|
||||
val externalFile =
|
||||
if (part.isInternalStorage) {
|
||||
null
|
||||
} else {
|
||||
part.completedDestinationUri?.let { DocumentFileCompat.fromUri(ctx, Uri.parse(it)) }
|
||||
?: resolveExternalFile(externalFolder, part)
|
||||
}
|
||||
Log.d(tag, "Resolve part ${part.filename}: externalFile=${externalFile?.uri}")
|
||||
val localFile = createLocalFile(part, externalFile) ?: return@forEach
|
||||
when {
|
||||
part.audioTrack != null -> {
|
||||
val serverTrack = part.audioTrack
|
||||
localItem.localFiles.removeAll { it.id == localFile.id }
|
||||
localItem.localFiles.add(localFile)
|
||||
val metadata =
|
||||
FileMetadata(
|
||||
file.name,
|
||||
file.extension,
|
||||
file.absolutePath,
|
||||
file.getBasePath(ctx),
|
||||
file.length()
|
||||
localFile.filename ?: "",
|
||||
File(localFile.filename ?: "").extension,
|
||||
localFile.absolutePath,
|
||||
localFile.basePath,
|
||||
localFile.size
|
||||
)
|
||||
// Create new audio track
|
||||
val track =
|
||||
AudioTrack(
|
||||
audioTrackFromServer.index,
|
||||
audioTrackFromServer.startOffset,
|
||||
audioTrackFromServer.duration,
|
||||
serverTrack.index,
|
||||
serverTrack.startOffset,
|
||||
serverTrack.duration,
|
||||
localFile.filename ?: "",
|
||||
localFile.contentUrl,
|
||||
localFile.mimeType ?: "",
|
||||
trackFileMetadata,
|
||||
metadata,
|
||||
true,
|
||||
localFileId,
|
||||
audioTrackFromServer.index
|
||||
localFile.id,
|
||||
serverTrack.index
|
||||
)
|
||||
audioTracks.add(track)
|
||||
|
||||
Log.d(
|
||||
tag,
|
||||
"scanInternalDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}"
|
||||
)
|
||||
|
||||
// Add podcast episodes to library
|
||||
downloadItemPart.episode?.let { podcastEpisode ->
|
||||
val podcast = localLibraryItem.media as Podcast
|
||||
val newEpisode = podcast.addEpisode(track, podcastEpisode)
|
||||
localEpisodeId = newEpisode.id
|
||||
Log.d(
|
||||
tag,
|
||||
"scanInternalDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}"
|
||||
)
|
||||
tracks.add(track)
|
||||
Log.d(tag, "Added local audio track ${track.contentUrl} (${track.metadata?.path})")
|
||||
part.episode?.let { episode ->
|
||||
val podcast = localItem.media as Podcast
|
||||
localEpisodeId = podcast.addEpisode(track, episode).id
|
||||
}
|
||||
} else if (downloadItemPart.ebookFile != null) {
|
||||
foundEBookFile = true
|
||||
Log.d(tag, "scanInternalDownloadItem: Ebook file found with mimetype=${file.mimeType}")
|
||||
val localFileId = DeviceManager.getBase64Id(file.name)
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
file.name,
|
||||
Uri.fromFile(file).toString(),
|
||||
file.getBasePath(ctx),
|
||||
file.absolutePath,
|
||||
file.getSimplePath(ctx),
|
||||
file.mimeType,
|
||||
file.length()
|
||||
)
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
val ebookFile =
|
||||
}
|
||||
part.ebookFile != null -> {
|
||||
foundEbook = true
|
||||
localItem.localFiles.removeAll { it.id == localFile.id }
|
||||
localItem.localFiles.add(localFile)
|
||||
(localItem.media as Book).ebookFile =
|
||||
EBookFile(
|
||||
downloadItemPart.ebookFile.ino,
|
||||
downloadItemPart.ebookFile.metadata,
|
||||
downloadItemPart.ebookFile.ebookFormat,
|
||||
part.ebookFile.ino,
|
||||
part.ebookFile.metadata,
|
||||
part.ebookFile.ebookFormat,
|
||||
true,
|
||||
localFileId,
|
||||
localFile.id,
|
||||
localFile.contentUrl
|
||||
)
|
||||
(localLibraryItem.media as Book).ebookFile = ebookFile
|
||||
Log.d(tag, "scanInternalDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
|
||||
} else {
|
||||
val localFileId = DeviceManager.getBase64Id(file.name)
|
||||
val localFile =
|
||||
LocalFile(
|
||||
localFileId,
|
||||
file.name,
|
||||
Uri.fromFile(file).toString(),
|
||||
file.getBasePath(ctx),
|
||||
file.absolutePath,
|
||||
file.getSimplePath(ctx),
|
||||
file.mimeType,
|
||||
file.length()
|
||||
)
|
||||
|
||||
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
||||
localLibraryItem.coverContentUrl = localFile.contentUrl
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
}
|
||||
else -> {
|
||||
localItem.coverAbsolutePath = localFile.absolutePath
|
||||
localItem.coverContentUrl = localFile.contentUrl
|
||||
localItem.localFiles.removeAll { it.id == localFile.id }
|
||||
localItem.localFiles.add(localFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (audioTracks.isEmpty() && !foundEBookFile) {
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}"
|
||||
)
|
||||
return cb(null)
|
||||
if (tracks.isEmpty() && !foundEbook) {
|
||||
callback(null)
|
||||
return
|
||||
}
|
||||
|
||||
// For books sort audio tracks then set
|
||||
if (downloadItem.mediaType == "book") {
|
||||
audioTracks.sortBy { it.index }
|
||||
|
||||
var indexCheck = 1
|
||||
var startOffset = 0.0
|
||||
audioTracks.forEach { audioTrack ->
|
||||
if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) {
|
||||
audioTrack.index = indexCheck
|
||||
audioTrack.startOffset = startOffset
|
||||
}
|
||||
indexCheck++
|
||||
startOffset += audioTrack.duration
|
||||
if (item.mediaType == "book") {
|
||||
tracks.sortBy { it.index }
|
||||
var expectedIndex = 1
|
||||
var offset = 0.0
|
||||
tracks.forEach { track ->
|
||||
track.index = expectedIndex++
|
||||
track.startOffset = offset
|
||||
offset += track.duration
|
||||
}
|
||||
|
||||
localLibraryItem.media.setAudioTracks(audioTracks)
|
||||
localItem.media.setAudioTracks(tracks)
|
||||
}
|
||||
|
||||
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 =
|
||||
val result = DownloadItemScanResult(localItem, null)
|
||||
item.userMediaProgress?.let { progress ->
|
||||
val progressId =
|
||||
if (item.episodeId.isNullOrEmpty()) localItem.id
|
||||
else "${localItem.id}-$localEpisodeId"
|
||||
result.localMediaProgress =
|
||||
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
|
||||
progressId,
|
||||
localItem.id,
|
||||
localEpisodeId,
|
||||
progress.duration,
|
||||
progress.progress,
|
||||
progress.currentTime,
|
||||
progress.isFinished,
|
||||
progress.ebookLocation,
|
||||
progress.ebookProgress,
|
||||
progress.lastUpdate,
|
||||
progress.startedAt,
|
||||
progress.finishedAt,
|
||||
item.serverConnectionConfigId,
|
||||
item.serverAddress,
|
||||
item.serverUserId,
|
||||
item.libraryItemId,
|
||||
item.episodeId
|
||||
)
|
||||
Log.d(
|
||||
tag,
|
||||
"scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}"
|
||||
)
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
|
||||
|
||||
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(result.localMediaProgress!!)
|
||||
}
|
||||
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||
|
||||
cb(downloadItemScanResult)
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(localItem)
|
||||
callback(result)
|
||||
}
|
||||
|
||||
// Scan item after download and create local library item
|
||||
fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
|
||||
// If downloading to internal storage handle separately
|
||||
if (downloadItem.isInternalStorage) {
|
||||
scanInternalDownloadItem(downloadItem, cb)
|
||||
private fun findFolderByPath(root: DocumentFile, subPath: String): DocumentFile? {
|
||||
if (subPath.isBlank()) return root
|
||||
var current = root
|
||||
subPath.split('/').filter { it.isNotBlank() }.forEach { segment ->
|
||||
if (segment == "." || segment == "..") return null
|
||||
current = current.findFile(segment) ?: return null
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
/**
|
||||
* DownloadsProvider on Android 10 may expose an audio document without its extension through
|
||||
* DocumentFile.findFile(). Match the manifest first, then match the provider-normalized base
|
||||
* filename. MIME type and server-reported size are unreliable for Opus on this platform.
|
||||
*/
|
||||
private fun resolveExternalFile(folder: DocumentFile?, part: DownloadItemPart): DocumentFile? {
|
||||
if (folder == null) return null
|
||||
folder.findFile(part.filename)?.let {
|
||||
return it
|
||||
}
|
||||
val expectedBaseName = part.filename.substringBeforeLast('.')
|
||||
return folder.listFiles().firstOrNull { document ->
|
||||
document.name == part.filename ||
|
||||
document.fullName == part.filename ||
|
||||
(part.audioTrack != null &&
|
||||
document.isFile &&
|
||||
(document.name ?: "").substringBeforeLast('.') == expectedBaseName) ||
|
||||
(part.audioTrack != null &&
|
||||
document.isFile &&
|
||||
document.fullName.substringBeforeLast('.') == expectedBaseName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mimeTypeFor(part: DownloadItemPart): String? {
|
||||
return part.audioTrack?.mimeType
|
||||
?: when (part.ebookFile?.ebookFormat?.lowercase()) {
|
||||
"epub" -> "application/epub+zip"
|
||||
"pdf" -> "application/pdf"
|
||||
else -> "image/jpeg"
|
||||
}
|
||||
}
|
||||
|
||||
fun scanDownloadItem(item: DownloadItem, callback: (DownloadItemScanResult?) -> Unit) {
|
||||
if (item.isInternalStorage) {
|
||||
val id = "local_${item.libraryItemId}"
|
||||
val localItem =
|
||||
DeviceManager.dbManager.getLocalLibraryItem(id)
|
||||
?: newLocalLibraryItem(id, item, item.itemFolderPath, item.itemFolderPath, "")
|
||||
scanParts(item, localItem, callback = callback)
|
||||
return
|
||||
}
|
||||
|
||||
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
|
||||
val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
|
||||
|
||||
var itemFolderId = ""
|
||||
var itemFolderUrl = ""
|
||||
var itemFolderBasePath = ""
|
||||
var itemFolderAbsolutePath = ""
|
||||
foldersFound.forEach {
|
||||
// e.g. absolute path is "storage/emulated/0/Audiobooks/Orson Scott Card/Enders Game"
|
||||
// and itemSubfolder is "Orson Scott Card/Enders Game"
|
||||
if (it.getAbsolutePath(ctx).endsWith(downloadItem.itemSubfolder)) {
|
||||
itemFolderId = it.id
|
||||
itemFolderUrl = it.uri.toString()
|
||||
itemFolderBasePath = it.getBasePath(ctx)
|
||||
itemFolderAbsolutePath = it.getAbsolutePath(ctx)
|
||||
}
|
||||
val root = DocumentFileCompat.fromUri(ctx, Uri.parse(item.localFolder.contentUrl))
|
||||
if (root == null) {
|
||||
Log.e(tag, "Invalid SAF root: ${item.localFolder.contentUrl}")
|
||||
callback(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (itemFolderUrl == "") {
|
||||
Log.d(tag, "scanDownloadItem failed to find media folder")
|
||||
return cb(null)
|
||||
val itemFolder = findFolderByPath(root, item.itemSubfolder)
|
||||
if (itemFolder == null) {
|
||||
Log.e(tag, "SAF item folder not found: ${item.itemSubfolder}")
|
||||
callback(null)
|
||||
return
|
||||
}
|
||||
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl))
|
||||
|
||||
if (df == null) {
|
||||
Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}")
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
val localLibraryItemId = getLocalLibraryItemId(itemFolderId)
|
||||
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/*")
|
||||
)
|
||||
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
|
||||
|
||||
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
|
||||
)
|
||||
} 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val audioTracks: MutableList<AudioTrack> = mutableListOf()
|
||||
var foundEBookFile = false
|
||||
|
||||
filesFound.forEach { docFile ->
|
||||
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}"
|
||||
)
|
||||
}
|
||||
} else if (itemPart.audioTrack != null) { // Is audio track
|
||||
val audioTrackFromServer = itemPart.audioTrack
|
||||
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()
|
||||
)
|
||||
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,
|
||||
audioTrackFromServer.index
|
||||
)
|
||||
audioTracks.add(track)
|
||||
|
||||
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}"
|
||||
)
|
||||
}
|
||||
} 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()
|
||||
)
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
|
||||
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()
|
||||
)
|
||||
|
||||
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
||||
localLibraryItem.coverContentUrl = localFile.contentUrl
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
}
|
||||
}
|
||||
|
||||
if (audioTracks.isEmpty() && !foundEBookFile) {
|
||||
Log.d(
|
||||
tag,
|
||||
"scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}"
|
||||
)
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
// For books sort audio tracks then set
|
||||
if (downloadItem.mediaType == "book") {
|
||||
audioTracks.sortBy { it.index }
|
||||
|
||||
var indexCheck = 1
|
||||
var startOffset = 0.0
|
||||
audioTracks.forEach { audioTrack ->
|
||||
if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) {
|
||||
audioTrack.index = indexCheck
|
||||
audioTrack.startOffset = startOffset
|
||||
}
|
||||
indexCheck++
|
||||
startOffset += audioTrack.duration
|
||||
}
|
||||
|
||||
localLibraryItem.media.setAudioTracks(audioTracks)
|
||||
}
|
||||
|
||||
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem, null)
|
||||
|
||||
// If library item had media progress then make local media progress and save
|
||||
downloadItem.userMediaProgress?.let { mediaProgress ->
|
||||
val localMediaProgressId =
|
||||
if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId
|
||||
else "$localLibraryItemId-$localEpisodeId"
|
||||
val newLocalMediaProgress =
|
||||
LocalMediaProgress(
|
||||
id = localMediaProgressId,
|
||||
localLibraryItemId = localLibraryItemId,
|
||||
localEpisodeId = localEpisodeId,
|
||||
duration = mediaProgress.duration,
|
||||
progress = mediaProgress.progress,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
isFinished = mediaProgress.isFinished,
|
||||
ebookLocation = mediaProgress.ebookLocation,
|
||||
ebookProgress = mediaProgress.ebookProgress,
|
||||
lastUpdate = mediaProgress.lastUpdate,
|
||||
startedAt = mediaProgress.startedAt,
|
||||
finishedAt = mediaProgress.finishedAt,
|
||||
serverConnectionConfigId = downloadItem.serverConnectionConfigId,
|
||||
serverAddress = downloadItem.serverAddress,
|
||||
serverUserId = downloadItem.serverUserId,
|
||||
libraryItemId = downloadItem.libraryItemId,
|
||||
episodeId = downloadItem.episodeId
|
||||
)
|
||||
Log.d(
|
||||
tag,
|
||||
"scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}"
|
||||
)
|
||||
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
|
||||
|
||||
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
|
||||
}
|
||||
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||
|
||||
cb(downloadItemScanResult)
|
||||
val id = localLibraryItemId(itemFolder.id)
|
||||
val localItem =
|
||||
DeviceManager.dbManager.getLocalLibraryItem(id)
|
||||
?: newLocalLibraryItem(
|
||||
id,
|
||||
item,
|
||||
itemFolder.getBasePath(ctx),
|
||||
itemFolder.getAbsolutePath(ctx),
|
||||
itemFolder.uri.toString()
|
||||
)
|
||||
scanParts(item, localItem, itemFolder, callback)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.audiobookshelf.app.plugins.AbsLog
|
||||
import com.audiobookshelf.app.plugins.AbsLogger
|
||||
import io.paperdb.Paper
|
||||
import java.io.File
|
||||
|
||||
class DbManager {
|
||||
val tag = "DbManager"
|
||||
@@ -122,6 +121,86 @@ class DbManager {
|
||||
return downloadItems
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate persisted download queue entries before restoring them.
|
||||
*
|
||||
* Queue entries can become incompatible or malformed, for example after an app update. If any
|
||||
* entry cannot be read or fails the checks required for queue restoration, the entire persisted
|
||||
* queue is cleared so startup can continue without restoring invalid data.
|
||||
*
|
||||
* @return true if the queue passes validation or was successfully cleared; false if an invalid
|
||||
* queue could not be cleared
|
||||
*/
|
||||
fun ensureValidDownloadQueue(): Boolean {
|
||||
val downloadBook = Paper.book("downloadItems")
|
||||
val queueIsValid =
|
||||
try {
|
||||
downloadBook.allKeys
|
||||
.map { downloadItemId ->
|
||||
val item =
|
||||
downloadBook.read<DownloadItem>(downloadItemId)
|
||||
?: throw IllegalStateException(
|
||||
"Download queue entry $downloadItemId could not be read"
|
||||
)
|
||||
if (item.id != downloadItemId) {
|
||||
throw IllegalStateException(
|
||||
"Download queue entry $downloadItemId has a mismatched item ID"
|
||||
)
|
||||
}
|
||||
item
|
||||
}
|
||||
.all { item ->
|
||||
listOf<String?>(item.id, item.libraryItemId, item.localFolder.id).all {
|
||||
!it.isNullOrBlank()
|
||||
} &&
|
||||
item.downloadItemParts.isNotEmpty() &&
|
||||
item.downloadItemParts.all { part ->
|
||||
listOf<String?>(
|
||||
part.id,
|
||||
part.downloadItemId,
|
||||
part.filename,
|
||||
part.destinationPath,
|
||||
part.finalDestinationPath,
|
||||
part.serverPath,
|
||||
part.localFolderId
|
||||
)
|
||||
.all { !it.isNullOrBlank() } &&
|
||||
part.downloadItemId == item.id
|
||||
}
|
||||
}
|
||||
} catch (readError: Exception) {
|
||||
AbsLogger.error(
|
||||
tag,
|
||||
"Could not read persisted download queue; clearing queue (${readError.message})"
|
||||
)
|
||||
false
|
||||
}
|
||||
|
||||
if (queueIsValid) return true
|
||||
|
||||
AbsLogger.error(tag, "Persisted download queue contains invalid data; clearing queue")
|
||||
return try {
|
||||
downloadBook.destroy()
|
||||
val remainingKeys = downloadBook.allKeys
|
||||
if (remainingKeys.isEmpty()) {
|
||||
AbsLogger.info(tag, "Cleared invalid persisted download queue")
|
||||
true
|
||||
} else {
|
||||
AbsLogger.error(
|
||||
tag,
|
||||
"Invalid persisted download queue still contains ${remainingKeys.size} entries after clearing"
|
||||
)
|
||||
false
|
||||
}
|
||||
} catch (clearError: Exception) {
|
||||
AbsLogger.error(
|
||||
tag,
|
||||
"Could not clear invalid persisted download queue (${clearError.message})"
|
||||
)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLocalMediaProgress(mediaProgress: LocalMediaProgress) {
|
||||
Paper.book("localMediaProgress").write(mediaProgress.id, mediaProgress)
|
||||
}
|
||||
@@ -148,7 +227,7 @@ class DbManager {
|
||||
}
|
||||
|
||||
// Make sure all local file ids still exist
|
||||
fun cleanLocalLibraryItems() {
|
||||
fun cleanLocalLibraryItems(context: Context) {
|
||||
val localLibraryItems = getLocalLibraryItems()
|
||||
|
||||
localLibraryItems.forEach { lli ->
|
||||
@@ -157,15 +236,15 @@ class DbManager {
|
||||
// Check local files
|
||||
lli.localFiles =
|
||||
lli.localFiles.filter { localFile ->
|
||||
val file = File(localFile.absolutePath)
|
||||
if (!file.exists()) {
|
||||
val exists = localFile.exists(context)
|
||||
if (!exists) {
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}"
|
||||
)
|
||||
hasUpdates = true
|
||||
}
|
||||
file.exists()
|
||||
exists
|
||||
} as
|
||||
MutableList<LocalFile>
|
||||
|
||||
@@ -203,9 +282,11 @@ class DbManager {
|
||||
|
||||
// Check cover still there
|
||||
lli.coverAbsolutePath?.let {
|
||||
val coverFile = File(it)
|
||||
|
||||
if (!coverFile.exists()) {
|
||||
val coverExists =
|
||||
lli.localFiles.any { localFile ->
|
||||
localFile.absolutePath == it && localFile.exists(context)
|
||||
}
|
||||
if (!coverExists) {
|
||||
Log.d(
|
||||
tag,
|
||||
"cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}"
|
||||
@@ -290,15 +371,13 @@ class DbManager {
|
||||
return sessions
|
||||
}
|
||||
|
||||
fun saveLog(log:AbsLog) {
|
||||
fun saveLog(log: AbsLog) {
|
||||
Paper.book("log").write(log.id, log)
|
||||
}
|
||||
fun getAllLogs() : List<AbsLog> {
|
||||
val logs:MutableList<AbsLog> = mutableListOf()
|
||||
fun getAllLogs(): List<AbsLog> {
|
||||
val logs: MutableList<AbsLog> = mutableListOf()
|
||||
Paper.book("log").allKeys.forEach { logId ->
|
||||
Paper.book("log").read<AbsLog>(logId)?.let {
|
||||
logs.add(it)
|
||||
}
|
||||
Paper.book("log").read<AbsLog>(logId)?.let { logs.add(it) }
|
||||
}
|
||||
return logs.sortedBy { it.timestamp }
|
||||
}
|
||||
@@ -317,7 +396,10 @@ class DbManager {
|
||||
}
|
||||
}
|
||||
if (logsRemoved > 0) {
|
||||
AbsLogger.info("DbManager", "cleanLogs: Removed $logsRemoved logs older than $numberOfHoursToKeep hours")
|
||||
AbsLogger.info(
|
||||
"DbManager",
|
||||
"cleanLogs: Removed $logsRemoved logs older than $numberOfHoursToKeep hours"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.audiobookshelf.app.plugins.AbsLogger
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** Removes only staging data for terminally failed downloads after their retention window. */
|
||||
object IncompleteDownloadCleanup {
|
||||
private const val tag = "IncompleteDownloadCleanup"
|
||||
private const val RETENTION_MS = 24L * 60L * 60L * 1000L
|
||||
private const val WORK_PREFIX = "incomplete-download-"
|
||||
|
||||
fun schedule(context: Context, item: DownloadItem) {
|
||||
val failedAt = item.terminalFailureAt ?: return
|
||||
if (item.stagingCleanupAt != null) return
|
||||
val delay = (failedAt + RETENTION_MS - System.currentTimeMillis()).coerceAtLeast(0L)
|
||||
val request = OneTimeWorkRequestBuilder<IncompleteDownloadCleanupWorker>()
|
||||
.setInitialDelay(delay, TimeUnit.MILLISECONDS)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
WORK_PREFIX + item.id, ExistingWorkPolicy.REPLACE, request)
|
||||
}
|
||||
|
||||
fun cancel(context: Context, itemId: String) {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(WORK_PREFIX + itemId)
|
||||
}
|
||||
|
||||
/** Cleans staging data retained longer than 24 hours when scheduled work did not run. */
|
||||
@Synchronized
|
||||
fun cleanupExpired(context: Context) {
|
||||
val now = System.currentTimeMillis()
|
||||
DeviceManager.dbManager.getDownloadItems()
|
||||
.filter { item -> isEligible(item, now) }
|
||||
.forEach { item ->
|
||||
deleteItem(context, item)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isEligible(item: DownloadItem, now: Long): Boolean {
|
||||
val failedAt = item.terminalFailureAt ?: return false
|
||||
if (item.stagingCleanupAt != null) return false
|
||||
if (now - failedAt < RETENTION_MS) return false
|
||||
return item.downloadItemParts.all { part ->
|
||||
part.moved || (part.failed && !part.isMoving)
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteItem(context: Context, item: DownloadItem) {
|
||||
item.downloadItemParts.forEach { part ->
|
||||
deleteAppOwnedFile(context, File(part.destinationPath))
|
||||
if (!part.moved) {
|
||||
part.bytesDownloaded = 0L
|
||||
part.completed = false
|
||||
}
|
||||
}
|
||||
item.stagingCleanupAt = System.currentTimeMillis()
|
||||
DeviceManager.dbManager.saveDownloadItem(item)
|
||||
cancel(context, item.id)
|
||||
AbsLogger.info(tag, "Deleted staging files for terminally failed download item ${item.id}")
|
||||
}
|
||||
|
||||
private fun deleteAppOwnedFile(context: Context, file: File) {
|
||||
val path = file.absolutePath
|
||||
val internal = context.filesDir.absolutePath
|
||||
val external = context.getExternalFilesDir(null)?.absolutePath
|
||||
if (path.startsWith(internal) || (external != null && path.startsWith(external))) {
|
||||
if (file.exists() && !file.delete()) AbsLogger.error(tag, "Could not delete expired staging file $path")
|
||||
file.parentFile?.takeIf { it.isDirectory && it.list()?.isEmpty() == true }?.delete()
|
||||
} else {
|
||||
AbsLogger.error(tag, "Refusing to delete non-app-owned path $path")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class IncompleteDownloadCleanupWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
|
||||
override fun doWork(): Result {
|
||||
DbManager.initialize(applicationContext)
|
||||
IncompleteDownloadCleanup.cleanupExpired(applicationContext)
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
@@ -1,114 +1,203 @@
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.util.Log
|
||||
import java.io.*
|
||||
import com.audiobookshelf.app.plugins.AbsLogger
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import java.util.concurrent.TimeUnit
|
||||
import okhttp3.*
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
|
||||
/**
|
||||
* Manages the internal download process.
|
||||
*
|
||||
* @property outputStream The output stream to write the downloaded data.
|
||||
* @property progressCallback The callback to report download progress.
|
||||
*/
|
||||
/** Streams a download into an app-owned staging file. */
|
||||
class InternalDownloadManager(
|
||||
private val outputStream: FileOutputStream,
|
||||
private val progressCallback: DownloadItemManager.InternalProgressCallback
|
||||
) : AutoCloseable {
|
||||
|
||||
private val destinationFile: File,
|
||||
private val expectedSize: Long,
|
||||
private val progressCallback: DownloadItemManager.InternalProgressCallback,
|
||||
private val hasAvailableSpace: () -> Boolean
|
||||
) {
|
||||
private val tag = "InternalDownloadManager"
|
||||
private val client: OkHttpClient =
|
||||
OkHttpClient.Builder().connectTimeout(30, TimeUnit.SECONDS).build()
|
||||
private val writer = BinaryFileWriter(outputStream, progressCallback)
|
||||
|
||||
/**
|
||||
* Downloads a file from the given URL.
|
||||
*
|
||||
* @param url The URL to download the file from.
|
||||
* @throws IOException If an I/O error occurs.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
fun download(url: String) {
|
||||
val request: Request = Request.Builder().url(url).addHeader("Accept-Encoding", "identity").build()
|
||||
client.newCall(request)
|
||||
.enqueue(
|
||||
object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(tag, "Download URL $url FAILED", e)
|
||||
progressCallback.onComplete(true)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
interface DownloadHandle {
|
||||
fun cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the download manager and releases resources.
|
||||
*
|
||||
* @throws Exception If an error occurs during closing.
|
||||
*/
|
||||
@Throws(Exception::class)
|
||||
override fun close() {
|
||||
writer.close()
|
||||
}
|
||||
}
|
||||
private class ActiveDownloadHandle : DownloadHandle {
|
||||
private val cancelled = AtomicBoolean(false)
|
||||
private val activeCall = AtomicReference<Call?>()
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
fun setCall(call: Call) {
|
||||
activeCall.set(call)
|
||||
if (cancelled.get()) call.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
BufferedInputStream(inputStream).use { input ->
|
||||
val dataBuffer = ByteArray(CHUNK_SIZE)
|
||||
var totalBytes: Long = 0
|
||||
var readBytes: Int
|
||||
while (input.read(dataBuffer).also { readBytes = it } != -1) {
|
||||
totalBytes += readBytes
|
||||
outputStream.write(dataBuffer, 0, readBytes)
|
||||
progressCallback.onProgress(totalBytes, (totalBytes * 100L) / length)
|
||||
}
|
||||
progressCallback.onComplete(false)
|
||||
return totalBytes
|
||||
override fun cancel() {
|
||||
cancelled.set(true)
|
||||
activeCall.get()?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the writer and releases resources.
|
||||
* Starts or resumes a download.
|
||||
*
|
||||
* @throws IOException If an error occurs during closing.
|
||||
* @param url download URL
|
||||
* @param token access token sent in the Authorization header
|
||||
* @return logical handle used to cancel the active request, including a restarted request
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
override fun close() {
|
||||
outputStream.close()
|
||||
fun download(url: String, token: String): DownloadHandle {
|
||||
destinationFile.parentFile?.mkdirs()
|
||||
val handle = ActiveDownloadHandle()
|
||||
startRequest(url, token, handle, allowRestart = true)
|
||||
return handle
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHUNK_SIZE = 8192 // Increased chunk size for better performance
|
||||
private fun startRequest(
|
||||
url: String,
|
||||
token: String,
|
||||
handle: ActiveDownloadHandle,
|
||||
allowRestart: Boolean
|
||||
) {
|
||||
var existingBytes = destinationFile.takeIf { it.exists() }?.length() ?: 0L
|
||||
AbsLogger.info(
|
||||
tag,
|
||||
"Starting ${if (existingBytes > 0L) "resumed" else "new"} download for ${destinationFile.name} at byte $existingBytes")
|
||||
val request =
|
||||
Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("Accept-Encoding", "identity")
|
||||
.addHeader("Authorization", "Bearer $token")
|
||||
.apply { if (existingBytes > 0L) header("Range", "bytes=$existingBytes-") }
|
||||
.build()
|
||||
val call = client.newCall(request)
|
||||
handle.setCall(call)
|
||||
call.enqueue(
|
||||
object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
AbsLogger.error(tag, "Download request failed for ${destinationFile.name}: ${e.message}")
|
||||
progressCallback.onComplete(true)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
try {
|
||||
if (response.code == 401) {
|
||||
AbsLogger.error(tag, "Download unauthorized (401) for ${destinationFile.name}")
|
||||
progressCallback.onAuthError()
|
||||
return
|
||||
}
|
||||
if (response.code == 416) {
|
||||
val serverSize =
|
||||
response.header("Content-Range")
|
||||
?.removePrefix("bytes */")
|
||||
?.toLongOrNull()
|
||||
if (serverSize != null) progressCallback.onSizeResolved(serverSize)
|
||||
if (serverSize != null && existingBytes == serverSize) {
|
||||
progressCallback.onProgress(existingBytes, 100L)
|
||||
AbsLogger.info(tag, "Download completed for ${destinationFile.name} ($existingBytes bytes)")
|
||||
progressCallback.onComplete(false)
|
||||
} else if (allowRestart && destinationFile.delete()) {
|
||||
AbsLogger.info(tag, "Restarting stale range from byte zero for ${destinationFile.name}")
|
||||
startRequest(url, token, handle, allowRestart = false)
|
||||
} else {
|
||||
AbsLogger.error(tag, "Could not recover invalid range for ${destinationFile.name} at byte $existingBytes")
|
||||
progressCallback.onComplete(true)
|
||||
}
|
||||
return
|
||||
}
|
||||
val append =
|
||||
existingBytes > 0L &&
|
||||
response.code == 206 &&
|
||||
hasExpectedRange(response, existingBytes)
|
||||
if (existingBytes > 0L && !append && response.code != 200) {
|
||||
AbsLogger.error(
|
||||
tag,
|
||||
"Invalid resume response ${response.code} for ${destinationFile.name} at byte $existingBytes"
|
||||
)
|
||||
progressCallback.onComplete(true)
|
||||
return
|
||||
}
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
AbsLogger.error(tag, "Download HTTP failure ${response.code} for ${destinationFile.name}")
|
||||
progressCallback.onComplete(true)
|
||||
return
|
||||
}
|
||||
|
||||
val startingBytes = if (append) existingBytes else 0L
|
||||
val responseLength = response.body!!.contentLength()
|
||||
val serverSize =
|
||||
if (append) contentRangeTotal(response)
|
||||
else responseLength.takeIf { it >= 0L }
|
||||
if (serverSize != null) progressCallback.onSizeResolved(serverSize)
|
||||
val totalLength =
|
||||
serverSize ?: if (expectedSize > 0L) expectedSize else 0L
|
||||
|
||||
FileOutputStream(destinationFile, append).use { output ->
|
||||
response.body!!.byteStream().use { input ->
|
||||
val buffer = ByteArray(CHUNK_SIZE)
|
||||
var totalBytes = startingBytes
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
if (!hasAvailableSpace())
|
||||
throw IOException("Download paused to preserve free storage")
|
||||
output.write(buffer, 0, read)
|
||||
totalBytes += read
|
||||
val progress =
|
||||
if (totalLength > 0L) (totalBytes * 100L) / totalLength else 0L
|
||||
progressCallback.onProgress(totalBytes, progress.coerceAtMost(100L))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val downloadedSize = destinationFile.length()
|
||||
if (serverSize != null && downloadedSize != serverSize) {
|
||||
AbsLogger.error(
|
||||
tag,
|
||||
"Downloaded size for ${destinationFile.name} was $downloadedSize, expected server size $serverSize"
|
||||
)
|
||||
progressCallback.onComplete(true)
|
||||
} else {
|
||||
AbsLogger.info(
|
||||
tag,
|
||||
"Download completed for ${destinationFile.name} ($downloadedSize bytes)"
|
||||
)
|
||||
progressCallback.onComplete(false)
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
AbsLogger.error(tag, "Could not write staging file ${destinationFile.name}: ${e.message}")
|
||||
progressCallback.onComplete(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun hasExpectedRange(response: Response, offset: Long): Boolean {
|
||||
val range = response.header("Content-Range") ?: return false
|
||||
val match = CONTENT_RANGE.matchEntire(range) ?: return false
|
||||
return match.groupValues[1].toLongOrNull() == offset &&
|
||||
match.groupValues[2].toLongOrNull()?.let { it >= offset } == true
|
||||
}
|
||||
|
||||
private fun contentRangeTotal(response: Response): Long? =
|
||||
CONTENT_RANGE.matchEntire(response.header("Content-Range") ?: "")
|
||||
?.groupValues
|
||||
?.get(3)
|
||||
?.takeUnless { it == "*" }
|
||||
?.toLongOrNull()
|
||||
|
||||
private companion object {
|
||||
const val CHUNK_SIZE = 512 * 1024 // 512 KB
|
||||
val CONTENT_RANGE = Regex("bytes (\\d+)-(\\d+)/(\\d+|\\*)")
|
||||
val client =
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.writeTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -527,4 +527,18 @@ constructor(private val playerNotificationService: PlayerNotificationService) {
|
||||
|
||||
checkAutoSleepTimer()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the current sleep timer state to the client.
|
||||
* Called when app resumes from background to sync UI state.
|
||||
*/
|
||||
fun sendCurrentSleepTimerState() {
|
||||
if (sleepTimerRunning) {
|
||||
val timeRemaining = getSleepTimerTimeRemainingSeconds(getPlaybackSpeed())
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(timeRemaining, isAutoSleepTimer)
|
||||
} else {
|
||||
// No timer running - send 0 to clear any stale UI state
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,11 @@ import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import com.getcapacitor.JSObject
|
||||
import java.util.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import kotlin.coroutines.resume
|
||||
@@ -46,6 +49,16 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
|
||||
var userSettingsPlaybackRate:Float? = null
|
||||
|
||||
// Android Auto browses on the media browser service main thread, so the server pings
|
||||
// and authorize call must not run there or they block it and trigger an ANR
|
||||
private val androidAutoIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
// Android Auto re-requests the browse root every ~2s and multiple clients do so at once,
|
||||
// so overlapping loads are coalesced into one to avoid racing on the shared server state
|
||||
private var isLoadingAndroidAutoItems = false
|
||||
private val pendingAndroidAutoLoadCallbacks = mutableListOf<() -> Unit>()
|
||||
private val androidAutoLoadLock = Any()
|
||||
|
||||
fun getIsLibrary(id:String) : Boolean {
|
||||
return serverLibraries.find { it.id == id } != null
|
||||
}
|
||||
@@ -136,7 +149,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
// and reset any server data already set
|
||||
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
|
||||
|
||||
if (!DeviceManager.isConnectedToServer || !DeviceManager.checkConnectivity(ctx) || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
|
||||
if (!DeviceManager.isConnectedToServer || !DeviceManager.checkConnectivity(ctx) || serverConnConfig == null || serverConnConfig.id != serverConfigIdUsed) {
|
||||
podcastEpisodeLibraryItemMap = mutableMapOf()
|
||||
serverLibraries = listOf()
|
||||
serverLibraryItems = mutableListOf()
|
||||
@@ -730,7 +743,10 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
return mediaProgress
|
||||
}
|
||||
|
||||
private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
|
||||
// Runs on [androidAutoIOScope] rather than blocking the caller: this is reached from
|
||||
// onLoadChildren on the media browser service main thread, and the pings/authorize below
|
||||
// are network calls, so runBlocking here caused ANRs while browsing in Android Auto
|
||||
private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = androidAutoIOScope.launch {
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig | serverConfigIdUsed=$serverConfigIdUsed | lastServerConnectionConfigId=${DeviceManager.deviceData.lastServerConnectionConfigId}")
|
||||
|
||||
coroutineScope {
|
||||
@@ -835,6 +851,26 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
fun loadAndroidAutoItems(cb: () -> Unit) {
|
||||
Log.d(tag, "Load android auto items")
|
||||
|
||||
// Coalesce overlapping calls into a single load, every caller still gets its callback
|
||||
synchronized(androidAutoLoadLock) {
|
||||
pendingAndroidAutoLoadCallbacks.add(cb)
|
||||
if (isLoadingAndroidAutoItems) {
|
||||
Log.d(tag, "loadAndroidAutoItems: Load already in progress, queued callback")
|
||||
return
|
||||
}
|
||||
isLoadingAndroidAutoItems = true
|
||||
}
|
||||
|
||||
val onLoadFinished = {
|
||||
val callbacks: List<() -> Unit>
|
||||
synchronized(androidAutoLoadLock) {
|
||||
isLoadingAndroidAutoItems = false
|
||||
callbacks = pendingAndroidAutoLoadCallbacks.toList()
|
||||
pendingAndroidAutoLoadCallbacks.clear()
|
||||
}
|
||||
callbacks.forEach { it() }
|
||||
}
|
||||
|
||||
// Check if any valid server connection if not use locally downloaded books
|
||||
checkSetValidServerConnectionConfig { isConnected ->
|
||||
if (isConnected) {
|
||||
@@ -844,14 +880,14 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
loadLibraries { libraries ->
|
||||
if (libraries.isEmpty()) {
|
||||
Log.w(tag, "No libraries returned from server request")
|
||||
cb()
|
||||
onLoadFinished()
|
||||
} else {
|
||||
cb() // Fully loaded
|
||||
onLoadFinished() // Fully loaded
|
||||
}
|
||||
}
|
||||
} else { // Not connected to server
|
||||
Log.d(tag, "loadAndroidAutoItems: Not connected to server")
|
||||
cb()
|
||||
onLoadFinished()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,34 +6,40 @@ import com.audiobookshelf.app.data.MediaType
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
|
||||
data class DownloadItem(
|
||||
val id: String,
|
||||
val libraryItemId:String,
|
||||
val episodeId:String?,
|
||||
val userMediaProgress: MediaProgress?,
|
||||
val serverConnectionConfigId:String,
|
||||
val serverAddress:String,
|
||||
val serverUserId:String,
|
||||
val mediaType: String,
|
||||
val itemFolderPath:String,
|
||||
val localFolder: LocalFolder,
|
||||
val itemTitle: String,
|
||||
val itemSubfolder: String,
|
||||
val media: MediaType,
|
||||
val downloadItemParts: MutableList<DownloadItemPart>
|
||||
val id: String,
|
||||
val libraryItemId: String,
|
||||
val episodeId: String?,
|
||||
val userMediaProgress: MediaProgress?,
|
||||
val serverConnectionConfigId: String,
|
||||
val serverAddress: String,
|
||||
val serverUserId: String,
|
||||
val mediaType: String,
|
||||
val itemFolderPath: String,
|
||||
val localFolder: LocalFolder,
|
||||
val itemTitle: String,
|
||||
val itemSubfolder: String,
|
||||
val media: MediaType,
|
||||
val downloadItemParts: MutableList<DownloadItemPart>,
|
||||
@JsonIgnore var terminalFailureAt: Long? = null,
|
||||
@JsonIgnore var stagingCleanupAt: Long? = null
|
||||
) {
|
||||
@get:JsonIgnore
|
||||
val isInternalStorage get() = localFolder.id.startsWith("internal-")
|
||||
val isInternalStorage
|
||||
get() = localFolder.id.startsWith("internal-")
|
||||
|
||||
@get:JsonIgnore
|
||||
val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving }
|
||||
val isDownloadFinished
|
||||
get() = downloadItemParts.isNotEmpty() && downloadItemParts.all {
|
||||
it.completed && it.moved && !it.isMoving && !it.failed
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getNextDownloadItemParts(limit:Int): MutableList<DownloadItemPart> {
|
||||
fun getNextDownloadItemParts(limit: Int): MutableList<DownloadItemPart> {
|
||||
val itemParts = mutableListOf<DownloadItemPart>()
|
||||
if (limit == 0) return itemParts
|
||||
|
||||
for (it in downloadItemParts) {
|
||||
if (!it.completed && it.downloadId == null) {
|
||||
if (!it.completed && !it.failed && it.downloadId == null) {
|
||||
itemParts.add(it)
|
||||
if (itemParts.size >= limit) break
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.audiobookshelf.app.models
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.AudioTrack
|
||||
@@ -15,7 +14,8 @@ data class DownloadItemPart(
|
||||
val id: String,
|
||||
val downloadItemId: String,
|
||||
val filename: String,
|
||||
val fileSize: Long,
|
||||
var fileSize: Long,
|
||||
@JsonIgnore val destinationPath: String,
|
||||
val finalDestinationPath:String,
|
||||
val serverPath: String,
|
||||
val localFolderName: String,
|
||||
@@ -31,28 +31,31 @@ data class DownloadItemPart(
|
||||
@JsonIgnore val uri: Uri,
|
||||
@JsonIgnore val destinationUri: Uri,
|
||||
@JsonIgnore val finalDestinationUri: Uri,
|
||||
@JsonIgnore var completedDestinationUri: String?,
|
||||
val finalDestinationSubfolder: String,
|
||||
var downloadId: Long?,
|
||||
@JsonIgnore var lastUpdateTime: Long?,
|
||||
var progress: Long,
|
||||
var bytesDownloaded: Long
|
||||
var bytesDownloaded: Long,
|
||||
@JsonIgnore var retryCount: Int = 0,
|
||||
@JsonIgnore var authRetryCount: Int = 0,
|
||||
@JsonIgnore var waitingForSpace: Boolean = false,
|
||||
@JsonIgnore var reusedExistingFile: Boolean = false
|
||||
) {
|
||||
companion object {
|
||||
fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, ebookFile: EBookFile?, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart {
|
||||
val destinationUri = Uri.fromFile(destinationFile)
|
||||
val finalDestinationUri = Uri.fromFile(finalDestinationFile)
|
||||
val rawCover = if (serverPath.endsWith("/cover")) "?raw=1" else ""
|
||||
val downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}$rawCover")
|
||||
|
||||
var downloadUrl = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
|
||||
if (serverPath.endsWith("/cover")) {
|
||||
downloadUrl += "&raw=1" // Download raw cover image
|
||||
}
|
||||
|
||||
val downloadUri = Uri.parse(downloadUrl)
|
||||
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Download URI $downloadUri")
|
||||
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Server Path $serverPath")
|
||||
return DownloadItemPart(
|
||||
id = DeviceManager.getBase64Id(finalDestinationFile.absolutePath),
|
||||
downloadItemId,
|
||||
filename = filename,
|
||||
fileSize = fileSize,
|
||||
destinationPath = destinationFile.absolutePath,
|
||||
finalDestinationPath = finalDestinationFile.absolutePath,
|
||||
serverPath = serverPath,
|
||||
localFolderName = localFolder.name,
|
||||
@@ -68,10 +71,13 @@ data class DownloadItemPart(
|
||||
uri = downloadUri,
|
||||
destinationUri = destinationUri,
|
||||
finalDestinationUri = finalDestinationUri,
|
||||
completedDestinationUri = null,
|
||||
finalDestinationSubfolder = subfolder,
|
||||
downloadId = null,
|
||||
lastUpdateTime = null,
|
||||
progress = 0,
|
||||
bytesDownloaded = 0
|
||||
bytesDownloaded = 0,
|
||||
reusedExistingFile = false
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -79,16 +85,4 @@ data class DownloadItemPart(
|
||||
@get:JsonIgnore
|
||||
val isInternalStorage get() = localFolderId.startsWith("internal-")
|
||||
|
||||
@get:JsonIgnore
|
||||
val serverUrl get() = uri.toString()
|
||||
|
||||
@JsonIgnore
|
||||
fun getDownloadRequest(): DownloadManager.Request {
|
||||
val dlRequest = DownloadManager.Request(uri)
|
||||
dlRequest.setTitle(filename)
|
||||
dlRequest.setDescription("Downloading to $localFolderName with filename $filename")
|
||||
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
|
||||
dlRequest.setDestinationUri(destinationUri)
|
||||
return dlRequest
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,8 @@ import android.graphics.ImageDecoder
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.support.v4.media.session.MediaControllerCompat
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.R
|
||||
import com.bumptech.glide.Glide
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||
import kotlinx.coroutines.*
|
||||
@@ -36,7 +34,9 @@ class AbMediaDescriptionAdapter (private val controller: MediaControllerCompat,
|
||||
): Bitmap? {
|
||||
val albumArtUri = controller.metadata.description.iconUri
|
||||
val albumBitmap = controller.metadata.description.iconBitmap
|
||||
?: controller.metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART)
|
||||
|
||||
// Reuse bitmap from queue navigator (local) or PlaybackSession.resolveCoverBitmapAsync (streaming)
|
||||
// For local cover images, bitmap is set in PlayerNotificationService TimelineQueueNavigator.getMediaDescription
|
||||
if (albumBitmap != null) {
|
||||
return albumBitmap
|
||||
@@ -59,7 +59,7 @@ class AbMediaDescriptionAdapter (private val controller: MediaControllerCompat,
|
||||
} else {
|
||||
serviceScope.launch {
|
||||
currentBitmap = albumArtUri?.let {
|
||||
resolveUriAsBitmap(it)
|
||||
resolveUriAsBitmap(playerNotificationService, it)
|
||||
}
|
||||
currentBitmap?.let { callback.onBitmap(it) }
|
||||
}
|
||||
@@ -69,26 +69,4 @@ class AbMediaDescriptionAdapter (private val controller: MediaControllerCompat,
|
||||
currentBitmap
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Glide.with(playerNotificationService)
|
||||
.asBitmap()
|
||||
.load(uri)
|
||||
.placeholder(R.drawable.icon)
|
||||
.error(R.drawable.icon)
|
||||
.submit()
|
||||
.get()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
|
||||
Glide.with(playerNotificationService)
|
||||
.asBitmap()
|
||||
.load(Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon))
|
||||
.submit()
|
||||
.get()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,31 @@ class CastManager constructor(val mainActivity:Activity) {
|
||||
|
||||
private var playerNotificationService:PlayerNotificationService? = null
|
||||
private var newConnectionListener: SessionListener? = null
|
||||
private var castStateListener: CastStateListener? = null
|
||||
|
||||
/**
|
||||
* The framework reports NO_DEVICES_AVAILABLE when a session ends, because it stops discovering -
|
||||
* taken at face value that hides the cast button. The media router is the second opinion.
|
||||
*/
|
||||
private fun isReceiverAvailable(castState: Int): Boolean {
|
||||
if (castState != CastState.NO_DEVICES_AVAILABLE) return true
|
||||
|
||||
// The framework's own selector, which carries the receiver id this app is configured with -
|
||||
// building one by hand here would ask about a different receiver than the one being cast to
|
||||
val selector = getContext().mergedSelector ?: return false
|
||||
|
||||
return getMediaRouter()?.isRouteAvailable(
|
||||
selector,
|
||||
MediaRouter.AVAILABILITY_FLAG_IGNORE_DEFAULT_ROUTE or
|
||||
MediaRouter.AVAILABILITY_FLAG_REQUIRE_MATCH
|
||||
) == true
|
||||
}
|
||||
|
||||
/** Drops what this manager registered for the activity, which does not outlive it. */
|
||||
fun detach() {
|
||||
castStateListener?.let { getContext().removeCastStateListener(it) }
|
||||
castStateListener = null
|
||||
}
|
||||
|
||||
private fun switchToPlayer(useCastPlayer:Boolean) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
@@ -123,6 +148,13 @@ class CastManager constructor(val mainActivity:Activity) {
|
||||
}
|
||||
|
||||
fun startRouteScan(connListener:ChromecastListener) {
|
||||
// ChromecastListener has always implemented CastStateListener, but nothing registered it, so
|
||||
// availability hung entirely on the media router callback below - which does not always fire
|
||||
val stateListener = CastStateListener { state -> connListener.onReceiverAvailableUpdate(isReceiverAvailable(state)) }
|
||||
castStateListener = stateListener
|
||||
getContext().addCastStateListener(stateListener)
|
||||
connListener.onReceiverAvailableUpdate(isReceiverAvailable(getContext().castState))
|
||||
|
||||
val callback = object : ScanCallback() {
|
||||
override fun onRouteUpdate(routes: List<MediaRouter.RouteInfo>?) {
|
||||
Log.d(tag, "CAST On ROUTE UPDATED ${routes?.size} | ${getContext().castState}")
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.audiobookshelf.app.player
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import kotlin.math.roundToInt
|
||||
import android.view.Surface
|
||||
import android.view.SurfaceHolder
|
||||
import android.view.SurfaceView
|
||||
@@ -53,6 +54,10 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
private var myCurrentTrackGroups: TrackGroupArray
|
||||
private var myCurrentTrackSelections: TrackSelectionArray
|
||||
|
||||
// Cache for pending volume to handle async setVolume
|
||||
private var pendingVolume: Int? = null
|
||||
private var pendingVolumeSetAt: Long = 0L
|
||||
|
||||
var currentMediaItems:List<MediaItem> = mutableListOf()
|
||||
var remoteMediaClient:RemoteMediaClient? = null
|
||||
var sessionAvailabilityListener:SessionAvailabilityListener? = null
|
||||
@@ -76,7 +81,10 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
COMMAND_GET_MEDIA_ITEMS_METADATA,
|
||||
COMMAND_SET_MEDIA_ITEMS_METADATA,
|
||||
COMMAND_CHANGE_MEDIA_ITEMS,
|
||||
COMMAND_GET_TRACKS)
|
||||
COMMAND_GET_TRACKS,
|
||||
COMMAND_GET_DEVICE_VOLUME,
|
||||
COMMAND_SET_DEVICE_VOLUME,
|
||||
COMMAND_ADJUST_DEVICE_VOLUME)
|
||||
.build()
|
||||
|
||||
var currentPlaybackState = Player.STATE_IDLE
|
||||
@@ -807,12 +815,21 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
return AudioAttributes.DEFAULT
|
||||
}
|
||||
|
||||
/**
|
||||
* The volume of the media stream, which is what the sleep timer fades - not the volume of the
|
||||
* receiver. The device volume stays where the user put it, and on a cast group the balance
|
||||
* between its members is left alone, because the receiver applies this to the group's output.
|
||||
*/
|
||||
override fun setVolume(audioVolume: Float) {
|
||||
|
||||
try {
|
||||
remoteMediaClient?.setStreamVolume(audioVolume.toDouble().coerceIn(0.0, 1.0))
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Failed to set the cast stream volume", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getVolume(): Float {
|
||||
return 0F
|
||||
return remoteMediaClient?.mediaStatus?.streamVolume?.toFloat() ?: 1F
|
||||
}
|
||||
|
||||
override fun clearVideoSurface() {
|
||||
@@ -861,27 +878,66 @@ class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
||||
}
|
||||
|
||||
override fun getDeviceVolume(): Int {
|
||||
return 0
|
||||
// Short-lived cache without even-value quantization
|
||||
val now = System.currentTimeMillis()
|
||||
if (pendingVolume != null && (now - pendingVolumeSetAt) < 100) {
|
||||
return pendingVolume!!
|
||||
}
|
||||
|
||||
val session = castContext.sessionManager.currentCastSession
|
||||
val vol = if (session != null && !session.isMute) {
|
||||
(session.volume * 100).roundToInt().coerceIn(0, 100)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
if (pendingVolume != null && vol == pendingVolume) {
|
||||
pendingVolume = null
|
||||
}
|
||||
|
||||
return vol
|
||||
}
|
||||
|
||||
override fun isDeviceMuted(): Boolean {
|
||||
return false
|
||||
val session = castContext.sessionManager.currentCastSession
|
||||
return session?.isMute ?: false
|
||||
}
|
||||
|
||||
override fun setDeviceVolume(volume: Int) {
|
||||
val session = castContext.sessionManager.currentCastSession
|
||||
try {
|
||||
val clamped = volume.coerceIn(0, 100)
|
||||
val target = (clamped / 100.0).let { kotlin.math.round(it * 100.0) / 100.0 }
|
||||
|
||||
// Cache for 100ms to return in getDeviceVolume during async period
|
||||
pendingVolume = clamped
|
||||
pendingVolumeSetAt = System.currentTimeMillis()
|
||||
|
||||
session?.setVolume(target)
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Failed to set cast device volume", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun increaseDeviceVolume() {
|
||||
|
||||
val current = getDeviceVolume()
|
||||
val next = current + 1
|
||||
setDeviceVolume(next.coerceAtMost(100))
|
||||
}
|
||||
|
||||
override fun decreaseDeviceVolume() {
|
||||
|
||||
val current = getDeviceVolume()
|
||||
val next = current - 1
|
||||
setDeviceVolume(next.coerceAtLeast(0))
|
||||
}
|
||||
|
||||
override fun setDeviceMuted(muted: Boolean) {
|
||||
|
||||
val session = castContext.sessionManager.currentCastSession
|
||||
try {
|
||||
session?.isMute = muted
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "Failed to set cast device mute state", e)
|
||||
}
|
||||
}
|
||||
|
||||
inner class StatusListener() : RemoteMediaClient.Callback(), SessionManagerListener<CastSession>, RemoteMediaClient.ProgressListener {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.audiobookshelf.app.player
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.BuildConfig
|
||||
import com.audiobookshelf.app.R
|
||||
import com.bumptech.glide.Glide
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private const val TAG = "CoverImageLoader"
|
||||
|
||||
/** Loads [uri] as a bitmap via Glide, falling back to the app icon if the load fails. */
|
||||
suspend fun resolveUriAsBitmap(context: Context, uri: Uri): Bitmap? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
Glide.with(context)
|
||||
.asBitmap()
|
||||
.load(uri)
|
||||
.placeholder(R.drawable.icon)
|
||||
.error(R.drawable.icon)
|
||||
.submit()
|
||||
.get()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load cover bitmap for uri: $uri", e)
|
||||
|
||||
Glide.with(context)
|
||||
.asBitmap()
|
||||
.load(Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon))
|
||||
.submit()
|
||||
.get()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -234,7 +234,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
playerNotificationService.jumpBackward()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
||||
playerNotificationService.closePlayback()
|
||||
playerNotificationService.pause()
|
||||
}
|
||||
else -> {
|
||||
Log.d(tag, "KeyCode:${keyEvent.keyCode}")
|
||||
|
||||
@@ -18,6 +18,8 @@ import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.support.v4.media.session.MediaControllerCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import androidx.media.VolumeProviderCompat
|
||||
import android.media.AudioManager
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
@@ -52,6 +54,11 @@ import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||
import com.google.android.exoplayer2.upstream.*
|
||||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
|
||||
@@ -94,6 +101,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
private lateinit var mediaSessionConnector: MediaSessionConnector
|
||||
private lateinit var playerNotificationManager: PlayerNotificationManager
|
||||
lateinit var mediaSession: MediaSessionCompat
|
||||
private var remoteVolumeProvider: VolumeProviderCompat? = null
|
||||
private lateinit var transportControls: MediaControllerCompat.TransportControls
|
||||
|
||||
lateinit var mediaManager: MediaManager
|
||||
@@ -113,6 +121,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
var currentPlaybackSession: PlaybackSession? = null
|
||||
private var initialPlaybackRate: Float? = null
|
||||
|
||||
private val metadataScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
private var metadataArtJob: Job? = null
|
||||
|
||||
private var isAndroidAuto = false
|
||||
|
||||
// The following are used for the shake detection
|
||||
@@ -195,6 +206,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
castPlayer?.release()
|
||||
mediaSession.release()
|
||||
mediaProgressSyncer.reset()
|
||||
metadataScope.cancel()
|
||||
|
||||
super.onDestroy()
|
||||
}
|
||||
@@ -292,6 +304,12 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
transportControls = mediaController.transportControls
|
||||
|
||||
mediaSessionConnector = MediaSessionConnector(mediaSession)
|
||||
// Without this, the connector's default metadata provider rebuilds metadata from the
|
||||
// player's own state on media item transitions/timeline changes, dropping the cover art
|
||||
// bitmap that PlaybackSession.resolveCoverBitmapAsync resolves separately.
|
||||
mediaSessionConnector.setMediaMetadataProvider { _ ->
|
||||
currentPlaybackSession?.getMediaMetadataCompat(ctx) ?: MediaMetadataCompat.Builder().build()
|
||||
}
|
||||
val queueNavigator: TimelineQueueNavigator =
|
||||
object : TimelineQueueNavigator(mediaSession) {
|
||||
override fun getSupportedQueueNavigatorActions(player: Player): Long {
|
||||
@@ -427,8 +445,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
isClosed = false
|
||||
|
||||
val metadata = playbackSession.getMediaMetadataCompat(ctx)
|
||||
mediaSession.setMetadata(metadata)
|
||||
val mediaItems = playbackSession.getMediaItems(ctx)
|
||||
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
|
||||
initialPlaybackRate = playbackRate
|
||||
@@ -456,6 +472,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
playbackSession
|
||||
) // Save playback session to use when app is closed
|
||||
|
||||
metadataArtJob?.cancel()
|
||||
metadataArtJob =
|
||||
playbackSession.resolveCoverBitmapAsync(ctx, metadataScope) {
|
||||
mediaSessionConnector.invalidateMediaSessionMetadata()
|
||||
}
|
||||
mediaSessionConnector.invalidateMediaSessionMetadata()
|
||||
|
||||
AbsLogger.info("PlayerNotificationService", "preparePlayer: Started playback session for item ${currentPlaybackSession?.mediaItemId}. MediaPlayer ${currentPlaybackSession?.mediaPlayer}")
|
||||
// Notify client
|
||||
clientEventEmitter?.onPlaybackSession(playbackSession)
|
||||
@@ -477,6 +500,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
val dataSourceFactory = DefaultDataSource.Factory(ctx)
|
||||
|
||||
val extractorsFactory = DefaultExtractorsFactory()
|
||||
extractorsFactory.setConstantBitrateSeekingEnabled(true)
|
||||
|
||||
if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) {
|
||||
// @see
|
||||
// https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files
|
||||
@@ -491,6 +516,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
||||
|
||||
val extractorsFactory = DefaultExtractorsFactory()
|
||||
extractorsFactory.setConstantBitrateSeekingEnabled(true)
|
||||
|
||||
if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) {
|
||||
// @see
|
||||
// https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files
|
||||
@@ -705,11 +732,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
Log.d(tag, "switchToPlayer: Using Cast Player " + castPlayer?.deviceInfo)
|
||||
mediaSessionConnector.setPlayer(castPlayer)
|
||||
playerNotificationManager.setPlayer(castPlayer)
|
||||
setMediaSessionToCastVolume()
|
||||
castPlayer as CastPlayer
|
||||
} else {
|
||||
Log.d(tag, "switchToPlayer: Using ExoPlayer")
|
||||
mediaSessionConnector.setPlayer(mPlayer)
|
||||
playerNotificationManager.setPlayer(mPlayer)
|
||||
setMediaSessionToLocalVolume()
|
||||
mPlayer
|
||||
}
|
||||
|
||||
@@ -726,6 +755,33 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun setMediaSessionToCastVolume() {
|
||||
val currentVol = try { castPlayer?.getDeviceVolume() ?: 0 } catch (_: Exception) { 0 }
|
||||
val provider = object : VolumeProviderCompat(VolumeProviderCompat.VOLUME_CONTROL_ABSOLUTE, 100, currentVol) {
|
||||
override fun onSetVolumeTo(volume: Int) {
|
||||
// Clamp, update UI immediately, then send to device
|
||||
val clamped = volume.coerceIn(0, 100)
|
||||
setCurrentVolume(clamped)
|
||||
try { castPlayer?.setDeviceVolume(clamped) } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
override fun onAdjustVolume(direction: Int) {
|
||||
// Use Android-provided step (−1, 0, +1). Clamp, update UI immediately, then send.
|
||||
val current = currentVolume
|
||||
val target = (current + direction).coerceIn(0, 100)
|
||||
setCurrentVolume(target)
|
||||
try { castPlayer?.setDeviceVolume(target) } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
remoteVolumeProvider = provider
|
||||
mediaSession.setPlaybackToRemote(provider)
|
||||
}
|
||||
|
||||
private fun setMediaSessionToLocalVolume() {
|
||||
mediaSession.setPlaybackToLocal(AudioManager.STREAM_MUSIC)
|
||||
remoteVolumeProvider = null
|
||||
}
|
||||
|
||||
fun getCurrentTrackStartOffsetMs(): Long {
|
||||
return if (currentPlayer.mediaItemCount > 1) {
|
||||
val windowIndex = currentPlayer.currentMediaItemIndex
|
||||
|
||||
@@ -33,6 +33,9 @@ class AbsAudioPlayer : Plugin() {
|
||||
|
||||
private var isCastAvailable:Boolean = false
|
||||
|
||||
// Track foreground state to avoid flooding WebView with events while backgrounded
|
||||
private var isInForeground: Boolean = true
|
||||
|
||||
override fun load() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
apiHandler = ApiHandler(mainActivity)
|
||||
@@ -60,6 +63,8 @@ class AbsAudioPlayer : Plugin() {
|
||||
}
|
||||
|
||||
override fun onMetadata(metadata: PlaybackMetadata) {
|
||||
// Skip frequent metadata updates when app is backgrounded to prevent event queue buildup
|
||||
if (!isInForeground) return
|
||||
notifyListeners("onMetadata", JSObject(jacksonMapper.writeValueAsString(metadata)))
|
||||
}
|
||||
|
||||
@@ -68,6 +73,8 @@ class AbsAudioPlayer : Plugin() {
|
||||
}
|
||||
|
||||
override fun onSleepTimerSet(sleepTimeRemaining: Int, isAutoSleepTimer:Boolean) {
|
||||
// Skip sleep timer updates when app is backgrounded to prevent event queue buildup
|
||||
if (!isInForeground) return
|
||||
val ret = JSObject()
|
||||
ret.put("value", sleepTimeRemaining)
|
||||
ret.put("isAuto", isAutoSleepTimer)
|
||||
@@ -75,6 +82,8 @@ class AbsAudioPlayer : Plugin() {
|
||||
}
|
||||
|
||||
override fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress) {
|
||||
// Skip progress updates when app is backgrounded to prevent event queue buildup
|
||||
if (!isInForeground) return
|
||||
notifyListeners("onLocalMediaProgressUpdate", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
||||
}
|
||||
|
||||
@@ -118,6 +127,32 @@ class AbsAudioPlayer : Plugin() {
|
||||
notifyListeners(evtName, ret)
|
||||
}
|
||||
|
||||
override fun handleOnDestroy() {
|
||||
castManager?.detach()
|
||||
super.handleOnDestroy()
|
||||
}
|
||||
|
||||
override fun handleOnPause() {
|
||||
super.handleOnPause()
|
||||
isInForeground = false
|
||||
}
|
||||
|
||||
override fun handleOnResume() {
|
||||
super.handleOnResume()
|
||||
isInForeground = true
|
||||
|
||||
// Send current state to UI after resume to sync up (with small delay to let WebView fully resume)
|
||||
if (::playerNotificationService.isInitialized && playerNotificationService.currentPlaybackSession != null) {
|
||||
Handler(Looper.getMainLooper()).postDelayed({
|
||||
playerNotificationService.sendClientMetadata(PlayerState.READY)
|
||||
playerNotificationService.sleepTimerManager.sendCurrentSleepTimerState()
|
||||
playerNotificationService.mediaProgressSyncer.currentLocalMediaProgress?.let {
|
||||
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it)
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
private fun initCastManager() {
|
||||
val googleApi = GoogleApiAvailability.getInstance()
|
||||
val statusCode = googleApi.isGooglePlayServicesAvailable(mainActivity)
|
||||
@@ -199,7 +234,7 @@ class AbsAudioPlayer : Plugin() {
|
||||
return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}"))
|
||||
}
|
||||
}
|
||||
if (!it.hasTracks(episode)) {
|
||||
if (!it.hasTracks(mainActivity, episode)) {
|
||||
return call.resolve(JSObject("{\"error\":\"No audio files found on device. Download book again to fix.\"}"))
|
||||
}
|
||||
|
||||
@@ -295,10 +330,10 @@ class AbsAudioPlayer : Plugin() {
|
||||
|
||||
@PluginMethod
|
||||
fun seek(call: PluginCall) {
|
||||
val time:Int = call.getInt("value", 0) ?: 0 // Value in seconds
|
||||
val time: Double = call.getDouble("value", 0.0) ?: 0.0 // Value in seconds, fractional
|
||||
Log.d(tag, "seek action to $time")
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.seekPlayer(time * 1000L) // convert to ms
|
||||
playerNotificationService.seekPlayer((time * 1000L).toLong())
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ class AbsDatabase : Plugin() {
|
||||
secureStorage = SecureStorage(mainActivity)
|
||||
|
||||
DeviceManager.dbManager.cleanLocalMediaProgress()
|
||||
DeviceManager.dbManager.cleanLocalLibraryItems()
|
||||
DeviceManager.dbManager.cleanLocalLibraryItems(mainActivity)
|
||||
DeviceManager.dbManager.cleanLogs()
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ class AbsDatabase : Plugin() {
|
||||
|
||||
@PluginMethod
|
||||
fun setCurrentServerConnectionConfig(call:PluginCall) {
|
||||
Log.d(tag, "setCurrentServerConnectionConfig ${call.data}")
|
||||
Log.d(tag, "setCurrentServerConnectionConfig called")
|
||||
val serverConfigPayload = jacksonMapper.readValue<ServerConnConfigPayload>(call.data.toString())
|
||||
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConfigPayload.id }
|
||||
|
||||
@@ -558,7 +558,7 @@ class AbsDatabase : Plugin() {
|
||||
|
||||
@PluginMethod
|
||||
fun updateDeviceSettings(call:PluginCall) { // Returns device data
|
||||
Log.d(tag, "updateDeviceSettings ${call.data}")
|
||||
Log.d(tag, "updateDeviceSettings called")
|
||||
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
@@ -576,7 +576,7 @@ class AbsDatabase : Plugin() {
|
||||
|
||||
@PluginMethod
|
||||
fun getMediaItemHistory(call:PluginCall) { // Returns device data
|
||||
Log.d(tag, "getMediaItemHistory ${call.data}")
|
||||
Log.d(tag, "getMediaItemHistory called")
|
||||
val mediaId = call.getString("mediaId") ?: ""
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
package com.audiobookshelf.app.plugins
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.device.FolderScanner
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.audiobookshelf.app.models.DownloadItemPart
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import com.audiobookshelf.app.managers.DownloadItemManager
|
||||
import com.audiobookshelf.app.services.DownloadServiceHost
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.getcapacitor.JSObject
|
||||
@@ -27,9 +24,7 @@ class AbsDownloader : Plugin() {
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
lateinit var mainActivity: MainActivity
|
||||
lateinit var downloadManager: DownloadManager
|
||||
lateinit var apiHandler: ApiHandler
|
||||
lateinit var folderScanner: FolderScanner
|
||||
lateinit var downloadItemManager: DownloadItemManager
|
||||
|
||||
private val clientEventEmitter = (object : DownloadItemManager.DownloadEventEmitter {
|
||||
@@ -42,14 +37,44 @@ class AbsDownloader : Plugin() {
|
||||
override fun onDownloadItemComplete(jsobj:JSObject) {
|
||||
notifyListeners("onItemDownloadComplete", jsobj)
|
||||
}
|
||||
override fun onQueueChanged(hasWork: Boolean) {
|
||||
notifyListeners("onQueueChanged", JSObject().put("hasWork", hasWork))
|
||||
}
|
||||
})
|
||||
|
||||
override fun load() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
folderScanner = FolderScanner(mainActivity)
|
||||
apiHandler = ApiHandler(mainActivity)
|
||||
downloadItemManager = DownloadItemManager(downloadManager, folderScanner, mainActivity, clientEventEmitter)
|
||||
downloadItemManager = DownloadServiceHost.ensure(mainActivity)
|
||||
DownloadServiceHost.attachBridge(mainActivity, clientEventEmitter)
|
||||
}
|
||||
|
||||
override fun handleOnDestroy() {
|
||||
DownloadServiceHost.detachBridge()
|
||||
super.handleOnDestroy()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setDownloadNotificationStrings(call: PluginCall) {
|
||||
DownloadServiceHost.setNotificationStrings(
|
||||
mainActivity,
|
||||
call.getString("preparing") ?: "Preparing downloads",
|
||||
call.getString("downloadingFile") ?: "Downloading {0}",
|
||||
call.getString("waitingForStorage") ?: "Waiting for available storage",
|
||||
call.getString("downloads") ?: "Downloads",
|
||||
call.getString("cancel") ?: "Cancel")
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
/** Replays restored queue items when the frontend subscribes to download events. */
|
||||
@PluginMethod(returnType = PluginMethod.RETURN_NONE)
|
||||
override fun addListener(call: PluginCall) {
|
||||
super.addListener(call)
|
||||
if (call.getString("eventName") == "onDownloadItem" && ::downloadItemManager.isInitialized) {
|
||||
downloadItemManager.downloadItemQueue.forEach { item ->
|
||||
notifyListeners("onDownloadItem", JSObject(jacksonMapper.writeValueAsString(item)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
@@ -58,57 +83,60 @@ class AbsDownloader : Plugin() {
|
||||
var episodeId = call.data.getString("episodeId").toString()
|
||||
if (episodeId == "null") episodeId = ""
|
||||
var localFolderId = call.data.getString("localFolderId", "").toString()
|
||||
Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId")
|
||||
AbsLogger.info(tag, "Requested download for item $libraryItemId${if (episodeId.isEmpty()) "" else " / episode $episodeId"}")
|
||||
|
||||
val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId"
|
||||
if (downloadItemManager.downloadItemQueue.find { it.id == downloadId } != null) {
|
||||
Log.d(tag, "Download already started for this media entity $downloadId")
|
||||
return call.resolve(JSObject("{\"error\":\"Download already started for this media entity\"}"))
|
||||
}
|
||||
|
||||
apiHandler.getLibraryItemWithProgress(libraryItemId, episodeId) { libraryItem ->
|
||||
if (libraryItem == null) {
|
||||
call.resolve(JSObject("{\"error\":\"Server request failed\"}"))
|
||||
} else {
|
||||
Log.d(tag, "Got library item from server ${libraryItem.id}")
|
||||
|
||||
if (localFolderId == "") {
|
||||
localFolderId = "internal-${libraryItem.mediaType}"
|
||||
DownloadServiceHost.retryExisting(mainActivity, downloadId) { result ->
|
||||
when (result) {
|
||||
DownloadServiceHost.ExistingDownloadResult.RETRIED -> call.resolve()
|
||||
DownloadServiceHost.ExistingDownloadResult.ACTIVE -> {
|
||||
AbsLogger.info(tag, "Download already active for $downloadId")
|
||||
call.resolve(JSObject("{\"error\":\"Download already started for this media entity\"}"))
|
||||
}
|
||||
var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
|
||||
|
||||
if (localFolder == null && localFolderId.startsWith("internal-")) {
|
||||
Log.d(tag, "Creating new App Storage internal LocalFolder $localFolderId")
|
||||
localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "", "internal", libraryItem.mediaType)
|
||||
DeviceManager.dbManager.saveLocalFolder(localFolder)
|
||||
}
|
||||
|
||||
if (localFolder != null) {
|
||||
if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
|
||||
Log.e(tag, "Library item is not a podcast but episode was requested")
|
||||
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
|
||||
} else if (episodeId.isNotEmpty()) {
|
||||
val podcast = libraryItem.media as Podcast
|
||||
val episode = podcast.episodes?.find { podcastEpisode ->
|
||||
podcastEpisode.id == episodeId
|
||||
}
|
||||
if (episode == null) {
|
||||
call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}"))
|
||||
DownloadServiceHost.ExistingDownloadResult.SERVICE_START_FAILED ->
|
||||
call.resolve(JSObject("{\"error\":\"Unable to start the Android download service\"}"))
|
||||
DownloadServiceHost.ExistingDownloadResult.NOT_FOUND -> {
|
||||
apiHandler.getLibraryItemWithProgress(libraryItemId, episodeId) { libraryItem ->
|
||||
if (libraryItem == null) {
|
||||
call.resolve(JSObject("{\"error\":\"Server request failed\"}"))
|
||||
} else {
|
||||
startLibraryItemDownload(libraryItem, localFolder, episode)
|
||||
call.resolve()
|
||||
AbsLogger.info(tag, "Preparing download for ${libraryItem.id}")
|
||||
|
||||
if (localFolderId == "") localFolderId = "internal-${libraryItem.mediaType}"
|
||||
var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
|
||||
if (localFolder == null && localFolderId.startsWith("internal-")) {
|
||||
AbsLogger.info(tag, "Creating internal download folder $localFolderId")
|
||||
localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "internal", libraryItem.mediaType)
|
||||
DeviceManager.dbManager.saveLocalFolder(localFolder)
|
||||
}
|
||||
|
||||
if (localFolder == null) {
|
||||
call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
|
||||
} else if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") {
|
||||
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
|
||||
} else if (episodeId.isNotEmpty()) {
|
||||
val podcast = libraryItem.media as Podcast
|
||||
val episode = podcast.episodes?.find { it.id == episodeId }
|
||||
if (episode == null) {
|
||||
call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}"))
|
||||
} else {
|
||||
startLibraryItemDownload(libraryItem, localFolder, episode) { error -> resolveDownloadCall(call, error) }
|
||||
}
|
||||
} else {
|
||||
startLibraryItemDownload(libraryItem, localFolder, null) { error -> resolveDownloadCall(call, error) }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
startLibraryItemDownload(libraryItem, localFolder, null)
|
||||
call.resolve()
|
||||
}
|
||||
} else {
|
||||
call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveDownloadCall(call: PluginCall, error: String?) {
|
||||
if (error == null) call.resolve()
|
||||
else call.resolve(JSObject().put("error", error))
|
||||
}
|
||||
|
||||
// Item filenames could be the same if they are in sub-folders, this will make them unique
|
||||
private fun getFilenameFromRelPath(relPath: String): String {
|
||||
var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
|
||||
@@ -129,21 +157,31 @@ class AbsDownloader : Plugin() {
|
||||
return newTitle
|
||||
}
|
||||
|
||||
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
|
||||
private fun startLibraryItemDownload(
|
||||
libraryItem: LibraryItem,
|
||||
localFolder: LocalFolder,
|
||||
episode: PodcastEpisode?,
|
||||
callback: (String?) -> Unit
|
||||
) {
|
||||
val isInternal = localFolder.id.startsWith("internal-")
|
||||
|
||||
val tempFolderPath = if (isInternal) "${mainActivity.filesDir}/downloads/${libraryItem.id}" else mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
||||
val finalInternalFolderPath = "${mainActivity.filesDir}/downloads/${libraryItem.id}"
|
||||
val tempFolderPath =
|
||||
if (isInternal) {
|
||||
"${mainActivity.filesDir}/download-staging/${libraryItem.id}"
|
||||
} else {
|
||||
"${mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: mainActivity.filesDir}/download-staging/${libraryItem.id}"
|
||||
}
|
||||
|
||||
Log.d(tag, "downloadCacheDirectory=$tempFolderPath")
|
||||
|
||||
if (libraryItem.mediaType == "book") {
|
||||
val bookTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
|
||||
val bookAuthor = cleanStringForFileSystem(libraryItem.media.metadata.getAuthorDisplayName())
|
||||
|
||||
val tracks = libraryItem.media.getAudioTracks()
|
||||
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
|
||||
AbsLogger.info(tag, "Queueing library item download with ${tracks.size} files")
|
||||
val itemSubfolder = "$bookAuthor/$bookTitle"
|
||||
val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$itemSubfolder"
|
||||
val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$itemSubfolder"
|
||||
val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf())
|
||||
|
||||
val book = libraryItem.media as Book
|
||||
@@ -152,17 +190,7 @@ class AbsDownloader : Plugin() {
|
||||
val serverPath = "/api/items/${libraryItem.id}/file/${ebookFile.ino}/download"
|
||||
val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "")
|
||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
|
||||
if (destinationFile.exists()) {
|
||||
Log.d(tag, "TEMP ebook file already exists, removing it from ${destinationFile.absolutePath}")
|
||||
destinationFile.delete()
|
||||
}
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "ebook file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,ebookFile,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
@@ -178,20 +206,9 @@ class AbsDownloader : Plugin() {
|
||||
|
||||
val serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
|
||||
val destinationFilename = getFilenameFromRelPath(audioTrack.relPath)
|
||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
||||
|
||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
|
||||
if (destinationFile.exists()) {
|
||||
Log.d(tag, "TEMP Audio file already exists, removing it from ${destinationFile.absolutePath}")
|
||||
destinationFile.delete()
|
||||
}
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,audioTrack,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
@@ -205,25 +222,15 @@ class AbsDownloader : Plugin() {
|
||||
|
||||
val serverPath = "/api/items/${libraryItem.id}/cover"
|
||||
val destinationFilename = "cover-${libraryItem.id}.jpg"
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
|
||||
if (destinationFile.exists()) {
|
||||
Log.d(tag, "TEMP Audio file already exists, removing it from ${destinationFile.absolutePath}")
|
||||
destinationFile.delete()
|
||||
}
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Cover already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
downloadItemManager.addDownloadItem(downloadItem)
|
||||
}
|
||||
DownloadServiceHost.enqueue(mainActivity, downloadItem, callback)
|
||||
} else callback("No downloadable files found")
|
||||
} else {
|
||||
// Podcast episode download
|
||||
val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
|
||||
@@ -232,22 +239,16 @@ class AbsDownloader : Plugin() {
|
||||
val audioFileIno = episode?.audioFile?.ino
|
||||
val fileSize = audioTrack?.metadata?.size ?: 0
|
||||
|
||||
Log.d(tag, "Starting podcast episode download")
|
||||
val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$podcastTitle"
|
||||
AbsLogger.info(tag, "Queueing podcast episode download")
|
||||
val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$podcastTitle"
|
||||
val downloadItemId = "${libraryItem.id}-${episode?.id}"
|
||||
val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf())
|
||||
|
||||
var serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
|
||||
var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
|
||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
||||
|
||||
var destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
var destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||
var finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,audioTrack,episode)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
|
||||
@@ -258,18 +259,14 @@ class AbsDownloader : Plugin() {
|
||||
serverPath = "/api/items/${libraryItem.id}/cover"
|
||||
destinationFilename = "cover.jpg"
|
||||
|
||||
destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
destinationFile = File("$tempFolderPath/$destinationFilename.part")
|
||||
finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Podcast cover already exists - not downloading cover again")
|
||||
} else {
|
||||
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
}
|
||||
|
||||
downloadItemManager.addDownloadItem(downloadItem)
|
||||
DownloadServiceHost.enqueue(mainActivity, downloadItem, callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.audiobookshelf.app.plugins
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
@@ -23,40 +24,62 @@ import java.io.File
|
||||
class AbsFileSystem : Plugin() {
|
||||
private val TAG = "AbsFileSystem"
|
||||
private val tag = "AbsFileSystem"
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
private var jacksonMapper =
|
||||
jacksonObjectMapper()
|
||||
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
lateinit var mainActivity: MainActivity
|
||||
|
||||
override fun load() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
|
||||
mainActivity.storage.storageAccessCallback = object : StorageAccessCallback {
|
||||
override fun onRootPathNotSelected(
|
||||
requestCode: Int,
|
||||
rootPath: String,
|
||||
uri: Uri,
|
||||
selectedStorageType: StorageType,
|
||||
expectedStorageType: StorageType
|
||||
) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
mainActivity.storage.storageAccessCallback =
|
||||
object : StorageAccessCallback {
|
||||
override fun onRootPathNotSelected(
|
||||
requestCode: Int,
|
||||
rootPath: String,
|
||||
uri: Uri,
|
||||
selectedStorageType: StorageType,
|
||||
expectedStorageType: StorageType
|
||||
) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
|
||||
override fun onCanceledByUser(requestCode: Int) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
override fun onCanceledByUser(requestCode: Int) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
|
||||
override fun onExpectedStorageNotSelected(requestCode: Int, selectedFolder: DocumentFile, selectedStorageType: StorageType, expectedBasePath: String, expectedStorageType: StorageType) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
override fun onExpectedStorageNotSelected(
|
||||
requestCode: Int,
|
||||
selectedFolder: DocumentFile,
|
||||
selectedStorageType: StorageType,
|
||||
expectedBasePath: String,
|
||||
expectedStorageType: StorageType
|
||||
) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
|
||||
override fun onStoragePermissionDenied(requestCode: Int) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
override fun onStoragePermissionDenied(requestCode: Int) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
|
||||
override fun onRootPathPermissionGranted(requestCode: Int, root: DocumentFile) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
}
|
||||
override fun onRootPathPermissionGranted(requestCode: Int, root: DocumentFile) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setFolderPickerStrings(call: PluginCall) {
|
||||
mainActivity.getSharedPreferences(FOLDER_PICKER_PREFERENCES, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(KEY_WRITE_ACCESS_REQUIRED, call.getString("writeAccessRequired"))
|
||||
.putString(KEY_ALLOW, call.getString("allow"))
|
||||
.putString(KEY_CANCEL, call.getString("cancel"))
|
||||
.putString(KEY_ACCESS_DENIED, call.getString("accessDenied"))
|
||||
.putString(KEY_PERMISSION_DENIED, call.getString("permissionDenied"))
|
||||
.apply()
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
@@ -65,60 +88,74 @@ class AbsFileSystem : Plugin() {
|
||||
val REQUEST_CODE_SELECT_FOLDER = 6
|
||||
val REQUEST_CODE_SDCARD_ACCESS = 7
|
||||
|
||||
mainActivity.storage.folderPickerCallback = object : FolderPickerCallback {
|
||||
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
|
||||
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
|
||||
val absolutePath = folder.getAbsolutePath(activity)
|
||||
val storageType = folder.getStorageType(activity)
|
||||
val simplePath = folder.getSimplePath(activity)
|
||||
val basePath = folder.getBasePath(activity)
|
||||
val folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT)
|
||||
mainActivity.storage.folderPickerCallback =
|
||||
object : FolderPickerCallback {
|
||||
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
|
||||
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
|
||||
val absolutePath = folder.getAbsolutePath(activity)
|
||||
val storageType = folder.getStorageType(activity)
|
||||
val basePath = folder.getBasePath(activity)
|
||||
val folderId =
|
||||
android.util.Base64.encodeToString(
|
||||
folder.id.toByteArray(),
|
||||
android.util.Base64.DEFAULT
|
||||
)
|
||||
|
||||
val localFolder = LocalFolder(folderId, folder.name ?: "", folder.uri.toString(),basePath,absolutePath, simplePath, storageType.toString(), mediaType)
|
||||
val localFolder =
|
||||
LocalFolder(
|
||||
folderId,
|
||||
folder.name ?: "",
|
||||
folder.uri.toString(),
|
||||
basePath,
|
||||
absolutePath,
|
||||
storageType.toString(),
|
||||
mediaType
|
||||
)
|
||||
|
||||
DeviceManager.dbManager.saveLocalFolder(localFolder)
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder)))
|
||||
}
|
||||
DeviceManager.dbManager.saveLocalFolder(localFolder)
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder)))
|
||||
}
|
||||
|
||||
override fun onStorageAccessDenied(
|
||||
requestCode: Int,
|
||||
folder: DocumentFile?,
|
||||
storageType: StorageType,
|
||||
storageId: String
|
||||
) {
|
||||
Log.e(tag, "Storage Access Denied ${folder?.getAbsolutePath(mainActivity)}")
|
||||
override fun onStorageAccessDenied(
|
||||
requestCode: Int,
|
||||
folder: DocumentFile?,
|
||||
storageType: StorageType,
|
||||
storageId: String
|
||||
) {
|
||||
Log.e(tag, "Storage Access Denied ${folder?.getAbsolutePath(mainActivity)}")
|
||||
|
||||
val jsobj = JSObject()
|
||||
if (requestCode == REQUEST_CODE_SELECT_FOLDER) {
|
||||
val jsobj = JSObject()
|
||||
if (requestCode == REQUEST_CODE_SELECT_FOLDER) {
|
||||
|
||||
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
|
||||
builder.setMessage(
|
||||
"You have no write access to this storage, thus selecting this folder is useless." +
|
||||
"\nWould you like to grant access to this folder?")
|
||||
builder.setNegativeButton("Dont Allow") { _, _ ->
|
||||
run {
|
||||
jsobj.put("error", "User Canceled, Access Denied")
|
||||
call.resolve(jsobj)
|
||||
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
|
||||
builder.setMessage(folderPickerString(KEY_WRITE_ACCESS_REQUIRED, DEFAULT_WRITE_ACCESS_REQUIRED))
|
||||
builder.setNegativeButton(folderPickerString(KEY_CANCEL, DEFAULT_CANCEL)) { _, _ ->
|
||||
run {
|
||||
jsobj.put("error", folderPickerString(KEY_ACCESS_DENIED, DEFAULT_ACCESS_DENIED))
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
}
|
||||
builder.setPositiveButton(folderPickerString(KEY_ALLOW, DEFAULT_ALLOW)) { _, _ ->
|
||||
mainActivity.storageHelper.requestStorageAccess(
|
||||
REQUEST_CODE_SDCARD_ACCESS,
|
||||
initialPath = FileFullPath(mainActivity, storageId, "")
|
||||
)
|
||||
}
|
||||
builder.show()
|
||||
} else {
|
||||
Log.d(TAG, "STORAGE ACCESS DENIED $requestCode")
|
||||
jsobj.put("error", folderPickerString(KEY_ACCESS_DENIED, DEFAULT_ACCESS_DENIED))
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStoragePermissionDenied(requestCode: Int) {
|
||||
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("error", folderPickerString(KEY_PERMISSION_DENIED, DEFAULT_PERMISSION_DENIED))
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
}
|
||||
}
|
||||
builder.setPositiveButton("Allow.") { _, _ -> mainActivity.storageHelper.requestStorageAccess(REQUEST_CODE_SDCARD_ACCESS, initialPath = FileFullPath(mainActivity, storageId, "")) }
|
||||
builder.show()
|
||||
} else {
|
||||
Log.d(TAG, "STORAGE ACCESS DENIED $requestCode")
|
||||
jsobj.put("error", "Access Denied")
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override fun onStoragePermissionDenied(requestCode: Int) {
|
||||
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("error", "Permission Denied")
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
mainActivity.storage.openFolderPicker(REQUEST_CODE_SELECT_FOLDER)
|
||||
}
|
||||
@@ -152,7 +189,7 @@ class AbsFileSystem : Plugin() {
|
||||
val folderUrl = call.data.getString("folderUrl", "").toString()
|
||||
Log.d(TAG, "Check Folder Permissions for $folderUrl")
|
||||
|
||||
val hasAccess = SimpleStorage.hasStorageAccess(context,folderUrl,true)
|
||||
val hasAccess = SimpleStorage.hasStorageAccess(context, folderUrl, true)
|
||||
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("value", hasAccess)
|
||||
@@ -196,26 +233,29 @@ class AbsFileSystem : Plugin() {
|
||||
if (localLibraryItem?.folderId?.startsWith("internal-") == true) {
|
||||
Log.d(tag, "Deleting internal library item at absolutePath $absolutePath")
|
||||
val file = File(absolutePath)
|
||||
success = if (file.exists()) {
|
||||
file.deleteRecursively()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
success =
|
||||
if (file.exists()) {
|
||||
file.deleteRecursively()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
var subfolderPathToDelete = ""
|
||||
localLibraryItem?.folderId?.let { folderId ->
|
||||
val folder = DeviceManager.dbManager.getLocalFolder(folderId)
|
||||
folder?.absolutePath?.let { folderPath ->
|
||||
val splitAbsolutePath = absolutePath.split("/")
|
||||
val fullSubDir = splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/")
|
||||
val fullSubDir =
|
||||
splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/")
|
||||
if (fullSubDir != folderPath) {
|
||||
val subdirHasAnItem = DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem ->
|
||||
if (_localLibraryItem.id == localLibraryItemId) {
|
||||
false
|
||||
} else {
|
||||
_localLibraryItem.absolutePath.startsWith(fullSubDir)
|
||||
}
|
||||
}
|
||||
val subdirHasAnItem =
|
||||
DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem ->
|
||||
if (_localLibraryItem.id == localLibraryItemId) {
|
||||
false
|
||||
} else {
|
||||
_localLibraryItem.absolutePath.startsWith(fullSubDir)
|
||||
}
|
||||
}
|
||||
subfolderPathToDelete = if (subdirHasAnItem) "" else fullSubDir
|
||||
}
|
||||
}
|
||||
@@ -266,4 +306,23 @@ class AbsFileSystem : Plugin() {
|
||||
call.resolve(JSObject("{\"success\":false}"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun folderPickerString(key: String, defaultValue: String): String =
|
||||
mainActivity.getSharedPreferences(FOLDER_PICKER_PREFERENCES, Context.MODE_PRIVATE)
|
||||
.getString(key, defaultValue) ?: defaultValue
|
||||
|
||||
private companion object {
|
||||
const val FOLDER_PICKER_PREFERENCES = "folder_picker"
|
||||
const val KEY_WRITE_ACCESS_REQUIRED = "write_access_required"
|
||||
const val KEY_ALLOW = "allow"
|
||||
const val KEY_CANCEL = "cancel"
|
||||
const val KEY_ACCESS_DENIED = "access_denied"
|
||||
const val KEY_PERMISSION_DENIED = "permission_denied"
|
||||
const val DEFAULT_WRITE_ACCESS_REQUIRED =
|
||||
"You do not have write access to this folder. Would you like to grant access?"
|
||||
const val DEFAULT_ALLOW = "Allow"
|
||||
const val DEFAULT_CANCEL = "Cancel"
|
||||
const val DEFAULT_ACCESS_DENIED = "Access denied"
|
||||
const val DEFAULT_PERMISSION_DENIED = "Permission denied"
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import com.audiobookshelf.app.plugins.AbsLogger
|
||||
import com.audiobookshelf.app.managers.SecureStorage
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.getcapacitor.JSArray
|
||||
@@ -48,7 +49,6 @@ class ApiHandler(var ctx:Context) {
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
private var secureStorage = SecureStorage(ctx)
|
||||
|
||||
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>, val deviceInfo:DeviceInfo)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LocalSessionSyncResult(val id:String, val success:Boolean, val progressSynced:Boolean?, val error:String?)
|
||||
data class LocalSessionsSyncResponsePayload(val results:List<LocalSessionSyncResult>)
|
||||
@@ -167,105 +167,116 @@ class ApiHandler(var ctx:Context) {
|
||||
* 2. Make a request to /auth/refresh endpoint with the refresh token
|
||||
* 3. Update the stored tokens with the new access token
|
||||
* 4. Retry the original request with the new access token
|
||||
* 5. If refresh fails, handle logout
|
||||
* 5. If refresh fails, fail the request ([refreshAuthTokens] owns clearing the session)
|
||||
*
|
||||
* @param originalRequest The original request that failed with 401
|
||||
* @param httpClient The HTTP client to use for the request
|
||||
* @param callback The callback to return the response
|
||||
*/
|
||||
private fun handleTokenRefresh(originalRequest: Request, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) {
|
||||
try {
|
||||
AbsLogger.info(tag, "handleTokenRefresh: Attempting to refresh auth tokens for server ${DeviceManager.serverConnectionConfigString}")
|
||||
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
|
||||
refreshAuthTokens(serverConnectionConfigId, httpClient) { result ->
|
||||
if (result is RefreshResult.Success) {
|
||||
retryOriginalRequest(originalRequest, result.accessToken, httpClient, callback)
|
||||
} else {
|
||||
callback(JSObject().put("error", "Authentication failed - login again"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get current server connection config ID
|
||||
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
|
||||
if (serverConnectionConfigId.isEmpty()) {
|
||||
AbsLogger.error(tag, "handleTokenRefresh: Unable to refresh auth tokens. No server connection config ID")
|
||||
val errorObj = JSObject()
|
||||
errorObj.put("error", "No server connection available")
|
||||
callback(errorObj)
|
||||
return
|
||||
sealed interface RefreshResult {
|
||||
data class Success(val accessToken: String) : RefreshResult
|
||||
|
||||
/** The server rejected the refresh token, so the session has already been cleared. */
|
||||
data object Rejected : RefreshResult
|
||||
|
||||
/** The refresh could not be completed. Credentials are untouched and the caller may retry. */
|
||||
data object Transient : RefreshResult
|
||||
}
|
||||
|
||||
/** Refreshes tokens for a specific saved server, including downloads queued while another server is active. */
|
||||
fun refreshAuthTokens(
|
||||
serverConnectionConfigId: String,
|
||||
httpClient: OkHttpClient? = null,
|
||||
onResult: (RefreshResult) -> Unit
|
||||
) {
|
||||
val config = DeviceManager.getServerConnectionConfig(serverConnectionConfigId)
|
||||
val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId)
|
||||
if (config == null || refreshToken.isNullOrEmpty()) {
|
||||
AbsLogger.error(tag, "No refresh token or server configuration for $serverConnectionConfigId")
|
||||
handleRefreshRejected(serverConnectionConfigId)
|
||||
onResult(RefreshResult.Rejected)
|
||||
return
|
||||
}
|
||||
val request = try {
|
||||
Request.Builder()
|
||||
.url("${config.address}/auth/refresh")
|
||||
.addHeader("x-refresh-token", refreshToken)
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(EMPTY_REQUEST)
|
||||
.build()
|
||||
} catch (e: Exception) {
|
||||
AbsLogger.error(tag, "Could not create refresh request for ${config.name}: ${e.message}")
|
||||
onResult(RefreshResult.Transient)
|
||||
return
|
||||
}
|
||||
(httpClient ?: defaultClient).newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
AbsLogger.error(tag, "Token refresh failed for ${config.name}: ${e.message}")
|
||||
onResult(RefreshResult.Transient)
|
||||
}
|
||||
|
||||
// Get refresh token from secure storage
|
||||
val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId)
|
||||
if (refreshToken.isNullOrEmpty()) {
|
||||
AbsLogger.error(tag, "handleTokenRefresh: Unable to refresh auth tokens. No refresh token available for server ${DeviceManager.serverConnectionConfigString}")
|
||||
val errorObj = JSObject()
|
||||
errorObj.put("error", "No refresh token available")
|
||||
callback(errorObj)
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(tag, "handleTokenRefresh: Retrieved refresh token, attempting to refresh access token")
|
||||
|
||||
// Create refresh token request
|
||||
val refreshEndpoint = "${DeviceManager.serverAddress}/auth/refresh"
|
||||
val refreshRequest = Request.Builder()
|
||||
.url(refreshEndpoint)
|
||||
.addHeader("x-refresh-token", refreshToken)
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(EMPTY_REQUEST)
|
||||
.build()
|
||||
|
||||
// Make the refresh request
|
||||
val client = httpClient ?: defaultClient
|
||||
client.newCall(refreshRequest).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(tag, "handleTokenRefresh: Failed to connect to refresh endpoint", e)
|
||||
AbsLogger.error(tag, "handleTokenRefresh: Failed to connect to refresh endpoint for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
|
||||
handleRefreshFailure(callback)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (!it.isSuccessful) {
|
||||
AbsLogger.error(tag, "handleTokenRefresh: Refresh request failed with status ${it.code} for server ${DeviceManager.serverConnectionConfigString}")
|
||||
handleRefreshFailure(callback)
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (!it.isSuccessful) {
|
||||
AbsLogger.error(tag, "Token refresh returned ${it.code} for ${config.name}")
|
||||
if (it.code != 401 && it.code != 403) {
|
||||
onResult(RefreshResult.Transient)
|
||||
return
|
||||
}
|
||||
|
||||
val bodyString = it.body!!.string()
|
||||
try {
|
||||
val responseJson = JSONObject(bodyString)
|
||||
val userObj = responseJson.optJSONObject("user")
|
||||
|
||||
if (userObj == null) {
|
||||
AbsLogger.error(tag, "handleTokenRefresh: No user object in refresh response for server ${DeviceManager.serverConnectionConfigString}")
|
||||
handleRefreshFailure(callback)
|
||||
return
|
||||
}
|
||||
|
||||
val newAccessToken = userObj.optString("accessToken")
|
||||
val newRefreshToken = userObj.optString("refreshToken")
|
||||
|
||||
if (newAccessToken.isEmpty()) {
|
||||
AbsLogger.error(tag, "handleTokenRefresh: No access token in refresh response for server ${DeviceManager.serverConnectionConfigString}")
|
||||
handleRefreshFailure(callback)
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(tag, "handleTokenRefresh: Successfully obtained new access token")
|
||||
|
||||
// Update tokens in secure storage and device manager
|
||||
updateTokens(newAccessToken, newRefreshToken.ifEmpty { refreshToken }, serverConnectionConfigId)
|
||||
|
||||
// Retry the original request with the new access token
|
||||
Log.d(tag, "handleTokenRefresh: Retrying original request with new token")
|
||||
retryOriginalRequest(originalRequest, newAccessToken, httpClient, callback)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "handleTokenRefresh: Failed to parse refresh response", e)
|
||||
AbsLogger.error(tag, "handleTokenRefresh: Failed to parse refresh response for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})")
|
||||
handleRefreshFailure(callback)
|
||||
handleRefreshRejected(serverConnectionConfigId)
|
||||
onResult(RefreshResult.Rejected)
|
||||
return
|
||||
}
|
||||
try {
|
||||
val user = JSONObject(it.body!!.string()).optJSONObject("user")
|
||||
val accessToken = user?.optString("accessToken").orEmpty()
|
||||
if (accessToken.isEmpty()) {
|
||||
AbsLogger.error(tag, "Refresh response had no access token for ${config.name}")
|
||||
onResult(RefreshResult.Transient)
|
||||
return
|
||||
}
|
||||
updateTokens(accessToken, user?.optString("refreshToken").orEmpty().ifEmpty { refreshToken }, serverConnectionConfigId)
|
||||
onResult(RefreshResult.Success(accessToken))
|
||||
} catch (e: Exception) {
|
||||
AbsLogger.error(tag, "Could not parse refresh response for ${config.name}: ${e.message}")
|
||||
onResult(RefreshResult.Transient)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears only the server that rejected the refresh token; queued downloads can target a non-active server.
|
||||
*
|
||||
* Only call this when the server explicitly rejected the refresh token. Transient failures should not log the user out
|
||||
*/
|
||||
private fun handleRefreshRejected(serverConnectionConfigId: String) {
|
||||
// Must not throw: callers still have to report the refresh result to an in-flight request or download.
|
||||
try {
|
||||
secureStorage.removeRefreshToken(serverConnectionConfigId)
|
||||
if (DeviceManager.serverConnectionConfigId != serverConnectionConfigId) return
|
||||
DeviceManager.serverConnectionConfig = null
|
||||
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
if (checkAbsDatabaseNotifyListenersInitted()) {
|
||||
absDatabaseNotifyListeners(
|
||||
"onTokenRefreshFailure",
|
||||
JSObject().put("error", "Token refresh failed").put("serverConnectionConfigId", serverConnectionConfigId))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "handleTokenRefresh: Unexpected error during token refresh", e)
|
||||
handleRefreshFailure(callback)
|
||||
AbsLogger.error(tag, "Could not clear session for $serverConnectionConfigId: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,15 +294,15 @@ class ApiHandler(var ctx:Context) {
|
||||
Log.d(tag, "updateTokens: Updated refresh token in secure storage")
|
||||
}
|
||||
|
||||
// Update the access token in the current server connection config
|
||||
DeviceManager.serverConnectionConfig?.let { config ->
|
||||
// The refreshed connection may be queued in the downloader rather than currently active.
|
||||
DeviceManager.getServerConnectionConfig(serverConnectionConfigId)?.let { config ->
|
||||
config.token = newAccessToken
|
||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
Log.d(tag, "updateTokens: Updated access token in server connection config")
|
||||
}
|
||||
|
||||
// Send access token to Webview frontend
|
||||
if (checkAbsDatabaseNotifyListenersInitted()) {
|
||||
if (DeviceManager.serverConnectionConfigId == serverConnectionConfigId && checkAbsDatabaseNotifyListenersInitted()) {
|
||||
val tokenJsObject = JSObject()
|
||||
tokenJsObject.put("accessToken", newAccessToken)
|
||||
absDatabaseNotifyListeners("onTokenRefresh", tokenJsObject)
|
||||
@@ -379,50 +390,6 @@ class ApiHandler(var ctx:Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the case when token refresh fails
|
||||
* This will clear the current session and notify the callback
|
||||
*
|
||||
* @param callback The callback to return the error
|
||||
*/
|
||||
private fun handleRefreshFailure(callback: (JSObject) -> Unit) {
|
||||
try {
|
||||
Log.d(tag, "handleRefreshFailure: Token refresh failed, clearing session")
|
||||
|
||||
// Clear the current server connection
|
||||
DeviceManager.serverConnectionConfig = null
|
||||
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
|
||||
// Remove refresh token from secure storage
|
||||
val serverConnectionConfigId = DeviceManager.serverConnectionConfigId
|
||||
if (serverConnectionConfigId.isNotEmpty()) {
|
||||
secureStorage.removeRefreshToken(serverConnectionConfigId)
|
||||
}
|
||||
|
||||
val errorObj = JSObject()
|
||||
errorObj.put("error", "Authentication failed - please login again")
|
||||
callback(errorObj)
|
||||
|
||||
if (checkAbsDatabaseNotifyListenersInitted()) {
|
||||
val tokenJsObject = JSObject()
|
||||
tokenJsObject.put("error", "Token refresh failed")
|
||||
if (serverConnectionConfigId.isNotEmpty()) {
|
||||
tokenJsObject.put("serverConnectionConfigId", serverConnectionConfigId)
|
||||
}
|
||||
absDatabaseNotifyListeners("onTokenRefreshFailure", tokenJsObject)
|
||||
} else {
|
||||
// Can happen if Webview is never run
|
||||
Log.i(tag, "AbsDatabaseNotifyListeners is not initialized so cannot send token refresh failure notification")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "handleRefreshFailure: Error during failure handling", e)
|
||||
val errorObj = JSObject()
|
||||
errorObj.put("error", "Authentication failed")
|
||||
callback(errorObj)
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentUser(cb: (User?) -> Unit) {
|
||||
getRequest("/api/me", null, null) {
|
||||
if (it.has("error")) {
|
||||
@@ -652,10 +619,29 @@ class ApiHandler(var ctx:Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean, String?) -> Unit) {
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(playbackSession))
|
||||
private fun createPartialPlaybackSession(playbackSession: PlaybackSession): ObjectNode {
|
||||
val json = jacksonMapper.createObjectNode()
|
||||
json.put("id", playbackSession.id)
|
||||
json.put("userId", playbackSession.userId)
|
||||
json.put("libraryItemId", playbackSession.libraryItemId)
|
||||
json.put("episodeId", playbackSession.episodeId)
|
||||
json.put("mediaType", playbackSession.mediaType)
|
||||
json.put("displayTitle", playbackSession.displayTitle)
|
||||
json.put("displayAuthor", playbackSession.displayAuthor)
|
||||
json.put("duration", playbackSession.duration)
|
||||
json.put("playMethod", playbackSession.playMethod)
|
||||
json.put("startedAt", playbackSession.startedAt)
|
||||
json.put("updatedAt", playbackSession.updatedAt)
|
||||
json.put("timeListening", playbackSession.timeListening)
|
||||
json.put("currentTime", playbackSession.currentTime)
|
||||
json.put("mediaPlayer", playbackSession.mediaPlayer)
|
||||
return json
|
||||
}
|
||||
|
||||
postRequest("/api/session/local", payload, null) {
|
||||
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean, String?) -> Unit) {
|
||||
val partialSession = createPartialPlaybackSession(playbackSession)
|
||||
partialSession.set<ObjectNode>("deviceInfo", jacksonMapper.valueToTree(playbackSession.deviceInfo))
|
||||
postRequest("/api/session/local", JSObject(partialSession.toString()), null) {
|
||||
if (!it.getString("error").isNullOrEmpty()) {
|
||||
cb(false, it.getString("error"))
|
||||
} else {
|
||||
@@ -748,10 +734,12 @@ class ApiHandler(var ctx:Context) {
|
||||
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
|
||||
val deviceInfo = DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions, deviceInfo)))
|
||||
val json = jacksonMapper.createObjectNode();
|
||||
json.putArray("sessions").addAll(playbackSessions.map(::createPartialPlaybackSession))
|
||||
json.set<ObjectNode>("deviceInfo", jacksonMapper.valueToTree(deviceInfo))
|
||||
AbsLogger.info("ApiHandler", "sendSyncLocalSessions: Sending ${playbackSessions.size} saved local playback sessions to server (${DeviceManager.serverConnectionConfigName})")
|
||||
|
||||
postRequest("/api/session/local-all", payload, null) {
|
||||
postRequest("/api/session/local-all", JSObject(json.toString()), null) {
|
||||
if (!it.getString("error").isNullOrEmpty()) {
|
||||
AbsLogger.error("ApiHandler", "sendSyncLocalSessions: Failed to sync local sessions. (${it.getString("error")})")
|
||||
cb(false, it.getString("error"))
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.audiobookshelf.app.services
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.models.DownloadItemPart
|
||||
|
||||
/** Android-owned foreground lifecycle for transfers that must outlive the WebView and Activity. */
|
||||
class DownloadService : Service() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createChannel()
|
||||
startForegroundWithType(DownloadServiceHost.notificationStrings(this).preparing)
|
||||
DownloadServiceHost.attachService(this)
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_CANCEL -> DownloadServiceHost.cancelAll(this)
|
||||
else -> {
|
||||
startForegroundWithType(DownloadServiceHost.notificationStrings(this).preparing)
|
||||
DownloadServiceHost.startWork(this)
|
||||
}
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
DownloadServiceHost.detachService(this)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
fun onPartUpdate(part: DownloadItemPart) {
|
||||
val strings = DownloadServiceHost.notificationStrings(this)
|
||||
val text =
|
||||
if (part.waitingForSpace) strings.waitingForStorage
|
||||
else strings.downloadingFile.replace("{0}", part.filename)
|
||||
val progress = part.progress.coerceIn(0L, 100L).toInt()
|
||||
val notification = notification(text, progress, part.fileSize > 0L)
|
||||
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager).notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
fun onQueueChanged(hasWork: Boolean) {
|
||||
if (!hasWork) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startForegroundWithType(text: String) {
|
||||
val notification = notification(text)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notification(text: String, progress: Int = 0, determinate: Boolean = false): Notification {
|
||||
val cancelIntent = PendingIntent.getService(
|
||||
this, 1, Intent(this, DownloadService::class.java).setAction(ACTION_CANCEL), pendingIntentFlags())
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.icon)
|
||||
.setContentTitle(DownloadServiceHost.notificationStrings(this).downloads)
|
||||
.setContentText(text)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setOngoing(true)
|
||||
.setProgress(100, progress, !determinate)
|
||||
.addAction(0, DownloadServiceHost.notificationStrings(this).cancel, cancelIntent)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
DownloadServiceHost.notificationStrings(this).downloads,
|
||||
NotificationManager.IMPORTANCE_LOW))
|
||||
}
|
||||
|
||||
private fun pendingIntentFlags(): Int = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "downloads"
|
||||
private const val NOTIFICATION_ID = 11
|
||||
private const val ACTION_CANCEL = "com.audiobookshelf.app.download.CANCEL"
|
||||
fun intent(context: Context) = Intent(context, DownloadService::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package com.audiobookshelf.app.services
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.device.FolderScanner
|
||||
import com.audiobookshelf.app.managers.DbManager
|
||||
import com.audiobookshelf.app.managers.DownloadItemManager
|
||||
import com.audiobookshelf.app.managers.IncompleteDownloadCleanup
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.audiobookshelf.app.plugins.AbsLogger
|
||||
import com.getcapacitor.JSObject
|
||||
import java.util.Collections
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Shared process owner used by the foreground service and the Capacitor bridge. */
|
||||
object DownloadServiceHost {
|
||||
enum class ExistingDownloadResult { NOT_FOUND, ACTIVE, RETRIED, SERVICE_START_FAILED }
|
||||
|
||||
data class NotificationStrings(
|
||||
val preparing: String,
|
||||
val downloadingFile: String,
|
||||
val waitingForStorage: String,
|
||||
val downloads: String,
|
||||
val cancel: String
|
||||
)
|
||||
|
||||
private var manager: DownloadItemManager? = null
|
||||
private var bridgeEmitter: DownloadItemManager.DownloadEventEmitter = NoopEmitter
|
||||
@Volatile private var service: DownloadService? = null
|
||||
@Volatile private var bridgeReady = false
|
||||
private val deferredCompletions = Collections.synchronizedList(mutableListOf<JSObject>())
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var restoreJob: Job? = null
|
||||
|
||||
@Synchronized
|
||||
fun ensure(context: Context): DownloadItemManager {
|
||||
if (manager == null) {
|
||||
val appContext = context.applicationContext
|
||||
DbManager.initialize(appContext)
|
||||
manager = DownloadItemManager(FolderScanner(appContext), appContext, ForwardingEmitter)
|
||||
restoreJob = scope.launch {
|
||||
try {
|
||||
if (DeviceManager.dbManager.ensureValidDownloadQueue()) {
|
||||
IncompleteDownloadCleanup.cleanupExpired(appContext)
|
||||
manager!!.restoreQueue()
|
||||
} else {
|
||||
AbsLogger.error(TAG, "Skipping invalid download queue because it could not be cleared")
|
||||
}
|
||||
onRestoreComplete(appContext)
|
||||
} catch (restoreError: Exception) {
|
||||
AbsLogger.error(
|
||||
TAG,
|
||||
"Could not restore download queue (${restoreError.javaClass.simpleName}: ${restoreError.message})"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
return manager!!
|
||||
}
|
||||
|
||||
/** Attaches the frontend after restored queue items have been emitted. */
|
||||
@Synchronized
|
||||
fun attachBridge(context: Context, emitter: DownloadItemManager.DownloadEventEmitter) {
|
||||
bridgeReady = false
|
||||
bridgeEmitter = emitter
|
||||
ensure(context).setEventEmitter(ForwardingEmitter)
|
||||
bridgeReady = true
|
||||
val completions = synchronized(deferredCompletions) {
|
||||
deferredCompletions.toList().also { deferredCompletions.clear() }
|
||||
}
|
||||
completions.forEach(bridgeEmitter::onDownloadItemComplete)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun detachBridge() {
|
||||
bridgeReady = false
|
||||
bridgeEmitter = NoopEmitter
|
||||
}
|
||||
|
||||
fun enqueue(context: Context, item: DownloadItem, callback: (String?) -> Unit) {
|
||||
val queue = ensure(context)
|
||||
scope.launch {
|
||||
restoreJob?.join()
|
||||
queue.addDownloadItem(item)
|
||||
if (startService(context)) callback(null)
|
||||
else callback("Unable to start the Android download service")
|
||||
}
|
||||
}
|
||||
|
||||
fun retryExisting(
|
||||
context: Context,
|
||||
downloadItemId: String,
|
||||
callback: (ExistingDownloadResult) -> Unit
|
||||
) {
|
||||
val queue = ensure(context)
|
||||
scope.launch {
|
||||
restoreJob?.join()
|
||||
val existing = queue.downloadItemQueue.find { it.id == downloadItemId }
|
||||
if (existing == null) {
|
||||
callback(ExistingDownloadResult.NOT_FOUND)
|
||||
} else if (!queue.retryDownloadItem(downloadItemId)) {
|
||||
callback(ExistingDownloadResult.ACTIVE)
|
||||
} else if (startService(context)) {
|
||||
callback(ExistingDownloadResult.RETRIED)
|
||||
} else {
|
||||
callback(ExistingDownloadResult.SERVICE_START_FAILED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelAll(context: Context) {
|
||||
val queue = ensure(context)
|
||||
scope.launch {
|
||||
restoreJob?.join()
|
||||
queue.cancelAll()
|
||||
}
|
||||
}
|
||||
|
||||
fun setNotificationStrings(
|
||||
context: Context,
|
||||
preparing: String,
|
||||
downloadingFile: String,
|
||||
waitingForStorage: String,
|
||||
downloads: String,
|
||||
cancel: String
|
||||
) {
|
||||
context.getSharedPreferences(NOTIFICATION_PREFERENCES, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(KEY_PREPARING, preparing)
|
||||
.putString(KEY_DOWNLOADING_FILE, downloadingFile)
|
||||
.putString(KEY_WAITING_FOR_STORAGE, waitingForStorage)
|
||||
.putString(KEY_DOWNLOADS, downloads)
|
||||
.putString(KEY_CANCEL, cancel)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun notificationStrings(context: Context): NotificationStrings {
|
||||
val preferences = context.getSharedPreferences(NOTIFICATION_PREFERENCES, Context.MODE_PRIVATE)
|
||||
return NotificationStrings(
|
||||
preferences.getString(KEY_PREPARING, DEFAULT_PREPARING) ?: DEFAULT_PREPARING,
|
||||
preferences.getString(KEY_DOWNLOADING_FILE, DEFAULT_DOWNLOADING_FILE)
|
||||
?: DEFAULT_DOWNLOADING_FILE,
|
||||
preferences.getString(KEY_WAITING_FOR_STORAGE, DEFAULT_WAITING_FOR_STORAGE)
|
||||
?: DEFAULT_WAITING_FOR_STORAGE,
|
||||
preferences.getString(KEY_DOWNLOADS, DEFAULT_DOWNLOADS) ?: DEFAULT_DOWNLOADS,
|
||||
preferences.getString(KEY_CANCEL, DEFAULT_CANCEL) ?: DEFAULT_CANCEL)
|
||||
}
|
||||
|
||||
fun attachService(downloadService: DownloadService) {
|
||||
synchronized(this) { service = downloadService }
|
||||
startWork(downloadService)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun detachService(downloadService: DownloadService) {
|
||||
if (service === downloadService) service = null
|
||||
}
|
||||
|
||||
fun startWork(context: Context) {
|
||||
val queue = ensure(context)
|
||||
scope.launch {
|
||||
restoreJob?.join()
|
||||
queue.resumeWork()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onRestoreComplete(context: Context) {
|
||||
val attachedService = synchronized(this) { service }
|
||||
if (attachedService != null) {
|
||||
manager?.resumeWork()
|
||||
} else if (bridgeReady && manager?.hasWork() == true) {
|
||||
startService(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startService(context: Context): Boolean {
|
||||
return try {
|
||||
ContextCompat.startForegroundService(context, DownloadService.intent(context))
|
||||
true
|
||||
} catch (e: RuntimeException) {
|
||||
AbsLogger.error(TAG, "Could not start download foreground service: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private object ForwardingEmitter : DownloadItemManager.DownloadEventEmitter {
|
||||
override fun onDownloadItem(downloadItem: DownloadItem) { bridgeEmitter.onDownloadItem(downloadItem) }
|
||||
override fun onDownloadItemPartUpdate(downloadItemPart: com.audiobookshelf.app.models.DownloadItemPart) {
|
||||
if (bridgeReady) bridgeEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||
service?.onPartUpdate(downloadItemPart)
|
||||
}
|
||||
override fun onDownloadItemComplete(jsobj: JSObject) {
|
||||
if (bridgeReady) bridgeEmitter.onDownloadItemComplete(jsobj) else deferredCompletions.add(jsobj)
|
||||
}
|
||||
override fun onQueueChanged(hasWork: Boolean) {
|
||||
bridgeEmitter.onQueueChanged(hasWork)
|
||||
service?.onQueueChanged(hasWork)
|
||||
}
|
||||
}
|
||||
|
||||
private object NoopEmitter : DownloadItemManager.DownloadEventEmitter {
|
||||
override fun onDownloadItem(downloadItem: DownloadItem) = Unit
|
||||
override fun onDownloadItemPartUpdate(downloadItemPart: com.audiobookshelf.app.models.DownloadItemPart) = Unit
|
||||
override fun onDownloadItemComplete(jsobj: JSObject) = Unit
|
||||
override fun onQueueChanged(hasWork: Boolean) = Unit
|
||||
}
|
||||
|
||||
private const val NOTIFICATION_PREFERENCES = "download_notifications"
|
||||
private const val KEY_PREPARING = "preparing"
|
||||
private const val KEY_DOWNLOADING_FILE = "downloading_file"
|
||||
private const val KEY_WAITING_FOR_STORAGE = "waiting_for_storage"
|
||||
private const val KEY_DOWNLOADS = "downloads"
|
||||
private const val KEY_CANCEL = "cancel"
|
||||
private const val DEFAULT_PREPARING = "Preparing downloads"
|
||||
private const val DEFAULT_DOWNLOADING_FILE = "Downloading {0}"
|
||||
private const val DEFAULT_WAITING_FOR_STORAGE = "Waiting for available storage"
|
||||
private const val DEFAULT_DOWNLOADS = "Downloads"
|
||||
private const val DEFAULT_CANCEL = "Cancel"
|
||||
private const val TAG = "DownloadServiceHost"
|
||||
}
|
||||
@@ -11,4 +11,4 @@
|
||||
android:targetCellWidth="4"
|
||||
android:targetCellHeight="1"
|
||||
android:updatePeriodMillis="86400000"
|
||||
android:widgetCategory="home_screen" />
|
||||
android:widgetCategory="home_screen|keyguard" />
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<samsung-appwidget-provider
|
||||
display="sub_screen"
|
||||
privacyWidget="false" />
|
||||
@@ -1,7 +1,7 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 35
|
||||
targetSdkVersion = 35
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
androidxActivityVersion = '1.9.2'
|
||||
androidxAppCompatVersion = '1.7.0'
|
||||
androidxCoordinatorLayoutVersion = '1.3.0'
|
||||
|
||||
@@ -3,7 +3,7 @@ class WrappingMarquee {
|
||||
#scrollSpeed = 30
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} el
|
||||
* @param {HTMLElement} el
|
||||
*/
|
||||
constructor(el) {
|
||||
this.el = el
|
||||
@@ -21,8 +21,8 @@ class WrappingMarquee {
|
||||
|
||||
/**
|
||||
* Transparent gradient mask shown when text is scrolling left and overflowing right
|
||||
*
|
||||
* @param {boolean} showLeft
|
||||
*
|
||||
* @param {boolean} showLeft
|
||||
*/
|
||||
setMask(showLeft) {
|
||||
if (!this.el) return
|
||||
@@ -39,11 +39,11 @@ class WrappingMarquee {
|
||||
this.setMask(true)
|
||||
|
||||
let textScrollAmount = this.el.scrollWidth
|
||||
this.pEl.innerHTML = this.innerText + ' '.repeat(15)
|
||||
this.pEl.textContent = this.innerText + '\u00A0'.repeat(15) // \u00A0 = non-breaking space
|
||||
let totalScrollAmount = this.el.scrollWidth
|
||||
let scrollDuration = totalScrollAmount * this.#scrollSpeed
|
||||
|
||||
this.pEl.innerHTML = this.pEl.innerHTML + this.innerText
|
||||
this.pEl.textContent = this.pEl.textContent + this.innerText
|
||||
|
||||
let done = false
|
||||
let start, previousTimeStamp
|
||||
@@ -55,13 +55,14 @@ class WrappingMarquee {
|
||||
const elapsed = timeStamp - start
|
||||
|
||||
if (this.isScrolling && previousTimeStamp !== timeStamp) {
|
||||
const amountToMove = Math.min(elapsed / scrollDuration * totalScrollAmount, totalScrollAmount)
|
||||
const amountToMove = Math.min((elapsed / scrollDuration) * totalScrollAmount, totalScrollAmount)
|
||||
this.pEl.style.transform = `translateX(-${amountToMove}px)`
|
||||
if (amountToMove === totalScrollAmount) done = true
|
||||
if (amountToMove > textScrollAmount) this.setMask(false)
|
||||
}
|
||||
|
||||
if (!this.isScrolling || done) { // canceled or done
|
||||
if (!this.isScrolling || done) {
|
||||
// canceled or done
|
||||
this.isScrolling = false
|
||||
this.pEl.style.transform = 'translateX(0px)'
|
||||
this.pEl.innerText = this.innerText
|
||||
@@ -69,7 +70,8 @@ class WrappingMarquee {
|
||||
if (done) {
|
||||
this.startTimer()
|
||||
}
|
||||
} else if (elapsed < scrollDuration) { // step
|
||||
} else if (elapsed < scrollDuration) {
|
||||
// step
|
||||
previousTimeStamp = timeStamp
|
||||
this.animationId = window.requestAnimationFrame(step)
|
||||
}
|
||||
@@ -94,8 +96,8 @@ class WrappingMarquee {
|
||||
/**
|
||||
* Initialize and start marquee if text overflows container
|
||||
* resets the marquee if already active
|
||||
*
|
||||
* @param {string} innerText
|
||||
*
|
||||
* @param {string} innerText
|
||||
*/
|
||||
init(innerText) {
|
||||
if (!this.el || !this.pEl) return
|
||||
@@ -114,4 +116,4 @@ class WrappingMarquee {
|
||||
}
|
||||
}
|
||||
}
|
||||
export default WrappingMarquee
|
||||
export default WrappingMarquee
|
||||
@@ -1,20 +1,31 @@
|
||||
<template>
|
||||
<!-- Main container - only shows when playback session exists, applies fullscreen/platform classes -->
|
||||
<div v-if="playbackSession" id="streamContainer" class="fixed top-0 left-0 layout-wrapper right-0 z-50 pointer-events-none" :class="{ fullscreen: showFullscreen, 'ios-player': $platform === 'ios', 'web-player': $platform === 'web' }">
|
||||
<!-- Fullscreen overlays: colored background and menu overlays -->
|
||||
<div v-if="showFullscreen" class="w-full h-full z-10 absolute top-0 left-0 pointer-events-auto" :style="{ backgroundColor: coverRgb }">
|
||||
<!-- Background gradient for player -->
|
||||
<div class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-audio-player)" />
|
||||
|
||||
<!-- Collapse button - minimizes player -->
|
||||
<div class="top-4 left-4 absolute cursor-pointer">
|
||||
<span class="material-symbols text-5xl" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" @click="collapseFullscreen">keyboard_arrow_down</span>
|
||||
</div>
|
||||
|
||||
<!-- Cast button - Chromecast toggle -->
|
||||
<div v-show="showCastBtn" class="top-6 right-16 absolute cursor-pointer">
|
||||
<span class="material-symbols text-3xl" :class="coverBgIsLight && theme !== 'black' ? 'text-black' : ''" @click="castClick">{{ isCasting ? 'cast_connected' : 'cast' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Player options menu button -->
|
||||
<div class="top-6 right-4 absolute cursor-pointer">
|
||||
<span class="material-symbols text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" @click="showMoreMenuDialog = true">more_vert</span>
|
||||
</div>
|
||||
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" style="font-size: 10px">{{ isDirectPlayMethod ? $strings.LabelPlaybackDirect : isLocalPlayMethod ? $strings.LabelPlaybackLocal : $strings.LabelPlaybackTranscode }}</p>
|
||||
|
||||
<!-- Playback method indicator (Direct/Local/Transcode) -->
|
||||
<p class="top-4 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75 z-50" :class="{ 'text-black text-opacity-75': coverBgIsLight && theme !== 'black' }" style="font-size: 10px">{{ isDirectPlayMethod ? $strings.LabelPlaybackDirect : isLocalPlayMethod ? $strings.LabelPlaybackLocal : $strings.LabelPlaybackTranscode }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Overall book progress bar -->
|
||||
<div v-if="playerSettings.useChapterTrack && playerSettings.useTotalTrack && showFullscreen" class="absolute total-track w-full z-30 px-6">
|
||||
<div class="flex">
|
||||
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ currentTimePretty }}</p>
|
||||
@@ -22,7 +33,7 @@
|
||||
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ totalTimeRemainingPretty }}</p>
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="h-1 w-full bg-track/50 relative rounded-full">
|
||||
<div class="h-1 w-full bg-track/50 relative rounded-full overflow-hidden">
|
||||
<div ref="totalReadyTrack" class="h-full bg-track-buffered absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||
<div ref="totalBufferedTrack" class="h-full bg-track absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||
<div ref="totalPlayedTrack" class="h-full bg-track-cursor absolute top-0 left-0 pointer-events-none rounded-full" />
|
||||
@@ -30,16 +41,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Book cover wrapper -->
|
||||
<div class="cover-wrapper absolute z-30 pointer-events-auto" @click="clickContainer">
|
||||
<div class="w-full h-full flex justify-center">
|
||||
<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>
|
||||
|
||||
<!-- Sync failed indicator - shows error icon if progress sync failed -->
|
||||
<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>
|
||||
|
||||
<!-- Title and author text -->
|
||||
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor">
|
||||
<div ref="titlewrapper" class="overflow-hidden relative">
|
||||
<p class="title-text whitespace-nowrap"></p>
|
||||
@@ -48,6 +62,7 @@
|
||||
</div>
|
||||
|
||||
<div id="playerContent" class="playerContainer w-full z-20 absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" :style="{ backgroundColor: showFullscreen ? '' : coverRgb }" @click="clickContainer">
|
||||
<!-- Top controls bar - fullscreen only: bookmarks, speed, sleep timer, chapters -->
|
||||
<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-symbols text-3xl text-fg-muted cursor-pointer" :class="{ fill: bookmarks.length }" @click="$emit('showBookmarks')">bookmark</span>
|
||||
@@ -67,28 +82,36 @@
|
||||
</div>
|
||||
<div v-else class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: var(--gradient-minimized-audio-player)" />
|
||||
|
||||
<!-- Playback controls - jump buttons, play/pause, chapter navigation -->
|
||||
<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-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>
|
||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||
<div v-show="!playerSettings.lockUi" class="jump-icon text-fg cursor-pointer flex flex-col items-center" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">
|
||||
<span class="material-symbols text-3xl leading-none">replay</span>
|
||||
<span v-if="showFullscreen" class="jump-label text-[10px] font-semibold leading-tight">{{ jumpBackwardsLabel }}</span>
|
||||
</div>
|
||||
<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-symbols fill" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
||||
<span v-if="!showLoadingState" 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-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 v-show="!playerSettings.lockUi" class="jump-icon text-fg cursor-pointer flex flex-col items-center" :class="showLoadingState ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">
|
||||
<span class="material-symbols text-3xl leading-none">forward_media</span>
|
||||
<span v-if="showFullscreen" class="jump-label text-[10px] font-semibold leading-tight">{{ jumpForwardLabel }}</span>
|
||||
</div>
|
||||
<span v-show="showFullscreen && !playerSettings.lockUi" class="material-symbols next-icon text-fg cursor-pointer" :class="nextChapter && !showLoadingState ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chapter progress bar -->
|
||||
<div id="playerTrack" class="absolute left-0 w-full px-6">
|
||||
<div class="flex pointer-events-none">
|
||||
<p class="font-mono text-fg" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="font-mono text-fg" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
|
||||
</div>
|
||||
<div ref="track" class="h-1.5 w-full bg-track/50 relative rounded-full" :class="{ 'animate-pulse': isLoading }" @click.stop>
|
||||
<div ref="track" class="h-1.5 w-full bg-track/50 relative rounded-full overflow-hidden" :class="{ 'animate-pulse': showLoadingState }" @click.stop>
|
||||
<div ref="readyTrack" class="h-full bg-track-buffered absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="bufferedTrack" class="h-full bg-track absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
<div ref="playedTrack" class="h-full bg-track-cursor absolute top-0 left-0 rounded-full pointer-events-none" />
|
||||
@@ -99,7 +122,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chapters modal - lists chapters for navigation -->
|
||||
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" :playback-rate="currentPlaybackRate" @select="selectChapter" />
|
||||
<!-- More menu dialog - player settings and options -->
|
||||
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" width="80vw" @action="clickMenuAction" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -108,8 +133,9 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
import { FastAverageColor } from 'fast-average-color'
|
||||
import { getAverageColorFromCoverUrl } from '@/utils/coverAverageColor'
|
||||
import WrappingMarquee from '@/assets/WrappingMarquee.js'
|
||||
import jumpLabelMixin from '@/mixins/jumpLabel'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@@ -121,6 +147,7 @@ export default {
|
||||
sleepTimeRemaining: Number,
|
||||
serverLibraryItemId: String
|
||||
},
|
||||
mixins: [jumpLabelMixin],
|
||||
data() {
|
||||
return {
|
||||
windowHeight: 0,
|
||||
@@ -149,6 +176,7 @@ export default {
|
||||
lockUi: false
|
||||
},
|
||||
isLoading: false,
|
||||
isCheckingServerProgress: false,
|
||||
isDraggingCursor: false,
|
||||
draggingTouchStartX: 0,
|
||||
draggingTouchStartTime: 0,
|
||||
@@ -221,11 +249,11 @@ export default {
|
||||
|
||||
return items
|
||||
},
|
||||
jumpForwardIcon() {
|
||||
return this.$store.getters['globals/getJumpForwardIcon'](this.jumpForwardTime)
|
||||
jumpForwardLabel() {
|
||||
return this.getJumpLabel(this.jumpForwardTime)
|
||||
},
|
||||
jumpBackwardsIcon() {
|
||||
return this.$store.getters['globals/getJumpBackwardsIcon'](this.jumpBackwardsTime)
|
||||
jumpBackwardsLabel() {
|
||||
return this.getJumpLabel(this.jumpBackwardsTime)
|
||||
},
|
||||
jumpForwardTime() {
|
||||
return this.$store.getters['getJumpForwardTime']
|
||||
@@ -262,6 +290,9 @@ export default {
|
||||
return 190 * heightScale
|
||||
}
|
||||
},
|
||||
showLoadingState() {
|
||||
return this.isLoading || this.isCheckingServerProgress
|
||||
},
|
||||
showCastBtn() {
|
||||
return this.$store.state.isCastAvailable
|
||||
},
|
||||
@@ -400,17 +431,14 @@ export default {
|
||||
},
|
||||
async coverImageLoaded(fullCoverUrl) {
|
||||
if (!fullCoverUrl) return
|
||||
|
||||
const fac = new FastAverageColor()
|
||||
fac
|
||||
.getColorAsync(fullCoverUrl)
|
||||
.then((color) => {
|
||||
this.coverRgb = color.rgba
|
||||
this.coverBgIsLight = color.isLight
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
const avg = await getAverageColorFromCoverUrl(this, fullCoverUrl)
|
||||
if (!avg) {
|
||||
this.coverRgb = 'rgb(55, 56, 56)'
|
||||
this.coverBgIsLight = false
|
||||
} else {
|
||||
this.coverRgb = avg.rgba
|
||||
this.coverBgIsLight = avg.isLight
|
||||
}
|
||||
},
|
||||
clickTitleAndAuthor() {
|
||||
if (!this.showFullscreen) return
|
||||
@@ -440,9 +468,8 @@ export default {
|
||||
this.showFullscreen = true
|
||||
if (this.titleMarquee) this.titleMarquee.reset()
|
||||
|
||||
// Update track for total time bar if useChapterTrack is set
|
||||
this.$nextTick(() => {
|
||||
this.updateTrack()
|
||||
this.measureAndUpdateTrackWidth()
|
||||
})
|
||||
},
|
||||
collapseFullscreen() {
|
||||
@@ -453,13 +480,13 @@ export default {
|
||||
},
|
||||
async jumpNextChapter() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.isLoading) return
|
||||
if (this.showLoadingState) return
|
||||
if (!this.nextChapter) return
|
||||
this.seek(this.nextChapter.start)
|
||||
},
|
||||
async jumpChapterStart() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.isLoading) return
|
||||
if (this.showLoadingState) return
|
||||
if (!this.currentChapter) {
|
||||
return this.restart()
|
||||
}
|
||||
@@ -489,12 +516,12 @@ export default {
|
||||
},
|
||||
async jumpBackwards() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.isLoading) return
|
||||
if (this.showLoadingState) return
|
||||
AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime })
|
||||
},
|
||||
async jumpForward() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.isLoading) return
|
||||
if (this.showLoadingState) return
|
||||
AbsAudioPlayer.seekForward({ value: this.jumpForwardTime })
|
||||
},
|
||||
setStreamReady() {
|
||||
@@ -522,13 +549,17 @@ export default {
|
||||
this.updateReadyTrack()
|
||||
},
|
||||
updateReadyTrack() {
|
||||
if (!this.$refs.readyTrack) return
|
||||
|
||||
if (this.playerSettings.useChapterTrack) {
|
||||
if (this.$refs.totalReadyTrack) {
|
||||
this.$refs.totalReadyTrack.style.width = this.readyTrackWidth + 'px'
|
||||
if (this.$refs.totalReadyTrack && this.trackWidth) {
|
||||
const readyPercent = (this.readyTrackWidth / this.trackWidth) * 100
|
||||
this.$refs.totalReadyTrack.style.width = readyPercent + '%'
|
||||
}
|
||||
this.$refs.readyTrack.style.width = this.trackWidth + 'px'
|
||||
} else {
|
||||
this.$refs.readyTrack.style.width = this.readyTrackWidth + 'px'
|
||||
this.$refs.readyTrack.style.width = '100%'
|
||||
} else if (this.trackWidth) {
|
||||
const readyPercent = (this.readyTrackWidth / this.trackWidth) * 100
|
||||
this.$refs.readyTrack.style.width = readyPercent + '%'
|
||||
}
|
||||
},
|
||||
updateTimestamp() {
|
||||
@@ -580,21 +611,27 @@ export default {
|
||||
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration))
|
||||
}
|
||||
|
||||
const ptWidth = Math.round(percentDone * this.trackWidth)
|
||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
|
||||
const playedPercent = percentDone * 100
|
||||
const bufferedPercentWidth = bufferedPercent * 100
|
||||
if (this.$refs.playedTrack) {
|
||||
this.$refs.playedTrack.style.width = playedPercent + '%'
|
||||
}
|
||||
if (this.$refs.bufferedTrack) {
|
||||
this.$refs.bufferedTrack.style.width = bufferedPercentWidth + '%'
|
||||
}
|
||||
|
||||
if (this.$refs.trackCursor) {
|
||||
this.$refs.trackCursor.style.left = ptWidth - 14 + 'px'
|
||||
if (this.$refs.trackCursor && this.$refs.track) {
|
||||
const trackWidth = this.$refs.track.clientWidth
|
||||
this.$refs.trackCursor.style.left = Math.round(percentDone * trackWidth) - 14 + 'px'
|
||||
}
|
||||
|
||||
if (this.playerSettings.useChapterTrack) {
|
||||
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px'
|
||||
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = Math.round(totalBufferedPercent * this.trackWidth) + 'px'
|
||||
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = totalPercentDone * 100 + '%'
|
||||
if (this.$refs.totalBufferedTrack) this.$refs.totalBufferedTrack.style.width = totalBufferedPercent * 100 + '%'
|
||||
}
|
||||
},
|
||||
seek(time) {
|
||||
if (this.isLoading) return
|
||||
if (this.showLoadingState) return
|
||||
if (this.seekLoading) {
|
||||
console.error('Already seek loading', this.seekedTime)
|
||||
return
|
||||
@@ -603,12 +640,12 @@ export default {
|
||||
this.seekedTime = time
|
||||
this.seekLoading = true
|
||||
|
||||
AbsAudioPlayer.seek({ value: Math.floor(time) })
|
||||
// Pass fractional seconds so seeks to non-integer chapter starts don't truncate
|
||||
AbsAudioPlayer.seek({ value: time })
|
||||
|
||||
if (this.$refs.playedTrack) {
|
||||
const perc = time / this.totalDuration
|
||||
const ptWidth = Math.round(perc * this.trackWidth)
|
||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
this.$refs.playedTrack.style.width = perc * 100 + '%'
|
||||
|
||||
this.$refs.playedTrack.classList.remove('bg-gray-200')
|
||||
this.$refs.playedTrack.classList.add('bg-yellow-300')
|
||||
@@ -626,11 +663,14 @@ export default {
|
||||
},
|
||||
async playPauseClick() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.isLoading) return
|
||||
if (this.showLoadingState) return
|
||||
|
||||
this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing
|
||||
this.isEnded = false
|
||||
},
|
||||
setIsCheckingServerProgress(value) {
|
||||
this.isCheckingServerProgress = !!value
|
||||
},
|
||||
play() {
|
||||
AbsAudioPlayer.playPlayer()
|
||||
this.startPlayInterval()
|
||||
@@ -714,7 +754,8 @@ export default {
|
||||
maxTime = minTime + duration
|
||||
}
|
||||
|
||||
const timePerPixel = duration / this.trackWidth
|
||||
const trackWidth = this.$refs.track?.clientWidth || this.trackWidth
|
||||
const timePerPixel = duration / trackWidth
|
||||
const newTime = this.draggingTouchStartTime + timePerPixel * distanceMoved
|
||||
this.draggingCurrentTime = Math.min(maxTime, Math.max(minTime, newTime))
|
||||
|
||||
@@ -852,11 +893,7 @@ export default {
|
||||
this.titleMarquee = new WrappingMarquee(this.$refs.titlewrapper)
|
||||
this.titleMarquee.init(this.title)
|
||||
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
} else {
|
||||
console.error('Track not loaded', this.$refs)
|
||||
}
|
||||
this.measureAndUpdateTrackWidth()
|
||||
})
|
||||
},
|
||||
onPlaybackClosed() {
|
||||
@@ -902,15 +939,26 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// playerContainer width transition is 150ms, remeasure after layout settles
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
this.refreshUI()
|
||||
|
||||
this.isRefreshingUI = false
|
||||
},
|
||||
measureAndUpdateTrackWidth() {
|
||||
if (!this.$refs.track) {
|
||||
console.error('Track not loaded', this.$refs)
|
||||
return
|
||||
}
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
this.updateTrack()
|
||||
this.updateReadyTrack()
|
||||
},
|
||||
refreshUI() {
|
||||
this.updateScreenSize()
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
this.updateTrack()
|
||||
this.updateReadyTrack()
|
||||
}
|
||||
this.$nextTick(() => {
|
||||
this.measureAndUpdateTrackWidth()
|
||||
})
|
||||
},
|
||||
updateScreenSize() {
|
||||
setTimeout(() => {
|
||||
@@ -1001,6 +1049,12 @@ export default {
|
||||
.fullscreen .playerContainer {
|
||||
height: 200px;
|
||||
}
|
||||
@media (orientation: landscape) {
|
||||
.fullscreen .playerContainer {
|
||||
width: 50%;
|
||||
left: 50%;
|
||||
}
|
||||
}
|
||||
#playerContent {
|
||||
box-shadow: 0px -8px 8px #11111155;
|
||||
}
|
||||
@@ -1012,6 +1066,7 @@ export default {
|
||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
transition-property: margin;
|
||||
bottom: 35px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.fullscreen #playerTrack {
|
||||
bottom: unset;
|
||||
@@ -1028,13 +1083,45 @@ export default {
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (orientation: landscape) {
|
||||
.fullscreen .cover-wrapper {
|
||||
left: 25px !important;
|
||||
top: 50px;
|
||||
width: calc(50% - 25px) !important;
|
||||
height: calc(100% - 85px) !important;
|
||||
}
|
||||
.fullscreen .cover-wrapper > div > div {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
max-width: 100% !important;
|
||||
max-height: 100% !important;
|
||||
}
|
||||
.fullscreen .cover-wrapper > div > div > div {
|
||||
background-color: transparent;
|
||||
}
|
||||
.fullscreen .cover-wrapper img {
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.total-track {
|
||||
bottom: 215px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
@media (orientation: landscape) {
|
||||
.fullscreen .total-track {
|
||||
left: 50%;
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (orientation: landscape) {
|
||||
.fullscreen .title-author-texts {
|
||||
left: 50% !important;
|
||||
width: 50% !important;
|
||||
}
|
||||
}
|
||||
.title-author-texts {
|
||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
transition-property: left, bottom, width, height;
|
||||
@@ -1051,12 +1138,22 @@ export default {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@media (orientation: landscape) {
|
||||
.fullscreen .title-author-texts .title-text {
|
||||
font-size: larger !important;
|
||||
}
|
||||
}
|
||||
.title-author-texts .author-text {
|
||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
transition-property: font-size;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@media (orientation: landscape) {
|
||||
.fullscreen .title-author-texts .author-text {
|
||||
font-size: inherit !important;
|
||||
}
|
||||
}
|
||||
|
||||
.fullscreen .title-author-texts {
|
||||
bottom: calc(50% - var(--cover-image-height) / 2 + 50px);
|
||||
@@ -1087,6 +1184,9 @@ export default {
|
||||
margin: 0px 0px;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
#playerControls .jump-label {
|
||||
margin-top: 2px;
|
||||
}
|
||||
#playerControls .play-btn {
|
||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
transition-property: padding, margin, height, width, min-width, min-height;
|
||||
|
||||
@@ -48,6 +48,9 @@ export default {
|
||||
},
|
||||
isIos() {
|
||||
return this.$platform === 'ios'
|
||||
},
|
||||
currentPlaybackSession() {
|
||||
return this.$store.state.currentPlaybackSession
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -226,7 +229,7 @@ export default {
|
||||
console.log('Already streaming item', startTime)
|
||||
if (startTime !== undefined && startTime !== null) {
|
||||
// seek to start time
|
||||
AbsAudioPlayer.seek({ value: Math.floor(startTime) })
|
||||
AbsAudioPlayer.seek({ value: startTime })
|
||||
} else if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.play()
|
||||
}
|
||||
@@ -304,48 +307,129 @@ export default {
|
||||
this.$refs.audioPlayer?.seek(currentTime)
|
||||
},
|
||||
/**
|
||||
* When device gains focus then refresh the timestamps in the audio player
|
||||
* Fetch the current user's media progress from the server for a given library item / episode.
|
||||
* Returns the server media progress object, or null if the request fails, times out, or the
|
||||
* response doesn't match the requested library item.
|
||||
*
|
||||
* The audio player's loading state is shown while the request is in flight so the user
|
||||
* doesn't tap play before we have a chance to update the timestamps. The request timeout
|
||||
* is 7 seconds so a slow/unresponsive server doesn't block the user for long.
|
||||
*/
|
||||
deviceFocused(hasFocus) {
|
||||
if (!this.$store.state.currentPlaybackSession) return
|
||||
async getServerMediaProgressForCurrentSession() {
|
||||
if (!this.$store.state.user.user || !this.$store.state.networkConnected) return null
|
||||
const libraryItemId = this.currentPlaybackSession?.libraryItemId
|
||||
const episodeId = this.currentPlaybackSession?.episodeId
|
||||
if (!libraryItemId) return null
|
||||
|
||||
if (hasFocus) {
|
||||
if (this.$refs.audioPlayer?.isCheckingServerProgress) {
|
||||
console.log('[AudioPlayerContainer] getServerMediaProgressForCurrentSession: already checking server progress')
|
||||
return null
|
||||
}
|
||||
|
||||
const url = episodeId ? `/api/me/progress/${libraryItemId}/${episodeId}` : `/api/me/progress/${libraryItemId}`
|
||||
|
||||
this.$refs.audioPlayer?.setIsCheckingServerProgress(true)
|
||||
try {
|
||||
const data = await this.$nativeHttp.get(url, { connectTimeout: 7000, readTimeout: 7000 })
|
||||
if (!data || data.libraryItemId !== libraryItemId) return null
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('[AudioPlayerContainer] Failed to get server media progress', error)
|
||||
return null
|
||||
} finally {
|
||||
this.$refs.audioPlayer?.setIsCheckingServerProgress(false)
|
||||
}
|
||||
},
|
||||
getLocalMediaProgressForCurrentSession() {
|
||||
if (!this.currentPlaybackSession) return null
|
||||
return this.$store.getters['globals/getLocalMediaProgressById'](this.currentPlaybackSession.localLibraryItem?.id, this.currentPlaybackSession.localEpisodeId)
|
||||
},
|
||||
/**
|
||||
* Sync the server media progress with the local media progress
|
||||
*/
|
||||
async syncServerMediaProgressWithLocalMediaProgress(localMediaProgressId, serverMediaProgress) {
|
||||
try {
|
||||
const newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress({
|
||||
localMediaProgressId,
|
||||
mediaProgress: serverMediaProgress
|
||||
})
|
||||
if (newLocalMediaProgress?.id) {
|
||||
this.$store.commit('globals/updateLocalMediaProgress', newLocalMediaProgress)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AudioPlayerContainer] Failed to sync server progress with local media progress', error)
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Check if the server media progress is more recent than the local media progress and sync if so
|
||||
*/
|
||||
async checkSyncServerProgressWithLocalProgress(localMediaProgress) {
|
||||
if (!localMediaProgress) return
|
||||
console.log('[AudioPlayerContainer] checkSyncServerProgressWithLocalProgress: checking server media progress for local media item open in player')
|
||||
const serverMediaProgress = await this.getServerMediaProgressForCurrentSession()
|
||||
if (!serverMediaProgress?.lastUpdate || serverMediaProgress.lastUpdate <= localMediaProgress.lastUpdate) return
|
||||
|
||||
console.log('[AudioPlayerContainer] checkSyncServerProgressWithLocalProgress: server progress is more recent than local progress. Server current time:', serverMediaProgress.currentTime, 'vs local', localMediaProgress.currentTime, `(server lastUpdate=${serverMediaProgress.lastUpdate} > local lastUpdate=${localMediaProgress.lastUpdate})`)
|
||||
if (!this.$refs.audioPlayer?.isPlaying && serverMediaProgress.currentTime !== localMediaProgress.currentTime) {
|
||||
// Use seek() so the native audio player's current session is updated
|
||||
this.$refs.audioPlayer.seek(serverMediaProgress.currentTime)
|
||||
}
|
||||
|
||||
await this.syncServerMediaProgressWithLocalMediaProgress(localMediaProgress.id, serverMediaProgress)
|
||||
},
|
||||
/**
|
||||
* When socket is reconnected after a delay, if a local media item is open in the player (paused)
|
||||
* we fetch the server media progress and sync it if it is more recent than the local progress
|
||||
*
|
||||
* If there is no socket connection we may have missed external progress updates
|
||||
*/
|
||||
async socketReconnected() {
|
||||
if (!this.currentPlaybackSession) return
|
||||
// dont update timestamps if player is playing
|
||||
if (this.$refs.audioPlayer?.isPlaying) return
|
||||
|
||||
if (this.$refs.audioPlayer.isLocalPlayMethod) {
|
||||
const localMediaProgress = this.getLocalMediaProgressForCurrentSession()
|
||||
if (!localMediaProgress) {
|
||||
console.error('[AudioPlayerContainer] socket reconnected: Local media progress not found')
|
||||
return
|
||||
}
|
||||
|
||||
await this.checkSyncServerProgressWithLocalProgress(localMediaProgress)
|
||||
}
|
||||
},
|
||||
/**
|
||||
* When device re-gains focus then refresh the timestamps in the audio player
|
||||
* if local item is open then fetch the server media progress and update if more recent
|
||||
*/
|
||||
async deviceFocused(hasFocus) {
|
||||
if (!this.currentPlaybackSession || !hasFocus) return
|
||||
// dont update timestamps if player is playing
|
||||
if (this.$refs.audioPlayer?.isPlaying) return
|
||||
|
||||
if (this.$refs.audioPlayer.isLocalPlayMethod) {
|
||||
const localMediaProgress = this.getLocalMediaProgressForCurrentSession()
|
||||
if (!localMediaProgress) {
|
||||
console.error('[AudioPlayerContainer] device visibility: Local media progress not found')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[AudioPlayerContainer] device visibility: found local media progress', localMediaProgress.currentTime, 'last time in player is', this.currentTime)
|
||||
this.$refs.audioPlayer.currentTime = localMediaProgress.currentTime
|
||||
this.$refs.audioPlayer.timeupdate()
|
||||
|
||||
await this.checkSyncServerProgressWithLocalProgress(localMediaProgress)
|
||||
} else {
|
||||
// server item so fetch server media progress and update player time
|
||||
console.log('[AudioPlayerContainer] device visibility: checking server media progress for server media item open in player')
|
||||
const data = await this.getServerMediaProgressForCurrentSession()
|
||||
if (!data) return
|
||||
if (!this.$refs.audioPlayer?.isPlaying) {
|
||||
const playbackSession = this.$store.state.currentPlaybackSession
|
||||
if (this.$refs.audioPlayer.isLocalPlayMethod) {
|
||||
const localLibraryItemId = playbackSession.localLibraryItem?.id
|
||||
const localEpisodeId = playbackSession.localEpisodeId
|
||||
if (!localLibraryItemId) {
|
||||
console.error('[AudioPlayerContainer] device visibility: no local library item for session', JSON.stringify(playbackSession))
|
||||
return
|
||||
}
|
||||
const localMediaProgress = this.$store.state.globals.localMediaProgress.find((mp) => {
|
||||
if (localEpisodeId) return mp.localEpisodeId === localEpisodeId
|
||||
return mp.localLibraryItemId === localLibraryItemId
|
||||
})
|
||||
if (localMediaProgress) {
|
||||
console.log('[AudioPlayerContainer] device visibility: found local media progress', localMediaProgress.currentTime, 'last time in player is', this.currentTime)
|
||||
this.$refs.audioPlayer.currentTime = localMediaProgress.currentTime
|
||||
this.$refs.audioPlayer.timeupdate()
|
||||
} else {
|
||||
console.error('[AudioPlayerContainer] device visibility: Local media progress not found')
|
||||
}
|
||||
} else {
|
||||
const libraryItemId = playbackSession.libraryItemId
|
||||
const episodeId = playbackSession.episodeId
|
||||
const url = episodeId ? `/api/me/progress/${libraryItemId}/${episodeId}` : `/api/me/progress/${libraryItemId}`
|
||||
this.$nativeHttp
|
||||
.get(url)
|
||||
.then((data) => {
|
||||
if (!this.$refs.audioPlayer?.isPlaying && data.libraryItemId === libraryItemId) {
|
||||
console.log('[AudioPlayerContainer] device visibility: got server media progress', data.currentTime, 'last time in player is', this.currentTime)
|
||||
this.$refs.audioPlayer.currentTime = data.currentTime
|
||||
this.$refs.audioPlayer.timeupdate()
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[AudioPlayerContainer] device visibility: Failed to get progress', error)
|
||||
})
|
||||
console.log('[AudioPlayerContainer] device visibility: got server media progress', data.currentTime, 'last time in player is', this.currentTime)
|
||||
// Only seek if the difference is greater than 1 second
|
||||
if (Math.abs(data.currentTime - this.currentTime) > 1) {
|
||||
// Use seek() so the native audio player's current session is updated
|
||||
this.$refs.audioPlayer.seek(data.currentTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -368,6 +452,7 @@ export default {
|
||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
|
||||
this.$eventBus.$on('device-focus-update', this.deviceFocused)
|
||||
this.$eventBus.$on('socket-reconnected', this.socketReconnected)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.onLocalMediaProgressUpdateListener?.remove()
|
||||
@@ -383,6 +468,7 @@ export default {
|
||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
|
||||
this.$eventBus.$off('device-focus-update', this.deviceFocused)
|
||||
this.$eventBus.$off('socket-reconnected', this.socketReconnected)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -3,10 +3,7 @@
|
||||
<div class="absolute top-0 left-0 w-full h-full bg-black transition-opacity duration-200" :class="show ? 'bg-opacity-60 pointer-events-auto' : 'bg-opacity-0'" @click="clickBackground" />
|
||||
<div class="absolute top-0 right-0 w-64 h-full bg-bg transform transition-transform py-6 pointer-events-auto" :class="show ? '' : 'translate-x-64'" @click.stop>
|
||||
<div class="px-6 mb-4">
|
||||
<p v-if="user" class="text-base">
|
||||
Welcome,
|
||||
<strong>{{ username }}</strong>
|
||||
</p>
|
||||
<p v-if="user" class="text-base" v-html="$getString('HeaderWelcome', [username])" />
|
||||
</div>
|
||||
|
||||
<div class="w-full overflow-y-auto">
|
||||
@@ -134,14 +131,12 @@ export default {
|
||||
to: '/settings'
|
||||
})
|
||||
|
||||
if (this.$platform !== 'ios') {
|
||||
items.push({
|
||||
icon: 'bug_report',
|
||||
iconOutlined: true,
|
||||
text: this.$strings.ButtonLogs,
|
||||
to: '/logs'
|
||||
})
|
||||
}
|
||||
items.push({
|
||||
icon: 'bug_report',
|
||||
iconOutlined: true,
|
||||
text: this.$strings.ButtonLogs,
|
||||
to: '/logs'
|
||||
})
|
||||
|
||||
if (this.serverConnectionConfig) {
|
||||
items.push({
|
||||
@@ -185,11 +180,18 @@ export default {
|
||||
await this.$hapticsImpact()
|
||||
await this.logout()
|
||||
|
||||
// Redirect to home page
|
||||
if (this.$route.name !== 'bookshelf') {
|
||||
this.$router.replace('/bookshelf')
|
||||
} else {
|
||||
location.reload()
|
||||
}
|
||||
|
||||
// If player is open and not playing locally, then close the player
|
||||
if (this.$store.getters['getIsPlayerOpen']) {
|
||||
this.$eventBus.$emit('close-stream')
|
||||
}
|
||||
|
||||
// Close side drawer
|
||||
this.show = false
|
||||
},
|
||||
touchstart(e) {
|
||||
this.touchEvent = new TouchEvent(e)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div class="grow px-2 audiobookSearchCardContent">
|
||||
<p class="truncate text-sm">{{ title }}</p>
|
||||
<p v-if="subtitle" class="truncate text-xs text-gray-300">{{ subtitle }}</p>
|
||||
<p class="text-xs text-gray-200 truncate">{{ $getString('LabelByAuthor', [authorName]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
episode: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
coverWidth() {
|
||||
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
|
||||
return 50
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
mediaType() {
|
||||
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||
},
|
||||
isPodcast() {
|
||||
return this.mediaType === 'podcast'
|
||||
},
|
||||
title() {
|
||||
return this.episode?.title || 'No Title'
|
||||
},
|
||||
subtitle() {
|
||||
return this.mediaMetadata.title
|
||||
},
|
||||
authorName() {
|
||||
return this.mediaMetadata.author
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.audiobookSearchCardContent {
|
||||
width: calc(100% - 80px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +1,10 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div class="flex-grow px-2 audiobookSearchCardContent">
|
||||
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
|
||||
<p v-else class="truncate text-sm" v-html="matchHtml" />
|
||||
|
||||
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-fg-muted">{{ matchHtml }}</p>
|
||||
|
||||
<p v-if="matchKey !== 'authors'" class="text-xs text-fg truncate">by {{ authorName }}</p>
|
||||
<p v-else class="truncate text-xs text-fg" v-html="matchHtml" />
|
||||
|
||||
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin' || matchKey === 'episode' || matchKey === 'narrators'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
||||
<div class="grow px-2 audiobookSearchCardContent">
|
||||
<p class="truncate text-sm">{{ title }}</p>
|
||||
<p v-if="subtitle" class="truncate text-xs text-gray-300">{{ subtitle }}</p>
|
||||
<p class="text-xs text-gray-200 truncate">{{ $getString('LabelByAuthor', [authorName]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -22,9 +16,7 @@ export default {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
search: String,
|
||||
matchKey: String,
|
||||
matchText: String
|
||||
search: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
@@ -58,35 +50,6 @@ export default {
|
||||
authorName() {
|
||||
if (this.isPodcast) return this.mediaMetadata.author
|
||||
return this.mediaMetadata.authorName
|
||||
},
|
||||
matchHtml() {
|
||||
if (!this.matchText || !this.search) return ''
|
||||
if (this.matchKey === 'subtitle') return ''
|
||||
var matchSplit = this.matchText.toLowerCase().split(this.search.toLowerCase().trim())
|
||||
if (matchSplit.length < 2) return ''
|
||||
|
||||
var html = ''
|
||||
var totalLenSoFar = 0
|
||||
for (let i = 0; i < matchSplit.length - 1; i++) {
|
||||
var indexOf = matchSplit[i].length
|
||||
var firstPart = this.matchText.substr(totalLenSoFar, indexOf)
|
||||
var actualWasThere = this.matchText.substr(totalLenSoFar + indexOf, this.search.length)
|
||||
totalLenSoFar += indexOf + this.search.length
|
||||
|
||||
html += `${firstPart}<strong class="text-warning">${actualWasThere}</strong>`
|
||||
}
|
||||
var lastPart = this.matchText.substr(totalLenSoFar)
|
||||
html += lastPart
|
||||
|
||||
if (this.matchKey === 'episode') return `<p class="truncate">Episode: ${html}</p>`
|
||||
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
||||
if (this.matchKey === 'subtitle') return `<p class="truncate">${html}</p>`
|
||||
if (this.matchKey === 'authors') return `by ${html}`
|
||||
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
||||
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
|
||||
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
|
||||
if (this.matchKey === 'narrators') return `<p class="truncate">Narrator: ${html}</p>`
|
||||
return `${html}`
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
@@ -101,4 +64,4 @@ export default {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -4,13 +4,16 @@
|
||||
<!-- list of server connection configs -->
|
||||
<template v-if="!showForm">
|
||||
<div v-for="config in serverConnectionConfigs" :key="config.id" class="border-b border-fg/10 py-4">
|
||||
<div class="flex items-center my-1 relative" @click="connectToServer(config)">
|
||||
<span class="material-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)">
|
||||
<div class="flex items-center my-1 relative space-x-2" @click="connectToServer(config)">
|
||||
<div class="grow inline-flex items-center overflow-hidden">
|
||||
<p class="text-base text-fg truncate">{{ config.name }}</p>
|
||||
</div>
|
||||
<div class="h-full w-6 flex items-center" @click.stop="editServerConfig(config)">
|
||||
<span class="material-symbols text-2xl text-fg-muted">more_vert</span>
|
||||
</div>
|
||||
<div class="h-full w-6 flex items-center" @click.stop="removeServerConfigClick(config)">
|
||||
<span class="material-symbols fill text-1.5xl text-fg-muted">delete</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">
|
||||
@@ -57,7 +60,7 @@
|
||||
<ui-text-input v-model="password" type="password" :disabled="processing" :placeholder="$strings.LabelPassword" class="w-full mb-2 text-lg" />
|
||||
|
||||
<div class="flex items-center pt-2">
|
||||
<ui-icon-btn v-if="serverConfig.id" small bg-color="error" icon="delete" type="button" @click="removeServerConfigClick" />
|
||||
<ui-icon-btn v-if="serverConfig.id" bg-color="error" icon="delete" type="button" @click="removeServerConfigClick(serverConfig)" />
|
||||
<div class="flex-grow" />
|
||||
<ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? $strings.ButtonSubmit : $strings.MessageNoNetworkConnection }}</ui-btn>
|
||||
</div>
|
||||
@@ -76,9 +79,6 @@
|
||||
|
||||
<div :class="processing ? 'opacity-100' : 'opacity-0 pointer-events-none'" class="fixed w-full h-full top-0 left-0 bg-black/75 flex items-center justify-center z-30 transition-opacity duration-500">
|
||||
<div>
|
||||
<div class="absolute top-0 left-0 w-full p-6 flex items-center flex-col justify-center z-0 short:hidden">
|
||||
<img src="/Logo.png" class="h-20 w-20 mb-2" />
|
||||
</div>
|
||||
<svg class="animate-spin w-16 h-16" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
</svg>
|
||||
@@ -478,19 +478,19 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
async removeServerConfigClick() {
|
||||
if (!this.serverConfig.id) return
|
||||
async removeServerConfigClick(serverConfig) {
|
||||
if (!serverConfig.id) return
|
||||
await this.$hapticsImpact()
|
||||
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: `Remove this server config?`
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message: this.$strings.MessageConfirmDeleteServerConfig
|
||||
})
|
||||
if (value) {
|
||||
this.processing = true
|
||||
await this.$db.removeServerConnectionConfig(this.serverConfig.id)
|
||||
await this.$db.removeServerConnectionConfig(serverConfig.id)
|
||||
const updatedDeviceData = { ...this.deviceData }
|
||||
updatedDeviceData.serverConnectionConfigs = this.deviceData.serverConnectionConfigs.filter((scc) => scc.id != this.serverConfig.id)
|
||||
updatedDeviceData.serverConnectionConfigs = this.deviceData.serverConnectionConfigs.filter((scc) => scc.id != serverConfig.id)
|
||||
this.$store.commit('setDeviceData', updatedDeviceData)
|
||||
|
||||
this.serverConfig = {
|
||||
@@ -502,6 +502,7 @@ export default {
|
||||
this.processing = false
|
||||
this.showAuth = false
|
||||
this.showForm = !this.serverConnectionConfigs.length
|
||||
this.error = null
|
||||
}
|
||||
},
|
||||
async editServerConfig(serverConfig) {
|
||||
@@ -842,7 +843,7 @@ export default {
|
||||
async setUserAndConnection({ user, userDefaultLibraryId, serverSettings, ereaderDevices }) {
|
||||
if (!user) return
|
||||
|
||||
console.log('Successfully logged in', JSON.stringify(user))
|
||||
console.log('Successfully logged in: ' + user.username)
|
||||
|
||||
this.$store.commit('setServerSettings', serverSettings)
|
||||
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
|
||||
@@ -950,8 +951,9 @@ export default {
|
||||
init() {
|
||||
if (this.$route.query.serverConnectionConfigId) {
|
||||
// Handle force re-login for servers using new JWT auth but still using an old token OR refresh token failed
|
||||
this.serverConfig = this.serverConnectionConfigs.find((scc) => scc.id === this.$route.query.serverConnectionConfigId)
|
||||
if (this.serverConfig) {
|
||||
const _serverConfig = this.serverConnectionConfigs.find((scc) => scc.id === this.$route.query.serverConnectionConfigId)
|
||||
if (_serverConfig) {
|
||||
this.serverConfig = { ..._serverConfig }
|
||||
this.setForceRelogin(this.$route.query.error)
|
||||
return
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="400" height="100%">
|
||||
<modals-modal v-model="show" :width="400" max-width="95%" height="100%">
|
||||
<template #outer>
|
||||
<div v-if="currentChapter" class="absolute top-10 left-4 z-40 pt-1" style="max-width: 80%">
|
||||
<p class="text-white text-lg truncate">{{ chapters.length }} {{ $strings.LabelChapters }}</p>
|
||||
@@ -8,19 +8,31 @@
|
||||
|
||||
<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-secondary rounded-lg border border-fg/20" style="max-height: 75%" @click.stop>
|
||||
<div class="sticky top-0 z-10 bg-secondary grid grid-cols-[1fr_auto_auto] gap-2 px-3 py-2 border-b border-fg/10 transition-shadow" :class="{ 'shadow-md': isScrolled }">
|
||||
<div>
|
||||
<p class="text-fg-muted text-sm">{{ $strings.LabelChapters }}</p>
|
||||
</div>
|
||||
<div class="text-right" style="min-width: 60px">
|
||||
<p class="text-fg-muted text-sm" style="letter-spacing: -0.5px">{{ $strings.LabelStart }}</p>
|
||||
</div>
|
||||
<div class="text-right" style="min-width: 60px">
|
||||
<p class="text-fg-muted text-sm" style="letter-spacing: -0.5px">{{ $strings.LabelDuration }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="chapter in chapters">
|
||||
<li :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-fg select-none relative py-4 cursor-pointer" :class="currentChapterId === chapter.id ? 'bg-primary bg-opacity-80' : ''" role="option" @click="clickedOption(chapter)">
|
||||
<div class="relative flex items-center pl-3 pr-20">
|
||||
<p class="font-normal block truncate text-sm text-fg/80">{{ chapter.title }}</p>
|
||||
<div class="absolute top-0 right-3 -mt-0.5">
|
||||
<span class="font-mono text-fg-muted leading-3 text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start / _playbackRate) }}</span>
|
||||
</div>
|
||||
<li v-for="chapter in chapters" :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-fg select-none relative cursor-pointer" :class="currentChapterId === chapter.id ? 'bg-primary bg-opacity-80' : ''" role="option" @click="clickedOption(chapter)">
|
||||
<div class="grid grid-cols-[1fr_auto_auto] gap-2 px-3 py-3 items-start">
|
||||
<p class="font-normal line-clamp-2 text-sm text-fg/80">{{ chapter.title }}</p>
|
||||
<div class="text-right" style="min-width: 60px">
|
||||
<span class="font-mono text-fg-muted text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start / _playbackRate) }}</span>
|
||||
</div>
|
||||
<div class="text-right" style="min-width: 60px">
|
||||
<span class="font-mono text-fg-muted text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(Math.max(0, chapter.end - chapter.start) / _playbackRate) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="chapter.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
||||
</li>
|
||||
</template>
|
||||
<div v-show="chapter.id === currentChapterId" class="w-0.5 h-full absolute top-0 left-0 bg-yellow-400" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,7 +54,9 @@ export default {
|
||||
playbackRate: Number
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
isScrolled: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value(newVal) {
|
||||
@@ -87,8 +101,25 @@ export default {
|
||||
container.scrollTo({ top: offsetTop - containerHeight / 2 })
|
||||
}
|
||||
}
|
||||
},
|
||||
handleScroll() {
|
||||
const container = this.$refs.container
|
||||
if (container) {
|
||||
this.isScrolled = container.scrollTop > 0
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
const container = this.$refs.container
|
||||
if (container) {
|
||||
container.addEventListener('scroll', this.handleScroll)
|
||||
}
|
||||
},
|
||||
beforeDestroy() {
|
||||
const container = this.$refs.container
|
||||
if (container) {
|
||||
container.removeEventListener('scroll', this.handleScroll)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -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="threeQuartersScreen ? 'h-[75vh] 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] short:min-h-0 short:h-screen' : 'h-screen'" @click.stop @touchstart.stop @touchend.stop>
|
||||
<div ref="content" class="relative text-fg h-full w-full bg-bg">
|
||||
<slot />
|
||||
</div>
|
||||
@@ -74,4 +74,4 @@ export default {
|
||||
this.$eventBus.$off('close-modal', this.closeModalEvt)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
@@ -303,7 +303,7 @@ export default {
|
||||
// Show confirm if item has progress since it will reset
|
||||
if (this.userItemProgress && this.userItemProgress.progress > 0 && !this.userIsFinished) {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message: this.$strings.MessageConfirmMarkAsFinished
|
||||
})
|
||||
if (!value) return
|
||||
@@ -369,7 +369,7 @@ export default {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message: this.$strings.MessageConfirmDiscardProgress
|
||||
})
|
||||
if (value) {
|
||||
@@ -407,7 +407,7 @@ export default {
|
||||
}
|
||||
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message: this.$getString('MessageConfirmDeleteLocalEpisode', [localFile.basePath])
|
||||
})
|
||||
if (value) {
|
||||
@@ -432,7 +432,7 @@ export default {
|
||||
await this.$hapticsImpact()
|
||||
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message: this.$strings.MessageConfirmDeleteLocalFiles
|
||||
})
|
||||
if (value) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" width="90%">
|
||||
<div class="w-full h-full bg-primary rounded-lg border border-fg/20">
|
||||
<ul class="w-full rounded-lg text-base" role="listbox" aria-labelledby="listbox-label">
|
||||
<ul class="w-full rounded-lg text-base max-h-[70vh] overflow-y-auto overscroll-contain" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-fg select-none relative py-4 pr-9 cursor-pointer" :class="item.value === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
@@ -64,6 +64,18 @@ export default {
|
||||
text: this.$strings.LabelFileModified,
|
||||
value: 'mtimeMs'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLibrarySortByProgress,
|
||||
value: 'progress'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLibrarySortByProgressStarted,
|
||||
value: 'progress.createdAt'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLibrarySortByProgressFinished,
|
||||
value: 'progress.finishedAt'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelRandomly,
|
||||
value: 'random'
|
||||
|
||||
@@ -108,7 +108,7 @@ export default {
|
||||
async cancelSleepTimer() {
|
||||
if (this.isAuto) {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message: this.$strings.MessageConfirmDisableAutoTimer
|
||||
})
|
||||
if (!value) return
|
||||
|
||||
@@ -37,6 +37,7 @@ export default {
|
||||
isRefreshingUI: false,
|
||||
ereaderSettings: {
|
||||
theme: 'dark',
|
||||
font: 'serif',
|
||||
fontScale: 100,
|
||||
lineSpacing: 115,
|
||||
textStroke: 0
|
||||
@@ -134,6 +135,7 @@ export default {
|
||||
|
||||
const fontScale = settings.fontScale || 100
|
||||
this.rendition.themes.fontSize(`${fontScale}%`)
|
||||
this.rendition.themes.font(settings.font)
|
||||
this.rendition.spread(settings.spread || 'auto')
|
||||
},
|
||||
goToChapter(href) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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=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="w-full px-2 fixed top-0 left-0 z-30 transition-transform bg-bg text-fg" :class="`${showingToolbar ? 'translate-y-0' : '-translate-y-36'} ${isIos ? 'pt-14 h-36' : 'pt-10 h-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-symbols text-3xl text-fg">chevron_left</span>
|
||||
@@ -62,7 +62,7 @@
|
||||
<span class="material-symbols">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-full overflow-y-auto overflow-x-hidden h-full max-h-[calc(75vh-85px)]">
|
||||
<div class="w-full overflow-y-auto overflow-x-hidden h-[calc(75vh-85px)] min-h-[320px] short:min-h-0 short:h-[calc(100vh-85px)]">
|
||||
<div class="w-full h-full px-4">
|
||||
<div class="flex items-center mb-6">
|
||||
<div class="w-32">
|
||||
@@ -70,6 +70,12 @@
|
||||
</div>
|
||||
<ui-toggle-btns v-model="ereaderSettings.theme" name="theme" :items="themeItems" @input="settingsUpdated" />
|
||||
</div>
|
||||
<div class="flex items-center mb-6">
|
||||
<div class="w-32">
|
||||
<p class="text-sm">{{ $strings.LabelFontFamily }}</p>
|
||||
</div>
|
||||
<ui-toggle-btns v-model="ereaderSettings.font" name="font" :items="fontItems" @input="settingsUpdated" />
|
||||
</div>
|
||||
<div class="flex items-center mb-6">
|
||||
<div class="w-32">
|
||||
<p class="text-sm">{{ $strings.LabelFontScale }}</p>
|
||||
@@ -141,6 +147,7 @@ export default {
|
||||
isInittingWatchVolume: false,
|
||||
ereaderSettings: {
|
||||
theme: 'dark',
|
||||
font: 'serif',
|
||||
fontScale: 100,
|
||||
lineSpacing: 115,
|
||||
spread: 'auto',
|
||||
@@ -180,6 +187,9 @@ export default {
|
||||
this.$store.commit('setShowReader', val)
|
||||
}
|
||||
},
|
||||
isIos() {
|
||||
return this.$platform === 'ios'
|
||||
},
|
||||
title() {
|
||||
return this.mediaMetadata.title || 'No Title'
|
||||
},
|
||||
@@ -252,6 +262,18 @@ export default {
|
||||
}
|
||||
]
|
||||
},
|
||||
fontItems() {
|
||||
return [
|
||||
{
|
||||
text: this.$strings.LabelFontFamilySans,
|
||||
value: 'sans-serif'
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelFontFamilySerif,
|
||||
value: 'serif'
|
||||
}
|
||||
]
|
||||
},
|
||||
readerComponentName() {
|
||||
if (this.ebookType === 'epub') return 'readers-epub-reader'
|
||||
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
|
||||
|
||||
@@ -183,10 +183,16 @@ export default {
|
||||
daysInARow() {
|
||||
var count = 0
|
||||
while (true) {
|
||||
var _date = this.$addDaysToToday(count * -1)
|
||||
var datestr = this.$formatJsDate(_date, 'yyyy-MM-dd')
|
||||
const _date = this.$addDaysToToday(count * -1 - 1)
|
||||
const datestr = this.$formatJsDate(_date, 'yyyy-MM-dd')
|
||||
|
||||
if (!this.listeningStatsDays[datestr] || this.listeningStatsDays[datestr] === 0) {
|
||||
// don't require listening today to count towards days in a row, but do count it if already listened today
|
||||
const today = this.$formatJsDate(new Date(), 'yyyy-MM-dd')
|
||||
if (this.listeningStatsDays[today]) {
|
||||
count++
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
count++
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<tr>
|
||||
<th class="text-left">{{ $strings.LabelTitle }}</th>
|
||||
<th class="text-center w-16">{{ $strings.LabelStart }}</th>
|
||||
<th class="text-center w-16">{{ $strings.LabelDuration }}</th>
|
||||
</tr>
|
||||
<tr v-for="chapter in chapters" :key="chapter.id">
|
||||
<td>
|
||||
@@ -23,6 +24,9 @@
|
||||
<td class="font-mono text-center underline w-16" @click.stop="goToTimestamp(chapter.start)">
|
||||
{{ $secondsToTimestamp(chapter.start) }}
|
||||
</td>
|
||||
<td class="font-mono text-center">
|
||||
{{ $secondsToTimestamp(Math.max(0, chapter.end - chapter.start)) }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</transition>
|
||||
|
||||
@@ -79,9 +79,6 @@ export default {
|
||||
episodesCopy: [],
|
||||
showFiltersModal: false,
|
||||
showSortModal: false,
|
||||
sortKey: 'publishedAt',
|
||||
sortDesc: true,
|
||||
filterKey: 'incomplete',
|
||||
fetchingRSSFeed: false,
|
||||
podcastFeedEpisodes: [],
|
||||
showPodcastEpisodeFeed: false,
|
||||
@@ -223,13 +220,34 @@ export default {
|
||||
if (!this.sortKey) return ''
|
||||
const _sel = this.episodeSortItems.find((i) => i.value === this.sortKey)
|
||||
return _sel?.text || ''
|
||||
},
|
||||
filterKey() {
|
||||
return this.$store.getters['user/getUserSetting']('podcastEpisodesFilterBy') || 'incomplete'
|
||||
},
|
||||
sortKey: {
|
||||
get() {
|
||||
return this.$store.getters['user/getUserSetting']('podcastEpisodesOrderBy') || 'publishedAt'
|
||||
},
|
||||
set(val) {
|
||||
this.$store.dispatch('user/updateUserSettings', { podcastEpisodesOrderBy: val })
|
||||
}
|
||||
},
|
||||
sortDesc: {
|
||||
get() {
|
||||
const desc = this.$store.getters['user/getUserSetting']('podcastEpisodesOrderDesc')
|
||||
if (desc == null) return this.mediaMetadata.type === 'episodic'
|
||||
return desc
|
||||
},
|
||||
set(val) {
|
||||
this.$store.dispatch('user/updateUserSettings', { podcastEpisodesOrderDesc: val })
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async clearDownloadQueue() {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: `Are you sure you want to clear episode download queue?`
|
||||
title: this.$strings.HeaderConfirm,
|
||||
message: this.$strings.MessageConfirmDeleteEpisodeDownloadQueue
|
||||
})
|
||||
|
||||
if (value) {
|
||||
@@ -277,8 +295,8 @@ export default {
|
||||
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
|
||||
},
|
||||
setFilter(filter) {
|
||||
this.filterKey = filter
|
||||
this.showFiltersModal = false
|
||||
this.$store.dispatch('user/updateUserSettings', { podcastEpisodesFilterBy: filter })
|
||||
},
|
||||
showFilters() {
|
||||
this.showFiltersModal = true
|
||||
@@ -291,7 +309,6 @@ 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 }
|
||||
})
|
||||
@@ -316,7 +333,7 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
if (this.$route.query['episodefilter'] === 'downloaded') {
|
||||
this.filterKey = 'downloaded'
|
||||
this.$store.dispatch('user/updateUserSettings', { podcastEpisodesFilterBy: 'downloaded' })
|
||||
}
|
||||
this.$socket.$on('episode_download_queued', this.episodeDownloadQueued)
|
||||
this.$socket.$on('episode_download_started', this.episodeDownloadStarted)
|
||||
|
||||
@@ -12,7 +12,8 @@ export default {
|
||||
return {
|
||||
downloadItemListener: null,
|
||||
completeListener: null,
|
||||
itemPartUpdateListener: null
|
||||
itemPartUpdateListener: null,
|
||||
queueChangedListener: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -76,17 +77,22 @@ export default {
|
||||
},
|
||||
onDownloadItemPartUpdate(itemPart) {
|
||||
this.$store.commit('globals/updateDownloadItemPart', itemPart)
|
||||
},
|
||||
onQueueChanged(data) {
|
||||
if (!data.hasWork) this.$store.commit('globals/clearItemDownloads')
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
this.downloadItemListener = await AbsDownloader.addListener('onDownloadItem', (data) => this.onDownloadItem(data))
|
||||
this.itemPartUpdateListener = await AbsDownloader.addListener('onDownloadItemPartUpdate', (data) => this.onDownloadItemPartUpdate(data))
|
||||
this.queueChangedListener = await AbsDownloader.addListener('onQueueChanged', (data) => this.onQueueChanged(data))
|
||||
this.completeListener = await AbsDownloader.addListener('onItemDownloadComplete', (data) => this.onItemDownloadComplete(data))
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.downloadItemListener?.remove()
|
||||
this.completeListener?.remove()
|
||||
this.itemPartUpdateListener?.remove()
|
||||
this.queueChangedListener?.remove()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
<p>Zur Verwendung mit Ihrem Audiobookshelf-Server. Audiobookshelf ist ein kostenloser Open-Source-Hörbuch- und Podcast-Manager.</p><p>Verbinden Sie sich mit Ihrem selbst gehosteten Audiobookshelf-Server und streamen Sie Ihre Hörbücher und Podcasts direkt auf Ihr Gerät.</p>
|
||||
@@ -0,0 +1 @@
|
||||
Hörbuch- und Podcast-Player für Ihren selbst gehosteten Audiobookshelf-Server
|
||||
@@ -0,0 +1 @@
|
||||
<p>For use with your audiobookshelf server. Audiobookshelf is a free and open source audiobook and podcast manager.</p><p>Connect to your self-hosted audiobookshelf server and stream your audiobooks and podcasts directly to your device.</p>
|
||||
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 45 KiB |
@@ -0,0 +1 @@
|
||||
Audiobook and podcast player for your self-hosted Audiobookshelf server
|
||||
@@ -36,6 +36,7 @@
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
65643F4DAA661FB0B247247E /* Pods_Audiobookshelf.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8F9A7CCC479333E44DC314BE /* Pods_Audiobookshelf.framework */; };
|
||||
6F2516272E68DA3000F40541 /* LogEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F2516262E68DA3000F40541 /* LogEntry.swift */; };
|
||||
E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504528AC1A3900C746DD /* LibraryItem.swift */; };
|
||||
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504728AC1A7A00C746DD /* MediaType.swift */; };
|
||||
E9D5504A28AC1AA600C746DD /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504928AC1AA600C746DD /* Metadata.swift */; };
|
||||
@@ -111,6 +112,7 @@
|
||||
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
6F2516262E68DA3000F40541 /* LogEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogEntry.swift; sourceTree = "<group>"; };
|
||||
8F9A7CCC479333E44DC314BE /* Pods_Audiobookshelf.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Audiobookshelf.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
BE96D57E131924D520D57057 /* Pods-Audiobookshelf.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Audiobookshelf.debug.xcconfig"; path = "Target Support Files/Pods-Audiobookshelf/Pods-Audiobookshelf.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
D2F7F575384A63F1C47DE984 /* Pods-Audiobookshelf.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Audiobookshelf.release.xcconfig"; path = "Target Support Files/Pods-Audiobookshelf/Pods-Audiobookshelf.release.xcconfig"; sourceTree = "<group>"; };
|
||||
@@ -231,6 +233,7 @@
|
||||
E9D5506128AC1CC900C746DD /* PlayerState.swift */,
|
||||
4DF74911287105C600AC7814 /* DeviceSettings.swift */,
|
||||
E9D5507428AEF93100C746DD /* PlayerSettings.swift */,
|
||||
6F2516262E68DA3000F40541 /* LogEntry.swift */,
|
||||
E9D5506328AC1D3F00C746DD /* server */,
|
||||
E9D5506428AC1D5800C746DD /* local */,
|
||||
E9D5506D28AC1E7400C746DD /* download */,
|
||||
@@ -533,6 +536,7 @@
|
||||
3ABF580928059BAE005DFBE5 /* PlaybackSession.swift in Sources */,
|
||||
E9D5506628AC1D7300C746DD /* LocalLibraryItem.swift in Sources */,
|
||||
E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */,
|
||||
6F2516272E68DA3000F40541 /* LogEntry.swift in Sources */,
|
||||
3ABF618F2804325C0070250E /* PlayerHandler.swift in Sources */,
|
||||
3AD4FCED28044E6C006DB301 /* Store.swift in Sources */,
|
||||
4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */,
|
||||
@@ -736,12 +740,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 40;
|
||||
CURRENT_PROJECT_VERSION = 48;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.10.1;
|
||||
MARKETING_VERSION = 0.14.2;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -760,12 +764,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 40;
|
||||
CURRENT_PROJECT_VERSION = 48;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.10.1;
|
||||
MARKETING_VERSION = 0.14.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
@@ -5,8 +5,6 @@ import RealmSwift
|
||||
@UIApplicationMain
|
||||
class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
private let logger = AppLogger(category: "AppDelegate")
|
||||
|
||||
lazy var window: UIWindow? = UIWindow(frame: UIScreen.main.bounds)
|
||||
var backgroundCompletionHandler: (() -> Void)?
|
||||
|
||||
@@ -14,16 +12,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
// Override point for customization after application launch.
|
||||
|
||||
let configuration = Realm.Configuration(
|
||||
schemaVersion: 20,
|
||||
schemaVersion: 21,
|
||||
migrationBlock: { [weak self] migration, oldSchemaVersion in
|
||||
if (oldSchemaVersion < 1) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)")
|
||||
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)")
|
||||
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
||||
newObject?["enableAltView"] = false
|
||||
}
|
||||
}
|
||||
if (oldSchemaVersion < 4) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Reindexing server configs")
|
||||
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Reindexing server configs")
|
||||
var indexCounter = 1
|
||||
migration.enumerateObjects(ofType: ServerConnectionConfig.className()) { oldObject, newObject in
|
||||
newObject?["index"] = indexCounter
|
||||
@@ -31,48 +29,52 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
}
|
||||
}
|
||||
if (oldSchemaVersion < 5) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding lockOrientation setting")
|
||||
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding lockOrientation setting")
|
||||
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
||||
newObject?["lockOrientation"] = "NONE"
|
||||
}
|
||||
}
|
||||
if (oldSchemaVersion < 6) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding hapticFeedback setting")
|
||||
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding hapticFeedback setting")
|
||||
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
||||
newObject?["hapticFeedback"] = "LIGHT"
|
||||
}
|
||||
}
|
||||
if (oldSchemaVersion < 15) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding languageCode setting")
|
||||
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding languageCode setting")
|
||||
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
||||
newObject?["languageCode"] = "en-us"
|
||||
}
|
||||
}
|
||||
if (oldSchemaVersion < 16) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding chapterTrack setting")
|
||||
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding chapterTrack setting")
|
||||
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
|
||||
newObject?["chapterTrack"] = false
|
||||
}
|
||||
}
|
||||
if (oldSchemaVersion < 17) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding downloadUsingCellular and streamingUsingCellular settings")
|
||||
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding downloadUsingCellular and streamingUsingCellular settings")
|
||||
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
|
||||
newObject?["downloadUsingCellular"] = "ALWAYS"
|
||||
newObject?["streamingUsingCellular"] = "ALWAYS"
|
||||
}
|
||||
}
|
||||
if (oldSchemaVersion < 18) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding disableSleepTimerFadeOut settings")
|
||||
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding disableSleepTimerFadeOut settings")
|
||||
migration.enumerateObjects(ofType: PlayerSettings.className()) { oldObject, newObject in
|
||||
newObject?["disableSleepTimerFadeOut"] = false
|
||||
}
|
||||
}
|
||||
if (oldSchemaVersion < 20) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding version to ServerConnectionConfigs")
|
||||
AbsLogger.info(message: "Realm schema version was \(oldSchemaVersion)... Adding version to ServerConnectionConfigs")
|
||||
migration.enumerateObjects(ofType: ServerConnectionConfig.className()) { oldObject, newObject in
|
||||
newObject?["version"] = ""
|
||||
}
|
||||
}
|
||||
if (oldSchemaVersion < 21) {
|
||||
// One-time wipe of accumulated logs that were freezing the app on load/clear
|
||||
migration.deleteData(forType: LogEntry.className())
|
||||
}
|
||||
}
|
||||
)
|
||||
Realm.Configuration.defaultConfiguration = configuration
|
||||
@@ -88,22 +90,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||
// Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
|
||||
// If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
|
||||
logger.log("Audiobookself is now in the background")
|
||||
AbsLogger.info(message: "Audiobookself is now in the background")
|
||||
}
|
||||
|
||||
func applicationWillEnterForeground(_ application: UIApplication) {
|
||||
// Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background.
|
||||
logger.log("Audiobookself is now in the foreground")
|
||||
AbsLogger.info(message: "Audiobookself is now in the foreground")
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
|
||||
logger.log("Audiobookself is now active")
|
||||
AbsLogger.info(message: "Audiobookself is now active")
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
// Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
|
||||
logger.log("Audiobookself is terminating")
|
||||
AbsLogger.info(message: "Audiobookself is terminating")
|
||||
}
|
||||
|
||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon-40.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-60.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-58.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-87.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-80.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-120.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-120.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-180.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-20.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-40.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-29.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-58.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-40.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-80.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-76.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-152.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-167.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "83.5x83.5"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-120.png",
|
||||
"idiom" : "car",
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-180.png",
|
||||
"idiom" : "car",
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-1024.png",
|
||||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-16.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-64.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-128.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-1024.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-48.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "notificationCenter",
|
||||
"scale" : "2x",
|
||||
"size" : "24x24",
|
||||
"subtype" : "38mm"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-55.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "notificationCenter",
|
||||
"scale" : "2x",
|
||||
"size" : "27.5x27.5",
|
||||
"subtype" : "42mm"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-58.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "companionSettings",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-87.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "companionSettings",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "notificationCenter",
|
||||
"scale" : "2x",
|
||||
"size" : "33x33",
|
||||
"subtype" : "45mm"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-80.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "appLauncher",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40",
|
||||
"subtype" : "38mm"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-88.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "appLauncher",
|
||||
"scale" : "2x",
|
||||
"size" : "44x44",
|
||||
"subtype" : "40mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "appLauncher",
|
||||
"scale" : "2x",
|
||||
"size" : "46x46",
|
||||
"subtype" : "41mm"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-100.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "appLauncher",
|
||||
"scale" : "2x",
|
||||
"size" : "50x50",
|
||||
"subtype" : "44mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "appLauncher",
|
||||
"scale" : "2x",
|
||||
"size" : "51x51",
|
||||
"subtype" : "45mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "appLauncher",
|
||||
"scale" : "2x",
|
||||
"size" : "54x54",
|
||||
"subtype" : "49mm"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-172.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "quickLook",
|
||||
"scale" : "2x",
|
||||
"size" : "86x86",
|
||||
"subtype" : "38mm"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-196.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "quickLook",
|
||||
"scale" : "2x",
|
||||
"size" : "98x98",
|
||||
"subtype" : "42mm"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-216.png",
|
||||
"idiom" : "watch",
|
||||
"role" : "quickLook",
|
||||
"scale" : "2x",
|
||||
"size" : "108x108",
|
||||
"subtype" : "44mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "quickLook",
|
||||
"scale" : "2x",
|
||||
"size" : "117x117",
|
||||
"subtype" : "45mm"
|
||||
},
|
||||
{
|
||||
"idiom" : "watch",
|
||||
"role" : "quickLook",
|
||||
"scale" : "2x",
|
||||
"size" : "129x129",
|
||||
"subtype" : "49mm"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-1024.png",
|
||||
"idiom" : "watch-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 399 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 672 B |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 845 B |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 4.0 KiB |